Compare commits
No commits in common. "6a86b2627dfb2ebb652b55d4fb6ba956d9e7eea0" and "679a1bc4b237ec85bece0dc3b2cc03e164d6bb5f" have entirely different histories.
6a86b2627d
...
679a1bc4b2
133
.github/copilot-instructions.md
vendored
133
.github/copilot-instructions.md
vendored
@ -58,30 +58,6 @@ Os scripts de deploy:
|
|||||||
- Novos comandos artisan
|
- Novos comandos artisan
|
||||||
- Mudança na estrutura do projeto
|
- Mudança na estrutura do projeto
|
||||||
|
|
||||||
## 🚫 Regras de UI/UX
|
|
||||||
|
|
||||||
**NUNCA use alert(), confirm() ou prompt() do navegador.**
|
|
||||||
|
|
||||||
Sempre usar componentes modais ou toast:
|
|
||||||
- Para erros: `toast.error('mensagem')`
|
|
||||||
- Para sucesso: `toast.success('mensagem')`
|
|
||||||
- Para confirmação: Usar `<ConfirmModal />` component
|
|
||||||
- Para formulários: Criar modal customizado
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
// ❌ PROIBIDO
|
|
||||||
alert('Erro!');
|
|
||||||
confirm('Tem certeza?');
|
|
||||||
|
|
||||||
// ✅ CORRETO
|
|
||||||
import { useToast } from '../components/Toast';
|
|
||||||
import { ConfirmModal } from '../components/Modal';
|
|
||||||
|
|
||||||
const toast = useToast();
|
|
||||||
toast.error('Erro!');
|
|
||||||
toast.success('Sucesso!');
|
|
||||||
```
|
|
||||||
|
|
||||||
## Estrutura do Servidor
|
## Estrutura do Servidor
|
||||||
|
|
||||||
```
|
```
|
||||||
@ -117,115 +93,6 @@ sshpass -p 'Master9354' ssh -o StrictHostKeyChecking=no root@213.165.93.60 "mysq
|
|||||||
|
|
||||||
❌ NUNCA usar `ssh root@213.165.93.60` sem sshpass (vai travar esperando senha)
|
❌ NUNCA usar `ssh root@213.165.93.60` sem sshpass (vai travar esperando senha)
|
||||||
|
|
||||||
## 🎨 Padrão Visual de Modais
|
|
||||||
|
|
||||||
**TODOS os modais de formulário devem seguir este padrão elegante:**
|
|
||||||
|
|
||||||
### Estrutura Base
|
|
||||||
```jsx
|
|
||||||
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.8)' }}>
|
|
||||||
<div className="modal-dialog modal-dialog-centered modal-lg">
|
|
||||||
<div className="modal-content border-0" style={{ background: '#1e293b', maxHeight: '90vh' }}>
|
|
||||||
|
|
||||||
{/* Header sem borda */}
|
|
||||||
<div className="modal-header border-0 pb-0">
|
|
||||||
<div>
|
|
||||||
<h5 className="modal-title text-white mb-1">
|
|
||||||
<i className="bi bi-icon me-2 text-primary"></i>
|
|
||||||
Título
|
|
||||||
</h5>
|
|
||||||
<p className="text-slate-400 mb-0 small">Subtítulo</p>
|
|
||||||
</div>
|
|
||||||
<button className="btn-close btn-close-white" onClick={onClose}></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Body com scroll */}
|
|
||||||
<div className="modal-body pt-3" style={{ maxHeight: '65vh', overflowY: 'auto' }}>
|
|
||||||
{/* Preview Card - SEMPRE no topo */}
|
|
||||||
<div className="mb-4 p-3 rounded-3" style={{ background: '#0f172a' }}>
|
|
||||||
{/* Preview visual do item sendo criado/editado */}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Campos em cards com background #0f172a */}
|
|
||||||
{/* Labels com ícones coloridos */}
|
|
||||||
{/* Badges "Opcional" quando necessário */}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer sem borda */}
|
|
||||||
<div className="modal-footer border-0">
|
|
||||||
<button className="btn btn-outline-secondary px-4">Cancelar</button>
|
|
||||||
<button className="btn btn-primary px-4">Salvar</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Cores do Sistema
|
|
||||||
- **Background modal**: `#1e293b`
|
|
||||||
- **Background campos/cards**: `#0f172a`
|
|
||||||
- **Texto principal**: `text-white`
|
|
||||||
- **Texto secundário**: `text-slate-400`
|
|
||||||
- **Texto desabilitado**: `text-slate-500`
|
|
||||||
|
|
||||||
### Labels com Ícones
|
|
||||||
```jsx
|
|
||||||
<label className="form-label text-white fw-medium mb-2">
|
|
||||||
<i className="bi bi-type me-2 text-primary"></i>
|
|
||||||
Nome *
|
|
||||||
</label>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Badge Opcional
|
|
||||||
```jsx
|
|
||||||
<span className="badge bg-secondary ms-2" style={{ fontSize: '0.65rem' }}>
|
|
||||||
{t('common.optional')}
|
|
||||||
</span>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Seleção Visual com Cards
|
|
||||||
Para seleções (categorias, ícones), usar cards clicáveis:
|
|
||||||
```jsx
|
|
||||||
<div
|
|
||||||
onClick={() => handleSelect(item)}
|
|
||||||
className="p-2 rounded text-center"
|
|
||||||
style={{
|
|
||||||
background: isSelected ? 'rgba(59, 130, 246, 0.15)' : '#0f172a',
|
|
||||||
cursor: 'pointer',
|
|
||||||
border: isSelected ? '2px solid #3b82f6' : '2px solid transparent'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<i className={`bi ${icon} d-block mb-1`} style={{ color }}></i>
|
|
||||||
<small className="text-white">{label}</small>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Seção de Keywords/Tags
|
|
||||||
```jsx
|
|
||||||
<div className="p-3 rounded" style={{ background: '#0f172a' }}>
|
|
||||||
<div className="input-group mb-2">
|
|
||||||
<input className="form-control bg-dark text-white border-0" />
|
|
||||||
<button className="btn btn-primary px-3">
|
|
||||||
<i className="bi bi-plus-lg"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="d-flex flex-wrap gap-2">
|
|
||||||
{/* Tags com cor do item */}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Switch de Status
|
|
||||||
```jsx
|
|
||||||
<div className="form-check form-switch">
|
|
||||||
<input type="checkbox" className="form-check-input" role="switch" />
|
|
||||||
<label className="form-check-label text-white">
|
|
||||||
<i className={`bi ${isActive ? 'bi-check-circle text-success' : 'bi-x-circle text-secondary'} me-2`}></i>
|
|
||||||
{isActive ? 'Activo' : 'Inactivo'}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Documentação
|
## Documentação
|
||||||
|
|
||||||
Consulte `.DIRETRIZES_DESENVOLVIMENTO_v5` para regras completas.
|
Consulte `.DIRETRIZES_DESENVOLVIMENTO_v5` para regras completas.
|
||||||
|
|||||||
274
CHANGELOG.md
274
CHANGELOG.md
@ -5,280 +5,6 @@ O formato segue [Keep a Changelog](https://keepachangelog.com/pt-BR/).
|
|||||||
Este projeto adota [Versionamento Semântico](https://semver.org/pt-BR/).
|
Este projeto adota [Versionamento Semântico](https://semver.org/pt-BR/).
|
||||||
|
|
||||||
|
|
||||||
## [1.57.0] - 2025-12-18
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
- 🎨 **Novo Design do Modal de Categorias** - Interface completamente redesenhada seguindo o estilo do BudgetWizard:
|
|
||||||
- Preview em tempo real da categoria (ícone, cor, nome)
|
|
||||||
- Seleção visual de categoria pai com cards clicáveis
|
|
||||||
- Seção de palavras-chave com badge de auto-categorização
|
|
||||||
- Layout mais organizado e elegante
|
|
||||||
- Feedback visual melhorado para estado ativo/inativo
|
|
||||||
|
|
||||||
- 🎨 **Modal de Categorização em Lote Redesenhado**:
|
|
||||||
- Cards de resumo com ícones e cores distintivas
|
|
||||||
- Tabela de preview mais limpa e legível
|
|
||||||
- Estado vazio com ilustração e orientação
|
|
||||||
- Loading spinner maior e mais visível
|
|
||||||
|
|
||||||
- 🌐 **Traduções i18n Atualizadas** para ES, PT-BR e EN:
|
|
||||||
- `common.continue`, `common.creating`, `common.remove`
|
|
||||||
- `budgets.general`
|
|
||||||
- `budgets.wizard.createBudget`, `fillRequired`, `updated`, `created`, `selectAtLeast`
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- 🔧 **Transações Demo Categorizadas** - 3 transações pendentes (luz, internet, seguro carro) agora possuem categorias corretas
|
|
||||||
|
|
||||||
|
|
||||||
## [1.56.0] - 2025-12-18
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- 🧙 **Novo Wizard de Contas Unificado** - Wizard inteligente para criar diferentes tipos de contas com roteamento automático:
|
|
||||||
- **Cuenta Corriente** → Criada como conta normal (Account)
|
|
||||||
- **Cuenta de Ahorro** → Automaticamente criada como **Activo** (poupança é um investimento)
|
|
||||||
- **Tarjeta de Crédito** → Automaticamente criada como **Pasivo** (dívida de cartão)
|
|
||||||
- **Efectivo** → Criada como conta normal de caixa (Account)
|
|
||||||
|
|
||||||
### Features do AccountWizard
|
|
||||||
- Interface de 4 etapas com feedback visual claro
|
|
||||||
- Etapa 1: Seleção visual do tipo de conta com badges indicando destino (Activo/Pasivo/Cuenta)
|
|
||||||
- Etapa 2: Informações básicas (nome, moeda, cor)
|
|
||||||
- Etapa 3: Informações financeiras (saldo, limite, taxas de juros)
|
|
||||||
- Etapa 4: Dados bancários opcionais e resumo
|
|
||||||
- Campos dinâmicos baseados no tipo de conta:
|
|
||||||
- Cartão de crédito: Limite, día de cierre, día de vencimiento, tasa de interés
|
|
||||||
- Poupança: Taxa de juros de rendimento
|
|
||||||
- Suporte completo a modo mobile (fullscreen)
|
|
||||||
- Integração com serviços existentes (accountService, assetAccountService, liabilityAccountService)
|
|
||||||
|
|
||||||
|
|
||||||
## [1.55.0] - 2025-12-18
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- 🎛️ **Campos Avançados no Wizard de Passivos** - Baseado em requisitos profissionais de contratos
|
|
||||||
- **Seção Indexadores de Taxa**:
|
|
||||||
- Tipo de indexador (CDI, SELIC, IPCA, Euribor, LIBOR, SOFR, Prime Rate, TJLP, INPC, TR, IGP-M, Fixo)
|
|
||||||
- Spread adicional (%)
|
|
||||||
- CET - Custo Efetivo Total (%)
|
|
||||||
|
|
||||||
- **Seção Garantias**:
|
|
||||||
- Tipo de garantia (Alienação Fiduciária, Hipoteca, Penhor, Avalista, Carta Fiança Bancária, Fiança, Seguro Garantia, Depósito Caução, Patrimônio Líquido, Sem Garantia)
|
|
||||||
- Valor da garantia
|
|
||||||
- Nome do fiador/avalista
|
|
||||||
- Descrição da garantia
|
|
||||||
|
|
||||||
- **Seção Penalidades por Atraso**:
|
|
||||||
- Multa por atraso (%)
|
|
||||||
- Mora diária (%)
|
|
||||||
- Dias de tolerância antes de aplicar multa
|
|
||||||
|
|
||||||
- **Seção Dados do Bem** (para Leasing/Veículo):
|
|
||||||
- Valor do bem
|
|
||||||
- Valor residual (opção de compra)
|
|
||||||
- Descrição do bem
|
|
||||||
|
|
||||||
- **Seção Gestão Interna**:
|
|
||||||
- Dias de alerta antes do vencimento
|
|
||||||
- Número do documento (ref. interna)
|
|
||||||
- Responsável interno
|
|
||||||
- Notas internas (covenants, condições especiais)
|
|
||||||
|
|
||||||
- 📊 **Expansão do Banco de Dados**:
|
|
||||||
- 20+ novos campos na tabela `liability_accounts`
|
|
||||||
- Suporte a JSON para covenants complexos
|
|
||||||
- Campos específicos por jurisdição (EU, USA, Brasil)
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- 🎨 **UI do Wizard Melhorada**:
|
|
||||||
- Nova seção "Opciones Avanzadas" em acordeão colapsável
|
|
||||||
- Badge "Opcional" para indicar campos não obrigatórios
|
|
||||||
- Campos específicos aparecem dinamicamente (ex: Dados do Bem só para leasing/veículo)
|
|
||||||
- Step 5 (Confirmação) exibe campos avançados preenchidos
|
|
||||||
|
|
||||||
- 🔧 **API Atualizada**:
|
|
||||||
- `contractTypes()` agora retorna `index_types` e `guarantee_types`
|
|
||||||
- `storeWithWizard()` valida e aceita todos os campos avançados
|
|
||||||
|
|
||||||
|
|
||||||
## [1.54.0] - 2025-12-18
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- 🎯 **Sistema de Criação de Passivos Completo** - Wizard profissional para registro de contratos
|
|
||||||
- **Tipos de Contrato Suportados**:
|
|
||||||
- Préstamo Personal (Sistema PRICE)
|
|
||||||
- Financiación de Vehículo
|
|
||||||
- Hipoteca/Financiación Inmobiliaria (SAC ou PRICE)
|
|
||||||
- Tarjeta de Crédito
|
|
||||||
- Consorcio
|
|
||||||
- Leasing
|
|
||||||
- Descubierto/Cheque Especial
|
|
||||||
- Préstamo con Nómina
|
|
||||||
- Outro
|
|
||||||
|
|
||||||
- **Sistemas de Amortização**:
|
|
||||||
- PRICE (Cuota Fija) - Parcelas iguais, juros decrescentes
|
|
||||||
- SAC (Amortización Constante) - Amortização fixa, parcelas decrescentes
|
|
||||||
- Americano - Só juros durante o prazo, principal no final
|
|
||||||
- Consorcio - Parcelas variáveis
|
|
||||||
|
|
||||||
- **Wizard de 5 Passos**:
|
|
||||||
1. Seleção do tipo de contrato (com ícones e descrições)
|
|
||||||
2. Dados básicos (nome, acreedor, contrato, moeda)
|
|
||||||
3. Valores e taxas (principal, taxas, amortização, carência, seguros)
|
|
||||||
4. Datas (início, primeiro vencimento) + Preview de parcelas
|
|
||||||
5. Confirmação com resumo completo
|
|
||||||
|
|
||||||
- **Preview Automático de Parcelas**:
|
|
||||||
- Calcula todas as parcelas baseado no sistema de amortização
|
|
||||||
- Mostra primeiras 6 e últimas 2 parcelas
|
|
||||||
- Exibe totais (valor total, juros total, parcela média)
|
|
||||||
|
|
||||||
- 📊 **Template Excel Profissional para Importação**
|
|
||||||
- Download direto via botão no frontend
|
|
||||||
- 3 abas: Parcelas, Instruções, Ejemplo
|
|
||||||
- Cabeçalhos formatados e validações de dados
|
|
||||||
- Exemplo preenchido com contrato PRICE real
|
|
||||||
- Instruções detalhadas sobre sistemas de amortização
|
|
||||||
- Suporta carência, seguros e taxas administrativas
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- 🔄 **UI Melhorada para Passivos**:
|
|
||||||
- Novo botão "Crear Pasivo" (verde) para abrir wizard
|
|
||||||
- Dropdown de importação com opções:
|
|
||||||
- Importar desde Excel
|
|
||||||
- Descargar Plantilla
|
|
||||||
- Estado vazio com 3 opções claras (Crear, Importar, Baixar Template)
|
|
||||||
|
|
||||||
- 📝 **Labels em Espanhol** - Status de contas e parcelas traduzidos:
|
|
||||||
- Activo, Liquidado, En mora, Renegociado
|
|
||||||
- Pendiente, Pagado, Parcial, Vencido, Cancelado
|
|
||||||
|
|
||||||
### Technical Details
|
|
||||||
- **Backend**:
|
|
||||||
- `LiabilityTemplateService.php` - Gera template Excel com PhpSpreadsheet
|
|
||||||
- `LiabilityAccount::CONTRACT_TYPES` - Constantes com metadata de cada tipo
|
|
||||||
- `LiabilityAccount::AMORTIZATION_SYSTEMS` - Sistemas de amortização suportados
|
|
||||||
- Migration: `add_contract_type_to_liability_accounts_table`
|
|
||||||
- Novos campos: `contract_type`, `amortization_system`, `has_grace_period`, `grace_period_months`
|
|
||||||
- Endpoint `POST /api/liability-accounts/wizard` - Criação via wizard
|
|
||||||
- Endpoint `GET /api/liability-accounts/template` - Download do template
|
|
||||||
- Endpoint `GET /api/liability-accounts/contract-types` - Lista tipos de contrato
|
|
||||||
- Geração automática de parcelas (PRICE, SAC, Americano)
|
|
||||||
|
|
||||||
- **Frontend**:
|
|
||||||
- `LiabilityWizard.jsx` - Componente de wizard com 5 passos
|
|
||||||
- Cálculo de parcelas em tempo real no frontend
|
|
||||||
- Download de arquivo Excel via Blob
|
|
||||||
- Integração com `liabilityAccountService`
|
|
||||||
|
|
||||||
### API Endpoints
|
|
||||||
```
|
|
||||||
GET /api/liability-accounts/template - Download template Excel
|
|
||||||
GET /api/liability-accounts/contract-types - Lista tipos de contrato
|
|
||||||
POST /api/liability-accounts/wizard - Criar conta via wizard
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [1.53.0] - 2025-12-17
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- 🔧 **Correção Middleware Admin** - Alterado de `admin` para `admin.only` nas rotas site-settings
|
|
||||||
- 🔧 **Correção Deploy cnxifly.com** - Controller atualizado para usar arquivos do servidor
|
|
||||||
- Páginas armazenadas em `/var/www/cnxifly-pages/`
|
|
||||||
- Deploy direto via `File::copy()` ao invés de instruções manuais
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- 📁 **Diretório de páginas no servidor** (`/var/www/cnxifly-pages/`)
|
|
||||||
- `index.html` - Página institucional live
|
|
||||||
- `maintenance.html` - Página de manutenção
|
|
||||||
- Permite trocar páginas via painel admin
|
|
||||||
|
|
||||||
### Technical Details
|
|
||||||
- Middleware correto: `admin.only` (registrado em bootstrap/app.php)
|
|
||||||
- SiteSettingsController agora copia arquivos diretamente no servidor
|
|
||||||
- Permissões ajustadas para www-data no cnxifly-pages
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [1.52.0] - 2025-12-17
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- 🌐 **Landing Page Institucional ConneXiFly** - Página completa para cnxifly.com
|
|
||||||
- **Produtos destacados**:
|
|
||||||
- WebMoney: Gestão financeira inteligente (Disponível)
|
|
||||||
- EZPool: Software de mantenimiento de piscinas (Próximamente)
|
|
||||||
- **Seções**:
|
|
||||||
- Hero com animações e gradientes
|
|
||||||
- Cards de produtos com preços
|
|
||||||
- Features do ConneXiFly
|
|
||||||
- CTA para registro
|
|
||||||
- Footer completo
|
|
||||||
- **Modal de notificação** para EZPool (pré-registro)
|
|
||||||
- **Design responsivo** e dark theme consistente
|
|
||||||
|
|
||||||
- 🔧 **Página de Manutenção** alternativa (`landing/maintenance.html`)
|
|
||||||
- Design simples para modo manutenção
|
|
||||||
- Links para produtos disponíveis
|
|
||||||
|
|
||||||
- ⚙️ **Painel Admin - Configurações do Site**
|
|
||||||
- **Backend**:
|
|
||||||
- `SiteSetting` model para configurações persistentes
|
|
||||||
- `SiteSettingsController` para gerenciar cnxifly.com
|
|
||||||
- Rotas admin para toggle de modo (live/maintenance)
|
|
||||||
- Migration para tabela `site_settings`
|
|
||||||
|
|
||||||
- **Frontend**:
|
|
||||||
- Nova página `/site-settings` (admin only)
|
|
||||||
- Toggle entre modo Live e Manutenção
|
|
||||||
- Preview links para todos os sites
|
|
||||||
- Instruções de deploy
|
|
||||||
|
|
||||||
- 📝 **Script de Deploy Landing** (`deploy-landing.sh`)
|
|
||||||
- Deploy automático para cnxifly.com
|
|
||||||
- Suporte a modos: live, maintenance
|
|
||||||
|
|
||||||
### Technical Details
|
|
||||||
- Arquivos criados em `/landing/` (index.html, maintenance.html)
|
|
||||||
- Bootstrap 5.3 + Bootstrap Icons para a landing
|
|
||||||
- Integração com menu do WebMoney admin
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [1.51.0] - 2025-12-17
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- 👥 **Gestão de Usuários Admin** - Interface completa para administradores criarem usuários
|
|
||||||
- **Backend**:
|
|
||||||
- `UserManagementController@store`: Novo endpoint para criar usuários
|
|
||||||
- Rota `POST /api/admin/users` para criação
|
|
||||||
- Suporte a tipos de usuário: Free, Pro, Admin
|
|
||||||
- Assinatura automática Pro/Admin (100 anos, sem PayPal)
|
|
||||||
|
|
||||||
- **Frontend**:
|
|
||||||
- Modal de criação de usuário em `/users`
|
|
||||||
- Seleção de tipo: Free (sem assinatura), Pro (com assinatura), Admin (admin + assinatura)
|
|
||||||
- Feedback visual após criação
|
|
||||||
|
|
||||||
### Tested
|
|
||||||
- 🧪 **Testes Completos do SaaS** - Validação do sistema de limites
|
|
||||||
- **Usuário Free testfree2@webmoney.test (ID: 4)**:
|
|
||||||
- ✅ Limite de contas: 1/1 → Bloqueia corretamente
|
|
||||||
- ✅ Limite de categorias: 10/10 → Bloqueia corretamente
|
|
||||||
- ✅ Limite de budgets: 3/3 → Bloqueia corretamente
|
|
||||||
- ✅ Mensagens de erro amigáveis em espanhol
|
|
||||||
- ✅ API retorna `usage` e `usage_percentages` corretos
|
|
||||||
- **Usuário Pro (admin)**:
|
|
||||||
- ✅ Limites `null` = ilimitado
|
|
||||||
- ✅ 173 categorias, 1204 transações sem bloqueio
|
|
||||||
|
|
||||||
### Technical Details
|
|
||||||
- Middleware `CheckPlanLimits` validado em produção
|
|
||||||
- Endpoint `/subscription/status` retorna uso atual corretamente
|
|
||||||
- Widget `PlanUsageWidget` funcional no Dashboard
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [1.50.0] - 2025-12-17
|
## [1.50.0] - 2025-12-17
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
BIN
Wanna.xlsx
BIN
Wanna.xlsx
Binary file not shown.
@ -1,119 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
|
||||||
|
|
||||||
use App\Models\LiabilityAccount;
|
|
||||||
use App\Models\LiabilityInstallment;
|
|
||||||
use Carbon\Carbon;
|
|
||||||
use Illuminate\Console\Command;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
|
|
||||||
class GenerateDemoInstallments extends Command
|
|
||||||
{
|
|
||||||
protected $signature = 'demo:generate-installments';
|
|
||||||
protected $description = 'Gerar parcelas de exemplo para passivos DEMO que não têm parcelas';
|
|
||||||
|
|
||||||
public function handle(): int
|
|
||||||
{
|
|
||||||
$this->info('Verificando passivos sem parcelas...');
|
|
||||||
|
|
||||||
// Buscar passivos que têm total_installments > 0 mas não têm parcelas
|
|
||||||
$accounts = LiabilityAccount::withCount('installments')
|
|
||||||
->having('installments_count', '=', 0)
|
|
||||||
->where('total_installments', '>', 0)
|
|
||||||
->get();
|
|
||||||
|
|
||||||
if ($accounts->isEmpty()) {
|
|
||||||
$this->info('Todos os passivos já têm parcelas geradas.');
|
|
||||||
return Command::SUCCESS;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->info("Encontrados {$accounts->count()} passivos sem parcelas.");
|
|
||||||
|
|
||||||
DB::beginTransaction();
|
|
||||||
try {
|
|
||||||
foreach ($accounts as $account) {
|
|
||||||
$this->generateInstallments($account);
|
|
||||||
}
|
|
||||||
DB::commit();
|
|
||||||
$this->info('✓ Parcelas geradas com sucesso!');
|
|
||||||
return Command::SUCCESS;
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
DB::rollBack();
|
|
||||||
$this->error('Erro: ' . $e->getMessage());
|
|
||||||
return Command::FAILURE;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function generateInstallments(LiabilityAccount $account): void
|
|
||||||
{
|
|
||||||
$this->info("Gerando parcelas para: {$account->name}");
|
|
||||||
|
|
||||||
$totalInstallments = $account->total_installments;
|
|
||||||
$paidInstallments = $account->paid_installments ?? 0;
|
|
||||||
$principal = $account->principal_amount;
|
|
||||||
$annualRate = $account->annual_interest_rate ?? 0;
|
|
||||||
$monthlyRate = $annualRate / 12 / 100;
|
|
||||||
$startDate = $account->first_due_date ?? $account->start_date ?? now();
|
|
||||||
|
|
||||||
if (is_string($startDate)) {
|
|
||||||
$startDate = Carbon::parse($startDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calcular parcela mensal (sistema PRICE)
|
|
||||||
if ($monthlyRate > 0) {
|
|
||||||
$pmt = $principal * ($monthlyRate * pow(1 + $monthlyRate, $totalInstallments)) /
|
|
||||||
(pow(1 + $monthlyRate, $totalInstallments) - 1);
|
|
||||||
} else {
|
|
||||||
$pmt = $principal / $totalInstallments;
|
|
||||||
}
|
|
||||||
|
|
||||||
$balance = $principal;
|
|
||||||
$installments = [];
|
|
||||||
|
|
||||||
for ($i = 1; $i <= $totalInstallments; $i++) {
|
|
||||||
$dueDate = $startDate->copy()->addMonths($i - 1);
|
|
||||||
|
|
||||||
// Calcular juros e capital
|
|
||||||
$interestAmount = $balance * $monthlyRate;
|
|
||||||
$principalPaid = $pmt - $interestAmount;
|
|
||||||
|
|
||||||
// Última parcela ajusta para zerar o saldo
|
|
||||||
if ($i === $totalInstallments) {
|
|
||||||
$principalPaid = $balance;
|
|
||||||
$pmt = $principalPaid + $interestAmount;
|
|
||||||
}
|
|
||||||
|
|
||||||
$balance -= $principalPaid;
|
|
||||||
|
|
||||||
// Determinar status
|
|
||||||
$isPaid = $i <= $paidInstallments;
|
|
||||||
$status = $isPaid ? 'paid' : ($dueDate->isPast() ? 'overdue' : 'pending');
|
|
||||||
|
|
||||||
$installments[] = [
|
|
||||||
'liability_account_id' => $account->id,
|
|
||||||
'installment_number' => $i,
|
|
||||||
'due_date' => $dueDate->format('Y-m-d'),
|
|
||||||
'installment_amount' => round($pmt, 2),
|
|
||||||
'principal_amount' => round($principalPaid, 2),
|
|
||||||
'interest_amount' => round($interestAmount, 2),
|
|
||||||
'fee_amount' => 0,
|
|
||||||
'paid_amount' => $isPaid ? round($pmt, 2) : 0,
|
|
||||||
'paid_date' => $isPaid ? $dueDate->format('Y-m-d') : null,
|
|
||||||
'status' => $status,
|
|
||||||
'created_at' => now(),
|
|
||||||
'updated_at' => now(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inserir em lotes
|
|
||||||
foreach (array_chunk($installments, 50) as $chunk) {
|
|
||||||
LiabilityInstallment::insert($chunk);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->info(" ✓ {$totalInstallments} parcelas criadas");
|
|
||||||
|
|
||||||
// Recalcular totais
|
|
||||||
$account->recalculateTotals();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,661 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
|
||||||
|
|
||||||
use App\Models\Account;
|
|
||||||
use App\Models\Category;
|
|
||||||
use App\Models\Transaction;
|
|
||||||
use App\Models\User;
|
|
||||||
use Carbon\Carbon;
|
|
||||||
use Illuminate\Console\Command;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
|
|
||||||
class PopulateDemoData extends Command
|
|
||||||
{
|
|
||||||
protected $signature = 'demo:populate {--fresh : Limpar dados existentes antes de popular}';
|
|
||||||
protected $description = 'Popular dados de demonstração (contas, categorias, transações) para o usuário DEMO';
|
|
||||||
|
|
||||||
private User $user;
|
|
||||||
private array $accounts = [];
|
|
||||||
private array $categories = [];
|
|
||||||
private array $subcategories = [];
|
|
||||||
|
|
||||||
public function handle(): int
|
|
||||||
{
|
|
||||||
$this->user = User::where('email', 'demo@webmoney.com')->first();
|
|
||||||
|
|
||||||
if (!$this->user) {
|
|
||||||
$this->error('Usuário demo@webmoney.com não encontrado!');
|
|
||||||
return Command::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->info("Populando dados para usuário DEMO (ID: {$this->user->id})...");
|
|
||||||
|
|
||||||
if ($this->option('fresh')) {
|
|
||||||
$this->clearExistingData();
|
|
||||||
}
|
|
||||||
|
|
||||||
DB::beginTransaction();
|
|
||||||
try {
|
|
||||||
$this->createAccounts();
|
|
||||||
$this->createCategories();
|
|
||||||
$this->createTransactions();
|
|
||||||
|
|
||||||
// Recalcular saldos das contas
|
|
||||||
$this->recalculateBalances();
|
|
||||||
|
|
||||||
DB::commit();
|
|
||||||
$this->newLine();
|
|
||||||
$this->info('✓ Dados DEMO populados com sucesso!');
|
|
||||||
$this->showSummary();
|
|
||||||
return Command::SUCCESS;
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
DB::rollBack();
|
|
||||||
$this->error('Erro: ' . $e->getMessage());
|
|
||||||
$this->error($e->getTraceAsString());
|
|
||||||
return Command::FAILURE;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function clearExistingData(): void
|
|
||||||
{
|
|
||||||
$this->info('Limpando dados existentes...');
|
|
||||||
Transaction::where('user_id', $this->user->id)->delete();
|
|
||||||
// Subcategorias são Category com parent_id
|
|
||||||
Category::where('user_id', $this->user->id)->whereNotNull('parent_id')->delete();
|
|
||||||
Category::where('user_id', $this->user->id)->whereNull('parent_id')->delete();
|
|
||||||
Account::where('user_id', $this->user->id)->delete();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function createAccounts(): void
|
|
||||||
{
|
|
||||||
$this->info('Criando contas...');
|
|
||||||
|
|
||||||
$accountsData = [
|
|
||||||
[
|
|
||||||
'name' => 'Cuenta Corriente Principal',
|
|
||||||
'type' => 'checking',
|
|
||||||
'bank_name' => 'Santander',
|
|
||||||
'account_number' => 'ES12 3456 7890 1234',
|
|
||||||
'initial_balance' => 5000.00,
|
|
||||||
'currency' => 'EUR',
|
|
||||||
'color' => '#3B82F6',
|
|
||||||
'icon' => 'bi-bank',
|
|
||||||
'is_active' => true,
|
|
||||||
'include_in_total' => true,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'name' => 'Cuenta de Ahorro',
|
|
||||||
'type' => 'savings',
|
|
||||||
'bank_name' => 'BBVA',
|
|
||||||
'account_number' => 'ES98 7654 3210 9876',
|
|
||||||
'initial_balance' => 15000.00,
|
|
||||||
'currency' => 'EUR',
|
|
||||||
'color' => '#10B981',
|
|
||||||
'icon' => 'bi-piggy-bank',
|
|
||||||
'is_active' => true,
|
|
||||||
'include_in_total' => true,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'name' => 'Efectivo',
|
|
||||||
'type' => 'cash',
|
|
||||||
'bank_name' => null,
|
|
||||||
'account_number' => null,
|
|
||||||
'initial_balance' => 500.00,
|
|
||||||
'currency' => 'EUR',
|
|
||||||
'color' => '#F59E0B',
|
|
||||||
'icon' => 'bi-cash-stack',
|
|
||||||
'is_active' => true,
|
|
||||||
'include_in_total' => true,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($accountsData as $data) {
|
|
||||||
$account = Account::create([
|
|
||||||
'user_id' => $this->user->id,
|
|
||||||
'name' => $data['name'],
|
|
||||||
'type' => $data['type'],
|
|
||||||
'bank_name' => $data['bank_name'],
|
|
||||||
'account_number' => $data['account_number'],
|
|
||||||
'initial_balance' => $data['initial_balance'],
|
|
||||||
'current_balance' => $data['initial_balance'],
|
|
||||||
'currency' => $data['currency'],
|
|
||||||
'color' => $data['color'],
|
|
||||||
'icon' => $data['icon'],
|
|
||||||
'is_active' => $data['is_active'],
|
|
||||||
'include_in_total' => $data['include_in_total'],
|
|
||||||
]);
|
|
||||||
$this->accounts[$data['type']] = $account;
|
|
||||||
$this->info(" ✓ {$data['name']}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function createCategories(): void
|
|
||||||
{
|
|
||||||
$this->info('Criando categorias e subcategorias...');
|
|
||||||
|
|
||||||
$categoriesData = [
|
|
||||||
// DESPESAS (expense)
|
|
||||||
[
|
|
||||||
'name' => 'Vivienda',
|
|
||||||
'type' => 'debit',
|
|
||||||
'icon' => 'bi-house',
|
|
||||||
'color' => '#EF4444',
|
|
||||||
'subcategories' => ['Alquiler', 'Hipoteca', 'Comunidad', 'Seguro Hogar', 'Reparaciones', 'Muebles'],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'name' => 'Alimentación',
|
|
||||||
'type' => 'debit',
|
|
||||||
'icon' => 'bi-cart',
|
|
||||||
'color' => '#F97316',
|
|
||||||
'subcategories' => ['Supermercado', 'Restaurantes', 'Cafeterías', 'Delivery', 'Panadería'],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'name' => 'Transporte',
|
|
||||||
'type' => 'debit',
|
|
||||||
'icon' => 'bi-car-front',
|
|
||||||
'color' => '#8B5CF6',
|
|
||||||
'subcategories' => ['Combustible', 'Transporte Público', 'Taxi/Uber', 'Mantenimiento Coche', 'Parking', 'Seguro Coche'],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'name' => 'Servicios',
|
|
||||||
'type' => 'debit',
|
|
||||||
'icon' => 'bi-lightning',
|
|
||||||
'color' => '#EC4899',
|
|
||||||
'subcategories' => ['Electricidad', 'Gas', 'Agua', 'Internet', 'Teléfono', 'Streaming'],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'name' => 'Salud',
|
|
||||||
'type' => 'debit',
|
|
||||||
'icon' => 'bi-heart-pulse',
|
|
||||||
'color' => '#14B8A6',
|
|
||||||
'subcategories' => ['Médico', 'Farmacia', 'Dentista', 'Óptica', 'Gimnasio', 'Seguro Médico'],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'name' => 'Ocio',
|
|
||||||
'type' => 'debit',
|
|
||||||
'icon' => 'bi-controller',
|
|
||||||
'color' => '#6366F1',
|
|
||||||
'subcategories' => ['Cine', 'Conciertos', 'Viajes', 'Hobbies', 'Libros', 'Videojuegos'],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'name' => 'Ropa',
|
|
||||||
'type' => 'debit',
|
|
||||||
'icon' => 'bi-bag',
|
|
||||||
'color' => '#A855F7',
|
|
||||||
'subcategories' => ['Ropa', 'Calzado', 'Accesorios', 'Ropa Deportiva'],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'name' => 'Educación',
|
|
||||||
'type' => 'debit',
|
|
||||||
'icon' => 'bi-book',
|
|
||||||
'color' => '#0EA5E9',
|
|
||||||
'subcategories' => ['Cursos', 'Material Escolar', 'Idiomas', 'Certificaciones'],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'name' => 'Mascotas',
|
|
||||||
'type' => 'debit',
|
|
||||||
'icon' => 'bi-piggy-bank',
|
|
||||||
'color' => '#84CC16',
|
|
||||||
'subcategories' => ['Comida Mascota', 'Veterinario', 'Accesorios Mascota'],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'name' => 'Otros Gastos',
|
|
||||||
'type' => 'debit',
|
|
||||||
'icon' => 'bi-three-dots',
|
|
||||||
'color' => '#64748B',
|
|
||||||
'subcategories' => ['Regalos', 'Donaciones', 'Imprevistos', 'Varios'],
|
|
||||||
],
|
|
||||||
// INGRESOS (income)
|
|
||||||
[
|
|
||||||
'name' => 'Salario',
|
|
||||||
'type' => 'credit',
|
|
||||||
'icon' => 'bi-briefcase',
|
|
||||||
'color' => '#22C55E',
|
|
||||||
'subcategories' => ['Nómina', 'Horas Extra', 'Bonus', 'Comisiones'],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'name' => 'Inversiones',
|
|
||||||
'type' => 'credit',
|
|
||||||
'icon' => 'bi-graph-up-arrow',
|
|
||||||
'color' => '#10B981',
|
|
||||||
'subcategories' => ['Dividendos', 'Intereses', 'Plusvalías', 'Alquileres'],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'name' => 'Freelance',
|
|
||||||
'type' => 'credit',
|
|
||||||
'icon' => 'bi-laptop',
|
|
||||||
'color' => '#06B6D4',
|
|
||||||
'subcategories' => ['Proyectos', 'Consultoría', 'Clases Particulares'],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'name' => 'Otros Ingresos',
|
|
||||||
'type' => 'credit',
|
|
||||||
'icon' => 'bi-plus-circle',
|
|
||||||
'color' => '#84CC16',
|
|
||||||
'subcategories' => ['Reembolsos', 'Ventas', 'Premios', 'Herencias'],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($categoriesData as $catData) {
|
|
||||||
$category = Category::create([
|
|
||||||
'user_id' => $this->user->id,
|
|
||||||
'name' => $catData['name'],
|
|
||||||
'type' => $catData['type'],
|
|
||||||
'icon' => $catData['icon'],
|
|
||||||
'color' => $catData['color'],
|
|
||||||
'is_active' => true,
|
|
||||||
'parent_id' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->categories[$catData['name']] = $category;
|
|
||||||
|
|
||||||
// Subcategorias são Category com parent_id
|
|
||||||
foreach ($catData['subcategories'] as $subName) {
|
|
||||||
$sub = Category::create([
|
|
||||||
'user_id' => $this->user->id,
|
|
||||||
'parent_id' => $category->id,
|
|
||||||
'name' => $subName,
|
|
||||||
'type' => $catData['type'],
|
|
||||||
'icon' => $catData['icon'],
|
|
||||||
'color' => $catData['color'],
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
$this->subcategories[$subName] = $sub;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->info(" ✓ {$catData['name']} ({$catData['type']}) - " . count($catData['subcategories']) . " subcategorias");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function createTransactions(): void
|
|
||||||
{
|
|
||||||
$this->info('Criando transações de 2025-2026...');
|
|
||||||
|
|
||||||
$checkingAccount = $this->accounts['checking'];
|
|
||||||
$savingsAccount = $this->accounts['savings'];
|
|
||||||
$cashAccount = $this->accounts['cash'];
|
|
||||||
|
|
||||||
$transactionCount = 0;
|
|
||||||
$today = Carbon::today();
|
|
||||||
|
|
||||||
// Helper para determinar status baseado na data
|
|
||||||
$getStatus = function(Carbon $date) use ($today) {
|
|
||||||
return $date->isAfter($today) ? 'pending' : 'effective';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Gerar transações de Janeiro 2025 a Março 2026
|
|
||||||
for ($i = 1; $i <= 15; $i++) { // 12 meses de 2025 + 3 de 2026
|
|
||||||
$year = $i <= 12 ? 2025 : 2026;
|
|
||||||
$month = $i <= 12 ? $i : $i - 12;
|
|
||||||
|
|
||||||
$daysInMonth = Carbon::create($year, $month)->daysInMonth;
|
|
||||||
|
|
||||||
// RECEITAS FIXAS (mensais)
|
|
||||||
// Salário - dia 28 ou último dia útil
|
|
||||||
$salaryDay = min(28, $daysInMonth);
|
|
||||||
$salaryDate = Carbon::create($year, $month, $salaryDay);
|
|
||||||
$this->createTransaction([
|
|
||||||
'account_id' => $checkingAccount->id,
|
|
||||||
'category' => 'Salario',
|
|
||||||
'subcategory' => 'Nómina',
|
|
||||||
'type' => 'credit',
|
|
||||||
'amount' => 3200.00,
|
|
||||||
'date' => $salaryDate,
|
|
||||||
'status' => $getStatus($salaryDate),
|
|
||||||
'description' => 'Salario mensual',
|
|
||||||
]);
|
|
||||||
$transactionCount++;
|
|
||||||
|
|
||||||
// DESPESAS FIXAS (mensais)
|
|
||||||
// Aluguel - dia 1
|
|
||||||
$rentDate = Carbon::create($year, $month, 1);
|
|
||||||
$this->createTransaction([
|
|
||||||
'account_id' => $checkingAccount->id,
|
|
||||||
'category' => 'Vivienda',
|
|
||||||
'subcategory' => 'Alquiler',
|
|
||||||
'type' => 'debit',
|
|
||||||
'amount' => 850.00,
|
|
||||||
'date' => $rentDate,
|
|
||||||
'status' => $getStatus($rentDate),
|
|
||||||
'description' => 'Alquiler apartamento',
|
|
||||||
]);
|
|
||||||
$transactionCount++;
|
|
||||||
|
|
||||||
// Serviços - vários dias do mês
|
|
||||||
$services = [
|
|
||||||
['subcategory' => 'Electricidad', 'amount' => rand(45, 85), 'day' => 5],
|
|
||||||
['subcategory' => 'Gas', 'amount' => rand(25, 55), 'day' => 8],
|
|
||||||
['subcategory' => 'Agua', 'amount' => rand(20, 35), 'day' => 10],
|
|
||||||
['subcategory' => 'Internet', 'amount' => 49.99, 'day' => 15],
|
|
||||||
['subcategory' => 'Teléfono', 'amount' => 25.00, 'day' => 15],
|
|
||||||
['subcategory' => 'Streaming', 'amount' => 17.99, 'day' => 20],
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($services as $service) {
|
|
||||||
if ($service['day'] <= $daysInMonth) {
|
|
||||||
$serviceDate = Carbon::create($year, $month, $service['day']);
|
|
||||||
$this->createTransaction([
|
|
||||||
'account_id' => $checkingAccount->id,
|
|
||||||
'category' => 'Servicios',
|
|
||||||
'subcategory' => $service['subcategory'],
|
|
||||||
'type' => 'debit',
|
|
||||||
'amount' => $service['amount'],
|
|
||||||
'date' => $serviceDate,
|
|
||||||
'status' => $getStatus($serviceDate),
|
|
||||||
'description' => $service['subcategory'],
|
|
||||||
]);
|
|
||||||
$transactionCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Supermercado - várias vezes por mês
|
|
||||||
$supermarketDays = [3, 10, 17, 24];
|
|
||||||
foreach ($supermarketDays as $day) {
|
|
||||||
if ($day <= $daysInMonth) {
|
|
||||||
$marketDate = Carbon::create($year, $month, $day);
|
|
||||||
$this->createTransaction([
|
|
||||||
'account_id' => $checkingAccount->id,
|
|
||||||
'category' => 'Alimentación',
|
|
||||||
'subcategory' => 'Supermercado',
|
|
||||||
'type' => 'debit',
|
|
||||||
'amount' => rand(60, 120),
|
|
||||||
'date' => $marketDate,
|
|
||||||
'status' => $getStatus($marketDate),
|
|
||||||
'description' => 'Compra supermercado',
|
|
||||||
]);
|
|
||||||
$transactionCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transporte - combustível e outros
|
|
||||||
$fuelDate = Carbon::create($year, $month, rand(1, min(15, $daysInMonth)));
|
|
||||||
$this->createTransaction([
|
|
||||||
'account_id' => $checkingAccount->id,
|
|
||||||
'category' => 'Transporte',
|
|
||||||
'subcategory' => 'Combustible',
|
|
||||||
'type' => 'debit',
|
|
||||||
'amount' => rand(50, 80),
|
|
||||||
'date' => $fuelDate,
|
|
||||||
'status' => $getStatus($fuelDate),
|
|
||||||
'description' => 'Gasolina',
|
|
||||||
]);
|
|
||||||
$transactionCount++;
|
|
||||||
|
|
||||||
// Restaurantes - algumas vezes por mês
|
|
||||||
$restaurantCount = rand(2, 4);
|
|
||||||
for ($j = 0; $j < $restaurantCount; $j++) {
|
|
||||||
$restaurantDate = Carbon::create($year, $month, rand(1, $daysInMonth));
|
|
||||||
$this->createTransaction([
|
|
||||||
'account_id' => $cashAccount->id,
|
|
||||||
'category' => 'Alimentación',
|
|
||||||
'subcategory' => 'Restaurantes',
|
|
||||||
'type' => 'debit',
|
|
||||||
'amount' => rand(25, 60),
|
|
||||||
'date' => $restaurantDate,
|
|
||||||
'status' => $getStatus($restaurantDate),
|
|
||||||
'description' => 'Cena/Almuerzo fuera',
|
|
||||||
]);
|
|
||||||
$transactionCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Café - várias vezes
|
|
||||||
$coffeeCount = rand(5, 10);
|
|
||||||
for ($j = 0; $j < $coffeeCount; $j++) {
|
|
||||||
$coffeeDate = Carbon::create($year, $month, rand(1, $daysInMonth));
|
|
||||||
$this->createTransaction([
|
|
||||||
'account_id' => $cashAccount->id,
|
|
||||||
'category' => 'Alimentación',
|
|
||||||
'subcategory' => 'Cafeterías',
|
|
||||||
'type' => 'debit',
|
|
||||||
'amount' => rand(3, 8),
|
|
||||||
'date' => $coffeeDate,
|
|
||||||
'status' => $getStatus($coffeeDate),
|
|
||||||
'description' => 'Café',
|
|
||||||
]);
|
|
||||||
$transactionCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retirada de cajero para efectivo (do banco para cash)
|
|
||||||
$atmDate = Carbon::create($year, $month, rand(1, min(5, $daysInMonth)));
|
|
||||||
$this->createTransaction([
|
|
||||||
'account_id' => $checkingAccount->id,
|
|
||||||
'category' => 'Otros Gastos',
|
|
||||||
'subcategory' => 'Varios',
|
|
||||||
'type' => 'debit',
|
|
||||||
'amount' => 200.00,
|
|
||||||
'date' => $atmDate,
|
|
||||||
'status' => $getStatus($atmDate),
|
|
||||||
'description' => 'Retiro cajero automático',
|
|
||||||
]);
|
|
||||||
$transactionCount++;
|
|
||||||
|
|
||||||
$this->createTransaction([
|
|
||||||
'account_id' => $cashAccount->id,
|
|
||||||
'category' => 'Otros Ingresos',
|
|
||||||
'subcategory' => 'Reembolsos',
|
|
||||||
'type' => 'credit',
|
|
||||||
'amount' => 200.00,
|
|
||||||
'date' => $atmDate,
|
|
||||||
'status' => $getStatus($atmDate),
|
|
||||||
'description' => 'Retiro cajero automático',
|
|
||||||
]);
|
|
||||||
$transactionCount++;
|
|
||||||
|
|
||||||
// Saúde - eventual
|
|
||||||
if (rand(1, 3) == 1) {
|
|
||||||
$healthDate = Carbon::create($year, $month, rand(1, $daysInMonth));
|
|
||||||
$healthSubs = ['Farmacia', 'Médico', 'Gimnasio'];
|
|
||||||
$this->createTransaction([
|
|
||||||
'account_id' => $checkingAccount->id,
|
|
||||||
'category' => 'Salud',
|
|
||||||
'subcategory' => $healthSubs[array_rand($healthSubs)],
|
|
||||||
'type' => 'debit',
|
|
||||||
'amount' => rand(15, 80),
|
|
||||||
'date' => $healthDate,
|
|
||||||
'status' => $getStatus($healthDate),
|
|
||||||
'description' => 'Gasto salud',
|
|
||||||
]);
|
|
||||||
$transactionCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ginásio - mensal
|
|
||||||
$gymDate = Carbon::create($year, $month, 1);
|
|
||||||
$this->createTransaction([
|
|
||||||
'account_id' => $checkingAccount->id,
|
|
||||||
'category' => 'Salud',
|
|
||||||
'subcategory' => 'Gimnasio',
|
|
||||||
'type' => 'debit',
|
|
||||||
'amount' => 35.00,
|
|
||||||
'date' => $gymDate,
|
|
||||||
'status' => $getStatus($gymDate),
|
|
||||||
'description' => 'Cuota gimnasio',
|
|
||||||
]);
|
|
||||||
$transactionCount++;
|
|
||||||
|
|
||||||
// Lazer - algumas vezes
|
|
||||||
if (rand(1, 2) == 1) {
|
|
||||||
$leisureDate = Carbon::create($year, $month, rand(1, $daysInMonth));
|
|
||||||
$leisureSubs = ['Cine', 'Conciertos', 'Hobbies', 'Libros', 'Videojuegos'];
|
|
||||||
$this->createTransaction([
|
|
||||||
'account_id' => $checkingAccount->id,
|
|
||||||
'category' => 'Ocio',
|
|
||||||
'subcategory' => $leisureSubs[array_rand($leisureSubs)],
|
|
||||||
'type' => 'debit',
|
|
||||||
'amount' => rand(15, 60),
|
|
||||||
'date' => $leisureDate,
|
|
||||||
'status' => $getStatus($leisureDate),
|
|
||||||
'description' => 'Entretenimiento',
|
|
||||||
]);
|
|
||||||
$transactionCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Roupa - eventual
|
|
||||||
if (rand(1, 4) == 1) {
|
|
||||||
$clothesDate = Carbon::create($year, $month, rand(1, $daysInMonth));
|
|
||||||
$this->createTransaction([
|
|
||||||
'account_id' => $checkingAccount->id,
|
|
||||||
'category' => 'Ropa',
|
|
||||||
'subcategory' => 'Ropa',
|
|
||||||
'type' => 'debit',
|
|
||||||
'amount' => rand(30, 120),
|
|
||||||
'date' => $clothesDate,
|
|
||||||
'status' => $getStatus($clothesDate),
|
|
||||||
'description' => 'Compra ropa',
|
|
||||||
]);
|
|
||||||
$transactionCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Freelance - eventual (2-3 vezes por trimestre)
|
|
||||||
if ($month % 3 == 0 || rand(1, 5) == 1) {
|
|
||||||
$freelanceDate = Carbon::create($year, $month, rand(10, min(25, $daysInMonth)));
|
|
||||||
$this->createTransaction([
|
|
||||||
'account_id' => $checkingAccount->id,
|
|
||||||
'category' => 'Freelance',
|
|
||||||
'subcategory' => 'Proyectos',
|
|
||||||
'type' => 'credit',
|
|
||||||
'amount' => rand(200, 800),
|
|
||||||
'date' => $freelanceDate,
|
|
||||||
'status' => $getStatus($freelanceDate),
|
|
||||||
'description' => 'Proyecto freelance',
|
|
||||||
]);
|
|
||||||
$transactionCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dividendos - trimestral
|
|
||||||
if ($month % 3 == 0) {
|
|
||||||
$dividendDate = Carbon::create($year, $month, 15);
|
|
||||||
$this->createTransaction([
|
|
||||||
'account_id' => $savingsAccount->id,
|
|
||||||
'category' => 'Inversiones',
|
|
||||||
'subcategory' => 'Dividendos',
|
|
||||||
'type' => 'credit',
|
|
||||||
'amount' => rand(50, 150),
|
|
||||||
'date' => $dividendDate,
|
|
||||||
'status' => $getStatus($dividendDate),
|
|
||||||
'description' => 'Dividendos trimestre',
|
|
||||||
]);
|
|
||||||
$transactionCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Juros poupança - mensal
|
|
||||||
$interestDate = Carbon::create($year, $month, $daysInMonth);
|
|
||||||
$this->createTransaction([
|
|
||||||
'account_id' => $savingsAccount->id,
|
|
||||||
'category' => 'Inversiones',
|
|
||||||
'subcategory' => 'Intereses',
|
|
||||||
'type' => 'credit',
|
|
||||||
'amount' => round(rand(15, 35) + (rand(0, 99) / 100), 2),
|
|
||||||
'date' => $interestDate,
|
|
||||||
'status' => $getStatus($interestDate),
|
|
||||||
'description' => 'Intereses cuenta ahorro',
|
|
||||||
]);
|
|
||||||
$transactionCount++;
|
|
||||||
|
|
||||||
// Transferência para poupança - mensal
|
|
||||||
if (rand(1, 2) == 1) {
|
|
||||||
$transferDate = Carbon::create($year, $month, rand(25, min(28, $daysInMonth)));
|
|
||||||
$transferAmount = rand(200, 500);
|
|
||||||
$this->createTransaction([
|
|
||||||
'account_id' => $checkingAccount->id,
|
|
||||||
'category' => 'Otros Gastos',
|
|
||||||
'subcategory' => 'Varios',
|
|
||||||
'type' => 'debit',
|
|
||||||
'amount' => $transferAmount,
|
|
||||||
'date' => $transferDate,
|
|
||||||
'status' => $getStatus($transferDate),
|
|
||||||
'description' => 'Transferencia a cuenta ahorro',
|
|
||||||
]);
|
|
||||||
$transactionCount++;
|
|
||||||
|
|
||||||
$this->createTransaction([
|
|
||||||
'account_id' => $savingsAccount->id,
|
|
||||||
'category' => 'Otros Ingresos',
|
|
||||||
'subcategory' => 'Reembolsos',
|
|
||||||
'type' => 'credit',
|
|
||||||
'amount' => $transferAmount,
|
|
||||||
'date' => $transferDate,
|
|
||||||
'status' => $getStatus($transferDate),
|
|
||||||
'description' => 'Transferencia desde cuenta corriente',
|
|
||||||
]);
|
|
||||||
$transactionCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->info(" ✓ Mes $month/$year procesado");
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->info(" Total: $transactionCount transacciones creadas");
|
|
||||||
}
|
|
||||||
|
|
||||||
private function createTransaction(array $data): Transaction
|
|
||||||
{
|
|
||||||
$category = $this->categories[$data['category']] ?? null;
|
|
||||||
// Subcategoria é usada diretamente como category_id (pois são Categories com parent_id)
|
|
||||||
$subcategory = $this->subcategories[$data['subcategory']] ?? null;
|
|
||||||
|
|
||||||
// Se tiver subcategoria, usa ela; senão usa a categoria pai
|
|
||||||
$categoryId = $subcategory?->id ?? $category?->id;
|
|
||||||
|
|
||||||
// Status vem do data ou default 'effective'
|
|
||||||
$status = $data['status'] ?? 'effective';
|
|
||||||
|
|
||||||
// Para transações pendentes, não definir effective_date
|
|
||||||
$effectiveDate = $status === 'pending' ? null : $data['date'];
|
|
||||||
|
|
||||||
return Transaction::create([
|
|
||||||
'user_id' => $this->user->id,
|
|
||||||
'account_id' => $data['account_id'],
|
|
||||||
'category_id' => $categoryId,
|
|
||||||
'type' => $data['type'],
|
|
||||||
'amount' => $status === 'pending' ? null : $data['amount'],
|
|
||||||
'planned_amount' => $data['amount'],
|
|
||||||
'planned_date' => $data['date'],
|
|
||||||
'effective_date' => $effectiveDate,
|
|
||||||
'description' => $data['description'],
|
|
||||||
'notes' => null,
|
|
||||||
'is_recurring' => false,
|
|
||||||
'status' => $status,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function recalculateBalances(): void
|
|
||||||
{
|
|
||||||
$this->info('Recalculando saldos das contas...');
|
|
||||||
|
|
||||||
foreach ($this->accounts as $account) {
|
|
||||||
// Apenas transações efetivas afetam o saldo atual
|
|
||||||
$income = Transaction::where('account_id', $account->id)
|
|
||||||
->where('type', 'credit')
|
|
||||||
->where('status', 'effective')
|
|
||||||
->sum('amount');
|
|
||||||
|
|
||||||
$expense = Transaction::where('account_id', $account->id)
|
|
||||||
->where('type', 'debit')
|
|
||||||
->where('status', 'effective')
|
|
||||||
->sum('amount');
|
|
||||||
|
|
||||||
$newBalance = $account->initial_balance + $income - $expense;
|
|
||||||
|
|
||||||
$account->update(['current_balance' => $newBalance]);
|
|
||||||
|
|
||||||
$this->info(" ✓ {$account->name}: €" . number_format($newBalance, 2));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function showSummary(): void
|
|
||||||
{
|
|
||||||
$this->newLine();
|
|
||||||
$this->info('=== RESUMO ===');
|
|
||||||
$this->info('Contas: ' . count($this->accounts));
|
|
||||||
$this->info('Categorias: ' . count($this->categories));
|
|
||||||
$this->info('Subcategorias: ' . count($this->subcategories));
|
|
||||||
$this->info('Transações: ' . Transaction::where('user_id', $this->user->id)->count());
|
|
||||||
|
|
||||||
$this->newLine();
|
|
||||||
$this->info('Saldos finais:');
|
|
||||||
foreach ($this->accounts as $account) {
|
|
||||||
$account->refresh();
|
|
||||||
$this->info(" {$account->name}: €" . number_format($account->current_balance, 2));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,189 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
|
||||||
|
|
||||||
use App\Models\Plan;
|
|
||||||
use App\Services\PayPalService;
|
|
||||||
use Illuminate\Console\Command;
|
|
||||||
|
|
||||||
class SetupPayPalPlans extends Command
|
|
||||||
{
|
|
||||||
protected $signature = 'paypal:setup-plans';
|
|
||||||
protected $description = 'Create products and billing plans in PayPal for all active plans';
|
|
||||||
|
|
||||||
public function handle()
|
|
||||||
{
|
|
||||||
$this->info('🚀 Starting PayPal Plans Setup...');
|
|
||||||
$this->newLine();
|
|
||||||
|
|
||||||
$paypal = new PayPalService();
|
|
||||||
|
|
||||||
// Test authentication first
|
|
||||||
$this->info('Testing PayPal authentication...');
|
|
||||||
try {
|
|
||||||
$token = $paypal->getAccessToken();
|
|
||||||
$this->info('✅ PayPal authentication successful!');
|
|
||||||
$this->newLine();
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
$this->error('❌ PayPal authentication failed: ' . $e->getMessage());
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get plans that don't have PayPal plan IDs yet
|
|
||||||
$plans = Plan::where('is_active', true)
|
|
||||||
->where('is_free', false)
|
|
||||||
->get();
|
|
||||||
|
|
||||||
if ($plans->isEmpty()) {
|
|
||||||
$this->warn('No paid plans found in database.');
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->info("Found {$plans->count()} paid plan(s) to setup in PayPal:");
|
|
||||||
$this->newLine();
|
|
||||||
|
|
||||||
foreach ($plans as $plan) {
|
|
||||||
$this->info("📦 Processing: {$plan->name}");
|
|
||||||
|
|
||||||
// Check if already has PayPal plan ID
|
|
||||||
if ($plan->paypal_plan_id) {
|
|
||||||
$this->warn(" Already has PayPal Plan ID: {$plan->paypal_plan_id}");
|
|
||||||
$this->line(" Skipping...");
|
|
||||||
$this->newLine();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Step 1: Create Product in PayPal
|
|
||||||
$this->line(" Creating product in PayPal...");
|
|
||||||
$product = $this->createProduct($paypal, $plan);
|
|
||||||
$this->info(" ✅ Product created: {$product['id']}");
|
|
||||||
|
|
||||||
// Step 2: Create Billing Plan in PayPal
|
|
||||||
$this->line(" Creating billing plan in PayPal...");
|
|
||||||
$billingPlan = $this->createBillingPlan($paypal, $plan, $product['id']);
|
|
||||||
$this->info(" ✅ Billing Plan created: {$billingPlan['id']}");
|
|
||||||
|
|
||||||
// Step 3: Save PayPal Plan ID to database
|
|
||||||
$plan->paypal_plan_id = $billingPlan['id'];
|
|
||||||
$plan->save();
|
|
||||||
$this->info(" ✅ Saved to database!");
|
|
||||||
|
|
||||||
$this->newLine();
|
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
$this->error(" ❌ Error: " . $e->getMessage());
|
|
||||||
$this->newLine();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->info('🎉 PayPal Plans Setup completed!');
|
|
||||||
$this->newLine();
|
|
||||||
|
|
||||||
// Show summary
|
|
||||||
$this->table(
|
|
||||||
['Plan', 'Price', 'Billing', 'PayPal Plan ID'],
|
|
||||||
Plan::where('is_free', false)->get()->map(function ($p) {
|
|
||||||
return [
|
|
||||||
$p->name,
|
|
||||||
'€' . number_format($p->price, 2),
|
|
||||||
$p->billing_period,
|
|
||||||
$p->paypal_plan_id ?? 'Not set'
|
|
||||||
];
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function createProduct(PayPalService $paypal, Plan $plan): array
|
|
||||||
{
|
|
||||||
$baseUrl = config('services.paypal.mode') === 'sandbox'
|
|
||||||
? 'https://api-m.sandbox.paypal.com'
|
|
||||||
: 'https://api-m.paypal.com';
|
|
||||||
|
|
||||||
// Get fresh token for this request
|
|
||||||
\Illuminate\Support\Facades\Cache::forget('paypal_access_token');
|
|
||||||
$token = $paypal->getAccessToken();
|
|
||||||
|
|
||||||
$response = \Illuminate\Support\Facades\Http::withToken($token)
|
|
||||||
->post("{$baseUrl}/v1/catalogs/products", [
|
|
||||||
'name' => "WEBMoney - {$plan->name}",
|
|
||||||
'description' => $plan->description ?? "Subscription plan for WEBMoney",
|
|
||||||
'type' => 'SERVICE',
|
|
||||||
'category' => 'SOFTWARE',
|
|
||||||
'home_url' => config('app.url'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!$response->successful()) {
|
|
||||||
throw new \Exception('Failed to create product: ' . $response->body());
|
|
||||||
}
|
|
||||||
|
|
||||||
return $response->json();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function createBillingPlan(PayPalService $paypal, Plan $plan, string $productId): array
|
|
||||||
{
|
|
||||||
$baseUrl = config('services.paypal.mode') === 'sandbox'
|
|
||||||
? 'https://api-m.sandbox.paypal.com'
|
|
||||||
: 'https://api-m.paypal.com';
|
|
||||||
|
|
||||||
$billingCycles = [];
|
|
||||||
|
|
||||||
// Add trial period if plan has trial
|
|
||||||
if ($plan->trial_days > 0) {
|
|
||||||
$billingCycles[] = [
|
|
||||||
'frequency' => [
|
|
||||||
'interval_unit' => 'DAY',
|
|
||||||
'interval_count' => $plan->trial_days,
|
|
||||||
],
|
|
||||||
'tenure_type' => 'TRIAL',
|
|
||||||
'sequence' => 1,
|
|
||||||
'total_cycles' => 1,
|
|
||||||
'pricing_scheme' => [
|
|
||||||
'fixed_price' => [
|
|
||||||
'value' => '0',
|
|
||||||
'currency_code' => $plan->currency,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regular billing cycle
|
|
||||||
$billingCycles[] = [
|
|
||||||
'frequency' => [
|
|
||||||
'interval_unit' => $plan->billing_period === 'annual' ? 'YEAR' : 'MONTH',
|
|
||||||
'interval_count' => 1,
|
|
||||||
],
|
|
||||||
'tenure_type' => 'REGULAR',
|
|
||||||
'sequence' => $plan->trial_days > 0 ? 2 : 1,
|
|
||||||
'total_cycles' => 0, // Infinite
|
|
||||||
'pricing_scheme' => [
|
|
||||||
'fixed_price' => [
|
|
||||||
'value' => number_format($plan->price, 2, '.', ''),
|
|
||||||
'currency_code' => $plan->currency,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
$response = \Illuminate\Support\Facades\Http::withToken($paypal->getAccessToken())
|
|
||||||
->post("{$baseUrl}/v1/billing/plans", [
|
|
||||||
'product_id' => $productId,
|
|
||||||
'name' => $plan->name,
|
|
||||||
'description' => $plan->description ?? "WEBMoney {$plan->name} subscription",
|
|
||||||
'status' => 'ACTIVE',
|
|
||||||
'billing_cycles' => $billingCycles,
|
|
||||||
'payment_preferences' => [
|
|
||||||
'auto_bill_outstanding' => true,
|
|
||||||
'setup_fee_failure_action' => 'CONTINUE',
|
|
||||||
'payment_failure_threshold' => 3,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!$response->successful()) {
|
|
||||||
throw new \Exception('Failed to create billing plan: ' . $response->body());
|
|
||||||
}
|
|
||||||
|
|
||||||
return $response->json();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,423 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Api;
|
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use App\Models\AssetAccount;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\Support\Facades\Validator;
|
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
|
|
||||||
class AssetAccountController extends Controller
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Listar todos os ativos do usuário
|
|
||||||
*/
|
|
||||||
public function index(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
$query = AssetAccount::where('user_id', Auth::id());
|
|
||||||
|
|
||||||
// Filtros
|
|
||||||
if ($request->has('asset_type') && $request->asset_type) {
|
|
||||||
$query->where('asset_type', $request->asset_type);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($request->has('status') && $request->status) {
|
|
||||||
$query->where('status', $request->status);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($request->has('search') && $request->search) {
|
|
||||||
$search = $request->search;
|
|
||||||
$query->where(function($q) use ($search) {
|
|
||||||
$q->where('name', 'like', "%{$search}%")
|
|
||||||
->orWhere('description', 'like', "%{$search}%")
|
|
||||||
->orWhere('document_number', 'like', "%{$search}%");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ordenação
|
|
||||||
$sortBy = $request->get('sort_by', 'created_at');
|
|
||||||
$sortDir = $request->get('sort_dir', 'desc');
|
|
||||||
$query->orderBy($sortBy, $sortDir);
|
|
||||||
|
|
||||||
// Paginação ou todos
|
|
||||||
if ($request->has('per_page')) {
|
|
||||||
$assets = $query->paginate($request->per_page);
|
|
||||||
} else {
|
|
||||||
$assets = $query->get();
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'success' => true,
|
|
||||||
'data' => $assets,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Criar novo ativo
|
|
||||||
*/
|
|
||||||
public function store(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
$validator = Validator::make($request->all(), [
|
|
||||||
'asset_type' => ['required', Rule::in(array_keys(AssetAccount::ASSET_TYPES))],
|
|
||||||
'name' => 'required|string|max:255',
|
|
||||||
'description' => 'nullable|string',
|
|
||||||
'currency' => 'required|string|size:3',
|
|
||||||
'color' => 'nullable|string|max:7',
|
|
||||||
'acquisition_value' => 'required|numeric|min:0',
|
|
||||||
'current_value' => 'required|numeric|min:0',
|
|
||||||
'acquisition_date' => 'nullable|date',
|
|
||||||
// Campos opcionais conforme tipo
|
|
||||||
'property_type' => 'nullable|string',
|
|
||||||
'investment_type' => 'nullable|string',
|
|
||||||
'depreciation_method' => 'nullable|string',
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($validator->fails()) {
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'errors' => $validator->errors(),
|
|
||||||
], 422);
|
|
||||||
}
|
|
||||||
|
|
||||||
$data = $request->all();
|
|
||||||
$data['user_id'] = Auth::id();
|
|
||||||
$data['business_id'] = $request->business_id ?? Auth::user()->businesses()->first()?->id;
|
|
||||||
|
|
||||||
$asset = AssetAccount::create($data);
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'success' => true,
|
|
||||||
'message' => 'Activo creado con éxito',
|
|
||||||
'data' => $asset,
|
|
||||||
], 201);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ver um ativo específico
|
|
||||||
*/
|
|
||||||
public function show(AssetAccount $assetAccount): JsonResponse
|
|
||||||
{
|
|
||||||
// Verificar se pertence ao usuário
|
|
||||||
if ($assetAccount->user_id !== Auth::id()) {
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'No autorizado',
|
|
||||||
], 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'success' => true,
|
|
||||||
'data' => $assetAccount->load('linkedLiability'),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Atualizar ativo
|
|
||||||
*/
|
|
||||||
public function update(Request $request, AssetAccount $assetAccount): JsonResponse
|
|
||||||
{
|
|
||||||
if ($assetAccount->user_id !== Auth::id()) {
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'No autorizado',
|
|
||||||
], 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$validator = Validator::make($request->all(), [
|
|
||||||
'asset_type' => ['nullable', Rule::in(array_keys(AssetAccount::ASSET_TYPES))],
|
|
||||||
'name' => 'nullable|string|max:255',
|
|
||||||
'current_value' => 'nullable|numeric|min:0',
|
|
||||||
'status' => ['nullable', Rule::in(array_keys(AssetAccount::STATUSES))],
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($validator->fails()) {
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'errors' => $validator->errors(),
|
|
||||||
], 422);
|
|
||||||
}
|
|
||||||
|
|
||||||
$assetAccount->update($request->all());
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'success' => true,
|
|
||||||
'message' => 'Activo actualizado con éxito',
|
|
||||||
'data' => $assetAccount->fresh(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletar ativo
|
|
||||||
*/
|
|
||||||
public function destroy(AssetAccount $assetAccount): JsonResponse
|
|
||||||
{
|
|
||||||
if ($assetAccount->user_id !== Auth::id()) {
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'No autorizado',
|
|
||||||
], 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$assetAccount->delete();
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'success' => true,
|
|
||||||
'message' => 'Activo eliminado con éxito',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retornar tipos de ativos e opções para o wizard
|
|
||||||
*/
|
|
||||||
public function assetTypes(): JsonResponse
|
|
||||||
{
|
|
||||||
return response()->json([
|
|
||||||
'success' => true,
|
|
||||||
'data' => AssetAccount::ASSET_TYPES,
|
|
||||||
'property_types' => AssetAccount::PROPERTY_TYPES,
|
|
||||||
'investment_types' => AssetAccount::INVESTMENT_TYPES,
|
|
||||||
'depreciation_methods' => AssetAccount::DEPRECIATION_METHODS,
|
|
||||||
'index_types' => AssetAccount::INDEX_TYPES,
|
|
||||||
'statuses' => AssetAccount::STATUSES,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Criar ativo via wizard
|
|
||||||
*/
|
|
||||||
public function storeWithWizard(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
$rules = [
|
|
||||||
// Step 1 - Tipo
|
|
||||||
'asset_type' => ['required', Rule::in(array_keys(AssetAccount::ASSET_TYPES))],
|
|
||||||
|
|
||||||
// Step 2 - Dados básicos
|
|
||||||
'name' => 'required|string|max:255',
|
|
||||||
'description' => 'nullable|string|max:1000',
|
|
||||||
'currency' => 'required|string|size:3',
|
|
||||||
'color' => 'nullable|string|max:7',
|
|
||||||
|
|
||||||
// Step 3 - Valores
|
|
||||||
'acquisition_value' => 'required|numeric|min:0',
|
|
||||||
'current_value' => 'required|numeric|min:0',
|
|
||||||
'acquisition_date' => 'nullable|date',
|
|
||||||
|
|
||||||
// Depreciação
|
|
||||||
'is_depreciable' => 'nullable|boolean',
|
|
||||||
'depreciation_method' => ['nullable', Rule::in(array_keys(AssetAccount::DEPRECIATION_METHODS))],
|
|
||||||
'useful_life_years' => 'nullable|numeric|min:0.5|max:100',
|
|
||||||
'residual_value' => 'nullable|numeric|min:0',
|
|
||||||
|
|
||||||
// Imóveis
|
|
||||||
'property_type' => ['nullable', Rule::in(array_keys(AssetAccount::PROPERTY_TYPES))],
|
|
||||||
'address' => 'nullable|string|max:500',
|
|
||||||
'city' => 'nullable|string|max:100',
|
|
||||||
'state' => 'nullable|string|max:100',
|
|
||||||
'postal_code' => 'nullable|string|max:20',
|
|
||||||
'country' => 'nullable|string|size:2',
|
|
||||||
'property_area_m2' => 'nullable|numeric|min:0',
|
|
||||||
'registry_number' => 'nullable|string|max:100',
|
|
||||||
|
|
||||||
// Veículos
|
|
||||||
'vehicle_brand' => 'nullable|string|max:100',
|
|
||||||
'vehicle_model' => 'nullable|string|max:100',
|
|
||||||
'vehicle_year' => 'nullable|integer|min:1900|max:2100',
|
|
||||||
'vehicle_plate' => 'nullable|string|max:20',
|
|
||||||
'vehicle_vin' => 'nullable|string|max:50',
|
|
||||||
'vehicle_mileage' => 'nullable|integer|min:0',
|
|
||||||
|
|
||||||
// Investimentos
|
|
||||||
'investment_type' => ['nullable', Rule::in(array_keys(AssetAccount::INVESTMENT_TYPES))],
|
|
||||||
'institution' => 'nullable|string|max:100',
|
|
||||||
'account_number' => 'nullable|string|max:100',
|
|
||||||
'quantity' => 'nullable|integer|min:0',
|
|
||||||
'unit_price' => 'nullable|numeric|min:0',
|
|
||||||
'ticker' => 'nullable|string|max:20',
|
|
||||||
'maturity_date' => 'nullable|date',
|
|
||||||
'interest_rate' => 'nullable|numeric|min:0|max:100',
|
|
||||||
'index_type' => ['nullable', Rule::in(array_keys(AssetAccount::INDEX_TYPES))],
|
|
||||||
|
|
||||||
// Equipamentos
|
|
||||||
'equipment_brand' => 'nullable|string|max:100',
|
|
||||||
'equipment_model' => 'nullable|string|max:100',
|
|
||||||
'serial_number' => 'nullable|string|max:100',
|
|
||||||
'warranty_expiry' => 'nullable|date',
|
|
||||||
|
|
||||||
// Recebíveis
|
|
||||||
'debtor_name' => 'nullable|string|max:200',
|
|
||||||
'debtor_document' => 'nullable|string|max:50',
|
|
||||||
'receivable_due_date' => 'nullable|date',
|
|
||||||
'receivable_amount' => 'nullable|numeric|min:0',
|
|
||||||
|
|
||||||
// Garantias
|
|
||||||
'is_collateral' => 'nullable|boolean',
|
|
||||||
'collateral_for' => 'nullable|string|max:200',
|
|
||||||
'linked_liability_id' => 'nullable|integer|exists:liability_accounts,id',
|
|
||||||
|
|
||||||
// Seguros
|
|
||||||
'has_insurance' => 'nullable|boolean',
|
|
||||||
'insurance_company' => 'nullable|string|max:100',
|
|
||||||
'insurance_policy' => 'nullable|string|max:100',
|
|
||||||
'insurance_value' => 'nullable|numeric|min:0',
|
|
||||||
'insurance_expiry' => 'nullable|date',
|
|
||||||
|
|
||||||
// Gestão
|
|
||||||
'alert_days_before' => 'nullable|integer|min:0|max:365',
|
|
||||||
'internal_responsible' => 'nullable|string|max:200',
|
|
||||||
'internal_notes' => 'nullable|string|max:2000',
|
|
||||||
'document_number' => 'nullable|string|max:100',
|
|
||||||
];
|
|
||||||
|
|
||||||
$validator = Validator::make($request->all(), $rules);
|
|
||||||
|
|
||||||
if ($validator->fails()) {
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'errors' => $validator->errors(),
|
|
||||||
], 422);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Criar o ativo
|
|
||||||
$data = $validator->validated();
|
|
||||||
$data['user_id'] = Auth::id();
|
|
||||||
// business_id é opcional e vem do request se fornecido
|
|
||||||
if ($request->filled('business_id')) {
|
|
||||||
$data['business_id'] = $request->business_id;
|
|
||||||
}
|
|
||||||
$data['status'] = 'active';
|
|
||||||
|
|
||||||
$asset = AssetAccount::create($data);
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'success' => true,
|
|
||||||
'message' => 'Activo creado con éxito',
|
|
||||||
'data' => $asset,
|
|
||||||
], 201);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resumo dos ativos do usuário
|
|
||||||
*/
|
|
||||||
public function summary(): JsonResponse
|
|
||||||
{
|
|
||||||
$userId = Auth::id();
|
|
||||||
|
|
||||||
$summary = [
|
|
||||||
'total_assets' => AssetAccount::where('user_id', $userId)->active()->count(),
|
|
||||||
'total_value' => AssetAccount::where('user_id', $userId)->active()->sum('current_value'),
|
|
||||||
'total_acquisition' => AssetAccount::where('user_id', $userId)->active()->sum('acquisition_value'),
|
|
||||||
'by_type' => [],
|
|
||||||
];
|
|
||||||
|
|
||||||
// Agrupar por tipo
|
|
||||||
$byType = AssetAccount::where('user_id', $userId)
|
|
||||||
->active()
|
|
||||||
->selectRaw('asset_type, COUNT(*) as count, SUM(current_value) as total_value')
|
|
||||||
->groupBy('asset_type')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
foreach ($byType as $item) {
|
|
||||||
$summary['by_type'][$item->asset_type] = [
|
|
||||||
'name' => AssetAccount::ASSET_TYPES[$item->asset_type]['name'] ?? $item->asset_type,
|
|
||||||
'count' => $item->count,
|
|
||||||
'total_value' => $item->total_value,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ganho/perda total
|
|
||||||
$summary['total_gain_loss'] = $summary['total_value'] - $summary['total_acquisition'];
|
|
||||||
$summary['total_gain_loss_percent'] = $summary['total_acquisition'] > 0
|
|
||||||
? (($summary['total_value'] - $summary['total_acquisition']) / $summary['total_acquisition']) * 100
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'success' => true,
|
|
||||||
'data' => $summary,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Atualizar valor de mercado de um ativo
|
|
||||||
*/
|
|
||||||
public function updateValue(Request $request, AssetAccount $assetAccount): JsonResponse
|
|
||||||
{
|
|
||||||
if ($assetAccount->user_id !== Auth::id()) {
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'No autorizado',
|
|
||||||
], 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$validator = Validator::make($request->all(), [
|
|
||||||
'current_value' => 'required|numeric|min:0',
|
|
||||||
'note' => 'nullable|string|max:500',
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($validator->fails()) {
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'errors' => $validator->errors(),
|
|
||||||
], 422);
|
|
||||||
}
|
|
||||||
|
|
||||||
$assetAccount->update([
|
|
||||||
'current_value' => $request->current_value,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Adicionar nota se fornecida
|
|
||||||
if ($request->note) {
|
|
||||||
$notes = $assetAccount->internal_notes ?? '';
|
|
||||||
$notes .= "\n[" . now()->format('d/m/Y') . "] Valor actualizado: {$request->note}";
|
|
||||||
$assetAccount->update(['internal_notes' => trim($notes)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'success' => true,
|
|
||||||
'message' => 'Valor actualizado con éxito',
|
|
||||||
'data' => $assetAccount->fresh(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Registrar venda/baixa de ativo
|
|
||||||
*/
|
|
||||||
public function dispose(Request $request, AssetAccount $assetAccount): JsonResponse
|
|
||||||
{
|
|
||||||
if ($assetAccount->user_id !== Auth::id()) {
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'No autorizado',
|
|
||||||
], 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$validator = Validator::make($request->all(), [
|
|
||||||
'disposal_date' => 'required|date',
|
|
||||||
'disposal_value' => 'required|numeric|min:0',
|
|
||||||
'disposal_reason' => 'nullable|string|max:200',
|
|
||||||
'status' => ['required', Rule::in(['sold', 'written_off', 'depreciated'])],
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($validator->fails()) {
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'errors' => $validator->errors(),
|
|
||||||
], 422);
|
|
||||||
}
|
|
||||||
|
|
||||||
$assetAccount->update([
|
|
||||||
'status' => $request->status,
|
|
||||||
'disposal_date' => $request->disposal_date,
|
|
||||||
'disposal_value' => $request->disposal_value,
|
|
||||||
'disposal_reason' => $request->disposal_reason,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'success' => true,
|
|
||||||
'message' => 'Baja registrada con éxito',
|
|
||||||
'data' => $assetAccount->fresh(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -4,21 +4,17 @@
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\EmailVerificationToken;
|
|
||||||
use App\Services\UserSetupService;
|
use App\Services\UserSetupService;
|
||||||
use App\Mail\AccountActivationMail;
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
use Illuminate\Support\Facades\Mail;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
|
|
||||||
class AuthController extends Controller
|
class AuthController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Register a new user (without auto-login - requires PayPal payment and email activation)
|
* Register a new user
|
||||||
*/
|
*/
|
||||||
public function register(Request $request): JsonResponse
|
public function register(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
@ -27,7 +23,6 @@ public function register(Request $request): JsonResponse
|
|||||||
'name' => 'required|string|max:255',
|
'name' => 'required|string|max:255',
|
||||||
'email' => 'required|string|email|max:255|unique:users',
|
'email' => 'required|string|email|max:255|unique:users',
|
||||||
'password' => 'required|string|min:8|confirmed',
|
'password' => 'required|string|min:8|confirmed',
|
||||||
'plan_id' => 'nullable|exists:plans,id',
|
|
||||||
], [
|
], [
|
||||||
'name.required' => 'El nombre es obligatorio',
|
'name.required' => 'El nombre es obligatorio',
|
||||||
'email.required' => 'El email es obligatorio',
|
'email.required' => 'El email es obligatorio',
|
||||||
@ -46,40 +41,32 @@ public function register(Request $request): JsonResponse
|
|||||||
], 422);
|
], 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create user WITHOUT email verification (will be verified after PayPal payment)
|
|
||||||
$user = User::create([
|
$user = User::create([
|
||||||
'name' => $request->name,
|
'name' => $request->name,
|
||||||
'email' => $request->email,
|
'email' => $request->email,
|
||||||
'password' => Hash::make($request->password),
|
'password' => Hash::make($request->password),
|
||||||
'email_verified_at' => null, // NOT verified yet
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// DISABLED: Create default categories and data for the new user
|
// Criar categorias e dados padrão para o novo usuário
|
||||||
// TODO: Re-enable when category templates are ready
|
$setupService = new UserSetupService();
|
||||||
// $setupService = new UserSetupService();
|
$setupService->setupNewUser($user->id);
|
||||||
// $setupService->setupNewUser($user->id);
|
|
||||||
|
|
||||||
// Create a temporary token for PayPal flow (expires in 1 hour)
|
$token = $user->createToken('auth-token')->plainTextToken;
|
||||||
$tempToken = $user->createToken('registration-flow', ['registration'])->plainTextToken;
|
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => 'Usuario registrado. Procede al pago para activar tu cuenta.',
|
'message' => 'Usuario registrado exitosamente',
|
||||||
'data' => [
|
'data' => [
|
||||||
'user' => [
|
'user' => [
|
||||||
'id' => $user->id,
|
'id' => $user->id,
|
||||||
'name' => $user->name,
|
'name' => $user->name,
|
||||||
'email' => $user->email,
|
'email' => $user->email,
|
||||||
'email_verified' => false,
|
|
||||||
],
|
],
|
||||||
'token' => $tempToken,
|
'token' => $token,
|
||||||
'requires_payment' => true,
|
|
||||||
'requires_activation' => true,
|
|
||||||
]
|
]
|
||||||
], 201);
|
], 201);
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Log::error('Registration error: ' . $e->getMessage());
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'message' => 'Error al registrar usuario',
|
'message' => 'Error al registrar usuario',
|
||||||
@ -111,63 +98,30 @@ public function login(Request $request): JsonResponse
|
|||||||
], 422);
|
], 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
$user = User::where('email', $request->email)->first();
|
if (!Auth::attempt($request->only('email', 'password'))) {
|
||||||
|
|
||||||
if (!$user || !Hash::check($request->password, $user->password)) {
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'message' => 'Credenciales incorrectas'
|
'message' => 'Credenciales incorrectas'
|
||||||
], 401);
|
], 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if email is verified (account activated)
|
$user = User::where('email', $request->email)->first();
|
||||||
if (!$user->email_verified_at) {
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'Tu cuenta aún no está activada. Revisa tu email para activarla.',
|
|
||||||
'error' => 'email_not_verified',
|
|
||||||
'data' => [
|
|
||||||
'email_verified' => false,
|
|
||||||
'can_resend' => true,
|
|
||||||
]
|
|
||||||
], 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user has an active subscription (skip for demo users)
|
|
||||||
if (!$user->is_demo) {
|
|
||||||
$hasActiveSubscription = $user->subscriptions()->active()->exists();
|
|
||||||
if (!$hasActiveSubscription) {
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'No tienes una suscripción activa. Por favor, completa el pago.',
|
|
||||||
'error' => 'no_subscription',
|
|
||||||
'data' => [
|
|
||||||
'email_verified' => true,
|
|
||||||
'has_subscription' => false,
|
|
||||||
]
|
|
||||||
], 403);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$token = $user->createToken('auth-token')->plainTextToken;
|
$token = $user->createToken('auth-token')->plainTextToken;
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => $user->is_demo ? 'Bienvenido al modo demostración' : 'Inicio de sesión exitoso',
|
'message' => 'Inicio de sesión exitoso',
|
||||||
'data' => [
|
'data' => [
|
||||||
'user' => [
|
'user' => [
|
||||||
'id' => $user->id,
|
'id' => $user->id,
|
||||||
'name' => $user->name,
|
'name' => $user->name,
|
||||||
'email' => $user->email,
|
'email' => $user->email,
|
||||||
'email_verified' => true,
|
|
||||||
'is_demo' => $user->is_demo ?? false,
|
|
||||||
],
|
],
|
||||||
'token' => $token,
|
'token' => $token,
|
||||||
]
|
]
|
||||||
], 200);
|
], 200);
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Log::error('Login error: ' . $e->getMessage());
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'message' => 'Error al iniciar sesión',
|
'message' => 'Error al iniciar sesión',
|
||||||
@ -176,175 +130,6 @@ public function login(Request $request): JsonResponse
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Activate account via email token
|
|
||||||
*/
|
|
||||||
public function activateAccount(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$validator = Validator::make($request->all(), [
|
|
||||||
'token' => 'required|string|size:64',
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($validator->fails()) {
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'Token inválido',
|
|
||||||
], 422);
|
|
||||||
}
|
|
||||||
|
|
||||||
$verificationToken = EmailVerificationToken::findValid($request->token);
|
|
||||||
|
|
||||||
if (!$verificationToken) {
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'El enlace de activación es inválido o ha expirado.',
|
|
||||||
'error' => 'invalid_token',
|
|
||||||
], 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = $verificationToken->user;
|
|
||||||
|
|
||||||
// Activate the user
|
|
||||||
$user->update(['email_verified_at' => now()]);
|
|
||||||
$verificationToken->markAsUsed();
|
|
||||||
|
|
||||||
// Create auth token for immediate login
|
|
||||||
$authToken = $user->createToken('auth-token')->plainTextToken;
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'success' => true,
|
|
||||||
'message' => '¡Tu cuenta ha sido activada! Ya puedes acceder al sistema.',
|
|
||||||
'data' => [
|
|
||||||
'user' => [
|
|
||||||
'id' => $user->id,
|
|
||||||
'name' => $user->name,
|
|
||||||
'email' => $user->email,
|
|
||||||
'email_verified' => true,
|
|
||||||
],
|
|
||||||
'token' => $authToken,
|
|
||||||
]
|
|
||||||
]);
|
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
Log::error('Activation error: ' . $e->getMessage());
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'Error al activar la cuenta',
|
|
||||||
], 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resend activation email
|
|
||||||
*/
|
|
||||||
public function resendActivation(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$validator = Validator::make($request->all(), [
|
|
||||||
'email' => 'required|email|exists:users,email',
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($validator->fails()) {
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'Email no encontrado',
|
|
||||||
], 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = User::where('email', $request->email)->first();
|
|
||||||
|
|
||||||
if ($user->email_verified_at) {
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'Esta cuenta ya está activada',
|
|
||||||
], 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user has completed payment
|
|
||||||
$subscription = $user->subscriptions()->active()->first();
|
|
||||||
if (!$subscription) {
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'Primero debes completar el pago de tu suscripción',
|
|
||||||
], 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new verification token and send email
|
|
||||||
$verificationToken = EmailVerificationToken::createForUser($user);
|
|
||||||
$frontendUrl = config('app.frontend_url', 'https://webmoney.cnxifly.com');
|
|
||||||
$activationUrl = "{$frontendUrl}/activate?token={$verificationToken->token}";
|
|
||||||
|
|
||||||
Mail::to($user->email)->send(new AccountActivationMail(
|
|
||||||
$user,
|
|
||||||
$activationUrl,
|
|
||||||
$subscription->plan->name
|
|
||||||
));
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'success' => true,
|
|
||||||
'message' => 'Email de activación reenviado. Revisa tu bandeja de entrada.',
|
|
||||||
]);
|
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
Log::error('Resend activation error: ' . $e->getMessage());
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'Error al reenviar el email',
|
|
||||||
], 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancel registration - delete unactivated user account
|
|
||||||
* Used when PayPal payment is canceled or fails
|
|
||||||
*/
|
|
||||||
public function cancelRegistration(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$validator = Validator::make($request->all(), [
|
|
||||||
'email' => 'required|email|exists:users,email',
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($validator->fails()) {
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'Usuario no encontrado',
|
|
||||||
], 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = User::where('email', $request->email)->first();
|
|
||||||
|
|
||||||
// Only allow deletion if account is NOT activated
|
|
||||||
if ($user->email_verified_at) {
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'Esta cuenta ya está activada y no puede ser eliminada',
|
|
||||||
], 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete associated data
|
|
||||||
$user->tokens()->delete(); // Delete all tokens
|
|
||||||
EmailVerificationToken::where('user_id', $user->id)->delete();
|
|
||||||
$user->subscriptions()->delete();
|
|
||||||
$user->delete();
|
|
||||||
|
|
||||||
Log::info("Unactivated user account deleted: {$request->email}");
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'success' => true,
|
|
||||||
'message' => 'Registro cancelado. Puedes intentar nuevamente.',
|
|
||||||
]);
|
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
Log::error('Cancel registration error: ' . $e->getMessage());
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'Error al cancelar el registro',
|
|
||||||
], 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Logout user (revoke token)
|
* Logout user (revoke token)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -62,36 +62,6 @@ public function store(Request $request): JsonResponse
|
|||||||
'keywords.*' => 'string|max:100',
|
'keywords.*' => 'string|max:100',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = Auth::user();
|
|
||||||
$plan = $user->currentPlan();
|
|
||||||
|
|
||||||
// Check subcategory limit if parent_id is provided
|
|
||||||
if (!empty($validated['parent_id']) && $plan) {
|
|
||||||
$limits = $plan->limits ?? [];
|
|
||||||
$subcategoryLimit = $limits['subcategories'] ?? null;
|
|
||||||
|
|
||||||
if ($subcategoryLimit !== null) {
|
|
||||||
$currentSubcategories = Category::where('user_id', $user->id)
|
|
||||||
->whereNotNull('parent_id')
|
|
||||||
->count();
|
|
||||||
|
|
||||||
if ($currentSubcategories >= $subcategoryLimit) {
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'message' => "Has alcanzado el límite de {$subcategoryLimit} subcategorías de tu plan. Actualiza a Pro para subcategorías ilimitadas.",
|
|
||||||
'error' => 'plan_limit_exceeded',
|
|
||||||
'data' => [
|
|
||||||
'resource' => 'subcategories',
|
|
||||||
'current' => $currentSubcategories,
|
|
||||||
'limit' => $subcategoryLimit,
|
|
||||||
'plan' => $plan->name,
|
|
||||||
'upgrade_url' => '/pricing',
|
|
||||||
],
|
|
||||||
], 403);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verificar se parent_id pertence ao usuário
|
// Verificar se parent_id pertence ao usuário
|
||||||
if (!empty($validated['parent_id'])) {
|
if (!empty($validated['parent_id'])) {
|
||||||
$parent = Category::where('user_id', Auth::id())
|
$parent = Category::where('user_id', Auth::id())
|
||||||
|
|||||||
@ -133,7 +133,7 @@ public function summary(Request $request): JsonResponse
|
|||||||
];
|
];
|
||||||
|
|
||||||
$monthlyStatsByCurrency = Transaction::where('transactions.user_id', $userId)
|
$monthlyStatsByCurrency = Transaction::where('transactions.user_id', $userId)
|
||||||
->whereIn('transactions.status', ['completed', 'effective'])
|
->where('transactions.status', 'completed')
|
||||||
->where('transactions.is_transfer', false)
|
->where('transactions.is_transfer', false)
|
||||||
->whereBetween('transactions.effective_date', $currentMonth)
|
->whereBetween('transactions.effective_date', $currentMonth)
|
||||||
->whereNull('transactions.deleted_at')
|
->whereNull('transactions.deleted_at')
|
||||||
@ -465,20 +465,18 @@ public function calendar(Request $request): JsonResponse
|
|||||||
$endDate = $startDate->copy()->endOfMonth();
|
$endDate = $startDate->copy()->endOfMonth();
|
||||||
|
|
||||||
// Buscar transações do período
|
// Buscar transações do período
|
||||||
// Usar planned_date para todas as transações (funciona para efetivadas e pendentes)
|
|
||||||
$transactions = Transaction::ofUser($userId)
|
$transactions = Transaction::ofUser($userId)
|
||||||
->whereBetween('planned_date', [$startDate, $endDate])
|
->whereBetween('effective_date', [$startDate, $endDate])
|
||||||
->with(['account:id,name,currency', 'category:id,name,color,icon'])
|
->with(['account:id,name,currency', 'category:id,name,color,icon'])
|
||||||
->orderBy('planned_date')
|
->orderBy('effective_date')
|
||||||
->get()
|
->get()
|
||||||
->map(function ($t) {
|
->map(function ($t) {
|
||||||
$date = $t->effective_date ?? $t->planned_date;
|
|
||||||
return [
|
return [
|
||||||
'id' => $t->id,
|
'id' => $t->id,
|
||||||
'type' => 'transaction',
|
'type' => 'transaction',
|
||||||
'date' => $date->format('Y-m-d'),
|
'date' => $t->effective_date->format('Y-m-d'),
|
||||||
'description' => $t->description,
|
'description' => $t->description,
|
||||||
'amount' => (float) ($t->amount ?? $t->planned_amount),
|
'amount' => (float) $t->amount,
|
||||||
'transaction_type' => $t->type,
|
'transaction_type' => $t->type,
|
||||||
'status' => $t->status,
|
'status' => $t->status,
|
||||||
'is_transfer' => $t->is_transfer,
|
'is_transfer' => $t->is_transfer,
|
||||||
@ -575,21 +573,20 @@ public function calendarDay(Request $request): JsonResponse
|
|||||||
$userId = $request->user()->id;
|
$userId = $request->user()->id;
|
||||||
$date = Carbon::parse($request->get('date', now()->format('Y-m-d')));
|
$date = Carbon::parse($request->get('date', now()->format('Y-m-d')));
|
||||||
|
|
||||||
// Buscar transações do dia (usar planned_date para incluir pendentes)
|
// Buscar transações do dia
|
||||||
$transactions = Transaction::ofUser($userId)
|
$transactions = Transaction::ofUser($userId)
|
||||||
->whereDate('planned_date', $date)
|
->whereDate('effective_date', $date)
|
||||||
->with(['account:id,name,currency', 'category:id,name,color,icon'])
|
->with(['account:id,name,currency', 'category:id,name,color,icon'])
|
||||||
->orderBy('planned_date')
|
->orderBy('effective_date')
|
||||||
->orderBy('created_at')
|
->orderBy('created_at')
|
||||||
->get()
|
->get()
|
||||||
->map(function ($t) {
|
->map(function ($t) {
|
||||||
$txDate = $t->effective_date ?? $t->planned_date;
|
|
||||||
return [
|
return [
|
||||||
'id' => $t->id,
|
'id' => $t->id,
|
||||||
'type' => 'transaction',
|
'type' => 'transaction',
|
||||||
'date' => $txDate->format('Y-m-d'),
|
'date' => $t->effective_date->format('Y-m-d'),
|
||||||
'description' => $t->description,
|
'description' => $t->description,
|
||||||
'amount' => (float) ($t->amount ?? $t->planned_amount),
|
'amount' => (float) $t->amount,
|
||||||
'transaction_type' => $t->type,
|
'transaction_type' => $t->type,
|
||||||
'status' => $t->status,
|
'status' => $t->status,
|
||||||
'is_transfer' => $t->is_transfer,
|
'is_transfer' => $t->is_transfer,
|
||||||
@ -673,28 +670,26 @@ public function upcomingTransactions(Request $request): JsonResponse
|
|||||||
$endDate = now()->addDays($days - 1)->endOfDay();
|
$endDate = now()->addDays($days - 1)->endOfDay();
|
||||||
|
|
||||||
// Buscar transações pendentes do período
|
// Buscar transações pendentes do período
|
||||||
// Para pendentes: usar planned_date (effective_date é NULL)
|
|
||||||
$transactions = Transaction::ofUser($userId)
|
$transactions = Transaction::ofUser($userId)
|
||||||
->whereIn('status', ['pending', 'scheduled'])
|
->whereIn('status', ['pending', 'scheduled'])
|
||||||
->whereBetween('planned_date', [$startDate, $endDate])
|
->whereBetween('effective_date', [$startDate, $endDate])
|
||||||
->with(['account:id,name,currency', 'category:id,name,color,icon'])
|
->with(['account:id,name,currency', 'category:id,name,color,icon'])
|
||||||
->orderBy('planned_date')
|
->orderBy('effective_date')
|
||||||
->orderBy('created_at')
|
->orderBy('created_at')
|
||||||
->get()
|
->get()
|
||||||
->map(function ($t) {
|
->map(function ($t) {
|
||||||
$date = $t->effective_date ?? $t->planned_date;
|
|
||||||
return [
|
return [
|
||||||
'id' => $t->id,
|
'id' => $t->id,
|
||||||
'type' => 'transaction',
|
'type' => 'transaction',
|
||||||
'date' => $date->format('Y-m-d'),
|
'date' => $t->effective_date->format('Y-m-d'),
|
||||||
'date_formatted' => $date->translatedFormat('D, d M'),
|
'date_formatted' => $t->effective_date->translatedFormat('D, d M'),
|
||||||
'description' => $t->description,
|
'description' => $t->description,
|
||||||
'amount' => (float) ($t->amount ?? $t->planned_amount),
|
'amount' => (float) $t->amount,
|
||||||
'currency' => $t->account->currency ?? 'EUR',
|
'currency' => $t->account->currency ?? 'EUR',
|
||||||
'transaction_type' => $t->type,
|
'transaction_type' => $t->type,
|
||||||
'status' => $t->status,
|
'status' => $t->status,
|
||||||
'is_transfer' => $t->is_transfer,
|
'is_transfer' => $t->is_transfer,
|
||||||
'days_until' => (int) now()->startOfDay()->diffInDays($date, false),
|
'days_until' => (int) now()->startOfDay()->diffInDays($t->effective_date, false),
|
||||||
'account' => $t->account ? [
|
'account' => $t->account ? [
|
||||||
'id' => $t->account->id,
|
'id' => $t->account->id,
|
||||||
'name' => $t->account->name,
|
'name' => $t->account->name,
|
||||||
@ -774,8 +769,6 @@ public function upcomingTransactions(Request $request): JsonResponse
|
|||||||
'recurring_count' => $recurringInstances->count(),
|
'recurring_count' => $recurringInstances->count(),
|
||||||
'total_credit' => $nonTransferItems->where('transaction_type', 'credit')->sum('amount'),
|
'total_credit' => $nonTransferItems->where('transaction_type', 'credit')->sum('amount'),
|
||||||
'total_debit' => $nonTransferItems->where('transaction_type', 'debit')->sum('amount'),
|
'total_debit' => $nonTransferItems->where('transaction_type', 'debit')->sum('amount'),
|
||||||
'credit_count' => $nonTransferItems->where('transaction_type', 'credit')->count(),
|
|
||||||
'debit_count' => $nonTransferItems->where('transaction_type', 'debit')->count(),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
@ -898,7 +891,7 @@ public function overdueTransactions(Request $request): JsonResponse
|
|||||||
'planned_date' => $li->due_date->format('Y-m-d'),
|
'planned_date' => $li->due_date->format('Y-m-d'),
|
||||||
'planned_date_formatted' => $li->due_date->translatedFormat('D, d M Y'),
|
'planned_date_formatted' => $li->due_date->translatedFormat('D, d M Y'),
|
||||||
'description' => $li->liabilityAccount->name . ' - Parcela ' . $li->installment_number,
|
'description' => $li->liabilityAccount->name . ' - Parcela ' . $li->installment_number,
|
||||||
'amount' => (float) $li->installment_amount,
|
'amount' => (float) $li->amount,
|
||||||
'currency' => $li->liabilityAccount->currency ?? 'EUR',
|
'currency' => $li->liabilityAccount->currency ?? 'EUR',
|
||||||
'transaction_type' => 'debit',
|
'transaction_type' => 'debit',
|
||||||
'status' => $li->status,
|
'status' => $li->status,
|
||||||
|
|||||||
@ -30,9 +30,6 @@ public function index(Request $request)
|
|||||||
$this->setPrimaryCurrency();
|
$this->setPrimaryCurrency();
|
||||||
$this->loadExchangeRates();
|
$this->loadExchangeRates();
|
||||||
|
|
||||||
// Verificar si hay datos suficientes para análisis
|
|
||||||
$dataStatus = $this->checkDataSufficiency();
|
|
||||||
|
|
||||||
// Obtener datos base
|
// Obtener datos base
|
||||||
$financialSummary = $this->getFinancialSummary();
|
$financialSummary = $this->getFinancialSummary();
|
||||||
$cashFlowAnalysis = $this->analyzeCashFlow();
|
$cashFlowAnalysis = $this->analyzeCashFlow();
|
||||||
@ -80,9 +77,6 @@ public function index(Request $request)
|
|||||||
'last_updated' => now()->toIso8601String(),
|
'last_updated' => now()->toIso8601String(),
|
||||||
'currency' => $this->primaryCurrency,
|
'currency' => $this->primaryCurrency,
|
||||||
|
|
||||||
// Estado de datos
|
|
||||||
'data_status' => $dataStatus,
|
|
||||||
|
|
||||||
// Resumen financiero
|
// Resumen financiero
|
||||||
'summary' => [
|
'summary' => [
|
||||||
'total_assets' => $financialSummary['total_assets'],
|
'total_assets' => $financialSummary['total_assets'],
|
||||||
@ -124,56 +118,6 @@ public function index(Request $request)
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Verificar si hay datos suficientes para análisis
|
|
||||||
*/
|
|
||||||
private function checkDataSufficiency()
|
|
||||||
{
|
|
||||||
$accountsCount = Account::where('user_id', $this->userId)->count();
|
|
||||||
$transactionsCount = Transaction::where('user_id', $this->userId)->count();
|
|
||||||
$categoriesCount = Category::where('user_id', $this->userId)->count();
|
|
||||||
|
|
||||||
// Transacciones de los últimos 30 días
|
|
||||||
$recentTransactions = Transaction::where('user_id', $this->userId)
|
|
||||||
->where('effective_date', '>=', now()->subDays(30))
|
|
||||||
->count();
|
|
||||||
|
|
||||||
// Determinar nivel de suficiencia
|
|
||||||
$hasSufficientData = $accountsCount >= 1 && $transactionsCount >= 10;
|
|
||||||
$hasMinimalData = $accountsCount >= 1 || $transactionsCount >= 1;
|
|
||||||
|
|
||||||
// Mensaje apropiado
|
|
||||||
$message = null;
|
|
||||||
$level = 'sufficient';
|
|
||||||
|
|
||||||
if ($accountsCount === 0 && $transactionsCount === 0) {
|
|
||||||
$level = 'no_data';
|
|
||||||
$message = 'No hay datos registrados. Añade cuentas y transacciones para comenzar el análisis.';
|
|
||||||
} elseif ($accountsCount === 0) {
|
|
||||||
$level = 'insufficient';
|
|
||||||
$message = 'Añade al menos una cuenta bancaria para un análisis más preciso.';
|
|
||||||
} elseif ($transactionsCount < 10) {
|
|
||||||
$level = 'limited';
|
|
||||||
$message = 'Hay pocos datos para un análisis completo. Registra más transacciones para mejorar la precisión.';
|
|
||||||
} elseif ($recentTransactions === 0) {
|
|
||||||
$level = 'outdated';
|
|
||||||
$message = 'No hay transacciones recientes. Los datos pueden estar desactualizados.';
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'has_sufficient_data' => $hasSufficientData,
|
|
||||||
'has_minimal_data' => $hasMinimalData,
|
|
||||||
'level' => $level,
|
|
||||||
'message' => $message,
|
|
||||||
'counts' => [
|
|
||||||
'accounts' => $accountsCount,
|
|
||||||
'transactions' => $transactionsCount,
|
|
||||||
'categories' => $categoriesCount,
|
|
||||||
'recent_transactions' => $recentTransactions,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Establecer moneda principal del usuario
|
* Establecer moneda principal del usuario
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -683,290 +683,4 @@ public function pendingReconciliation(): JsonResponse
|
|||||||
'count' => $installments->count(),
|
'count' => $installments->count(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Download template Excel para importação
|
|
||||||
*/
|
|
||||||
public function downloadTemplate()
|
|
||||||
{
|
|
||||||
$service = new \App\Services\LiabilityTemplateService();
|
|
||||||
$spreadsheet = $service->generateTemplate();
|
|
||||||
|
|
||||||
$writer = new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet);
|
|
||||||
|
|
||||||
$filename = 'plantilla_importacion_pasivo.xlsx';
|
|
||||||
|
|
||||||
// Criar response com stream
|
|
||||||
return response()->streamDownload(function () use ($writer) {
|
|
||||||
$writer->save('php://output');
|
|
||||||
}, $filename, [
|
|
||||||
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
||||||
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
|
|
||||||
'Cache-Control' => 'max-age=0',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Obter tipos de contrato disponíveis
|
|
||||||
*/
|
|
||||||
public function contractTypes(): JsonResponse
|
|
||||||
{
|
|
||||||
return response()->json([
|
|
||||||
'success' => true,
|
|
||||||
'data' => LiabilityAccount::CONTRACT_TYPES,
|
|
||||||
'amortization_systems' => LiabilityAccount::AMORTIZATION_SYSTEMS,
|
|
||||||
'index_types' => LiabilityAccount::INDEX_TYPES,
|
|
||||||
'guarantee_types' => LiabilityAccount::GUARANTEE_TYPES,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Criar conta passivo com wizard (formulário completo)
|
|
||||||
*/
|
|
||||||
public function storeWithWizard(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
$validated = $request->validate([
|
|
||||||
// Dados básicos
|
|
||||||
'name' => 'required|string|max:150',
|
|
||||||
'contract_type' => ['required', \Illuminate\Validation\Rule::in(array_keys(LiabilityAccount::CONTRACT_TYPES))],
|
|
||||||
'creditor' => 'nullable|string|max:150',
|
|
||||||
'contract_number' => 'nullable|string|max:100',
|
|
||||||
'description' => 'nullable|string',
|
|
||||||
'currency' => 'nullable|string|size:3',
|
|
||||||
'color' => 'nullable|string|max:7',
|
|
||||||
|
|
||||||
// Dados do contrato
|
|
||||||
'principal_amount' => 'required|numeric|min:0.01',
|
|
||||||
'annual_interest_rate' => 'nullable|numeric|min:0|max:100',
|
|
||||||
'monthly_interest_rate' => 'nullable|numeric|min:0|max:20',
|
|
||||||
'amortization_system' => ['nullable', \Illuminate\Validation\Rule::in(array_keys(LiabilityAccount::AMORTIZATION_SYSTEMS))],
|
|
||||||
'total_installments' => 'nullable|integer|min:1|max:600',
|
|
||||||
'start_date' => 'required|date',
|
|
||||||
'first_due_date' => 'required|date',
|
|
||||||
'end_date' => 'nullable|date',
|
|
||||||
|
|
||||||
// Opções básicas
|
|
||||||
'has_grace_period' => 'nullable|boolean',
|
|
||||||
'grace_period_months' => 'nullable|integer|min:0|max:12',
|
|
||||||
'include_insurance' => 'nullable|boolean',
|
|
||||||
'insurance_amount' => 'nullable|numeric|min:0',
|
|
||||||
'include_admin_fee' => 'nullable|boolean',
|
|
||||||
'admin_fee_amount' => 'nullable|numeric|min:0',
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// CAMPOS AVANÇADOS (opcionais)
|
|
||||||
// ============================================
|
|
||||||
// Indexadores
|
|
||||||
'index_type' => ['nullable', \Illuminate\Validation\Rule::in(array_keys(LiabilityAccount::INDEX_TYPES))],
|
|
||||||
'index_spread' => 'nullable|numeric|min:-50|max:50',
|
|
||||||
'total_effective_cost' => 'nullable|numeric|min:0|max:500',
|
|
||||||
|
|
||||||
// Garantias
|
|
||||||
'guarantee_type' => ['nullable', \Illuminate\Validation\Rule::in(array_keys(LiabilityAccount::GUARANTEE_TYPES))],
|
|
||||||
'guarantee_value' => 'nullable|numeric|min:0',
|
|
||||||
'guarantee_description' => 'nullable|string|max:500',
|
|
||||||
'guarantor_name' => 'nullable|string|max:150',
|
|
||||||
|
|
||||||
// Penalidades
|
|
||||||
'late_fee_percent' => 'nullable|numeric|min:0|max:100',
|
|
||||||
'daily_penalty_percent' => 'nullable|numeric|min:0|max:10',
|
|
||||||
'grace_days_for_penalty' => 'nullable|integer|min:0|max:30',
|
|
||||||
|
|
||||||
// Específicos por tipo
|
|
||||||
'asset_value' => 'nullable|numeric|min:0',
|
|
||||||
'asset_description' => 'nullable|string|max:300',
|
|
||||||
'residual_value' => 'nullable|numeric|min:0',
|
|
||||||
'admin_fee_percent' => 'nullable|numeric|min:0|max:50',
|
|
||||||
'reserve_fund_percent' => 'nullable|numeric|min:0|max:20',
|
|
||||||
|
|
||||||
// Covenants e gestão
|
|
||||||
'covenants' => 'nullable|array',
|
|
||||||
'covenants.*.name' => 'required_with:covenants|string|max:100',
|
|
||||||
'covenants.*.condition' => 'required_with:covenants|string|max:50',
|
|
||||||
'covenants.*.value' => 'required_with:covenants|string|max:100',
|
|
||||||
'alert_days_before' => 'nullable|integer|min:0|max:60',
|
|
||||||
'internal_responsible' => 'nullable|string|max:150',
|
|
||||||
'internal_notes' => 'nullable|string',
|
|
||||||
'document_number' => 'nullable|string|max:100',
|
|
||||||
'registry_office' => 'nullable|string|max:200',
|
|
||||||
|
|
||||||
// Parcelas (opcional - se não enviado, será calculado)
|
|
||||||
'installments' => 'nullable|array',
|
|
||||||
'installments.*.installment_number' => 'required|integer|min:1',
|
|
||||||
'installments.*.due_date' => 'required|date',
|
|
||||||
'installments.*.installment_amount' => 'required|numeric|min:0',
|
|
||||||
'installments.*.principal_amount' => 'nullable|numeric|min:0',
|
|
||||||
'installments.*.interest_amount' => 'nullable|numeric|min:0',
|
|
||||||
'installments.*.fee_amount' => 'nullable|numeric|min:0',
|
|
||||||
'installments.*.status' => 'nullable|string',
|
|
||||||
]);
|
|
||||||
|
|
||||||
try {
|
|
||||||
DB::beginTransaction();
|
|
||||||
|
|
||||||
// Calcular taxa mensal se não informada
|
|
||||||
$monthlyRate = $validated['monthly_interest_rate'] ?? null;
|
|
||||||
if (!$monthlyRate && isset($validated['annual_interest_rate'])) {
|
|
||||||
$monthlyRate = $validated['annual_interest_rate'] / 12;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Criar conta passivo com todos os campos
|
|
||||||
$account = LiabilityAccount::create([
|
|
||||||
'user_id' => Auth::id(),
|
|
||||||
'name' => $validated['name'],
|
|
||||||
'contract_type' => $validated['contract_type'],
|
|
||||||
'creditor' => $validated['creditor'] ?? null,
|
|
||||||
'contract_number' => $validated['contract_number'] ?? null,
|
|
||||||
'description' => $validated['description'] ?? null,
|
|
||||||
'currency' => $validated['currency'] ?? 'EUR',
|
|
||||||
'color' => $validated['color'] ?? null,
|
|
||||||
'principal_amount' => $validated['principal_amount'],
|
|
||||||
'annual_interest_rate' => $validated['annual_interest_rate'] ?? null,
|
|
||||||
'monthly_interest_rate' => $monthlyRate,
|
|
||||||
'amortization_system' => $validated['amortization_system'] ?? 'price',
|
|
||||||
'total_installments' => $validated['total_installments'] ?? null,
|
|
||||||
'start_date' => $validated['start_date'],
|
|
||||||
'first_due_date' => $validated['first_due_date'],
|
|
||||||
'end_date' => $validated['end_date'] ?? null,
|
|
||||||
'has_grace_period' => $validated['has_grace_period'] ?? false,
|
|
||||||
'grace_period_months' => $validated['grace_period_months'] ?? 0,
|
|
||||||
'status' => LiabilityAccount::STATUS_ACTIVE,
|
|
||||||
// Campos avançados - Indexadores
|
|
||||||
'index_type' => $validated['index_type'] ?? 'fixed',
|
|
||||||
'index_spread' => $validated['index_spread'] ?? null,
|
|
||||||
'total_effective_cost' => $validated['total_effective_cost'] ?? null,
|
|
||||||
// Campos avançados - Garantias
|
|
||||||
'guarantee_type' => $validated['guarantee_type'] ?? 'none',
|
|
||||||
'guarantee_value' => $validated['guarantee_value'] ?? null,
|
|
||||||
'guarantee_description' => $validated['guarantee_description'] ?? null,
|
|
||||||
'guarantor_name' => $validated['guarantor_name'] ?? null,
|
|
||||||
// Campos avançados - Penalidades
|
|
||||||
'late_fee_percent' => $validated['late_fee_percent'] ?? null,
|
|
||||||
'daily_penalty_percent' => $validated['daily_penalty_percent'] ?? null,
|
|
||||||
'grace_days_for_penalty' => $validated['grace_days_for_penalty'] ?? 0,
|
|
||||||
// Campos avançados - Específicos
|
|
||||||
'asset_value' => $validated['asset_value'] ?? null,
|
|
||||||
'asset_description' => $validated['asset_description'] ?? null,
|
|
||||||
'residual_value' => $validated['residual_value'] ?? null,
|
|
||||||
'admin_fee_percent' => $validated['admin_fee_percent'] ?? null,
|
|
||||||
'reserve_fund_percent' => $validated['reserve_fund_percent'] ?? null,
|
|
||||||
// Campos avançados - Covenants e gestão
|
|
||||||
'covenants' => $validated['covenants'] ?? null,
|
|
||||||
'alert_days_before' => $validated['alert_days_before'] ?? 5,
|
|
||||||
'internal_responsible' => $validated['internal_responsible'] ?? null,
|
|
||||||
'internal_notes' => $validated['internal_notes'] ?? null,
|
|
||||||
'document_number' => $validated['document_number'] ?? null,
|
|
||||||
'registry_office' => $validated['registry_office'] ?? null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Se parcelas foram enviadas, criar diretamente
|
|
||||||
if (!empty($validated['installments'])) {
|
|
||||||
foreach ($validated['installments'] as $inst) {
|
|
||||||
LiabilityInstallment::create([
|
|
||||||
'liability_account_id' => $account->id,
|
|
||||||
'installment_number' => $inst['installment_number'],
|
|
||||||
'due_date' => $inst['due_date'],
|
|
||||||
'installment_amount' => $inst['installment_amount'],
|
|
||||||
'principal_amount' => $inst['principal_amount'] ?? 0,
|
|
||||||
'interest_amount' => $inst['interest_amount'] ?? 0,
|
|
||||||
'fee_amount' => $inst['fee_amount'] ?? 0,
|
|
||||||
'status' => $inst['status'] ?? 'pending',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Gerar parcelas automaticamente
|
|
||||||
$this->generateInstallments($account, $validated);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recalcular totais
|
|
||||||
$account->recalculateTotals();
|
|
||||||
|
|
||||||
DB::commit();
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'success' => true,
|
|
||||||
'message' => 'Cuenta pasivo creada con éxito',
|
|
||||||
'data' => $account->load('installments'),
|
|
||||||
], 201);
|
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
DB::rollBack();
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'Error al crear cuenta: ' . $e->getMessage(),
|
|
||||||
], 422);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gerar parcelas automaticamente baseado no sistema de amortização
|
|
||||||
*/
|
|
||||||
private function generateInstallments(LiabilityAccount $account, array $data): void
|
|
||||||
{
|
|
||||||
$principal = $account->principal_amount;
|
|
||||||
$monthlyRate = ($account->monthly_interest_rate ?? 0) / 100;
|
|
||||||
$totalInstallments = $data['total_installments'] ?? 12;
|
|
||||||
$amortizationSystem = $account->amortization_system ?? 'price';
|
|
||||||
$hasGracePeriod = $data['has_grace_period'] ?? false;
|
|
||||||
$gracePeriodMonths = $data['grace_period_months'] ?? 0;
|
|
||||||
$insuranceAmount = $data['insurance_amount'] ?? 0;
|
|
||||||
$adminFeeAmount = $data['admin_fee_amount'] ?? 0;
|
|
||||||
|
|
||||||
$firstDueDate = new \DateTime($data['first_due_date']);
|
|
||||||
$remainingPrincipal = $principal;
|
|
||||||
|
|
||||||
// Para sistema PRICE, calcular parcela fixa
|
|
||||||
$fixedInstallment = 0;
|
|
||||||
if ($amortizationSystem === 'price' && $monthlyRate > 0) {
|
|
||||||
$fixedInstallment = $principal * ($monthlyRate * pow(1 + $monthlyRate, $totalInstallments)) /
|
|
||||||
(pow(1 + $monthlyRate, $totalInstallments) - 1);
|
|
||||||
} elseif ($amortizationSystem === 'price') {
|
|
||||||
$fixedInstallment = $principal / $totalInstallments;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Para sistema SAC, calcular amortização fixa
|
|
||||||
$fixedAmortization = $principal / $totalInstallments;
|
|
||||||
|
|
||||||
for ($i = 1; $i <= $totalInstallments; $i++) {
|
|
||||||
$dueDate = clone $firstDueDate;
|
|
||||||
$dueDate->modify('+' . ($i - 1) . ' months');
|
|
||||||
|
|
||||||
// Carência
|
|
||||||
if ($hasGracePeriod && $i <= $gracePeriodMonths) {
|
|
||||||
$interestAmount = $remainingPrincipal * $monthlyRate;
|
|
||||||
$principalAmount = 0;
|
|
||||||
$installmentAmount = $interestAmount + $insuranceAmount + $adminFeeAmount;
|
|
||||||
} else {
|
|
||||||
if ($amortizationSystem === 'price') {
|
|
||||||
// Sistema PRICE - parcela fixa
|
|
||||||
$interestAmount = $remainingPrincipal * $monthlyRate;
|
|
||||||
$principalAmount = $fixedInstallment - $interestAmount;
|
|
||||||
$installmentAmount = $fixedInstallment + $insuranceAmount + $adminFeeAmount;
|
|
||||||
} elseif ($amortizationSystem === 'sac') {
|
|
||||||
// Sistema SAC - amortização constante
|
|
||||||
$principalAmount = $fixedAmortization;
|
|
||||||
$interestAmount = $remainingPrincipal * $monthlyRate;
|
|
||||||
$installmentAmount = $principalAmount + $interestAmount + $insuranceAmount + $adminFeeAmount;
|
|
||||||
} else {
|
|
||||||
// Americano - só juros, principal no final
|
|
||||||
$interestAmount = $remainingPrincipal * $monthlyRate;
|
|
||||||
$principalAmount = ($i === $totalInstallments) ? $remainingPrincipal : 0;
|
|
||||||
$installmentAmount = $interestAmount + $principalAmount + $insuranceAmount + $adminFeeAmount;
|
|
||||||
}
|
|
||||||
|
|
||||||
$remainingPrincipal -= $principalAmount;
|
|
||||||
}
|
|
||||||
|
|
||||||
LiabilityInstallment::create([
|
|
||||||
'liability_account_id' => $account->id,
|
|
||||||
'installment_number' => $i,
|
|
||||||
'due_date' => $dueDate->format('Y-m-d'),
|
|
||||||
'installment_amount' => round($installmentAmount, 2),
|
|
||||||
'principal_amount' => round(max(0, $principalAmount), 2),
|
|
||||||
'interest_amount' => round($interestAmount, 2),
|
|
||||||
'fee_amount' => round($insuranceAmount + $adminFeeAmount, 2),
|
|
||||||
'status' => 'pending',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,25 +10,16 @@
|
|||||||
class PlanController extends Controller
|
class PlanController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* List all plans for pricing page (including coming soon)
|
* List all active plans for pricing page
|
||||||
*/
|
*/
|
||||||
public function index(): JsonResponse
|
public function index(): JsonResponse
|
||||||
{
|
{
|
||||||
// Get active plans
|
$plans = Plan::active()->ordered()->get();
|
||||||
$activePlans = Plan::active()->ordered()->get();
|
|
||||||
|
|
||||||
// Get coming soon plans (inactive but should be displayed)
|
|
||||||
$comingSoonPlans = Plan::where('is_active', false)
|
|
||||||
->whereIn('slug', ['business'])
|
|
||||||
->ordered()
|
|
||||||
->get();
|
|
||||||
|
|
||||||
$allPlans = $activePlans->merge($comingSoonPlans);
|
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'data' => [
|
'data' => [
|
||||||
'plans' => $allPlans->map(function ($plan) {
|
'plans' => $plans->map(function ($plan) {
|
||||||
return [
|
return [
|
||||||
'id' => $plan->id,
|
'id' => $plan->id,
|
||||||
'slug' => $plan->slug,
|
'slug' => $plan->slug,
|
||||||
@ -44,8 +35,6 @@ public function index(): JsonResponse
|
|||||||
'limits' => $plan->limits,
|
'limits' => $plan->limits,
|
||||||
'is_free' => $plan->is_free,
|
'is_free' => $plan->is_free,
|
||||||
'is_featured' => $plan->is_featured,
|
'is_featured' => $plan->is_featured,
|
||||||
'is_active' => $plan->is_active,
|
|
||||||
'coming_soon' => !$plan->is_active,
|
|
||||||
'has_trial' => $plan->has_trial,
|
'has_trial' => $plan->has_trial,
|
||||||
'savings_percent' => $plan->savings_percent,
|
'savings_percent' => $plan->savings_percent,
|
||||||
];
|
];
|
||||||
|
|||||||
@ -1,192 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Api;
|
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use App\Models\SiteSetting;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\File;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
|
|
||||||
class SiteSettingsController extends Controller
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Get all site settings
|
|
||||||
*/
|
|
||||||
public function index()
|
|
||||||
{
|
|
||||||
$settings = SiteSetting::all()->mapWithKeys(function ($setting) {
|
|
||||||
return [$setting->key => [
|
|
||||||
'value' => $setting->value['value'] ?? $setting->value,
|
|
||||||
'description' => $setting->description,
|
|
||||||
'updated_at' => $setting->updated_at,
|
|
||||||
]];
|
|
||||||
});
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'success' => true,
|
|
||||||
'settings' => $settings,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a specific setting
|
|
||||||
*/
|
|
||||||
public function show(string $key)
|
|
||||||
{
|
|
||||||
$setting = SiteSetting::where('key', $key)->first();
|
|
||||||
|
|
||||||
if (!$setting) {
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'Configuración no encontrada',
|
|
||||||
], 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'success' => true,
|
|
||||||
'key' => $setting->key,
|
|
||||||
'value' => $setting->value['value'] ?? $setting->value,
|
|
||||||
'description' => $setting->description,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update a setting
|
|
||||||
*/
|
|
||||||
public function update(Request $request, string $key)
|
|
||||||
{
|
|
||||||
$request->validate([
|
|
||||||
'value' => 'required',
|
|
||||||
'description' => 'nullable|string',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$setting = SiteSetting::setValue(
|
|
||||||
$key,
|
|
||||||
$request->value,
|
|
||||||
$request->description
|
|
||||||
);
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'success' => true,
|
|
||||||
'message' => 'Configuración actualizada',
|
|
||||||
'setting' => [
|
|
||||||
'key' => $setting->key,
|
|
||||||
'value' => $setting->value['value'] ?? $setting->value,
|
|
||||||
'description' => $setting->description,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggle cnxifly.com page mode
|
|
||||||
*/
|
|
||||||
public function toggleCnxiflyPage(Request $request)
|
|
||||||
{
|
|
||||||
$request->validate([
|
|
||||||
'mode' => 'required|in:live,maintenance,construction',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$mode = $request->mode;
|
|
||||||
|
|
||||||
// Update database setting
|
|
||||||
SiteSetting::setValue('cnxifly_page_type', $mode, 'Tipo de página cnxifly.com');
|
|
||||||
SiteSetting::setValue('cnxifly_maintenance_mode', $mode !== 'live', 'Modo de mantenimiento');
|
|
||||||
|
|
||||||
// Get paths
|
|
||||||
$serverPath = '/var/www/cnxifly';
|
|
||||||
$localLandingPath = base_path('../landing');
|
|
||||||
|
|
||||||
// Log the action
|
|
||||||
Log::info("CnxiFly page mode changed to: {$mode}");
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'success' => true,
|
|
||||||
'message' => "Modo de página cambiado a: {$mode}",
|
|
||||||
'current_mode' => $mode,
|
|
||||||
'instructions' => $this->getInstructions($mode),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get cnxifly page status
|
|
||||||
*/
|
|
||||||
public function getCnxiflyStatus()
|
|
||||||
{
|
|
||||||
$mode = SiteSetting::getValue('cnxifly_page_type', 'live');
|
|
||||||
$maintenanceMode = SiteSetting::getValue('cnxifly_maintenance_mode', false);
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'success' => true,
|
|
||||||
'mode' => $mode,
|
|
||||||
'maintenance_mode' => $maintenanceMode,
|
|
||||||
'modes_available' => [
|
|
||||||
'live' => 'Página institucional completa',
|
|
||||||
'maintenance' => 'Página de mantenimiento',
|
|
||||||
'construction' => 'Página en construcción',
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deploy cnxifly landing page to server
|
|
||||||
*/
|
|
||||||
public function deployCnxiflyPage(Request $request)
|
|
||||||
{
|
|
||||||
$request->validate([
|
|
||||||
'mode' => 'required|in:live,maintenance',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$mode = $request->mode;
|
|
||||||
|
|
||||||
// Source files are in /var/www/cnxifly-pages/ on the server
|
|
||||||
$sourceDir = '/var/www/cnxifly-pages';
|
|
||||||
$targetDir = '/var/www/cnxifly';
|
|
||||||
$sourceFile = $mode === 'live' ? 'index.html' : 'maintenance.html';
|
|
||||||
$sourcePath = "{$sourceDir}/{$sourceFile}";
|
|
||||||
$targetPath = "{$targetDir}/index.html";
|
|
||||||
|
|
||||||
// Check if source file exists
|
|
||||||
if (!File::exists($sourcePath)) {
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'message' => "Archivo de origen no encontrado: {$sourcePath}. Ejecute deploy-landing.sh primero.",
|
|
||||||
], 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy the file
|
|
||||||
try {
|
|
||||||
File::copy($sourcePath, $targetPath);
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'message' => "Error al copiar archivo: " . $e->getMessage(),
|
|
||||||
], 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update setting
|
|
||||||
SiteSetting::setValue('cnxifly_page_type', $mode);
|
|
||||||
SiteSetting::setValue('cnxifly_maintenance_mode', $mode === 'maintenance');
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'success' => true,
|
|
||||||
'message' => "Página cnxifly.com actualizada a modo: {$mode}",
|
|
||||||
'current_mode' => $mode,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getInstructions(string $mode): array
|
|
||||||
{
|
|
||||||
$sourceFile = match($mode) {
|
|
||||||
'live' => 'index.html',
|
|
||||||
'maintenance' => 'maintenance.html',
|
|
||||||
'construction' => 'maintenance.html',
|
|
||||||
};
|
|
||||||
|
|
||||||
return [
|
|
||||||
'description' => "Para aplicar el modo '{$mode}', copie el archivo {$sourceFile} al servidor",
|
|
||||||
'source_file' => $sourceFile,
|
|
||||||
'target_path' => '/var/www/cnxifly/index.html',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -9,10 +9,6 @@
|
|||||||
use App\Services\PayPalService;
|
use App\Services\PayPalService;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use App\Models\EmailVerificationToken;
|
|
||||||
use App\Mail\AccountActivationMail;
|
|
||||||
use App\Mail\SubscriptionCancelledMail;
|
|
||||||
use Illuminate\Support\Facades\Mail;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
|
|
||||||
@ -34,66 +30,18 @@ public function status(Request $request): JsonResponse
|
|||||||
$subscription = $user->subscriptions()->active()->with('plan')->first();
|
$subscription = $user->subscriptions()->active()->with('plan')->first();
|
||||||
$currentPlan = $user->currentPlan();
|
$currentPlan = $user->currentPlan();
|
||||||
|
|
||||||
// Get current usage
|
|
||||||
$usage = [
|
|
||||||
'accounts' => $user->accounts()->count(),
|
|
||||||
'categories' => $user->categories()->count(),
|
|
||||||
'budgets' => $user->budgets()->count(),
|
|
||||||
'transactions' => $user->transactions()->count(),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Calculate usage percentages if plan has limits
|
|
||||||
$limits = $currentPlan?->limits ?? [];
|
|
||||||
$usagePercentages = [];
|
|
||||||
foreach ($usage as $resource => $count) {
|
|
||||||
$limit = $limits[$resource] ?? null;
|
|
||||||
if ($limit !== null && $limit > 0) {
|
|
||||||
$usagePercentages[$resource] = round(($count / $limit) * 100, 1);
|
|
||||||
} else {
|
|
||||||
$usagePercentages[$resource] = null; // unlimited
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate guarantee period info (7 days from subscription creation)
|
|
||||||
$withinGuaranteePeriod = false;
|
|
||||||
$guaranteeDaysRemaining = 0;
|
|
||||||
$guaranteeEndsAt = null;
|
|
||||||
|
|
||||||
if ($subscription && $subscription->created_at) {
|
|
||||||
$guaranteeEndsAt = $subscription->created_at->copy()->addDays(7);
|
|
||||||
$withinGuaranteePeriod = now()->lt($guaranteeEndsAt);
|
|
||||||
$guaranteeDaysRemaining = $withinGuaranteePeriod
|
|
||||||
? (int) ceil(now()->diffInHours($guaranteeEndsAt) / 24)
|
|
||||||
: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'data' => [
|
'data' => [
|
||||||
'has_subscription' => $subscription !== null,
|
'has_subscription' => $subscription !== null,
|
||||||
'subscription' => $subscription ? [
|
|
||||||
'id' => $subscription->id,
|
|
||||||
'status' => $subscription->status,
|
|
||||||
'trial_ends_at' => $subscription->trial_ends_at,
|
|
||||||
'current_period_start' => $subscription->current_period_start,
|
|
||||||
'current_period_end' => $subscription->current_period_end,
|
|
||||||
'canceled_at' => $subscription->canceled_at,
|
|
||||||
'ends_at' => $subscription->ends_at,
|
|
||||||
'on_trial' => $subscription->isOnTrial(),
|
|
||||||
'on_grace_period' => $subscription->onGracePeriod(),
|
|
||||||
] : null,
|
|
||||||
'on_trial' => $subscription?->isOnTrial() ?? false,
|
'on_trial' => $subscription?->isOnTrial() ?? false,
|
||||||
'trial_ends_at' => $subscription?->trial_ends_at,
|
'trial_ends_at' => $subscription?->trial_ends_at,
|
||||||
'days_until_trial_ends' => $subscription?->days_until_trial_ends,
|
'days_until_trial_ends' => $subscription?->days_until_trial_ends,
|
||||||
'current_period_start' => $subscription?->current_period_start,
|
|
||||||
'current_period_end' => $subscription?->current_period_end,
|
'current_period_end' => $subscription?->current_period_end,
|
||||||
'status' => $subscription?->status,
|
'status' => $subscription?->status,
|
||||||
'status_label' => $subscription?->status_label,
|
'status_label' => $subscription?->status_label,
|
||||||
'canceled_at' => $subscription?->canceled_at,
|
'canceled_at' => $subscription?->canceled_at,
|
||||||
'on_grace_period' => $subscription?->onGracePeriod() ?? false,
|
'on_grace_period' => $subscription?->onGracePeriod() ?? false,
|
||||||
'within_guarantee_period' => $withinGuaranteePeriod,
|
|
||||||
'guarantee_days_remaining' => $guaranteeDaysRemaining,
|
|
||||||
'guarantee_ends_at' => $guaranteeEndsAt?->toIso8601String(),
|
|
||||||
'plan' => $currentPlan ? [
|
'plan' => $currentPlan ? [
|
||||||
'id' => $currentPlan->id,
|
'id' => $currentPlan->id,
|
||||||
'slug' => $currentPlan->slug,
|
'slug' => $currentPlan->slug,
|
||||||
@ -105,8 +53,6 @@ public function status(Request $request): JsonResponse
|
|||||||
'features' => $currentPlan->features,
|
'features' => $currentPlan->features,
|
||||||
'limits' => $currentPlan->limits,
|
'limits' => $currentPlan->limits,
|
||||||
] : null,
|
] : null,
|
||||||
'usage' => $usage,
|
|
||||||
'usage_percentages' => $usagePercentages,
|
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@ -198,116 +144,6 @@ public function subscribe(Request $request): JsonResponse
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Start subscription for newly registered user (public - no auth required)
|
|
||||||
* Used immediately after registration, before user is logged in
|
|
||||||
*/
|
|
||||||
public function startSubscription(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
$request->validate([
|
|
||||||
'plan_id' => 'required|integer|exists:plans,id',
|
|
||||||
'user_email' => 'required|email|exists:users,email',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$user = \App\Models\User::where('email', $request->user_email)->first();
|
|
||||||
|
|
||||||
if (!$user) {
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'User not found',
|
|
||||||
], 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify user hasn't already verified email (prevent abuse)
|
|
||||||
if ($user->email_verified_at) {
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'User already activated. Please login.',
|
|
||||||
], 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
$plan = Plan::where('id', $request->plan_id)->where('is_active', true)->first();
|
|
||||||
|
|
||||||
if (!$plan) {
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'Plan not found or inactive',
|
|
||||||
], 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// All plans are paid now - no free subscriptions during registration
|
|
||||||
if ($plan->is_free || $plan->price <= 0) {
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'All plans require payment',
|
|
||||||
], 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if PayPal is configured
|
|
||||||
if (!$this->paypal->isConfigured()) {
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'Payment gateway not configured',
|
|
||||||
], 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if plan has PayPal plan ID
|
|
||||||
if (!$plan->paypal_plan_id) {
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'Plan not configured for payments yet',
|
|
||||||
], 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create PayPal subscription
|
|
||||||
$frontendUrl = config('app.frontend_url', 'https://webmoney.cnxifly.com');
|
|
||||||
$returnUrl = "{$frontendUrl}/payment-success?user_email={$user->email}&plan={$plan->slug}";
|
|
||||||
$cancelUrl = "{$frontendUrl}/register?payment_canceled=true";
|
|
||||||
|
|
||||||
$paypalSubscription = $this->paypal->createSubscription($plan, $returnUrl, $cancelUrl);
|
|
||||||
|
|
||||||
if (!$paypalSubscription) {
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'Failed to create subscription',
|
|
||||||
], 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find approve link
|
|
||||||
$approveUrl = collect($paypalSubscription['links'] ?? [])
|
|
||||||
->firstWhere('rel', 'approve')['href'] ?? null;
|
|
||||||
|
|
||||||
if (!$approveUrl) {
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'No approval URL received',
|
|
||||||
], 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create pending subscription in our DB
|
|
||||||
$subscription = Subscription::create([
|
|
||||||
'user_id' => $user->id,
|
|
||||||
'plan_id' => $plan->id,
|
|
||||||
'status' => Subscription::STATUS_PENDING,
|
|
||||||
'paypal_subscription_id' => $paypalSubscription['id'],
|
|
||||||
'paypal_status' => $paypalSubscription['status'],
|
|
||||||
'paypal_data' => $paypalSubscription,
|
|
||||||
'price_paid' => $plan->price,
|
|
||||||
'currency' => $plan->currency,
|
|
||||||
]);
|
|
||||||
|
|
||||||
\Illuminate\Support\Facades\Log::info("Started subscription for user {$user->email}, PayPal ID: {$paypalSubscription['id']}");
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'success' => true,
|
|
||||||
'data' => [
|
|
||||||
'subscription_id' => $subscription->id,
|
|
||||||
'paypal_subscription_id' => $paypalSubscription['id'],
|
|
||||||
'approve_url' => $approveUrl,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscribe to free plan
|
* Subscribe to free plan
|
||||||
*/
|
*/
|
||||||
@ -388,139 +224,13 @@ public function confirm(Request $request): JsonResponse
|
|||||||
)->markAsPaid($paypalData['id'] ?? null);
|
)->markAsPaid($paypalData['id'] ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send activation email if subscription is active and user not verified yet
|
|
||||||
$activationSent = false;
|
|
||||||
if ($subscription->isActive() && !$user->email_verified_at) {
|
|
||||||
try {
|
|
||||||
$verificationToken = EmailVerificationToken::createForUser($user);
|
|
||||||
$frontendUrl = config('app.frontend_url', 'https://webmoney.cnxifly.com');
|
|
||||||
$activationUrl = "{$frontendUrl}/activate?token={$verificationToken->token}";
|
|
||||||
|
|
||||||
Mail::to($user->email)->send(new AccountActivationMail(
|
|
||||||
$user,
|
|
||||||
$activationUrl,
|
|
||||||
$subscription->plan->name
|
|
||||||
));
|
|
||||||
|
|
||||||
$activationSent = true;
|
|
||||||
Log::info("Activation email sent to {$user->email}");
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
Log::error("Failed to send activation email: " . $e->getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => $activationSent
|
'message' => 'Subscription confirmed',
|
||||||
? 'Suscripción confirmada. Revisa tu email para activar tu cuenta.'
|
|
||||||
: 'Subscription confirmed',
|
|
||||||
'data' => [
|
'data' => [
|
||||||
'status' => $subscription->status,
|
'status' => $subscription->status,
|
||||||
'status_label' => $subscription->status_label,
|
'status_label' => $subscription->status_label,
|
||||||
'plan' => $subscription->plan->name,
|
'plan' => $subscription->plan->name,
|
||||||
'activation_email_sent' => $activationSent,
|
|
||||||
'email_verified' => $user->email_verified_at !== null,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Confirm subscription after PayPal approval (public - no auth required)
|
|
||||||
* Used for new user registration flow
|
|
||||||
*/
|
|
||||||
public function confirmPublic(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
$request->validate([
|
|
||||||
'subscription_id' => 'required|string',
|
|
||||||
'user_email' => 'required|email',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$user = \App\Models\User::where('email', $request->user_email)->first();
|
|
||||||
|
|
||||||
if (!$user) {
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'User not found',
|
|
||||||
], 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$subscription = Subscription::where('paypal_subscription_id', $request->subscription_id)
|
|
||||||
->where('user_id', $user->id)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if (!$subscription) {
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'Subscription not found',
|
|
||||||
], 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get subscription details from PayPal
|
|
||||||
$paypalData = $this->paypal->getSubscription($request->subscription_id);
|
|
||||||
|
|
||||||
if (!$paypalData) {
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'Failed to verify subscription with PayPal',
|
|
||||||
], 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
\Illuminate\Support\Facades\Log::info("PayPal confirmation for {$user->email}: " . json_encode($paypalData));
|
|
||||||
|
|
||||||
// Update subscription based on PayPal status
|
|
||||||
$this->updateSubscriptionFromPayPal($subscription, $paypalData);
|
|
||||||
|
|
||||||
// Cancel other active subscriptions
|
|
||||||
$user->subscriptions()
|
|
||||||
->where('id', '!=', $subscription->id)
|
|
||||||
->active()
|
|
||||||
->update([
|
|
||||||
'status' => Subscription::STATUS_CANCELED,
|
|
||||||
'canceled_at' => now(),
|
|
||||||
'ends_at' => now(),
|
|
||||||
'cancel_reason' => 'Replaced by new subscription',
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Create invoice for the subscription
|
|
||||||
if ($subscription->isActive() && !$subscription->plan->is_free) {
|
|
||||||
Invoice::createForSubscription(
|
|
||||||
$subscription,
|
|
||||||
Invoice::REASON_SUBSCRIPTION_CREATE,
|
|
||||||
"{$subscription->plan->name} - Nueva suscripción"
|
|
||||||
)->markAsPaid($paypalData['id'] ?? null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send activation email if subscription is active and user not verified yet
|
|
||||||
$activationSent = false;
|
|
||||||
if ($subscription->isActive() && !$user->email_verified_at) {
|
|
||||||
try {
|
|
||||||
$verificationToken = EmailVerificationToken::createForUser($user);
|
|
||||||
$frontendUrl = config('app.frontend_url', 'https://webmoney.cnxifly.com');
|
|
||||||
$activationUrl = "{$frontendUrl}/activate?token={$verificationToken->token}";
|
|
||||||
|
|
||||||
Mail::to($user->email)->send(new AccountActivationMail(
|
|
||||||
$user,
|
|
||||||
$activationUrl,
|
|
||||||
$subscription->plan->name
|
|
||||||
));
|
|
||||||
|
|
||||||
$activationSent = true;
|
|
||||||
\Illuminate\Support\Facades\Log::info("Activation email sent to {$user->email}");
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
\Illuminate\Support\Facades\Log::error("Failed to send activation email: " . $e->getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'success' => true,
|
|
||||||
'message' => $activationSent
|
|
||||||
? 'Pagamento confirmado! Verifique seu email para ativar sua conta.'
|
|
||||||
: 'Payment confirmed',
|
|
||||||
'data' => [
|
|
||||||
'status' => $subscription->status,
|
|
||||||
'status_label' => $subscription->status_label,
|
|
||||||
'plan' => $subscription->plan->name,
|
|
||||||
'activation_email_sent' => $activationSent,
|
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@ -533,7 +243,6 @@ public function cancel(Request $request): JsonResponse
|
|||||||
$request->validate([
|
$request->validate([
|
||||||
'reason' => 'nullable|string|max:500',
|
'reason' => 'nullable|string|max:500',
|
||||||
'immediately' => 'nullable|boolean',
|
'immediately' => 'nullable|boolean',
|
||||||
'request_refund' => 'nullable|boolean',
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
@ -546,108 +255,35 @@ public function cancel(Request $request): JsonResponse
|
|||||||
], 404);
|
], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$refundResult = null;
|
// If it's a paid plan, cancel on PayPal
|
||||||
$isWithinGuaranteePeriod = false;
|
|
||||||
|
|
||||||
// Check if within 7-day guarantee period (from subscription creation date)
|
|
||||||
if ($subscription->created_at) {
|
|
||||||
$daysSinceCreation = now()->diffInDays($subscription->created_at);
|
|
||||||
$isWithinGuaranteePeriod = $daysSinceCreation <= 7;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If it's a paid plan with PayPal subscription
|
|
||||||
if ($subscription->paypal_subscription_id && !$subscription->plan->is_free) {
|
if ($subscription->paypal_subscription_id && !$subscription->plan->is_free) {
|
||||||
|
$canceled = $this->paypal->cancelSubscription(
|
||||||
// If user requests refund and is within guarantee period, cancel and refund
|
$subscription->paypal_subscription_id,
|
||||||
if ($request->boolean('request_refund') && $isWithinGuaranteePeriod) {
|
$request->reason ?? 'User requested cancellation'
|
||||||
$refundResult = $this->paypal->cancelAndRefund(
|
);
|
||||||
$subscription->paypal_subscription_id,
|
|
||||||
$request->reason ?? 'Refund within 7-day guarantee period'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!$refundResult['canceled']) {
|
if (!$canceled) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'message' => 'Failed to cancel subscription on PayPal',
|
'message' => 'Failed to cancel subscription on PayPal',
|
||||||
], 500);
|
], 500);
|
||||||
}
|
|
||||||
|
|
||||||
Log::info('Subscription canceled with refund', [
|
|
||||||
'user_id' => $user->id,
|
|
||||||
'subscription_id' => $subscription->id,
|
|
||||||
'refund_result' => $refundResult,
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
// Just cancel without refund
|
|
||||||
$canceled = $this->paypal->cancelSubscription(
|
|
||||||
$subscription->paypal_subscription_id,
|
|
||||||
$request->reason ?? 'User requested cancellation'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!$canceled) {
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'Failed to cancel subscription on PayPal',
|
|
||||||
], 500);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cancel in our DB - always immediately when refunded
|
// Cancel in our DB
|
||||||
$cancelImmediately = $request->boolean('request_refund') || $request->boolean('immediately', false);
|
|
||||||
$subscription->cancel(
|
$subscription->cancel(
|
||||||
$request->reason,
|
$request->reason,
|
||||||
$cancelImmediately
|
$request->boolean('immediately', false)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Send cancellation confirmation email
|
|
||||||
try {
|
|
||||||
$wasRefunded = $refundResult && $refundResult['refunded'];
|
|
||||||
$refundAmount = $wasRefunded && isset($refundResult['refund_amount'])
|
|
||||||
? number_format($refundResult['refund_amount'], 2) . ' ' . ($subscription->plan->currency ?? 'EUR')
|
|
||||||
: null;
|
|
||||||
|
|
||||||
Mail::to($user->email)->send(new SubscriptionCancelledMail(
|
|
||||||
$user,
|
|
||||||
$subscription->plan->name,
|
|
||||||
$wasRefunded,
|
|
||||||
$refundAmount
|
|
||||||
));
|
|
||||||
|
|
||||||
Log::info('Cancellation email sent', [
|
|
||||||
'user_id' => $user->id,
|
|
||||||
'email' => $user->email,
|
|
||||||
'refunded' => $wasRefunded,
|
|
||||||
]);
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
Log::error('Failed to send cancellation email', [
|
|
||||||
'user_id' => $user->id,
|
|
||||||
'error' => $e->getMessage(),
|
|
||||||
]);
|
|
||||||
// Don't fail the cancellation just because email failed
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build response message
|
|
||||||
$message = 'Subscription canceled';
|
|
||||||
if ($refundResult && $refundResult['refunded']) {
|
|
||||||
$message = 'Subscription canceled and refund processed';
|
|
||||||
} elseif ($refundResult && !$refundResult['refunded']) {
|
|
||||||
$message = 'Subscription canceled. Refund could not be processed automatically - please contact support.';
|
|
||||||
} elseif ($cancelImmediately) {
|
|
||||||
$message = 'Subscription canceled immediately';
|
|
||||||
} else {
|
|
||||||
$message = 'Subscription will be canceled at period end';
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => $message,
|
'message' => $request->boolean('immediately')
|
||||||
|
? 'Subscription canceled immediately'
|
||||||
|
: 'Subscription will be canceled at period end',
|
||||||
'data' => [
|
'data' => [
|
||||||
'status' => $subscription->status,
|
'status' => $subscription->status,
|
||||||
'ends_at' => $subscription->ends_at,
|
'ends_at' => $subscription->ends_at,
|
||||||
'refunded' => $refundResult['refunded'] ?? false,
|
|
||||||
'refund_id' => $refundResult['refund_id'] ?? null,
|
|
||||||
'within_guarantee_period' => $isWithinGuaranteePeriod,
|
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@ -716,12 +352,6 @@ public function paypalConfig(): JsonResponse
|
|||||||
*/
|
*/
|
||||||
public function webhook(Request $request): JsonResponse
|
public function webhook(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
// Log all incoming webhooks for debugging
|
|
||||||
Log::channel('single')->info('=== PAYPAL WEBHOOK RECEIVED ===', [
|
|
||||||
'event_type' => $request->input('event_type'),
|
|
||||||
'content' => $request->all(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$webhookId = config('services.paypal.webhook_id');
|
$webhookId = config('services.paypal.webhook_id');
|
||||||
|
|
||||||
// Verify webhook signature (skip in sandbox for testing)
|
// Verify webhook signature (skip in sandbox for testing)
|
||||||
@ -889,39 +519,11 @@ private function updateSubscriptionFromPayPal(Subscription $subscription, array
|
|||||||
switch ($status) {
|
switch ($status) {
|
||||||
case 'ACTIVE':
|
case 'ACTIVE':
|
||||||
$subscription->status = Subscription::STATUS_ACTIVE;
|
$subscription->status = Subscription::STATUS_ACTIVE;
|
||||||
|
|
||||||
// Always set current_period_start to now() on activation if not set
|
|
||||||
if (!$subscription->current_period_start) {
|
|
||||||
$subscription->current_period_start = now();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate period end based on plan interval
|
|
||||||
$plan = $subscription->plan;
|
|
||||||
if ($plan) {
|
|
||||||
$periodEnd = now();
|
|
||||||
if ($plan->interval === 'year') {
|
|
||||||
$periodEnd = now()->addYear();
|
|
||||||
} else {
|
|
||||||
$periodEnd = now()->addMonth();
|
|
||||||
}
|
|
||||||
$subscription->current_period_end = $periodEnd;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only use PayPal dates if they make sense (within reasonable range)
|
|
||||||
if (isset($paypalData['billing_info']['last_payment']['time'])) {
|
|
||||||
$lastPayment = Carbon::parse($paypalData['billing_info']['last_payment']['time']);
|
|
||||||
// Accept if within last 30 days
|
|
||||||
if ($lastPayment->gte(now()->subDays(30)) && $lastPayment->lte(now()->addDay())) {
|
|
||||||
$subscription->current_period_start = $lastPayment;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isset($paypalData['billing_info']['next_billing_time'])) {
|
if (isset($paypalData['billing_info']['next_billing_time'])) {
|
||||||
$nextBilling = Carbon::parse($paypalData['billing_info']['next_billing_time']);
|
$subscription->current_period_end = Carbon::parse($paypalData['billing_info']['next_billing_time']);
|
||||||
// Accept if within next 13 months (reasonable for monthly/yearly plans)
|
}
|
||||||
if ($nextBilling->gt(now()) && $nextBilling->lt(now()->addMonths(13))) {
|
if (isset($paypalData['billing_info']['last_payment']['time'])) {
|
||||||
$subscription->current_period_end = $nextBilling;
|
$subscription->current_period_start = Carbon::parse($paypalData['billing_info']['last_payment']['time']);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|||||||
@ -44,9 +44,6 @@ public function index(Request $request): JsonResponse
|
|||||||
if ($request->has('start_date') && $request->has('end_date')) {
|
if ($request->has('start_date') && $request->has('end_date')) {
|
||||||
$dateField = $request->get('date_field', 'planned_date');
|
$dateField = $request->get('date_field', 'planned_date');
|
||||||
$query->inPeriod($request->start_date, $request->end_date, $dateField);
|
$query->inPeriod($request->start_date, $request->end_date, $dateField);
|
||||||
} else {
|
|
||||||
// Sem filtro de data: não mostrar transações futuras
|
|
||||||
$query->where('planned_date', '<=', now()->toDateString());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Busca por descrição e valores
|
// Busca por descrição e valores
|
||||||
@ -427,9 +424,6 @@ public function byWeek(Request $request): JsonResponse
|
|||||||
// Filtro por período
|
// Filtro por período
|
||||||
if ($request->has('start_date') && $request->has('end_date')) {
|
if ($request->has('start_date') && $request->has('end_date')) {
|
||||||
$query->inPeriod($request->start_date, $request->end_date, $dateField);
|
$query->inPeriod($request->start_date, $request->end_date, $dateField);
|
||||||
} else {
|
|
||||||
// Sem filtro de data: não mostrar transações futuras
|
|
||||||
$query->where('planned_date', '<=', now()->toDateString());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Busca por descrição e valores
|
// Busca por descrição e valores
|
||||||
|
|||||||
@ -1,413 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Api;
|
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Plan;
|
|
||||||
use App\Models\Subscription;
|
|
||||||
use App\Mail\WelcomeNewUser;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Illuminate\Support\Facades\Mail;
|
|
||||||
use Illuminate\Validation\Rules\Password;
|
|
||||||
use Carbon\Carbon;
|
|
||||||
|
|
||||||
class UserManagementController extends Controller
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Create a new user with specified role/plan
|
|
||||||
* user_type: 'free' | 'pro' | 'admin'
|
|
||||||
*/
|
|
||||||
public function store(Request $request)
|
|
||||||
{
|
|
||||||
$validated = $request->validate([
|
|
||||||
'name' => 'required|string|max:255',
|
|
||||||
'email' => 'required|email|unique:users,email',
|
|
||||||
'password' => 'nullable|string|min:8',
|
|
||||||
'language' => 'sometimes|string|in:es,pt-BR,en',
|
|
||||||
'currency' => 'sometimes|string|size:3',
|
|
||||||
'user_type' => 'sometimes|string|in:free,pro,admin',
|
|
||||||
'send_welcome_email' => 'sometimes|boolean',
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Generate random password if not provided or empty
|
|
||||||
$password = !empty($validated['password']) ? $validated['password'] : bin2hex(random_bytes(8));
|
|
||||||
$userType = $validated['user_type'] ?? 'free';
|
|
||||||
$sendWelcomeEmail = $validated['send_welcome_email'] ?? true;
|
|
||||||
|
|
||||||
$user = User::create([
|
|
||||||
'name' => $validated['name'],
|
|
||||||
'email' => $validated['email'],
|
|
||||||
'password' => Hash::make($password),
|
|
||||||
'language' => $validated['language'] ?? 'es',
|
|
||||||
'currency' => $validated['currency'] ?? 'EUR',
|
|
||||||
'email_verified_at' => now(), // Auto-verify admin-created users
|
|
||||||
'is_admin' => $userType === 'admin',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$subscriptionInfo = null;
|
|
||||||
|
|
||||||
// Create Pro subscription if user_type is 'pro' or 'admin'
|
|
||||||
if (in_array($userType, ['pro', 'admin'])) {
|
|
||||||
$proPlan = Plan::where('slug', 'pro-annual')->first();
|
|
||||||
|
|
||||||
if ($proPlan) {
|
|
||||||
$subscription = Subscription::create([
|
|
||||||
'user_id' => $user->id,
|
|
||||||
'plan_id' => $proPlan->id,
|
|
||||||
'status' => Subscription::STATUS_ACTIVE,
|
|
||||||
'current_period_start' => now(),
|
|
||||||
'current_period_end' => Carbon::create(2037, 12, 31, 23, 59, 59), // "Lifetime" subscription (max timestamp)
|
|
||||||
'paypal_subscription_id' => 'ADMIN_GRANTED_' . strtoupper(bin2hex(random_bytes(8))),
|
|
||||||
'paypal_status' => 'ACTIVE',
|
|
||||||
'price_paid' => 0,
|
|
||||||
'currency' => 'EUR',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$subscriptionInfo = [
|
|
||||||
'plan' => $proPlan->name,
|
|
||||||
'status' => 'active',
|
|
||||||
'expires' => 'Nunca (otorgado por admin)',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send welcome email with temporary password
|
|
||||||
$emailSent = false;
|
|
||||||
if ($sendWelcomeEmail) {
|
|
||||||
try {
|
|
||||||
Mail::to($user->email)->send(new WelcomeNewUser($user, $password));
|
|
||||||
$emailSent = true;
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
\Log::error('Failed to send welcome email: ' . $e->getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'success' => true,
|
|
||||||
'message' => 'Usuario creado correctamente',
|
|
||||||
'data' => [
|
|
||||||
'user' => [
|
|
||||||
'id' => $user->id,
|
|
||||||
'name' => $user->name,
|
|
||||||
'email' => $user->email,
|
|
||||||
'is_admin' => $user->is_admin,
|
|
||||||
'language' => $user->language,
|
|
||||||
],
|
|
||||||
'user_type' => $userType,
|
|
||||||
'subscription' => $subscriptionInfo,
|
|
||||||
'temporary_password' => isset($validated['password']) ? null : $password,
|
|
||||||
'welcome_email_sent' => $emailSent,
|
|
||||||
],
|
|
||||||
], 201);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List all users with their subscription info
|
|
||||||
*/
|
|
||||||
public function index(Request $request)
|
|
||||||
{
|
|
||||||
$query = User::with(['subscription.plan'])
|
|
||||||
->withCount(['accounts', 'categories', 'budgets', 'transactions']);
|
|
||||||
|
|
||||||
// Search
|
|
||||||
if ($request->has('search') && $request->search) {
|
|
||||||
$search = $request->search;
|
|
||||||
$query->where(function ($q) use ($search) {
|
|
||||||
$q->where('name', 'like', "%{$search}%")
|
|
||||||
->orWhere('email', 'like', "%{$search}%");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by subscription status
|
|
||||||
if ($request->has('subscription_status')) {
|
|
||||||
if ($request->subscription_status === 'active') {
|
|
||||||
$query->whereHas('subscription');
|
|
||||||
} elseif ($request->subscription_status === 'free') {
|
|
||||||
$query->whereDoesntHave('subscription');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pagination
|
|
||||||
$perPage = $request->get('per_page', 20);
|
|
||||||
$users = $query->orderBy('created_at', 'desc')->paginate($perPage);
|
|
||||||
|
|
||||||
// Transform data
|
|
||||||
$users->getCollection()->transform(function ($user) {
|
|
||||||
return [
|
|
||||||
'id' => $user->id,
|
|
||||||
'name' => $user->name,
|
|
||||||
'email' => $user->email,
|
|
||||||
'language' => $user->language,
|
|
||||||
'currency' => $user->currency,
|
|
||||||
'is_admin' => (bool) $user->is_admin,
|
|
||||||
'created_at' => $user->created_at,
|
|
||||||
'last_login_at' => $user->last_login_at,
|
|
||||||
'email_verified_at' => $user->email_verified_at,
|
|
||||||
'subscription' => $user->subscription ? [
|
|
||||||
'plan_name' => $user->subscription->plan->name ?? 'Unknown',
|
|
||||||
'plan_slug' => $user->subscription->plan->slug ?? 'unknown',
|
|
||||||
'status' => $user->subscription->status,
|
|
||||||
'current_period_end' => $user->subscription->current_period_end,
|
|
||||||
] : null,
|
|
||||||
'usage' => [
|
|
||||||
'accounts' => $user->accounts_count,
|
|
||||||
'categories' => $user->categories_count,
|
|
||||||
'budgets' => $user->budgets_count,
|
|
||||||
'transactions' => $user->transactions_count,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'success' => true,
|
|
||||||
'data' => $users->items(),
|
|
||||||
'pagination' => [
|
|
||||||
'current_page' => $users->currentPage(),
|
|
||||||
'last_page' => $users->lastPage(),
|
|
||||||
'per_page' => $users->perPage(),
|
|
||||||
'total' => $users->total(),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get single user details
|
|
||||||
*/
|
|
||||||
public function show($id)
|
|
||||||
{
|
|
||||||
$user = User::with(['subscription.plan', 'subscriptions.plan'])
|
|
||||||
->withCount(['accounts', 'categories', 'budgets', 'transactions', 'goals'])
|
|
||||||
->findOrFail($id);
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'success' => true,
|
|
||||||
'data' => [
|
|
||||||
'id' => $user->id,
|
|
||||||
'name' => $user->name,
|
|
||||||
'email' => $user->email,
|
|
||||||
'language' => $user->language,
|
|
||||||
'currency' => $user->currency,
|
|
||||||
'created_at' => $user->created_at,
|
|
||||||
'last_login_at' => $user->last_login_at,
|
|
||||||
'email_verified_at' => $user->email_verified_at,
|
|
||||||
'subscription' => $user->subscription ? [
|
|
||||||
'id' => $user->subscription->id,
|
|
||||||
'plan' => $user->subscription->plan,
|
|
||||||
'status' => $user->subscription->status,
|
|
||||||
'paypal_subscription_id' => $user->subscription->paypal_subscription_id,
|
|
||||||
'current_period_start' => $user->subscription->current_period_start,
|
|
||||||
'current_period_end' => $user->subscription->current_period_end,
|
|
||||||
'canceled_at' => $user->subscription->canceled_at,
|
|
||||||
] : null,
|
|
||||||
'subscription_history' => $user->subscriptions->map(function ($sub) {
|
|
||||||
return [
|
|
||||||
'plan_name' => $sub->plan->name ?? 'Unknown',
|
|
||||||
'status' => $sub->status,
|
|
||||||
'created_at' => $sub->created_at,
|
|
||||||
'canceled_at' => $sub->canceled_at,
|
|
||||||
];
|
|
||||||
}),
|
|
||||||
'usage' => [
|
|
||||||
'accounts' => $user->accounts_count,
|
|
||||||
'categories' => $user->categories_count,
|
|
||||||
'budgets' => $user->budgets_count,
|
|
||||||
'transactions' => $user->transactions_count,
|
|
||||||
'goals' => $user->goals_count,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update user
|
|
||||||
*/
|
|
||||||
public function update(Request $request, $id)
|
|
||||||
{
|
|
||||||
$user = User::findOrFail($id);
|
|
||||||
|
|
||||||
// Don't allow changing main admin's admin status
|
|
||||||
if ($user->email === 'marco@cnxifly.com' && $request->has('is_admin') && !$request->is_admin) {
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'No se puede remover permisos del administrador principal',
|
|
||||||
], 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$validated = $request->validate([
|
|
||||||
'name' => 'sometimes|string|max:255',
|
|
||||||
'email' => 'sometimes|email|unique:users,email,' . $id,
|
|
||||||
'language' => 'sometimes|string|in:es,pt-BR,en',
|
|
||||||
'currency' => 'sometimes|string|size:3',
|
|
||||||
'is_admin' => 'sometimes|boolean',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$user->update($validated);
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'success' => true,
|
|
||||||
'message' => 'Usuario actualizado correctamente',
|
|
||||||
'data' => [
|
|
||||||
'id' => $user->id,
|
|
||||||
'name' => $user->name,
|
|
||||||
'email' => $user->email,
|
|
||||||
'language' => $user->language,
|
|
||||||
'currency' => $user->currency,
|
|
||||||
'is_admin' => $user->is_admin,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reset user password (generate random)
|
|
||||||
*/
|
|
||||||
public function resetPassword($id)
|
|
||||||
{
|
|
||||||
$user = User::findOrFail($id);
|
|
||||||
|
|
||||||
// Generate random password
|
|
||||||
$newPassword = bin2hex(random_bytes(8)); // 16 chars
|
|
||||||
$user->password = Hash::make($newPassword);
|
|
||||||
$user->save();
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'success' => true,
|
|
||||||
'message' => 'Contraseña restablecida correctamente',
|
|
||||||
'data' => [
|
|
||||||
'temporary_password' => $newPassword,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete user and all their data
|
|
||||||
*/
|
|
||||||
public function destroy($id)
|
|
||||||
{
|
|
||||||
$user = User::findOrFail($id);
|
|
||||||
|
|
||||||
// Don't allow deleting admin
|
|
||||||
if ($user->email === 'marco@cnxifly.com') {
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'No se puede eliminar el usuario administrador',
|
|
||||||
], 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete user (cascade will handle related data)
|
|
||||||
$user->delete();
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'success' => true,
|
|
||||||
'message' => 'Usuario eliminado correctamente',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Change user subscription plan
|
|
||||||
*/
|
|
||||||
public function changePlan(Request $request, $id)
|
|
||||||
{
|
|
||||||
$user = User::findOrFail($id);
|
|
||||||
|
|
||||||
$validated = $request->validate([
|
|
||||||
'plan' => 'required|string|in:free,pro',
|
|
||||||
]);
|
|
||||||
|
|
||||||
// If changing to free, cancel any existing subscription
|
|
||||||
if ($validated['plan'] === 'free') {
|
|
||||||
$subscription = $user->subscription;
|
|
||||||
if ($subscription) {
|
|
||||||
$subscription->update([
|
|
||||||
'status' => Subscription::STATUS_CANCELED,
|
|
||||||
'canceled_at' => now(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'success' => true,
|
|
||||||
'message' => 'Usuario cambiado a plan Free',
|
|
||||||
'data' => [
|
|
||||||
'plan' => 'free',
|
|
||||||
'subscription' => null,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If changing to pro, create or reactivate subscription
|
|
||||||
if ($validated['plan'] === 'pro') {
|
|
||||||
$proPlan = Plan::where('slug', 'pro-annual')->first();
|
|
||||||
|
|
||||||
if (!$proPlan) {
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'Plan Pro no encontrado en el sistema',
|
|
||||||
], 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user has existing subscription
|
|
||||||
$subscription = $user->subscription;
|
|
||||||
|
|
||||||
if ($subscription) {
|
|
||||||
// Reactivate existing subscription
|
|
||||||
$subscription->update([
|
|
||||||
'status' => Subscription::STATUS_ACTIVE,
|
|
||||||
'canceled_at' => null,
|
|
||||||
'current_period_start' => now(),
|
|
||||||
'current_period_end' => Carbon::create(2037, 12, 31, 23, 59, 59),
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
// Create new subscription
|
|
||||||
$subscription = Subscription::create([
|
|
||||||
'user_id' => $user->id,
|
|
||||||
'plan_id' => $proPlan->id,
|
|
||||||
'status' => Subscription::STATUS_ACTIVE,
|
|
||||||
'current_period_start' => now(),
|
|
||||||
'current_period_end' => Carbon::create(2037, 12, 31, 23, 59, 59),
|
|
||||||
'paypal_subscription_id' => 'ADMIN_GRANTED_' . strtoupper(bin2hex(random_bytes(8))),
|
|
||||||
'paypal_status' => 'ACTIVE',
|
|
||||||
'price_paid' => 0,
|
|
||||||
'currency' => 'EUR',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'success' => true,
|
|
||||||
'message' => 'Usuario cambiado a plan Pro',
|
|
||||||
'data' => [
|
|
||||||
'plan' => 'pro',
|
|
||||||
'subscription' => [
|
|
||||||
'id' => $subscription->id,
|
|
||||||
'plan_name' => $proPlan->name,
|
|
||||||
'status' => $subscription->status,
|
|
||||||
'current_period_end' => $subscription->current_period_end,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get summary statistics
|
|
||||||
*/
|
|
||||||
public function summary()
|
|
||||||
{
|
|
||||||
$totalUsers = User::count();
|
|
||||||
$activeSubscribers = Subscription::where('status', 'active')->count();
|
|
||||||
$freeUsers = $totalUsers - $activeSubscribers;
|
|
||||||
$newUsersThisMonth = User::whereMonth('created_at', now()->month)
|
|
||||||
->whereYear('created_at', now()->year)
|
|
||||||
->count();
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'success' => true,
|
|
||||||
'data' => [
|
|
||||||
'total_users' => $totalUsers,
|
|
||||||
'active_subscribers' => $activeSubscribers,
|
|
||||||
'free_users' => $freeUsers,
|
|
||||||
'new_users_this_month' => $newUsersThisMonth,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Middleware;
|
|
||||||
|
|
||||||
use Closure;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
|
|
||||||
class AdminOnly
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Admin email - only this user can access restricted features
|
|
||||||
*/
|
|
||||||
private const ADMIN_EMAIL = 'marco@cnxifly.com';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle an incoming request.
|
|
||||||
*/
|
|
||||||
public function handle(Request $request, Closure $next): Response
|
|
||||||
{
|
|
||||||
$user = $request->user();
|
|
||||||
|
|
||||||
if (!$user || $user->email !== self::ADMIN_EMAIL) {
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'Access denied. This feature is not available.',
|
|
||||||
], 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $next($request);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,109 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Middleware;
|
|
||||||
|
|
||||||
use Closure;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
|
|
||||||
class CheckPlanLimits
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Resource type to limit mapping
|
|
||||||
*/
|
|
||||||
protected array $resourceLimits = [
|
|
||||||
'accounts' => 'accounts',
|
|
||||||
'categories' => 'categories',
|
|
||||||
'subcategories' => 'subcategories',
|
|
||||||
'budgets' => 'budgets',
|
|
||||||
'transactions' => 'transactions',
|
|
||||||
'goals' => 'goals',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle an incoming request.
|
|
||||||
*/
|
|
||||||
public function handle(Request $request, Closure $next, string $resource): Response
|
|
||||||
{
|
|
||||||
// Only check on store (create) requests
|
|
||||||
if (!in_array($request->method(), ['POST'])) {
|
|
||||||
return $next($request);
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = $request->user();
|
|
||||||
if (!$user) {
|
|
||||||
return $next($request);
|
|
||||||
}
|
|
||||||
|
|
||||||
$plan = $user->currentPlan();
|
|
||||||
if (!$plan) {
|
|
||||||
return $next($request);
|
|
||||||
}
|
|
||||||
|
|
||||||
$limits = $plan->limits ?? [];
|
|
||||||
$limitKey = $this->resourceLimits[$resource] ?? null;
|
|
||||||
|
|
||||||
if (!$limitKey || !isset($limits[$limitKey])) {
|
|
||||||
return $next($request);
|
|
||||||
}
|
|
||||||
|
|
||||||
$limit = $limits[$limitKey];
|
|
||||||
|
|
||||||
// null means unlimited
|
|
||||||
if ($limit === null) {
|
|
||||||
return $next($request);
|
|
||||||
}
|
|
||||||
|
|
||||||
$currentCount = $this->getCurrentCount($user, $resource);
|
|
||||||
|
|
||||||
if ($currentCount >= $limit) {
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'message' => $this->getLimitMessage($resource, $limit),
|
|
||||||
'error' => 'plan_limit_exceeded',
|
|
||||||
'data' => [
|
|
||||||
'resource' => $resource,
|
|
||||||
'current' => $currentCount,
|
|
||||||
'limit' => $limit,
|
|
||||||
'plan' => $plan->name,
|
|
||||||
'upgrade_url' => '/pricing',
|
|
||||||
],
|
|
||||||
], 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $next($request);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current count for a resource
|
|
||||||
*/
|
|
||||||
protected function getCurrentCount($user, string $resource): int
|
|
||||||
{
|
|
||||||
return match ($resource) {
|
|
||||||
'accounts' => $user->accounts()->count(),
|
|
||||||
'categories' => $user->categories()->whereNull('parent_id')->count(),
|
|
||||||
'subcategories' => $user->categories()->whereNotNull('parent_id')->count(),
|
|
||||||
'budgets' => $user->budgets()->count(),
|
|
||||||
'transactions' => $user->transactions()->count(),
|
|
||||||
'goals' => $user->goals()->count(),
|
|
||||||
default => 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get user-friendly limit message
|
|
||||||
*/
|
|
||||||
protected function getLimitMessage(string $resource, int $limit): string
|
|
||||||
{
|
|
||||||
$messages = [
|
|
||||||
'accounts' => "Has alcanzado el límite de {$limit} cuenta(s) de tu plan. Actualiza a Pro para cuentas ilimitadas.",
|
|
||||||
'categories' => "Has alcanzado el límite de {$limit} categorías de tu plan. Actualiza a Pro para categorías ilimitadas.",
|
|
||||||
'subcategories' => "Has alcanzado el límite de {$limit} subcategorías de tu plan. Actualiza a Pro para subcategorías ilimitadas.",
|
|
||||||
'budgets' => "Has alcanzado el límite de {$limit} presupuesto(s) de tu plan. Actualiza a Pro para presupuestos ilimitados.",
|
|
||||||
'transactions' => "Has alcanzado el límite de {$limit} transacciones de tu plan. Actualiza a Pro para transacciones ilimitadas.",
|
|
||||||
'goals' => "Has alcanzado el límite de {$limit} meta(s) de tu plan. Actualiza a Pro para metas ilimitadas.",
|
|
||||||
];
|
|
||||||
|
|
||||||
return $messages[$resource] ?? "Has alcanzado el límite de tu plan para este recurso.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,78 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Middleware;
|
|
||||||
|
|
||||||
use Closure;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
|
|
||||||
class DemoProtection
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Rotas e métodos permitidos para usuário demo (whitelist)
|
|
||||||
* Estas rotas são operações de leitura/cálculo que não modificam dados reais
|
|
||||||
*/
|
|
||||||
protected array $allowedRoutes = [
|
|
||||||
'GET' => ['*'], // Permite todos os GETs
|
|
||||||
'POST' => [
|
|
||||||
'api/login',
|
|
||||||
'api/logout',
|
|
||||||
'api/refresh',
|
|
||||||
'api/accounts/recalculate-all', // Recálculo de saldos (não modifica dados)
|
|
||||||
'api/accounts/*/recalculate', // Recálculo de conta específica
|
|
||||||
'api/categories/match', // Matching de categorias (leitura)
|
|
||||||
'api/cost-centers/match', // Matching de centros de custo (leitura)
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle an incoming request.
|
|
||||||
* Bloqueia operações de escrita (POST/PUT/PATCH/DELETE) para usuário demo
|
|
||||||
*/
|
|
||||||
public function handle(Request $request, Closure $next): Response
|
|
||||||
{
|
|
||||||
$user = $request->user();
|
|
||||||
|
|
||||||
// Se não está logado ou não é demo, permite tudo
|
|
||||||
if (!$user || !$user->is_demo) {
|
|
||||||
return $next($request);
|
|
||||||
}
|
|
||||||
|
|
||||||
$method = $request->method();
|
|
||||||
$path = $request->path();
|
|
||||||
|
|
||||||
// GET sempre permitido para demo
|
|
||||||
if ($method === 'GET') {
|
|
||||||
return $next($request);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verifica se a rota está na whitelist
|
|
||||||
if (isset($this->allowedRoutes[$method])) {
|
|
||||||
foreach ($this->allowedRoutes[$method] as $allowedPath) {
|
|
||||||
if ($allowedPath === '*' || $this->pathMatches($path, $allowedPath)) {
|
|
||||||
return $next($request);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bloqueia a operação para usuário demo
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'Usuario DEMO: Esta acción no está permitida en modo demostración. Los datos no pueden ser modificados.',
|
|
||||||
'demo_mode' => true,
|
|
||||||
], 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifica se o path corresponde ao padrão
|
|
||||||
*/
|
|
||||||
protected function pathMatches(string $path, string $pattern): bool
|
|
||||||
{
|
|
||||||
if ($pattern === '*') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$pattern = str_replace('*', '.*', $pattern);
|
|
||||||
return (bool) preg_match('#^' . $pattern . '$#', $path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Mail;
|
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Bus\Queueable;
|
|
||||||
use Illuminate\Mail\Mailable;
|
|
||||||
use Illuminate\Mail\Mailables\Content;
|
|
||||||
use Illuminate\Mail\Mailables\Envelope;
|
|
||||||
use Illuminate\Queue\SerializesModels;
|
|
||||||
|
|
||||||
class AccountActivationMail extends Mailable
|
|
||||||
{
|
|
||||||
use Queueable, SerializesModels;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
public User $user,
|
|
||||||
public string $activationUrl,
|
|
||||||
public string $planName
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function envelope(): Envelope
|
|
||||||
{
|
|
||||||
return new Envelope(
|
|
||||||
subject: '🎉 Ativa a tua conta WEBMoney - Pagamento confirmado!',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function content(): Content
|
|
||||||
{
|
|
||||||
return new Content(
|
|
||||||
view: 'emails.account-activation',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,122 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Mail;
|
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Subscription;
|
|
||||||
use Illuminate\Bus\Queueable;
|
|
||||||
use Illuminate\Mail\Mailable;
|
|
||||||
use Illuminate\Mail\Mailables\Content;
|
|
||||||
use Illuminate\Mail\Mailables\Envelope;
|
|
||||||
use Illuminate\Mail\Mailables\Address;
|
|
||||||
use Illuminate\Queue\SerializesModels;
|
|
||||||
use Illuminate\Support\Facades\App;
|
|
||||||
|
|
||||||
class SubscriptionCancelledMail extends Mailable
|
|
||||||
{
|
|
||||||
use Queueable, SerializesModels;
|
|
||||||
|
|
||||||
public User $user;
|
|
||||||
public string $planName;
|
|
||||||
public bool $wasRefunded;
|
|
||||||
public ?string $refundAmount;
|
|
||||||
public string $userLocale;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new message instance.
|
|
||||||
*/
|
|
||||||
public function __construct(
|
|
||||||
User $user,
|
|
||||||
string $planName,
|
|
||||||
bool $wasRefunded = false,
|
|
||||||
?string $refundAmount = null
|
|
||||||
) {
|
|
||||||
$this->user = $user;
|
|
||||||
$this->planName = $planName;
|
|
||||||
$this->wasRefunded = $wasRefunded;
|
|
||||||
$this->refundAmount = $refundAmount;
|
|
||||||
$this->userLocale = $user->locale ?? $user->language ?? 'es';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the message envelope.
|
|
||||||
*/
|
|
||||||
public function envelope(): Envelope
|
|
||||||
{
|
|
||||||
// Set locale for translations
|
|
||||||
App::setLocale($this->userLocale);
|
|
||||||
|
|
||||||
$subject = $this->getSubject();
|
|
||||||
|
|
||||||
return new Envelope(
|
|
||||||
from: new Address('no-reply@cnxifly.com', 'WEBMoney - ConneXiFly'),
|
|
||||||
replyTo: [
|
|
||||||
new Address('support@cnxifly.com', $this->getSupportName()),
|
|
||||||
],
|
|
||||||
subject: $subject,
|
|
||||||
tags: ['subscription', 'cancellation', $this->wasRefunded ? 'refund' : 'no-refund'],
|
|
||||||
metadata: [
|
|
||||||
'user_id' => $this->user->id,
|
|
||||||
'user_email' => $this->user->email,
|
|
||||||
'plan' => $this->planName,
|
|
||||||
'refunded' => $this->wasRefunded,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the message content definition.
|
|
||||||
*/
|
|
||||||
public function content(): Content
|
|
||||||
{
|
|
||||||
// Set locale for translations
|
|
||||||
App::setLocale($this->userLocale);
|
|
||||||
|
|
||||||
return new Content(
|
|
||||||
view: 'emails.subscription-cancelled',
|
|
||||||
text: 'emails.subscription-cancelled-text',
|
|
||||||
with: [
|
|
||||||
'userName' => $this->user->name,
|
|
||||||
'userEmail' => $this->user->email,
|
|
||||||
'planName' => $this->planName,
|
|
||||||
'wasRefunded' => $this->wasRefunded,
|
|
||||||
'refundAmount' => $this->refundAmount,
|
|
||||||
'locale' => $this->userLocale,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the subject based on locale
|
|
||||||
*/
|
|
||||||
private function getSubject(): string
|
|
||||||
{
|
|
||||||
$subjects = [
|
|
||||||
'es' => $this->wasRefunded
|
|
||||||
? 'Confirmación de cancelación y reembolso - WEBMoney'
|
|
||||||
: 'Confirmación de cancelación de suscripción - WEBMoney',
|
|
||||||
'pt-BR' => $this->wasRefunded
|
|
||||||
? 'Confirmação de cancelamento e reembolso - WEBMoney'
|
|
||||||
: 'Confirmação de cancelamento de assinatura - WEBMoney',
|
|
||||||
'en' => $this->wasRefunded
|
|
||||||
? 'Cancellation and Refund Confirmation - WEBMoney'
|
|
||||||
: 'Subscription Cancellation Confirmation - WEBMoney',
|
|
||||||
];
|
|
||||||
|
|
||||||
return $subjects[$this->userLocale] ?? $subjects['es'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get support name based on locale
|
|
||||||
*/
|
|
||||||
private function getSupportName(): string
|
|
||||||
{
|
|
||||||
$names = [
|
|
||||||
'es' => 'Soporte WEBMoney',
|
|
||||||
'pt-BR' => 'Suporte WEBMoney',
|
|
||||||
'en' => 'WEBMoney Support',
|
|
||||||
];
|
|
||||||
|
|
||||||
return $names[$this->userLocale] ?? $names['es'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,77 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Mail;
|
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Bus\Queueable;
|
|
||||||
use Illuminate\Mail\Mailable;
|
|
||||||
use Illuminate\Mail\Mailables\Content;
|
|
||||||
use Illuminate\Mail\Mailables\Envelope;
|
|
||||||
use Illuminate\Mail\Mailables\Headers;
|
|
||||||
use Illuminate\Queue\SerializesModels;
|
|
||||||
|
|
||||||
class WelcomeNewUser extends Mailable
|
|
||||||
{
|
|
||||||
use Queueable, SerializesModels;
|
|
||||||
|
|
||||||
public User $user;
|
|
||||||
public string $temporaryPassword;
|
|
||||||
public string $language;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new message instance.
|
|
||||||
*/
|
|
||||||
public function __construct(User $user, string $temporaryPassword)
|
|
||||||
{
|
|
||||||
$this->user = $user;
|
|
||||||
$this->temporaryPassword = $temporaryPassword;
|
|
||||||
$this->language = $user->language ?? 'es';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the message headers.
|
|
||||||
*/
|
|
||||||
public function headers(): Headers
|
|
||||||
{
|
|
||||||
return new Headers(
|
|
||||||
text: [
|
|
||||||
'X-Priority' => '3',
|
|
||||||
'X-Mailer' => 'WebMoney Mailer',
|
|
||||||
'List-Unsubscribe' => '<mailto:unsubscribe@cnxifly.com>',
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the message envelope.
|
|
||||||
*/
|
|
||||||
public function envelope(): Envelope
|
|
||||||
{
|
|
||||||
$subjects = [
|
|
||||||
'es' => 'WebMoney - Credenciales de acceso a tu cuenta',
|
|
||||||
'pt-BR' => 'WebMoney - Credenciais de acesso à sua conta',
|
|
||||||
'en' => 'WebMoney - Your account access credentials',
|
|
||||||
];
|
|
||||||
|
|
||||||
return new Envelope(
|
|
||||||
subject: $subjects[$this->language] ?? $subjects['es'],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the message content definition.
|
|
||||||
*/
|
|
||||||
public function content(): Content
|
|
||||||
{
|
|
||||||
return new Content(
|
|
||||||
view: 'emails.welcome-new-user',
|
|
||||||
text: 'emails.welcome-new-user-text',
|
|
||||||
with: [
|
|
||||||
'user' => $this->user,
|
|
||||||
'temporaryPassword' => $this->temporaryPassword,
|
|
||||||
'language' => $this->language,
|
|
||||||
'loginUrl' => config('app.frontend_url', 'https://webmoney.cnxifly.com') . '/login',
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,351 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
|
|
||||||
class AssetAccount extends Model
|
|
||||||
{
|
|
||||||
use HasFactory, SoftDeletes;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Status do ativo
|
|
||||||
*/
|
|
||||||
public const STATUS_ACTIVE = 'active';
|
|
||||||
public const STATUS_SOLD = 'sold';
|
|
||||||
public const STATUS_DEPRECIATED = 'depreciated';
|
|
||||||
public const STATUS_WRITTEN_OFF = 'written_off';
|
|
||||||
|
|
||||||
public const STATUSES = [
|
|
||||||
self::STATUS_ACTIVE => 'Activo',
|
|
||||||
self::STATUS_SOLD => 'Vendido',
|
|
||||||
self::STATUS_DEPRECIATED => 'Depreciado',
|
|
||||||
self::STATUS_WRITTEN_OFF => 'Dado de baja',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tipos de ativo
|
|
||||||
*/
|
|
||||||
public const ASSET_TYPE_REAL_ESTATE = 'real_estate';
|
|
||||||
public const ASSET_TYPE_VEHICLE = 'vehicle';
|
|
||||||
public const ASSET_TYPE_INVESTMENT = 'investment';
|
|
||||||
public const ASSET_TYPE_EQUIPMENT = 'equipment';
|
|
||||||
public const ASSET_TYPE_INVENTORY = 'inventory';
|
|
||||||
public const ASSET_TYPE_RECEIVABLE = 'receivable';
|
|
||||||
public const ASSET_TYPE_CASH = 'cash';
|
|
||||||
public const ASSET_TYPE_OTHER = 'other';
|
|
||||||
|
|
||||||
public const ASSET_TYPES = [
|
|
||||||
self::ASSET_TYPE_REAL_ESTATE => [
|
|
||||||
'name' => 'Inmueble',
|
|
||||||
'description' => 'Casa, apartamento, terreno, local comercial',
|
|
||||||
'icon' => 'home',
|
|
||||||
'is_depreciable' => true,
|
|
||||||
'fields' => ['property_type', 'address', 'property_area_m2', 'registry_number'],
|
|
||||||
],
|
|
||||||
self::ASSET_TYPE_VEHICLE => [
|
|
||||||
'name' => 'Vehículo',
|
|
||||||
'description' => 'Coche, moto, camión, maquinaria móvil',
|
|
||||||
'icon' => 'truck',
|
|
||||||
'is_depreciable' => true,
|
|
||||||
'fields' => ['vehicle_brand', 'vehicle_model', 'vehicle_year', 'vehicle_plate', 'vehicle_mileage'],
|
|
||||||
],
|
|
||||||
self::ASSET_TYPE_INVESTMENT => [
|
|
||||||
'name' => 'Inversión',
|
|
||||||
'description' => 'Acciones, fondos, bonos, criptomonedas',
|
|
||||||
'icon' => 'chart-bar',
|
|
||||||
'is_depreciable' => false,
|
|
||||||
'fields' => ['investment_type', 'institution', 'ticker', 'quantity', 'unit_price'],
|
|
||||||
],
|
|
||||||
self::ASSET_TYPE_EQUIPMENT => [
|
|
||||||
'name' => 'Equipamiento',
|
|
||||||
'description' => 'Maquinaria, herramientas, ordenadores',
|
|
||||||
'icon' => 'cog',
|
|
||||||
'is_depreciable' => true,
|
|
||||||
'fields' => ['equipment_brand', 'equipment_model', 'serial_number', 'warranty_expiry'],
|
|
||||||
],
|
|
||||||
self::ASSET_TYPE_INVENTORY => [
|
|
||||||
'name' => 'Inventario',
|
|
||||||
'description' => 'Stock, mercancías, materias primas',
|
|
||||||
'icon' => 'archive-box',
|
|
||||||
'is_depreciable' => false,
|
|
||||||
'fields' => ['quantity'],
|
|
||||||
],
|
|
||||||
self::ASSET_TYPE_RECEIVABLE => [
|
|
||||||
'name' => 'Cuenta por Cobrar',
|
|
||||||
'description' => 'Créditos, préstamos otorgados',
|
|
||||||
'icon' => 'banknotes',
|
|
||||||
'is_depreciable' => false,
|
|
||||||
'fields' => ['debtor_name', 'receivable_due_date', 'receivable_amount'],
|
|
||||||
],
|
|
||||||
self::ASSET_TYPE_CASH => [
|
|
||||||
'name' => 'Efectivo/Caja',
|
|
||||||
'description' => 'Dinero en efectivo, caja chica',
|
|
||||||
'icon' => 'currency-euro',
|
|
||||||
'is_depreciable' => false,
|
|
||||||
'fields' => [],
|
|
||||||
],
|
|
||||||
self::ASSET_TYPE_OTHER => [
|
|
||||||
'name' => 'Otro',
|
|
||||||
'description' => 'Otros tipos de activos',
|
|
||||||
'icon' => 'document-text',
|
|
||||||
'is_depreciable' => false,
|
|
||||||
'fields' => [],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tipos de imóvel
|
|
||||||
*/
|
|
||||||
public const PROPERTY_TYPES = [
|
|
||||||
'house' => 'Casa',
|
|
||||||
'apartment' => 'Apartamento/Piso',
|
|
||||||
'land' => 'Terreno',
|
|
||||||
'commercial' => 'Local Comercial',
|
|
||||||
'industrial' => 'Nave Industrial',
|
|
||||||
'office' => 'Oficina',
|
|
||||||
'parking' => 'Plaza de Garaje',
|
|
||||||
'warehouse' => 'Almacén',
|
|
||||||
'rural' => 'Finca Rústica',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tipos de investimento
|
|
||||||
*/
|
|
||||||
public const INVESTMENT_TYPES = [
|
|
||||||
'stocks' => ['name' => 'Acciones', 'description' => 'Participaciones en empresas cotizadas'],
|
|
||||||
'bonds' => ['name' => 'Bonos', 'description' => 'Títulos de deuda pública o privada'],
|
|
||||||
'funds' => ['name' => 'Fondos de Inversión', 'description' => 'Fondos mutuos, ETFs'],
|
|
||||||
'fixed_income' => ['name' => 'Renta Fija', 'description' => 'Depósitos, letras del tesoro'],
|
|
||||||
'crypto' => ['name' => 'Criptomonedas', 'description' => 'Bitcoin, Ethereum, etc.'],
|
|
||||||
'real_estate_fund' => ['name' => 'Fondo Inmobiliario', 'description' => 'REITs, SOCIMIs'],
|
|
||||||
'pension' => ['name' => 'Plan de Pensiones', 'description' => 'Ahorro para jubilación'],
|
|
||||||
'savings' => ['name' => 'Cuenta de Ahorro', 'description' => 'Cuentas remuneradas'],
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Métodos de depreciação
|
|
||||||
*/
|
|
||||||
public const DEPRECIATION_METHODS = [
|
|
||||||
'linear' => ['name' => 'Lineal', 'description' => 'Depreciación constante cada año'],
|
|
||||||
'declining_balance' => ['name' => 'Saldo Decreciente', 'description' => 'Mayor depreciación al inicio'],
|
|
||||||
'units_production' => ['name' => 'Unidades Producidas', 'description' => 'Según uso/producción'],
|
|
||||||
'sum_years' => ['name' => 'Suma de Años', 'description' => 'Depreciación acelerada'],
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tipos de indexadores (para investimentos)
|
|
||||||
*/
|
|
||||||
public const INDEX_TYPES = [
|
|
||||||
'fixed' => ['name' => 'Tasa Fija', 'description' => 'Rentabilidad fija'],
|
|
||||||
'cdi' => ['name' => 'CDI', 'description' => '% del CDI (Brasil)'],
|
|
||||||
'selic' => ['name' => 'SELIC', 'description' => 'Tasa SELIC (Brasil)'],
|
|
||||||
'ipca' => ['name' => 'IPCA+', 'description' => 'Inflación + spread (Brasil)'],
|
|
||||||
'euribor' => ['name' => 'Euribor+', 'description' => 'Euribor + spread (UE)'],
|
|
||||||
'ibex' => ['name' => 'IBEX 35', 'description' => 'Índice bursátil español'],
|
|
||||||
'sp500' => ['name' => 'S&P 500', 'description' => 'Índice bursátil americano'],
|
|
||||||
];
|
|
||||||
|
|
||||||
protected $fillable = [
|
|
||||||
'user_id',
|
|
||||||
'business_id',
|
|
||||||
'asset_type',
|
|
||||||
'name',
|
|
||||||
'description',
|
|
||||||
'currency',
|
|
||||||
'color',
|
|
||||||
|
|
||||||
// Valores
|
|
||||||
'acquisition_value',
|
|
||||||
'current_value',
|
|
||||||
'acquisition_date',
|
|
||||||
|
|
||||||
// Depreciação
|
|
||||||
'is_depreciable',
|
|
||||||
'depreciation_method',
|
|
||||||
'useful_life_years',
|
|
||||||
'residual_value',
|
|
||||||
'accumulated_depreciation',
|
|
||||||
|
|
||||||
// Imóveis
|
|
||||||
'property_type',
|
|
||||||
'address',
|
|
||||||
'city',
|
|
||||||
'state',
|
|
||||||
'postal_code',
|
|
||||||
'country',
|
|
||||||
'property_area_m2',
|
|
||||||
'registry_number',
|
|
||||||
|
|
||||||
// Veículos
|
|
||||||
'vehicle_brand',
|
|
||||||
'vehicle_model',
|
|
||||||
'vehicle_year',
|
|
||||||
'vehicle_plate',
|
|
||||||
'vehicle_vin',
|
|
||||||
'vehicle_mileage',
|
|
||||||
|
|
||||||
// Investimentos
|
|
||||||
'investment_type',
|
|
||||||
'institution',
|
|
||||||
'account_number',
|
|
||||||
'quantity',
|
|
||||||
'unit_price',
|
|
||||||
'ticker',
|
|
||||||
'maturity_date',
|
|
||||||
'interest_rate',
|
|
||||||
'index_type',
|
|
||||||
|
|
||||||
// Equipamentos
|
|
||||||
'equipment_brand',
|
|
||||||
'equipment_model',
|
|
||||||
'serial_number',
|
|
||||||
'warranty_expiry',
|
|
||||||
|
|
||||||
// Recebíveis
|
|
||||||
'debtor_name',
|
|
||||||
'debtor_document',
|
|
||||||
'receivable_due_date',
|
|
||||||
'receivable_amount',
|
|
||||||
|
|
||||||
// Garantias
|
|
||||||
'is_collateral',
|
|
||||||
'collateral_for',
|
|
||||||
'linked_liability_id',
|
|
||||||
|
|
||||||
// Seguros
|
|
||||||
'has_insurance',
|
|
||||||
'insurance_company',
|
|
||||||
'insurance_policy',
|
|
||||||
'insurance_value',
|
|
||||||
'insurance_expiry',
|
|
||||||
|
|
||||||
// Gestão
|
|
||||||
'alert_days_before',
|
|
||||||
'internal_responsible',
|
|
||||||
'internal_notes',
|
|
||||||
'document_number',
|
|
||||||
|
|
||||||
// Status
|
|
||||||
'status',
|
|
||||||
'disposal_date',
|
|
||||||
'disposal_value',
|
|
||||||
'disposal_reason',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'acquisition_value' => 'decimal:2',
|
|
||||||
'current_value' => 'decimal:2',
|
|
||||||
'residual_value' => 'decimal:2',
|
|
||||||
'accumulated_depreciation' => 'decimal:2',
|
|
||||||
'property_area_m2' => 'decimal:2',
|
|
||||||
'useful_life_years' => 'decimal:2',
|
|
||||||
'unit_price' => 'decimal:6',
|
|
||||||
'insurance_value' => 'decimal:2',
|
|
||||||
'receivable_amount' => 'decimal:2',
|
|
||||||
'disposal_value' => 'decimal:2',
|
|
||||||
'interest_rate' => 'decimal:4',
|
|
||||||
'is_depreciable' => 'boolean',
|
|
||||||
'is_collateral' => 'boolean',
|
|
||||||
'has_insurance' => 'boolean',
|
|
||||||
'acquisition_date' => 'date',
|
|
||||||
'maturity_date' => 'date',
|
|
||||||
'warranty_expiry' => 'date',
|
|
||||||
'receivable_due_date' => 'date',
|
|
||||||
'insurance_expiry' => 'date',
|
|
||||||
'disposal_date' => 'date',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Relações
|
|
||||||
*/
|
|
||||||
public function user(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(User::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function business(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Business::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function linkedLiability(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(LiabilityAccount::class, 'linked_liability_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scopes
|
|
||||||
*/
|
|
||||||
public function scopeActive($query)
|
|
||||||
{
|
|
||||||
return $query->where('status', self::STATUS_ACTIVE);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function scopeOfType($query, string $type)
|
|
||||||
{
|
|
||||||
return $query->where('asset_type', $type);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function scopeDepreciable($query)
|
|
||||||
{
|
|
||||||
return $query->where('is_depreciable', true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Accessors
|
|
||||||
*/
|
|
||||||
public function getAssetTypeNameAttribute(): string
|
|
||||||
{
|
|
||||||
return self::ASSET_TYPES[$this->asset_type]['name'] ?? $this->asset_type;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getStatusNameAttribute(): string
|
|
||||||
{
|
|
||||||
return self::STATUSES[$this->status] ?? $this->status;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getNetValueAttribute(): float
|
|
||||||
{
|
|
||||||
return $this->current_value - $this->accumulated_depreciation;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getGainLossAttribute(): float
|
|
||||||
{
|
|
||||||
return $this->current_value - $this->acquisition_value;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getGainLossPercentAttribute(): float
|
|
||||||
{
|
|
||||||
if ($this->acquisition_value == 0) return 0;
|
|
||||||
return (($this->current_value - $this->acquisition_value) / $this->acquisition_value) * 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calcular depreciação anual (método linear)
|
|
||||||
*/
|
|
||||||
public function calculateAnnualDepreciation(): float
|
|
||||||
{
|
|
||||||
if (!$this->is_depreciable || !$this->useful_life_years || $this->useful_life_years == 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
$depreciableValue = $this->acquisition_value - ($this->residual_value ?? 0);
|
|
||||||
return $depreciableValue / $this->useful_life_years;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verificar se seguro está próximo do vencimento
|
|
||||||
*/
|
|
||||||
public function isInsuranceExpiringSoon(int $days = 30): bool
|
|
||||||
{
|
|
||||||
if (!$this->has_insurance || !$this->insurance_expiry) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->insurance_expiry->diffInDays(now()) <= $days;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,72 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
|
|
||||||
class EmailVerificationToken extends Model
|
|
||||||
{
|
|
||||||
protected $fillable = [
|
|
||||||
'user_id',
|
|
||||||
'token',
|
|
||||||
'expires_at',
|
|
||||||
'used_at',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'expires_at' => 'datetime',
|
|
||||||
'used_at' => 'datetime',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Relationship: User
|
|
||||||
*/
|
|
||||||
public function user(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(User::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new verification token for user
|
|
||||||
*/
|
|
||||||
public static function createForUser(User $user, int $expiresInHours = 24): self
|
|
||||||
{
|
|
||||||
// Invalidate existing tokens
|
|
||||||
self::where('user_id', $user->id)->whereNull('used_at')->delete();
|
|
||||||
|
|
||||||
return self::create([
|
|
||||||
'user_id' => $user->id,
|
|
||||||
'token' => Str::random(64),
|
|
||||||
'expires_at' => now()->addHours($expiresInHours),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find valid token
|
|
||||||
*/
|
|
||||||
public static function findValid(string $token): ?self
|
|
||||||
{
|
|
||||||
return self::where('token', $token)
|
|
||||||
->where('expires_at', '>', now())
|
|
||||||
->whereNull('used_at')
|
|
||||||
->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if token is valid
|
|
||||||
*/
|
|
||||||
public function isValid(): bool
|
|
||||||
{
|
|
||||||
return $this->expires_at > now() && $this->used_at === null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark token as used
|
|
||||||
*/
|
|
||||||
public function markAsUsed(): void
|
|
||||||
{
|
|
||||||
$this->update(['used_at' => now()]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -21,158 +21,16 @@ class LiabilityAccount extends Model
|
|||||||
public const STATUS_RENEGOTIATED = 'renegotiated';
|
public const STATUS_RENEGOTIATED = 'renegotiated';
|
||||||
|
|
||||||
public const STATUSES = [
|
public const STATUSES = [
|
||||||
self::STATUS_ACTIVE => 'Activo',
|
self::STATUS_ACTIVE => 'Ativo',
|
||||||
self::STATUS_PAID_OFF => 'Liquidado',
|
self::STATUS_PAID_OFF => 'Quitado',
|
||||||
self::STATUS_DEFAULTED => 'En mora',
|
self::STATUS_DEFAULTED => 'Inadimplente',
|
||||||
self::STATUS_RENEGOTIATED => 'Renegociado',
|
self::STATUS_RENEGOTIATED => 'Renegociado',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
|
||||||
* Tipos de contrato
|
|
||||||
*/
|
|
||||||
public const CONTRACT_TYPE_PERSONAL_LOAN = 'personal_loan';
|
|
||||||
public const CONTRACT_TYPE_VEHICLE = 'vehicle_financing';
|
|
||||||
public const CONTRACT_TYPE_MORTGAGE = 'mortgage';
|
|
||||||
public const CONTRACT_TYPE_CREDIT_CARD = 'credit_card';
|
|
||||||
public const CONTRACT_TYPE_CONSORTIUM = 'consortium';
|
|
||||||
public const CONTRACT_TYPE_LEASING = 'leasing';
|
|
||||||
public const CONTRACT_TYPE_OVERDRAFT = 'overdraft';
|
|
||||||
public const CONTRACT_TYPE_PAYROLL_LOAN = 'payroll_loan';
|
|
||||||
public const CONTRACT_TYPE_OTHER = 'other';
|
|
||||||
|
|
||||||
public const CONTRACT_TYPES = [
|
|
||||||
self::CONTRACT_TYPE_PERSONAL_LOAN => [
|
|
||||||
'name' => 'Préstamo Personal',
|
|
||||||
'description' => 'Préstamo con cuotas fijas (Sistema PRICE)',
|
|
||||||
'icon' => 'banknotes',
|
|
||||||
'default_amortization' => 'price',
|
|
||||||
'fields' => ['principal_amount', 'annual_interest_rate', 'total_installments', 'start_date', 'first_due_date'],
|
|
||||||
],
|
|
||||||
self::CONTRACT_TYPE_VEHICLE => [
|
|
||||||
'name' => 'Financiación de Vehículo',
|
|
||||||
'description' => 'Crédito para compra de coche o moto',
|
|
||||||
'icon' => 'truck',
|
|
||||||
'default_amortization' => 'price',
|
|
||||||
'fields' => ['principal_amount', 'annual_interest_rate', 'total_installments', 'start_date', 'first_due_date', 'asset_value'],
|
|
||||||
],
|
|
||||||
self::CONTRACT_TYPE_MORTGAGE => [
|
|
||||||
'name' => 'Hipoteca / Financiación Inmobiliaria',
|
|
||||||
'description' => 'Crédito para compra de inmueble',
|
|
||||||
'icon' => 'home',
|
|
||||||
'default_amortization' => 'sac',
|
|
||||||
'fields' => ['principal_amount', 'annual_interest_rate', 'total_installments', 'start_date', 'first_due_date', 'asset_value', 'insurance_amount'],
|
|
||||||
],
|
|
||||||
self::CONTRACT_TYPE_CREDIT_CARD => [
|
|
||||||
'name' => 'Tarjeta de Crédito',
|
|
||||||
'description' => 'Financiación de compras a plazos',
|
|
||||||
'icon' => 'credit-card',
|
|
||||||
'default_amortization' => 'price',
|
|
||||||
'fields' => ['principal_amount', 'annual_interest_rate', 'total_installments', 'first_due_date'],
|
|
||||||
],
|
|
||||||
self::CONTRACT_TYPE_CONSORTIUM => [
|
|
||||||
'name' => 'Consorcio',
|
|
||||||
'description' => 'Grupo de compras con cuotas variables',
|
|
||||||
'icon' => 'users',
|
|
||||||
'default_amortization' => 'consortium',
|
|
||||||
'fields' => ['principal_amount', 'total_installments', 'admin_fee_percent', 'start_date', 'first_due_date'],
|
|
||||||
],
|
|
||||||
self::CONTRACT_TYPE_LEASING => [
|
|
||||||
'name' => 'Leasing',
|
|
||||||
'description' => 'Arrendamiento con opción de compra',
|
|
||||||
'icon' => 'key',
|
|
||||||
'default_amortization' => 'price',
|
|
||||||
'fields' => ['principal_amount', 'annual_interest_rate', 'total_installments', 'start_date', 'first_due_date', 'residual_value'],
|
|
||||||
],
|
|
||||||
self::CONTRACT_TYPE_OVERDRAFT => [
|
|
||||||
'name' => 'Descubierto / Cheque Especial',
|
|
||||||
'description' => 'Línea de crédito rotativa',
|
|
||||||
'icon' => 'arrow-trending-down',
|
|
||||||
'default_amortization' => 'american',
|
|
||||||
'fields' => ['principal_amount', 'monthly_interest_rate'],
|
|
||||||
],
|
|
||||||
self::CONTRACT_TYPE_PAYROLL_LOAN => [
|
|
||||||
'name' => 'Préstamo con Nómina',
|
|
||||||
'description' => 'Crédito con descuento en nómina',
|
|
||||||
'icon' => 'briefcase',
|
|
||||||
'default_amortization' => 'price',
|
|
||||||
'fields' => ['principal_amount', 'annual_interest_rate', 'total_installments', 'start_date', 'first_due_date'],
|
|
||||||
],
|
|
||||||
self::CONTRACT_TYPE_OTHER => [
|
|
||||||
'name' => 'Otro',
|
|
||||||
'description' => 'Otro tipo de pasivo',
|
|
||||||
'icon' => 'document-text',
|
|
||||||
'default_amortization' => 'price',
|
|
||||||
'fields' => ['principal_amount', 'total_installments', 'first_due_date'],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sistemas de amortização
|
|
||||||
*/
|
|
||||||
public const AMORTIZATION_PRICE = 'price';
|
|
||||||
public const AMORTIZATION_SAC = 'sac';
|
|
||||||
public const AMORTIZATION_AMERICAN = 'american';
|
|
||||||
public const AMORTIZATION_CONSORTIUM = 'consortium';
|
|
||||||
|
|
||||||
public const AMORTIZATION_SYSTEMS = [
|
|
||||||
self::AMORTIZATION_PRICE => [
|
|
||||||
'name' => 'PRICE (Cuota Fija)',
|
|
||||||
'description' => 'Cuotas iguales. Intereses decrecientes, amortización creciente.',
|
|
||||||
],
|
|
||||||
self::AMORTIZATION_SAC => [
|
|
||||||
'name' => 'SAC (Amortización Constante)',
|
|
||||||
'description' => 'Amortización fija. Cuotas e intereses decrecientes.',
|
|
||||||
],
|
|
||||||
self::AMORTIZATION_AMERICAN => [
|
|
||||||
'name' => 'Americano',
|
|
||||||
'description' => 'Solo intereses durante el plazo, principal al final.',
|
|
||||||
],
|
|
||||||
self::AMORTIZATION_CONSORTIUM => [
|
|
||||||
'name' => 'Consorcio',
|
|
||||||
'description' => 'Cuotas variables según el grupo.',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tipos de indexadores
|
|
||||||
*/
|
|
||||||
public const INDEX_TYPES = [
|
|
||||||
'fixed' => ['name' => 'Tasa Fija', 'description' => 'Sin indexación'],
|
|
||||||
'cdi' => ['name' => 'CDI', 'description' => 'Certificado de Depósito Interbancario (Brasil)'],
|
|
||||||
'selic' => ['name' => 'SELIC', 'description' => 'Tasa básica de interés (Brasil)'],
|
|
||||||
'ipca' => ['name' => 'IPCA', 'description' => 'Índice de precios al consumidor (Brasil)'],
|
|
||||||
'igpm' => ['name' => 'IGP-M', 'description' => 'Índice general de precios (Brasil)'],
|
|
||||||
'tr' => ['name' => 'TR', 'description' => 'Tasa referencial (Brasil)'],
|
|
||||||
'euribor' => ['name' => 'Euribor', 'description' => 'Euro Interbank Offered Rate (UE)'],
|
|
||||||
'libor' => ['name' => 'LIBOR', 'description' => 'London Interbank Offered Rate'],
|
|
||||||
'sofr' => ['name' => 'SOFR', 'description' => 'Secured Overnight Financing Rate (EUA)'],
|
|
||||||
'prime' => ['name' => 'Prime Rate', 'description' => 'Tasa preferencial (EUA)'],
|
|
||||||
'ipc' => ['name' => 'IPC', 'description' => 'Índice de precios al consumidor (España)'],
|
|
||||||
'other' => ['name' => 'Otro', 'description' => 'Otro indexador'],
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tipos de garantia
|
|
||||||
*/
|
|
||||||
public const GUARANTEE_TYPES = [
|
|
||||||
'none' => ['name' => 'Sin garantía', 'description' => 'Préstamo sin garantía'],
|
|
||||||
'fiduciary_alienation' => ['name' => 'Alienación Fiduciaria', 'description' => 'El bien queda en garantía hasta el pago total'],
|
|
||||||
'mortgage' => ['name' => 'Hipoteca', 'description' => 'Garantía sobre inmueble'],
|
|
||||||
'pledge' => ['name' => 'Prenda', 'description' => 'Garantía sobre bien mueble'],
|
|
||||||
'guarantor' => ['name' => 'Fiador/Avalista', 'description' => 'Persona que garantiza la deuda'],
|
|
||||||
'payroll' => ['name' => 'Descuento en Nómina', 'description' => 'Descuento directo del salario'],
|
|
||||||
'investment' => ['name' => 'Inversión', 'description' => 'Garantía con inversiones/aplicaciones'],
|
|
||||||
'letter_of_credit' => ['name' => 'Carta de Crédito', 'description' => 'Garantía bancaria'],
|
|
||||||
'surety_bond' => ['name' => 'Seguro Fianza', 'description' => 'Seguro que garantiza la obligación'],
|
|
||||||
'other' => ['name' => 'Otra', 'description' => 'Otro tipo de garantía'],
|
|
||||||
];
|
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'user_id',
|
'user_id',
|
||||||
'account_id',
|
'account_id',
|
||||||
'name',
|
'name',
|
||||||
'contract_type',
|
|
||||||
'amortization_system',
|
|
||||||
'contract_number',
|
'contract_number',
|
||||||
'creditor',
|
'creditor',
|
||||||
'description',
|
'description',
|
||||||
@ -194,39 +52,11 @@ class LiabilityAccount extends Model
|
|||||||
'start_date',
|
'start_date',
|
||||||
'end_date',
|
'end_date',
|
||||||
'first_due_date',
|
'first_due_date',
|
||||||
'has_grace_period',
|
|
||||||
'grace_period_months',
|
|
||||||
'currency',
|
'currency',
|
||||||
'color',
|
'color',
|
||||||
'icon',
|
'icon',
|
||||||
'status',
|
'status',
|
||||||
'is_active',
|
'is_active',
|
||||||
// Campos avançados - Indexadores
|
|
||||||
'index_type',
|
|
||||||
'index_spread',
|
|
||||||
'total_effective_cost',
|
|
||||||
// Campos avançados - Garantias
|
|
||||||
'guarantee_type',
|
|
||||||
'guarantee_value',
|
|
||||||
'guarantee_description',
|
|
||||||
'guarantor_name',
|
|
||||||
// Campos avançados - Penalidades
|
|
||||||
'late_fee_percent',
|
|
||||||
'daily_penalty_percent',
|
|
||||||
'grace_days_for_penalty',
|
|
||||||
// Campos avançados - Específicos por tipo
|
|
||||||
'asset_value',
|
|
||||||
'asset_description',
|
|
||||||
'residual_value',
|
|
||||||
'admin_fee_percent',
|
|
||||||
'reserve_fund_percent',
|
|
||||||
// Campos avançados - Covenants e gestão
|
|
||||||
'covenants',
|
|
||||||
'alert_days_before',
|
|
||||||
'internal_responsible',
|
|
||||||
'internal_notes',
|
|
||||||
'document_number',
|
|
||||||
'registry_office',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
@ -246,21 +76,6 @@ class LiabilityAccount extends Model
|
|||||||
'end_date' => 'date',
|
'end_date' => 'date',
|
||||||
'first_due_date' => 'date',
|
'first_due_date' => 'date',
|
||||||
'is_active' => 'boolean',
|
'is_active' => 'boolean',
|
||||||
'has_grace_period' => 'boolean',
|
|
||||||
'grace_period_months' => 'integer',
|
|
||||||
// Campos avançados
|
|
||||||
'index_spread' => 'decimal:4',
|
|
||||||
'total_effective_cost' => 'decimal:4',
|
|
||||||
'guarantee_value' => 'decimal:2',
|
|
||||||
'late_fee_percent' => 'decimal:2',
|
|
||||||
'daily_penalty_percent' => 'decimal:4',
|
|
||||||
'grace_days_for_penalty' => 'integer',
|
|
||||||
'asset_value' => 'decimal:2',
|
|
||||||
'residual_value' => 'decimal:2',
|
|
||||||
'admin_fee_percent' => 'decimal:2',
|
|
||||||
'reserve_fund_percent' => 'decimal:2',
|
|
||||||
'covenants' => 'array',
|
|
||||||
'alert_days_before' => 'integer',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $appends = ['progress_percentage', 'remaining_balance'];
|
protected $appends = ['progress_percentage', 'remaining_balance'];
|
||||||
|
|||||||
@ -22,7 +22,6 @@ class Plan extends Model
|
|||||||
'limits',
|
'limits',
|
||||||
'is_active',
|
'is_active',
|
||||||
'is_featured',
|
'is_featured',
|
||||||
'is_free',
|
|
||||||
'sort_order',
|
'sort_order',
|
||||||
'paypal_plan_id',
|
'paypal_plan_id',
|
||||||
];
|
];
|
||||||
@ -33,7 +32,6 @@ class Plan extends Model
|
|||||||
'limits' => 'array',
|
'limits' => 'array',
|
||||||
'is_active' => 'boolean',
|
'is_active' => 'boolean',
|
||||||
'is_featured' => 'boolean',
|
'is_featured' => 'boolean',
|
||||||
'is_free' => 'boolean',
|
|
||||||
'trial_days' => 'integer',
|
'trial_days' => 'integer',
|
||||||
'sort_order' => 'integer',
|
'sort_order' => 'integer',
|
||||||
];
|
];
|
||||||
|
|||||||
@ -1,52 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
|
|
||||||
class SiteSetting extends Model
|
|
||||||
{
|
|
||||||
protected $fillable = [
|
|
||||||
'key',
|
|
||||||
'value',
|
|
||||||
'description',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'value' => 'array',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a setting value by key
|
|
||||||
*/
|
|
||||||
public static function getValue(string $key, $default = null)
|
|
||||||
{
|
|
||||||
$setting = static::where('key', $key)->first();
|
|
||||||
|
|
||||||
if (!$setting) {
|
|
||||||
return $default;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If value is a simple string stored as JSON array, extract it
|
|
||||||
$value = $setting->value;
|
|
||||||
if (is_array($value) && isset($value['value'])) {
|
|
||||||
return $value['value'];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $value ?? $default;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set a setting value by key
|
|
||||||
*/
|
|
||||||
public static function setValue(string $key, $value, string $description = null): self
|
|
||||||
{
|
|
||||||
return static::updateOrCreate(
|
|
||||||
['key' => $key],
|
|
||||||
[
|
|
||||||
'value' => is_array($value) ? $value : ['value' => $value],
|
|
||||||
'description' => $description,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -12,7 +12,6 @@ class Subscription extends Model
|
|||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
const STATUS_PENDING = 'pending';
|
|
||||||
const STATUS_TRIALING = 'trialing';
|
const STATUS_TRIALING = 'trialing';
|
||||||
const STATUS_ACTIVE = 'active';
|
const STATUS_ACTIVE = 'active';
|
||||||
const STATUS_PAST_DUE = 'past_due';
|
const STATUS_PAST_DUE = 'past_due';
|
||||||
|
|||||||
@ -142,8 +142,7 @@ public function scopePending($query)
|
|||||||
|
|
||||||
public function scopeCompleted($query)
|
public function scopeCompleted($query)
|
||||||
{
|
{
|
||||||
// Incluir 'completed' e 'effective' como transações efetivadas
|
return $query->where('status', 'completed');
|
||||||
return $query->whereIn('status', ['completed', 'effective']);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeCancelled($query)
|
public function scopeCancelled($query)
|
||||||
|
|||||||
@ -31,12 +31,8 @@ class User extends Authenticatable
|
|||||||
'country',
|
'country',
|
||||||
'timezone',
|
'timezone',
|
||||||
'locale',
|
'locale',
|
||||||
'language',
|
|
||||||
'currency',
|
|
||||||
'password',
|
'password',
|
||||||
'is_admin',
|
'is_admin',
|
||||||
'is_demo',
|
|
||||||
'email_verified_at',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -60,7 +56,6 @@ protected function casts(): array
|
|||||||
'email_verified_at' => 'datetime',
|
'email_verified_at' => 'datetime',
|
||||||
'password' => 'hashed',
|
'password' => 'hashed',
|
||||||
'is_admin' => 'boolean',
|
'is_admin' => 'boolean',
|
||||||
'is_demo' => 'boolean',
|
|
||||||
'accept_whatsapp' => 'boolean',
|
'accept_whatsapp' => 'boolean',
|
||||||
'accept_emails' => 'boolean',
|
'accept_emails' => 'boolean',
|
||||||
];
|
];
|
||||||
@ -96,48 +91,6 @@ public function isAdmin(): bool
|
|||||||
return $this->is_admin === true;
|
return $this->is_admin === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== RESOURCE RELATIONSHIPS ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all accounts for the user
|
|
||||||
*/
|
|
||||||
public function accounts()
|
|
||||||
{
|
|
||||||
return $this->hasMany(Account::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all categories for the user
|
|
||||||
*/
|
|
||||||
public function categories()
|
|
||||||
{
|
|
||||||
return $this->hasMany(Category::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all budgets for the user
|
|
||||||
*/
|
|
||||||
public function budgets()
|
|
||||||
{
|
|
||||||
return $this->hasMany(Budget::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all transactions for the user
|
|
||||||
*/
|
|
||||||
public function transactions()
|
|
||||||
{
|
|
||||||
return $this->hasMany(Transaction::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all goals for the user
|
|
||||||
*/
|
|
||||||
public function goals()
|
|
||||||
{
|
|
||||||
return $this->hasMany(FinancialGoal::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== SUBSCRIPTION RELATIONSHIPS ====================
|
// ==================== SUBSCRIPTION RELATIONSHIPS ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,291 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Services;
|
|
||||||
|
|
||||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
|
||||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
|
||||||
use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
|
||||||
use PhpOffice\PhpSpreadsheet\Style\Border;
|
|
||||||
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
|
||||||
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
|
|
||||||
use PhpOffice\PhpSpreadsheet\Cell\DataValidation;
|
|
||||||
|
|
||||||
class LiabilityTemplateService
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Gerar template Excel para importação de passivos
|
|
||||||
*/
|
|
||||||
public function generateTemplate(): Spreadsheet
|
|
||||||
{
|
|
||||||
$spreadsheet = new Spreadsheet();
|
|
||||||
|
|
||||||
// Criar aba de Parcelas (principal)
|
|
||||||
$installmentsSheet = $spreadsheet->getActiveSheet();
|
|
||||||
$installmentsSheet->setTitle('Parcelas');
|
|
||||||
$this->createInstallmentsSheet($installmentsSheet);
|
|
||||||
|
|
||||||
// Criar aba de Instruções
|
|
||||||
$instructionsSheet = $spreadsheet->createSheet();
|
|
||||||
$instructionsSheet->setTitle('Instrucciones');
|
|
||||||
$this->createInstructionsSheet($instructionsSheet);
|
|
||||||
|
|
||||||
// Criar aba de Exemplo
|
|
||||||
$exampleSheet = $spreadsheet->createSheet();
|
|
||||||
$exampleSheet->setTitle('Ejemplo');
|
|
||||||
$this->createExampleSheet($exampleSheet);
|
|
||||||
|
|
||||||
// Voltar para primeira aba
|
|
||||||
$spreadsheet->setActiveSheetIndex(0);
|
|
||||||
|
|
||||||
return $spreadsheet;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Criar aba de parcelas com cabeçalho e validações
|
|
||||||
*/
|
|
||||||
private function createInstallmentsSheet($sheet): void
|
|
||||||
{
|
|
||||||
// Definir largura das colunas
|
|
||||||
$sheet->getColumnDimension('A')->setWidth(12); // Nº
|
|
||||||
$sheet->getColumnDimension('B')->setWidth(15); // Fecha
|
|
||||||
$sheet->getColumnDimension('C')->setWidth(15); // Cuota
|
|
||||||
$sheet->getColumnDimension('D')->setWidth(15); // Intereses
|
|
||||||
$sheet->getColumnDimension('E')->setWidth(15); // Capital
|
|
||||||
$sheet->getColumnDimension('F')->setWidth(15); // Tasas/Seguros
|
|
||||||
$sheet->getColumnDimension('G')->setWidth(15); // Estado
|
|
||||||
$sheet->getColumnDimension('H')->setWidth(30); // Observaciones
|
|
||||||
|
|
||||||
// Cabeçalho principal - título
|
|
||||||
$sheet->setCellValue('A1', 'PLANTILLA DE IMPORTACIÓN - CUENTA PASIVO');
|
|
||||||
$sheet->mergeCells('A1:H1');
|
|
||||||
$sheet->getStyle('A1')->applyFromArray([
|
|
||||||
'font' => ['bold' => true, 'size' => 16, 'color' => ['rgb' => 'FFFFFF']],
|
|
||||||
'fill' => ['fillType' => Fill::FILL_SOLID, 'color' => ['rgb' => '1E40AF']],
|
|
||||||
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER],
|
|
||||||
]);
|
|
||||||
$sheet->getRowDimension(1)->setRowHeight(30);
|
|
||||||
|
|
||||||
// Subtítulo
|
|
||||||
$sheet->setCellValue('A2', 'Rellene las columnas con los datos de sus cuotas. Las columnas marcadas con * son obligatorias.');
|
|
||||||
$sheet->mergeCells('A2:H2');
|
|
||||||
$sheet->getStyle('A2')->applyFromArray([
|
|
||||||
'font' => ['italic' => true, 'size' => 10, 'color' => ['rgb' => '6B7280']],
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Cabeçalhos das colunas
|
|
||||||
$headers = [
|
|
||||||
'A3' => 'Nº Cuota *',
|
|
||||||
'B3' => 'Fecha Venc. *',
|
|
||||||
'C3' => 'Valor Cuota *',
|
|
||||||
'D3' => 'Intereses',
|
|
||||||
'E3' => 'Capital',
|
|
||||||
'F3' => 'Tasas/Seguros',
|
|
||||||
'G3' => 'Estado',
|
|
||||||
'H3' => 'Observaciones',
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($headers as $cell => $value) {
|
|
||||||
$sheet->setCellValue($cell, $value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Estilo do cabeçalho
|
|
||||||
$sheet->getStyle('A3:H3')->applyFromArray([
|
|
||||||
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
|
|
||||||
'fill' => ['fillType' => Fill::FILL_SOLID, 'color' => ['rgb' => '3B82F6']],
|
|
||||||
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER],
|
|
||||||
'borders' => [
|
|
||||||
'allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['rgb' => '1E40AF']],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Dicas sob o cabeçalho
|
|
||||||
$tips = [
|
|
||||||
'A4' => '1, 2, 3...',
|
|
||||||
'B4' => 'DD/MM/AAAA',
|
|
||||||
'C4' => '0.00',
|
|
||||||
'D4' => '0.00',
|
|
||||||
'E4' => '0.00',
|
|
||||||
'F4' => '0.00',
|
|
||||||
'G4' => 'Pendiente/Pagado',
|
|
||||||
'H4' => 'Texto libre',
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($tips as $cell => $value) {
|
|
||||||
$sheet->setCellValue($cell, $value);
|
|
||||||
}
|
|
||||||
|
|
||||||
$sheet->getStyle('A4:H4')->applyFromArray([
|
|
||||||
'font' => ['italic' => true, 'size' => 9, 'color' => ['rgb' => '9CA3AF']],
|
|
||||||
'fill' => ['fillType' => Fill::FILL_SOLID, 'color' => ['rgb' => 'F3F4F6']],
|
|
||||||
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER],
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Área de dados (linhas 5-64 para até 60 parcelas)
|
|
||||||
for ($row = 5; $row <= 64; $row++) {
|
|
||||||
// Número da parcela
|
|
||||||
$sheet->setCellValue("A{$row}", $row - 4);
|
|
||||||
|
|
||||||
// Aplicar formato de número nas colunas de valores
|
|
||||||
$sheet->getStyle("C{$row}:F{$row}")->getNumberFormat()
|
|
||||||
->setFormatCode(NumberFormat::FORMAT_NUMBER_COMMA_SEPARATED1);
|
|
||||||
|
|
||||||
// Aplicar formato de data na coluna B
|
|
||||||
$sheet->getStyle("B{$row}")->getNumberFormat()
|
|
||||||
->setFormatCode('DD/MM/YYYY');
|
|
||||||
|
|
||||||
// Adicionar validação de lista para Estado
|
|
||||||
$validation = $sheet->getCell("G{$row}")->getDataValidation();
|
|
||||||
$validation->setType(DataValidation::TYPE_LIST);
|
|
||||||
$validation->setErrorStyle(DataValidation::STYLE_INFORMATION);
|
|
||||||
$validation->setAllowBlank(true);
|
|
||||||
$validation->setShowDropDown(true);
|
|
||||||
$validation->setFormula1('"Pendiente,Pagado,Vencido"');
|
|
||||||
|
|
||||||
// Bordas leves
|
|
||||||
$sheet->getStyle("A{$row}:H{$row}")->applyFromArray([
|
|
||||||
'borders' => [
|
|
||||||
'allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['rgb' => 'E5E7EB']],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Alternar cores das linhas
|
|
||||||
if (($row - 5) % 2 == 1) {
|
|
||||||
$sheet->getStyle("A{$row}:H{$row}")->applyFromArray([
|
|
||||||
'fill' => ['fillType' => Fill::FILL_SOLID, 'color' => ['rgb' => 'F9FAFB']],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Congelar painel no cabeçalho
|
|
||||||
$sheet->freezePane('A5');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Criar aba de instruções
|
|
||||||
*/
|
|
||||||
private function createInstructionsSheet($sheet): void
|
|
||||||
{
|
|
||||||
$sheet->getColumnDimension('A')->setWidth(80);
|
|
||||||
|
|
||||||
$instructions = [
|
|
||||||
['INSTRUCCIONES DE USO', true, '1E40AF'],
|
|
||||||
['', false, 'FFFFFF'],
|
|
||||||
['1. DATOS OBLIGATORIOS', true, '059669'],
|
|
||||||
[' • Nº Cuota: Número secuencial de la cuota (1, 2, 3...)', false, '000000'],
|
|
||||||
[' • Fecha Venc.: Fecha de vencimiento en formato DD/MM/AAAA', false, '000000'],
|
|
||||||
[' • Valor Cuota: Valor total de la cuota a pagar', false, '000000'],
|
|
||||||
['', false, 'FFFFFF'],
|
|
||||||
['2. DATOS OPCIONALES (recomendados)', true, '059669'],
|
|
||||||
[' • Intereses: Parte de la cuota correspondiente a intereses', false, '000000'],
|
|
||||||
[' • Capital: Parte de la cuota correspondiente a amortización', false, '000000'],
|
|
||||||
[' • Tasas/Seguros: Otros cargos incluidos en la cuota', false, '000000'],
|
|
||||||
[' • Estado: Pendiente, Pagado o Vencido', false, '000000'],
|
|
||||||
[' • Observaciones: Notas adicionales', false, '000000'],
|
|
||||||
['', false, 'FFFFFF'],
|
|
||||||
['3. TIPOS DE CONTRATOS SOPORTADOS', true, '059669'],
|
|
||||||
[' • Préstamo Personal (Sistema PRICE - cuota fija)', false, '000000'],
|
|
||||||
[' • Financiación de Vehículo', false, '000000'],
|
|
||||||
[' • Financiación Inmobiliaria (Sistema SAC o PRICE)', false, '000000'],
|
|
||||||
[' • Consorcio (cuotas variables)', false, '000000'],
|
|
||||||
[' • Leasing', false, '000000'],
|
|
||||||
['', false, 'FFFFFF'],
|
|
||||||
['4. SISTEMA PRICE (Cuota Fija)', true, '059669'],
|
|
||||||
[' En este sistema:', false, '000000'],
|
|
||||||
[' • La cuota es CONSTANTE todos los meses', false, '000000'],
|
|
||||||
[' • Los intereses DISMINUYEN cada mes', false, '000000'],
|
|
||||||
[' • La amortización AUMENTA cada mes', false, '000000'],
|
|
||||||
[' • Cuota = Intereses + Capital + Tasas', false, '000000'],
|
|
||||||
['', false, 'FFFFFF'],
|
|
||||||
['5. SISTEMA SAC (Amortización Constante)', true, '059669'],
|
|
||||||
[' En este sistema:', false, '000000'],
|
|
||||||
[' • La amortización es CONSTANTE todos los meses', false, '000000'],
|
|
||||||
[' • Los intereses DISMINUYEN cada mes', false, '000000'],
|
|
||||||
[' • La cuota DISMINUYE cada mes', false, '000000'],
|
|
||||||
['', false, 'FFFFFF'],
|
|
||||||
['6. CUOTA DE CARENCIA', true, '059669'],
|
|
||||||
[' Algunos contratos tienen una primera cuota de carencia:', false, '000000'],
|
|
||||||
[' • Solo se pagan intereses (sin amortización de capital)', false, '000000'],
|
|
||||||
[' • El capital amortizado en esta cuota debe ser 0', false, '000000'],
|
|
||||||
['', false, 'FFFFFF'],
|
|
||||||
['7. ESTADOS VÁLIDOS', true, '059669'],
|
|
||||||
[' • Pendiente: Cuota aún no pagada', false, '000000'],
|
|
||||||
[' • Pagado: Cuota ya abonada', false, '000000'],
|
|
||||||
[' • Vencido: Cuota no pagada y pasada la fecha de vencimiento', false, '000000'],
|
|
||||||
];
|
|
||||||
|
|
||||||
$row = 1;
|
|
||||||
foreach ($instructions as $item) {
|
|
||||||
$sheet->setCellValue("A{$row}", $item[0]);
|
|
||||||
|
|
||||||
$style = ['font' => ['color' => ['rgb' => $item[2]]]];
|
|
||||||
if ($item[1]) {
|
|
||||||
$style['font']['bold'] = true;
|
|
||||||
$style['font']['size'] = 12;
|
|
||||||
}
|
|
||||||
$sheet->getStyle("A{$row}")->applyFromArray($style);
|
|
||||||
|
|
||||||
$row++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Criar aba de exemplo preenchida
|
|
||||||
*/
|
|
||||||
private function createExampleSheet($sheet): void
|
|
||||||
{
|
|
||||||
// Copiar estrutura da aba de parcelas
|
|
||||||
$this->createInstallmentsSheet($sheet);
|
|
||||||
|
|
||||||
// Dados de exemplo (Empréstimo PRICE típico)
|
|
||||||
$exampleData = [
|
|
||||||
[1, '05/06/2025', 20.85, 20.85, 0.00, 0.00, 'Pagado', 'Cuota de carencia (solo intereses)'],
|
|
||||||
[2, '05/07/2025', 122.00, 48.33, 73.67, 0.00, 'Pagado', ''],
|
|
||||||
[3, '05/08/2025', 122.00, 47.68, 74.32, 0.00, 'Pagado', ''],
|
|
||||||
[4, '05/09/2025', 122.00, 47.01, 74.99, 0.00, 'Pagado', ''],
|
|
||||||
[5, '05/10/2025', 122.00, 46.35, 75.65, 0.00, 'Pagado', ''],
|
|
||||||
[6, '05/11/2025', 122.00, 45.68, 76.32, 0.00, 'Pagado', ''],
|
|
||||||
[7, '05/12/2025', 122.00, 45.00, 77.00, 0.00, 'Pendiente', ''],
|
|
||||||
[8, '05/01/2026', 122.00, 44.31, 77.69, 0.00, 'Pendiente', ''],
|
|
||||||
[9, '05/02/2026', 122.00, 43.62, 78.38, 0.00, 'Pendiente', ''],
|
|
||||||
[10, '05/03/2026', 122.00, 42.93, 79.07, 0.00, 'Pendiente', ''],
|
|
||||||
];
|
|
||||||
|
|
||||||
$row = 5;
|
|
||||||
foreach ($exampleData as $data) {
|
|
||||||
$sheet->setCellValue("A{$row}", $data[0]);
|
|
||||||
$sheet->setCellValue("B{$row}", $data[1]);
|
|
||||||
$sheet->setCellValue("C{$row}", $data[2]);
|
|
||||||
$sheet->setCellValue("D{$row}", $data[3]);
|
|
||||||
$sheet->setCellValue("E{$row}", $data[4]);
|
|
||||||
$sheet->setCellValue("F{$row}", $data[5]);
|
|
||||||
$sheet->setCellValue("G{$row}", $data[6]);
|
|
||||||
$sheet->setCellValue("H{$row}", $data[7]);
|
|
||||||
$row++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Destacar que é exemplo
|
|
||||||
$sheet->setCellValue('A1', 'EJEMPLO - PRÉSTAMO PERSONAL (Sistema PRICE)');
|
|
||||||
$sheet->getStyle('A1')->applyFromArray([
|
|
||||||
'font' => ['bold' => true, 'size' => 16, 'color' => ['rgb' => 'FFFFFF']],
|
|
||||||
'fill' => ['fillType' => Fill::FILL_SOLID, 'color' => ['rgb' => '059669']],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Salvar template em arquivo
|
|
||||||
*/
|
|
||||||
public function saveTemplate(string $path): void
|
|
||||||
{
|
|
||||||
$spreadsheet = $this->generateTemplate();
|
|
||||||
$writer = new Xlsx($spreadsheet);
|
|
||||||
$writer->save($path);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Obter caminho do template
|
|
||||||
*/
|
|
||||||
public static function getTemplatePath(): string
|
|
||||||
{
|
|
||||||
return storage_path('app/templates/passivo_template.xlsx');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -9,8 +9,8 @@
|
|||||||
|
|
||||||
class PayPalService
|
class PayPalService
|
||||||
{
|
{
|
||||||
private ?string $clientId;
|
private string $clientId;
|
||||||
private ?string $clientSecret;
|
private string $clientSecret;
|
||||||
private string $baseUrl;
|
private string $baseUrl;
|
||||||
private bool $sandbox;
|
private bool $sandbox;
|
||||||
|
|
||||||
@ -29,11 +29,6 @@ public function __construct()
|
|||||||
*/
|
*/
|
||||||
public function getAccessToken(): ?string
|
public function getAccessToken(): ?string
|
||||||
{
|
{
|
||||||
if (!$this->isConfigured()) {
|
|
||||||
Log::warning('PayPal not configured');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Cache::remember('paypal_access_token', 28800, function () {
|
return Cache::remember('paypal_access_token', 28800, function () {
|
||||||
try {
|
try {
|
||||||
$response = Http::withBasicAuth($this->clientId, $this->clientSecret)
|
$response = Http::withBasicAuth($this->clientId, $this->clientSecret)
|
||||||
@ -252,15 +247,7 @@ public function cancelSubscription(string $subscriptionId, string $reason = 'Use
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if subscription is already cancelled - treat as success
|
Log::error('PayPal cancel subscription failed', ['response' => $response->json()]);
|
||||||
$responseData = $response->json();
|
|
||||||
if (isset($responseData['details'][0]['issue']) &&
|
|
||||||
$responseData['details'][0]['issue'] === 'SUBSCRIPTION_STATUS_INVALID') {
|
|
||||||
Log::info('PayPal subscription already cancelled', ['subscription_id' => $subscriptionId]);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
Log::error('PayPal cancel subscription failed', ['response' => $responseData]);
|
|
||||||
return false;
|
return false;
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Log::error('PayPal cancel subscription exception', ['error' => $e->getMessage()]);
|
Log::error('PayPal cancel subscription exception', ['error' => $e->getMessage()]);
|
||||||
@ -351,124 +338,6 @@ public function getSubscriptionTransactions(string $subscriptionId, string $star
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Refund a capture (payment)
|
|
||||||
*/
|
|
||||||
public function refundCapture(string $captureId, ?float $amount = null, ?string $currency = 'EUR', string $note = 'Refund within 7-day guarantee period'): ?array
|
|
||||||
{
|
|
||||||
$token = $this->getAccessToken();
|
|
||||||
if (!$token) return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
$body = [
|
|
||||||
'note_to_payer' => $note,
|
|
||||||
];
|
|
||||||
|
|
||||||
// If amount specified, do partial refund; otherwise full refund
|
|
||||||
if ($amount !== null) {
|
|
||||||
$body['amount'] = [
|
|
||||||
'value' => number_format($amount, 2, '.', ''),
|
|
||||||
'currency_code' => $currency,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$response = Http::withToken($token)
|
|
||||||
->post("{$this->baseUrl}/v2/payments/captures/{$captureId}/refund", $body);
|
|
||||||
|
|
||||||
if ($response->successful()) {
|
|
||||||
Log::info('PayPal refund successful', ['capture_id' => $captureId, 'response' => $response->json()]);
|
|
||||||
return $response->json();
|
|
||||||
}
|
|
||||||
|
|
||||||
Log::error('PayPal refund failed', ['capture_id' => $captureId, 'response' => $response->json()]);
|
|
||||||
return null;
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
Log::error('PayPal refund exception', ['error' => $e->getMessage()]);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the last transaction/capture for a subscription to refund
|
|
||||||
*/
|
|
||||||
public function getLastSubscriptionCapture(string $subscriptionId): ?string
|
|
||||||
{
|
|
||||||
$token = $this->getAccessToken();
|
|
||||||
if (!$token) return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Get transactions from last 30 days
|
|
||||||
$startTime = now()->subDays(30)->toIso8601String();
|
|
||||||
$endTime = now()->toIso8601String();
|
|
||||||
|
|
||||||
$response = Http::withToken($token)
|
|
||||||
->get("{$this->baseUrl}/v1/billing/subscriptions/{$subscriptionId}/transactions", [
|
|
||||||
'start_time' => $startTime,
|
|
||||||
'end_time' => $endTime,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($response->successful()) {
|
|
||||||
$transactions = $response->json('transactions') ?? [];
|
|
||||||
|
|
||||||
// Find the most recent COMPLETED transaction
|
|
||||||
foreach ($transactions as $transaction) {
|
|
||||||
if (($transaction['status'] ?? '') === 'COMPLETED' && !empty($transaction['id'])) {
|
|
||||||
Log::info('Found capture for refund', ['subscription_id' => $subscriptionId, 'capture_id' => $transaction['id']]);
|
|
||||||
return $transaction['id'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Log::warning('No capture found for subscription', ['subscription_id' => $subscriptionId]);
|
|
||||||
return null;
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
Log::error('PayPal get capture exception', ['error' => $e->getMessage()]);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancel subscription and refund if within guarantee period
|
|
||||||
*/
|
|
||||||
public function cancelAndRefund(string $subscriptionId, string $reason = 'Refund within 7-day guarantee'): array
|
|
||||||
{
|
|
||||||
$result = [
|
|
||||||
'canceled' => false,
|
|
||||||
'refunded' => false,
|
|
||||||
'refund_id' => null,
|
|
||||||
'refund_amount' => null,
|
|
||||||
'error' => null,
|
|
||||||
];
|
|
||||||
|
|
||||||
// First, get capture ID for refund
|
|
||||||
$captureId = $this->getLastSubscriptionCapture($subscriptionId);
|
|
||||||
|
|
||||||
// Cancel the subscription
|
|
||||||
$canceled = $this->cancelSubscription($subscriptionId, $reason);
|
|
||||||
$result['canceled'] = $canceled;
|
|
||||||
|
|
||||||
if (!$canceled) {
|
|
||||||
$result['error'] = 'Failed to cancel subscription';
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process refund if we have a capture
|
|
||||||
if ($captureId) {
|
|
||||||
$refund = $this->refundCapture($captureId, null, 'EUR', $reason);
|
|
||||||
if ($refund) {
|
|
||||||
$result['refunded'] = true;
|
|
||||||
$result['refund_id'] = $refund['id'] ?? null;
|
|
||||||
$result['refund_amount'] = $refund['amount']['value'] ?? null;
|
|
||||||
} else {
|
|
||||||
$result['error'] = 'Subscription canceled but refund failed';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
$result['error'] = 'Subscription canceled but no payment found to refund';
|
|
||||||
}
|
|
||||||
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if PayPal is configured
|
* Check if PayPal is configured
|
||||||
*/
|
*/
|
||||||
@ -480,7 +349,7 @@ public function isConfigured(): bool
|
|||||||
/**
|
/**
|
||||||
* Get client ID for frontend
|
* Get client ID for frontend
|
||||||
*/
|
*/
|
||||||
public function getClientId(): ?string
|
public function getClientId(): string
|
||||||
{
|
{
|
||||||
return $this->clientId;
|
return $this->clientId;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,14 +18,6 @@
|
|||||||
// Alias para rate limiting
|
// Alias para rate limiting
|
||||||
$middleware->alias([
|
$middleware->alias([
|
||||||
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
|
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
|
||||||
'plan.limits' => \App\Http\Middleware\CheckPlanLimits::class,
|
|
||||||
'admin.only' => \App\Http\Middleware\AdminOnly::class,
|
|
||||||
'demo.protect' => \App\Http\Middleware\DemoProtection::class,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Aplicar proteção demo em todas as rotas de API autenticadas
|
|
||||||
$middleware->api(append: [
|
|
||||||
\App\Http\Middleware\DemoProtection::class,
|
|
||||||
]);
|
]);
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
|
|||||||
@ -54,18 +54,6 @@
|
|||||||
|
|
||||||
'url' => env('APP_URL', 'http://localhost'),
|
'url' => env('APP_URL', 'http://localhost'),
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Frontend URL
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| This URL is used for generating links to the frontend application,
|
|
||||||
| such as password reset links and email verification links.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'frontend_url' => env('FRONTEND_URL', 'https://webmoney.cnxifly.com'),
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Application Timezone
|
| Application Timezone
|
||||||
|
|||||||
@ -1,28 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::create('site_settings', function (Blueprint $table) {
|
|
||||||
$table->id();
|
|
||||||
$table->string('key')->unique();
|
|
||||||
$table->json('value')->nullable();
|
|
||||||
$table->string('description')->nullable();
|
|
||||||
$table->timestamps();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Insert default settings
|
|
||||||
\App\Models\SiteSetting::setValue('cnxifly_maintenance_mode', false, 'Modo de mantenimiento para cnxifly.com');
|
|
||||||
\App\Models\SiteSetting::setValue('cnxifly_page_type', 'live', 'Tipo de página: live, maintenance, construction');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::dropIfExists('site_settings');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Run the migrations.
|
|
||||||
*/
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::table('liability_accounts', function (Blueprint $table) {
|
|
||||||
// Adicionar tipo de contrato e sistema de amortização após 'name'
|
|
||||||
$table->string('contract_type', 50)->default('other')->after('name');
|
|
||||||
$table->string('amortization_system', 20)->default('price')->after('contract_type');
|
|
||||||
|
|
||||||
// Adicionar campos de carência após end_date
|
|
||||||
$table->boolean('has_grace_period')->default(false)->after('first_due_date');
|
|
||||||
$table->unsignedTinyInteger('grace_period_months')->default(0)->after('has_grace_period');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse the migrations.
|
|
||||||
*/
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::table('liability_accounts', function (Blueprint $table) {
|
|
||||||
$table->dropColumn([
|
|
||||||
'contract_type',
|
|
||||||
'amortization_system',
|
|
||||||
'has_grace_period',
|
|
||||||
'grace_period_months',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,124 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Run the migrations.
|
|
||||||
*
|
|
||||||
* Adiciona campos avançados para gestão completa de passivos:
|
|
||||||
* - Indexadores (CDI, IPCA, Euribor, etc.)
|
|
||||||
* - Garantias (tipo, valor, descrição)
|
|
||||||
* - Penalidades (multa, mora)
|
|
||||||
* - Campos específicos por tipo de contrato
|
|
||||||
*/
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::table('liability_accounts', function (Blueprint $table) {
|
|
||||||
// ============================================
|
|
||||||
// INDEXADORES E TAXAS
|
|
||||||
// ============================================
|
|
||||||
// Tipo de indexador (CDI, IPCA, SELIC, Euribor, Fixed, etc.)
|
|
||||||
$table->string('index_type', 30)->nullable()->after('annual_interest_rate');
|
|
||||||
// Spread sobre o indexador (ex: CDI + 2.5%)
|
|
||||||
$table->decimal('index_spread', 6, 4)->nullable()->after('index_type');
|
|
||||||
// CET - Custo Efetivo Total (%)
|
|
||||||
$table->decimal('total_effective_cost', 8, 4)->nullable()->after('index_spread');
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// GARANTIAS
|
|
||||||
// ============================================
|
|
||||||
// Tipo de garantia (none, fiduciary_alienation, mortgage, guarantor, pledge, etc.)
|
|
||||||
$table->string('guarantee_type', 50)->nullable()->after('total_effective_cost');
|
|
||||||
// Valor da garantia
|
|
||||||
$table->decimal('guarantee_value', 15, 2)->nullable()->after('guarantee_type');
|
|
||||||
// Descrição da garantia (ex: "Imóvel Rua X, Matrícula 12345")
|
|
||||||
$table->string('guarantee_description', 500)->nullable()->after('guarantee_value');
|
|
||||||
// Nome do fiador (se aplicável)
|
|
||||||
$table->string('guarantor_name', 150)->nullable()->after('guarantee_description');
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// PENALIDADES E MULTAS
|
|
||||||
// ============================================
|
|
||||||
// Multa por atraso (%)
|
|
||||||
$table->decimal('late_fee_percent', 5, 2)->nullable()->after('guarantor_name');
|
|
||||||
// Mora diária (%)
|
|
||||||
$table->decimal('daily_penalty_percent', 5, 4)->nullable()->after('late_fee_percent');
|
|
||||||
// Dias de carência antes de aplicar multa
|
|
||||||
$table->unsignedTinyInteger('grace_days_for_penalty')->default(0)->after('daily_penalty_percent');
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// CAMPOS ESPECÍFICOS POR TIPO
|
|
||||||
// ============================================
|
|
||||||
// Valor do bem (para financiamentos e leasing)
|
|
||||||
$table->decimal('asset_value', 15, 2)->nullable()->after('grace_days_for_penalty');
|
|
||||||
// Descrição do bem
|
|
||||||
$table->string('asset_description', 300)->nullable()->after('asset_value');
|
|
||||||
// Valor residual (para leasing)
|
|
||||||
$table->decimal('residual_value', 15, 2)->nullable()->after('asset_description');
|
|
||||||
// Taxa de administração (% para consórcio)
|
|
||||||
$table->decimal('admin_fee_percent', 5, 2)->nullable()->after('residual_value');
|
|
||||||
// Fundo de reserva (% para consórcio)
|
|
||||||
$table->decimal('reserve_fund_percent', 5, 2)->nullable()->after('admin_fee_percent');
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// COVENANTS E RESTRIÇÕES
|
|
||||||
// ============================================
|
|
||||||
// Covenants financeiros (JSON: [{name, condition, value}])
|
|
||||||
$table->json('covenants')->nullable()->after('reserve_fund_percent');
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// ALERTAS E GESTÃO
|
|
||||||
// ============================================
|
|
||||||
// Dias de antecedência para alerta de vencimento
|
|
||||||
$table->unsignedTinyInteger('alert_days_before')->default(5)->after('covenants');
|
|
||||||
// Responsável interno
|
|
||||||
$table->string('internal_responsible', 150)->nullable()->after('alert_days_before');
|
|
||||||
// Notas/observações internas
|
|
||||||
$table->text('internal_notes')->nullable()->after('internal_responsible');
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// DOCUMENTAÇÃO
|
|
||||||
// ============================================
|
|
||||||
// Número do documento/escritura
|
|
||||||
$table->string('document_number', 100)->nullable()->after('internal_notes');
|
|
||||||
// Cartório/Registro
|
|
||||||
$table->string('registry_office', 200)->nullable()->after('document_number');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse the migrations.
|
|
||||||
*/
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::table('liability_accounts', function (Blueprint $table) {
|
|
||||||
$table->dropColumn([
|
|
||||||
'index_type',
|
|
||||||
'index_spread',
|
|
||||||
'total_effective_cost',
|
|
||||||
'guarantee_type',
|
|
||||||
'guarantee_value',
|
|
||||||
'guarantee_description',
|
|
||||||
'guarantor_name',
|
|
||||||
'late_fee_percent',
|
|
||||||
'daily_penalty_percent',
|
|
||||||
'grace_days_for_penalty',
|
|
||||||
'asset_value',
|
|
||||||
'asset_description',
|
|
||||||
'residual_value',
|
|
||||||
'admin_fee_percent',
|
|
||||||
'reserve_fund_percent',
|
|
||||||
'covenants',
|
|
||||||
'alert_days_before',
|
|
||||||
'internal_responsible',
|
|
||||||
'internal_notes',
|
|
||||||
'document_number',
|
|
||||||
'registry_office',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Run the migrations.
|
|
||||||
*/
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::table('users', function (Blueprint $table) {
|
|
||||||
$table->string('language', 10)->default('es')->after('locale');
|
|
||||||
$table->string('currency', 3)->default('EUR')->after('language');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse the migrations.
|
|
||||||
*/
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::table('users', function (Blueprint $table) {
|
|
||||||
$table->dropColumn(['language', 'currency']);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::create('email_verification_tokens', function (Blueprint $table) {
|
|
||||||
$table->id();
|
|
||||||
$table->foreignId('user_id')->constrained()->onDelete('cascade');
|
|
||||||
$table->string('token', 64)->unique();
|
|
||||||
$table->timestamp('expires_at');
|
|
||||||
$table->timestamp('used_at')->nullable();
|
|
||||||
$table->timestamps();
|
|
||||||
|
|
||||||
$table->index(['token', 'expires_at']);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::dropIfExists('email_verification_tokens');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Run the migrations.
|
|
||||||
*/
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
// Add 'pending' to status enum
|
|
||||||
DB::statement("ALTER TABLE subscriptions MODIFY COLUMN status ENUM('pending', 'trialing', 'active', 'past_due', 'canceled', 'expired') NOT NULL DEFAULT 'trialing'");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse the migrations.
|
|
||||||
*/
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
// Remove 'pending' from status enum
|
|
||||||
DB::statement("ALTER TABLE subscriptions MODIFY COLUMN status ENUM('trialing', 'active', 'past_due', 'canceled', 'expired') NOT NULL DEFAULT 'trialing'");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Run the migrations.
|
|
||||||
*/
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::table('users', function (Blueprint $table) {
|
|
||||||
$table->boolean('is_demo')->default(false)->after('is_admin');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse the migrations.
|
|
||||||
*/
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::table('users', function (Blueprint $table) {
|
|
||||||
$table->dropColumn('is_demo');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,122 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Run the migrations.
|
|
||||||
*/
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::create('asset_accounts', function (Blueprint $table) {
|
|
||||||
$table->id();
|
|
||||||
$table->foreignId('user_id')->constrained()->onDelete('cascade');
|
|
||||||
$table->unsignedBigInteger('business_id')->nullable(); // Sem FK por enquanto
|
|
||||||
|
|
||||||
// Tipo de ativo
|
|
||||||
$table->string('asset_type', 50); // real_estate, vehicle, investment, equipment, inventory, receivable, other
|
|
||||||
|
|
||||||
// Dados básicos
|
|
||||||
$table->string('name');
|
|
||||||
$table->text('description')->nullable();
|
|
||||||
$table->string('currency', 3)->default('EUR');
|
|
||||||
$table->string('color', 7)->default('#3B82F6');
|
|
||||||
|
|
||||||
// Valores
|
|
||||||
$table->decimal('acquisition_value', 15, 2)->default(0); // Valor de aquisição
|
|
||||||
$table->decimal('current_value', 15, 2)->default(0); // Valor atual/de mercado
|
|
||||||
$table->date('acquisition_date')->nullable();
|
|
||||||
|
|
||||||
// Depreciação (para ativos depreciáveis)
|
|
||||||
$table->boolean('is_depreciable')->default(false);
|
|
||||||
$table->string('depreciation_method', 30)->nullable(); // linear, declining_balance, units_production
|
|
||||||
$table->decimal('useful_life_years', 5, 2)->nullable();
|
|
||||||
$table->decimal('residual_value', 15, 2)->nullable();
|
|
||||||
$table->decimal('accumulated_depreciation', 15, 2)->default(0);
|
|
||||||
|
|
||||||
// Para imóveis
|
|
||||||
$table->string('property_type', 50)->nullable(); // house, apartment, land, commercial, industrial
|
|
||||||
$table->text('address')->nullable();
|
|
||||||
$table->string('city', 100)->nullable();
|
|
||||||
$table->string('state', 100)->nullable();
|
|
||||||
$table->string('postal_code', 20)->nullable();
|
|
||||||
$table->string('country', 2)->nullable();
|
|
||||||
$table->decimal('property_area_m2', 12, 2)->nullable();
|
|
||||||
$table->string('registry_number', 100)->nullable();
|
|
||||||
|
|
||||||
// Para veículos
|
|
||||||
$table->string('vehicle_brand', 100)->nullable();
|
|
||||||
$table->string('vehicle_model', 100)->nullable();
|
|
||||||
$table->year('vehicle_year')->nullable();
|
|
||||||
$table->string('vehicle_plate', 20)->nullable();
|
|
||||||
$table->string('vehicle_vin', 50)->nullable();
|
|
||||||
$table->integer('vehicle_mileage')->nullable();
|
|
||||||
|
|
||||||
// Para investimentos
|
|
||||||
$table->string('investment_type', 50)->nullable(); // stocks, bonds, funds, crypto, savings, fixed_income
|
|
||||||
$table->string('institution', 100)->nullable();
|
|
||||||
$table->string('account_number', 100)->nullable();
|
|
||||||
$table->integer('quantity')->nullable(); // Quantidade de ativos (ações, cotas)
|
|
||||||
$table->decimal('unit_price', 15, 6)->nullable();
|
|
||||||
$table->string('ticker', 20)->nullable();
|
|
||||||
$table->date('maturity_date')->nullable();
|
|
||||||
$table->decimal('interest_rate', 8, 4)->nullable();
|
|
||||||
$table->string('index_type', 30)->nullable();
|
|
||||||
|
|
||||||
// Para equipamentos/maquinário
|
|
||||||
$table->string('equipment_brand', 100)->nullable();
|
|
||||||
$table->string('equipment_model', 100)->nullable();
|
|
||||||
$table->string('serial_number', 100)->nullable();
|
|
||||||
$table->date('warranty_expiry')->nullable();
|
|
||||||
|
|
||||||
// Para recebíveis
|
|
||||||
$table->string('debtor_name', 200)->nullable();
|
|
||||||
$table->string('debtor_document', 50)->nullable();
|
|
||||||
$table->date('receivable_due_date')->nullable();
|
|
||||||
$table->decimal('receivable_amount', 15, 2)->nullable();
|
|
||||||
|
|
||||||
// Garantias (se o ativo serve como garantia)
|
|
||||||
$table->boolean('is_collateral')->default(false);
|
|
||||||
$table->string('collateral_for', 200)->nullable();
|
|
||||||
$table->foreignId('linked_liability_id')->nullable()->constrained('liability_accounts')->onDelete('set null');
|
|
||||||
|
|
||||||
// Seguros
|
|
||||||
$table->boolean('has_insurance')->default(false);
|
|
||||||
$table->string('insurance_company', 100)->nullable();
|
|
||||||
$table->string('insurance_policy', 100)->nullable();
|
|
||||||
$table->decimal('insurance_value', 15, 2)->nullable();
|
|
||||||
$table->date('insurance_expiry')->nullable();
|
|
||||||
|
|
||||||
// Gestão
|
|
||||||
$table->integer('alert_days_before')->default(30);
|
|
||||||
$table->string('internal_responsible', 200)->nullable();
|
|
||||||
$table->text('internal_notes')->nullable();
|
|
||||||
$table->string('document_number', 100)->nullable();
|
|
||||||
|
|
||||||
// Status
|
|
||||||
$table->string('status', 30)->default('active'); // active, sold, depreciated, written_off
|
|
||||||
$table->date('disposal_date')->nullable();
|
|
||||||
$table->decimal('disposal_value', 15, 2)->nullable();
|
|
||||||
$table->string('disposal_reason', 200)->nullable();
|
|
||||||
|
|
||||||
$table->timestamps();
|
|
||||||
$table->softDeletes();
|
|
||||||
|
|
||||||
// Índices
|
|
||||||
$table->index(['user_id', 'asset_type']);
|
|
||||||
$table->index(['user_id', 'status']);
|
|
||||||
$table->index(['business_id', 'asset_type']);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse the migrations.
|
|
||||||
*/
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::dropIfExists('asset_accounts');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,320 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Database\Seeders;
|
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\LiabilityAccount;
|
|
||||||
use App\Models\AssetAccount;
|
|
||||||
use Illuminate\Database\Seeder;
|
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Carbon\Carbon;
|
|
||||||
|
|
||||||
class DemoUserSeeder extends Seeder
|
|
||||||
{
|
|
||||||
public function run(): void
|
|
||||||
{
|
|
||||||
// 1. Criar usuário DEMO
|
|
||||||
$user = User::updateOrCreate(
|
|
||||||
['email' => 'demo@webmoney.com'],
|
|
||||||
[
|
|
||||||
'name' => 'Usuario Demo',
|
|
||||||
'first_name' => 'Demo',
|
|
||||||
'last_name' => 'WebMoney',
|
|
||||||
'password' => Hash::make('DEMO'),
|
|
||||||
'is_demo' => true,
|
|
||||||
'is_admin' => false,
|
|
||||||
'email_verified_at' => now(),
|
|
||||||
'country' => 'ES',
|
|
||||||
'timezone' => 'Europe/Madrid',
|
|
||||||
'locale' => 'es_ES',
|
|
||||||
'language' => 'es',
|
|
||||||
'currency' => 'EUR',
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->command->info("✓ Usuario demo creado: demo@webmoney.com / DEMO");
|
|
||||||
|
|
||||||
// 2. Criar passivos de exemplo
|
|
||||||
$this->createSampleLiabilities($user);
|
|
||||||
|
|
||||||
// 3. Criar ativos de exemplo
|
|
||||||
$this->createSampleAssets($user);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function createSampleLiabilities(User $user): void
|
|
||||||
{
|
|
||||||
$liabilities = [
|
|
||||||
[
|
|
||||||
'contract_type' => 'personal_loan',
|
|
||||||
'name' => 'Préstamo Personal Santander',
|
|
||||||
'creditor' => 'Banco Santander',
|
|
||||||
'contract_number' => 'PL-2024-001234',
|
|
||||||
'description' => 'Préstamo personal para reforma del hogar',
|
|
||||||
'currency' => 'EUR',
|
|
||||||
'principal_amount' => 15000.00,
|
|
||||||
'total_pending' => 12500.00,
|
|
||||||
'total_paid' => 2500.00,
|
|
||||||
'annual_interest_rate' => 7.5,
|
|
||||||
'monthly_interest_rate' => 0.625,
|
|
||||||
'amortization_system' => 'price',
|
|
||||||
'total_installments' => 48,
|
|
||||||
'paid_installments' => 12,
|
|
||||||
'start_date' => '2024-01-15',
|
|
||||||
'first_due_date' => '2024-02-15',
|
|
||||||
'index_type' => 'fixed',
|
|
||||||
'alert_days_before' => 5,
|
|
||||||
'color' => '#3B82F6',
|
|
||||||
'status' => 'active',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'contract_type' => 'vehicle',
|
|
||||||
'name' => 'Financiación Coche BMW X3',
|
|
||||||
'creditor' => 'BMW Financial Services',
|
|
||||||
'contract_number' => 'VF-2024-BMW-5678',
|
|
||||||
'description' => 'Financiación vehículo BMW X3 xDrive20d',
|
|
||||||
'currency' => 'EUR',
|
|
||||||
'principal_amount' => 45000.00,
|
|
||||||
'total_pending' => 38750.00,
|
|
||||||
'total_paid' => 6250.00,
|
|
||||||
'annual_interest_rate' => 5.9,
|
|
||||||
'amortization_system' => 'price',
|
|
||||||
'total_installments' => 60,
|
|
||||||
'paid_installments' => 10,
|
|
||||||
'start_date' => '2024-03-01',
|
|
||||||
'first_due_date' => '2024-04-01',
|
|
||||||
'guarantee_type' => 'fiduciary_alienation',
|
|
||||||
'guarantee_value' => 45000.00,
|
|
||||||
'index_type' => 'fixed',
|
|
||||||
'color' => '#10B981',
|
|
||||||
'status' => 'active',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'contract_type' => 'mortgage',
|
|
||||||
'name' => 'Hipoteca Vivienda Principal',
|
|
||||||
'creditor' => 'CaixaBank',
|
|
||||||
'contract_number' => 'HIP-2020-987654',
|
|
||||||
'description' => 'Hipoteca vivienda familiar - Madrid Centro',
|
|
||||||
'currency' => 'EUR',
|
|
||||||
'principal_amount' => 250000.00,
|
|
||||||
'total_pending' => 215000.00,
|
|
||||||
'total_paid' => 35000.00,
|
|
||||||
'annual_interest_rate' => 2.5,
|
|
||||||
'amortization_system' => 'sac',
|
|
||||||
'total_installments' => 300,
|
|
||||||
'paid_installments' => 48,
|
|
||||||
'start_date' => '2020-06-15',
|
|
||||||
'first_due_date' => '2020-07-15',
|
|
||||||
'guarantee_type' => 'mortgage_guarantee',
|
|
||||||
'guarantee_value' => 350000.00,
|
|
||||||
'index_type' => 'euribor',
|
|
||||||
'index_spread' => 0.80,
|
|
||||||
'color' => '#F59E0B',
|
|
||||||
'status' => 'active',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'contract_type' => 'credit_card',
|
|
||||||
'name' => 'Tarjeta Visa Oro BBVA',
|
|
||||||
'creditor' => 'BBVA',
|
|
||||||
'contract_number' => 'CC-VISA-4242',
|
|
||||||
'currency' => 'EUR',
|
|
||||||
'principal_amount' => 5000.00,
|
|
||||||
'total_pending' => 3250.00,
|
|
||||||
'total_paid' => 1750.00,
|
|
||||||
'annual_interest_rate' => 18.99,
|
|
||||||
'amortization_system' => 'revolving',
|
|
||||||
'total_installments' => 0,
|
|
||||||
'paid_installments' => 0,
|
|
||||||
'start_date' => '2023-01-01',
|
|
||||||
'first_due_date' => '2023-02-01',
|
|
||||||
'color' => '#EF4444',
|
|
||||||
'status' => 'active',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'contract_type' => 'leasing',
|
|
||||||
'name' => 'Leasing Maquinaria Industrial',
|
|
||||||
'creditor' => 'Banco Sabadell Leasing',
|
|
||||||
'contract_number' => 'LEA-2024-MAQ-456',
|
|
||||||
'currency' => 'EUR',
|
|
||||||
'principal_amount' => 120000.00,
|
|
||||||
'total_pending' => 105000.00,
|
|
||||||
'total_paid' => 15000.00,
|
|
||||||
'annual_interest_rate' => 4.5,
|
|
||||||
'amortization_system' => 'price',
|
|
||||||
'total_installments' => 48,
|
|
||||||
'paid_installments' => 6,
|
|
||||||
'start_date' => '2024-05-01',
|
|
||||||
'first_due_date' => '2024-06-01',
|
|
||||||
'color' => '#06B6D4',
|
|
||||||
'status' => 'active',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($liabilities as $data) {
|
|
||||||
LiabilityAccount::updateOrCreate(
|
|
||||||
['user_id' => $user->id, 'contract_number' => $data['contract_number']],
|
|
||||||
array_merge($data, ['user_id' => $user->id])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->command->info("✓ " . count($liabilities) . " pasivos de ejemplo creados");
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function createSampleAssets(User $user): void
|
|
||||||
{
|
|
||||||
$assets = [
|
|
||||||
[
|
|
||||||
'asset_type' => 'real_estate',
|
|
||||||
'name' => 'Vivienda Principal Madrid',
|
|
||||||
'description' => 'Piso de 120m² en el centro de Madrid',
|
|
||||||
'currency' => 'EUR',
|
|
||||||
'acquisition_value' => 350000.00,
|
|
||||||
'current_value' => 420000.00,
|
|
||||||
'acquisition_date' => '2020-06-15',
|
|
||||||
'property_type' => 'apartment',
|
|
||||||
'address' => 'Calle Mayor 45, 3º A',
|
|
||||||
'city' => 'Madrid',
|
|
||||||
'postal_code' => '28013',
|
|
||||||
'country' => 'ES',
|
|
||||||
'property_area_m2' => 120.00,
|
|
||||||
'color' => '#3B82F6',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'asset_type' => 'real_estate',
|
|
||||||
'name' => 'Local Comercial Barcelona',
|
|
||||||
'description' => 'Local comercial en zona turística',
|
|
||||||
'currency' => 'EUR',
|
|
||||||
'acquisition_value' => 180000.00,
|
|
||||||
'current_value' => 210000.00,
|
|
||||||
'acquisition_date' => '2019-03-20',
|
|
||||||
'property_type' => 'commercial',
|
|
||||||
'city' => 'Barcelona',
|
|
||||||
'property_area_m2' => 85.00,
|
|
||||||
'color' => '#10B981',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'asset_type' => 'vehicle',
|
|
||||||
'name' => 'BMW X3 xDrive20d',
|
|
||||||
'description' => 'SUV premium para uso personal',
|
|
||||||
'currency' => 'EUR',
|
|
||||||
'acquisition_value' => 55000.00,
|
|
||||||
'current_value' => 42000.00,
|
|
||||||
'acquisition_date' => '2022-09-01',
|
|
||||||
'vehicle_brand' => 'BMW',
|
|
||||||
'vehicle_model' => 'X3 xDrive20d',
|
|
||||||
'vehicle_year' => 2022,
|
|
||||||
'vehicle_plate' => '1234 ABC',
|
|
||||||
'vehicle_mileage' => 45000,
|
|
||||||
'color' => '#F59E0B',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'asset_type' => 'investment',
|
|
||||||
'name' => 'Cartera Acciones IBEX',
|
|
||||||
'description' => 'Cartera diversificada de acciones españolas',
|
|
||||||
'currency' => 'EUR',
|
|
||||||
'acquisition_value' => 25000.00,
|
|
||||||
'current_value' => 28500.00,
|
|
||||||
'acquisition_date' => '2023-01-15',
|
|
||||||
'investment_type' => 'stocks',
|
|
||||||
'institution' => 'Interactive Brokers',
|
|
||||||
'ticker' => 'IBEX35',
|
|
||||||
'color' => '#8B5CF6',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'asset_type' => 'investment',
|
|
||||||
'name' => 'Fondo Indexado S&P 500',
|
|
||||||
'description' => 'ETF que replica el índice S&P 500',
|
|
||||||
'currency' => 'USD',
|
|
||||||
'acquisition_value' => 15000.00,
|
|
||||||
'current_value' => 18200.00,
|
|
||||||
'acquisition_date' => '2022-06-01',
|
|
||||||
'investment_type' => 'etf',
|
|
||||||
'institution' => 'Vanguard',
|
|
||||||
'ticker' => 'VOO',
|
|
||||||
'color' => '#06B6D4',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'asset_type' => 'investment',
|
|
||||||
'name' => 'Depósito a Plazo Fijo',
|
|
||||||
'currency' => 'EUR',
|
|
||||||
'acquisition_value' => 50000.00,
|
|
||||||
'current_value' => 51500.00,
|
|
||||||
'acquisition_date' => '2024-01-15',
|
|
||||||
'investment_type' => 'fixed_deposit',
|
|
||||||
'institution' => 'Openbank',
|
|
||||||
'interest_rate' => 3.00,
|
|
||||||
'maturity_date' => '2025-01-15',
|
|
||||||
'color' => '#14B8A6',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'asset_type' => 'investment',
|
|
||||||
'name' => 'Bitcoin (BTC)',
|
|
||||||
'currency' => 'EUR',
|
|
||||||
'acquisition_value' => 10000.00,
|
|
||||||
'current_value' => 15800.00,
|
|
||||||
'acquisition_date' => '2023-08-01',
|
|
||||||
'investment_type' => 'crypto',
|
|
||||||
'institution' => 'Binance',
|
|
||||||
'ticker' => 'BTC',
|
|
||||||
'color' => '#F97316',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'asset_type' => 'equipment',
|
|
||||||
'name' => 'MacBook Pro 16"',
|
|
||||||
'description' => 'Ordenador portátil profesional',
|
|
||||||
'currency' => 'EUR',
|
|
||||||
'acquisition_value' => 3500.00,
|
|
||||||
'current_value' => 2800.00,
|
|
||||||
'acquisition_date' => '2023-03-15',
|
|
||||||
'equipment_brand' => 'Apple',
|
|
||||||
'equipment_model' => 'MacBook Pro 16 M2 Pro',
|
|
||||||
'serial_number' => 'C02XL234H1',
|
|
||||||
'warranty_expiry' => '2026-03-15',
|
|
||||||
'color' => '#64748B',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'asset_type' => 'receivable',
|
|
||||||
'name' => 'Factura Cliente ABC Corp',
|
|
||||||
'description' => 'Factura pendiente de cobro',
|
|
||||||
'currency' => 'EUR',
|
|
||||||
'acquisition_value' => 12500.00,
|
|
||||||
'current_value' => 12500.00,
|
|
||||||
'acquisition_date' => '2024-11-15',
|
|
||||||
'debtor_name' => 'ABC Corporation S.L.',
|
|
||||||
'receivable_due_date' => '2025-01-15',
|
|
||||||
'receivable_amount' => 12500.00,
|
|
||||||
'color' => '#84CC16',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'asset_type' => 'cash',
|
|
||||||
'name' => 'Reserva de Emergencia',
|
|
||||||
'description' => 'Fondo de emergencia en cuenta de ahorro',
|
|
||||||
'currency' => 'EUR',
|
|
||||||
'acquisition_value' => 20000.00,
|
|
||||||
'current_value' => 20500.00,
|
|
||||||
'acquisition_date' => '2023-01-01',
|
|
||||||
'institution' => 'ING Direct',
|
|
||||||
'interest_rate' => 2.50,
|
|
||||||
'color' => '#22C55E',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'asset_type' => 'other',
|
|
||||||
'name' => 'Colección de Arte',
|
|
||||||
'description' => 'Obras de arte y antigüedades',
|
|
||||||
'currency' => 'EUR',
|
|
||||||
'acquisition_value' => 35000.00,
|
|
||||||
'current_value' => 42000.00,
|
|
||||||
'acquisition_date' => '2021-01-01',
|
|
||||||
'color' => '#A855F7',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($assets as $data) {
|
|
||||||
AssetAccount::updateOrCreate(
|
|
||||||
['user_id' => $user->id, 'name' => $data['name']],
|
|
||||||
array_merge($data, ['user_id' => $user->id, 'status' => 'active'])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->command->info("✓ " . count($assets) . " activos de ejemplo creados");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,166 +0,0 @@
|
|||||||
@extends('emails.layouts.base')
|
|
||||||
|
|
||||||
@php $locale = $locale ?? 'pt-BR'; @endphp
|
|
||||||
|
|
||||||
@section('title')
|
|
||||||
@if($locale === 'pt-BR')
|
|
||||||
Ativação de Conta
|
|
||||||
@elseif($locale === 'en')
|
|
||||||
Account Activation
|
|
||||||
@else
|
|
||||||
Activación de Cuenta
|
|
||||||
@endif
|
|
||||||
@endsection
|
|
||||||
|
|
||||||
@section('content')
|
|
||||||
@if($locale === 'pt-BR')
|
|
||||||
{{-- Portuguese (Brazil) --}}
|
|
||||||
<p class="greeting">Olá, {{ $user->name }}</p>
|
|
||||||
|
|
||||||
<div class="content">
|
|
||||||
<p>O seu pagamento foi processado com sucesso e a sua subscrição do plano <strong>{{ $planName }}</strong> está ativa.</p>
|
|
||||||
|
|
||||||
<div class="status-card success">
|
|
||||||
<table cellpadding="0" cellspacing="0" border="0" width="100%">
|
|
||||||
<tr>
|
|
||||||
<td width="52" valign="top">
|
|
||||||
<div style="width: 40px; height: 40px; background-color: #c6f6d5; border-radius: 50%; text-align: center; line-height: 40px; font-size: 18px;">✓</div>
|
|
||||||
</td>
|
|
||||||
<td valign="top">
|
|
||||||
<p class="status-title">Pagamento Confirmado</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<p style="margin-top: 16px;">A sua subscrição está pronta para ser ativada.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="status-card info">
|
|
||||||
<p class="status-title" style="color: #2d3748; margin-bottom: 16px;">Detalhes da Subscrição</p>
|
|
||||||
<ul class="info-list">
|
|
||||||
<li><strong>Plano:</strong> {{ $planName }}</li>
|
|
||||||
<li><strong>Email:</strong> {{ $user->email }}</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p>Para começar a utilizar o WEBMoney, ative a sua conta clicando no botão abaixo:</p>
|
|
||||||
|
|
||||||
<div class="btn-container">
|
|
||||||
<a href="{{ $activationUrl }}" class="btn btn-primary">ATIVAR CONTA</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="status-card warning">
|
|
||||||
<table cellpadding="0" cellspacing="0" border="0" width="100%">
|
|
||||||
<tr>
|
|
||||||
<td width="52" valign="top">
|
|
||||||
<div style="width: 40px; height: 40px; background-color: #feebc8; border-radius: 50%; text-align: center; line-height: 40px; font-size: 18px;">!</div>
|
|
||||||
</td>
|
|
||||||
<td valign="top">
|
|
||||||
<p class="status-title">Validade do Link</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<p style="margin-top: 12px;">Este link é válido por 24 horas. Após este período, será necessário solicitar um novo link de ativação.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@elseif($locale === 'en')
|
|
||||||
{{-- English --}}
|
|
||||||
<p class="greeting">Hello, {{ $user->name }}</p>
|
|
||||||
|
|
||||||
<div class="content">
|
|
||||||
<p>Your payment has been successfully processed and your <strong>{{ $planName }}</strong> subscription is active.</p>
|
|
||||||
|
|
||||||
<div class="status-card success">
|
|
||||||
<table cellpadding="0" cellspacing="0" border="0" width="100%">
|
|
||||||
<tr>
|
|
||||||
<td width="52" valign="top">
|
|
||||||
<div style="width: 40px; height: 40px; background-color: #c6f6d5; border-radius: 50%; text-align: center; line-height: 40px; font-size: 18px;">✓</div>
|
|
||||||
</td>
|
|
||||||
<td valign="top">
|
|
||||||
<p class="status-title">Payment Confirmed</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<p style="margin-top: 16px;">Your subscription is ready to be activated.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="status-card info">
|
|
||||||
<p class="status-title" style="color: #2d3748; margin-bottom: 16px;">Subscription Details</p>
|
|
||||||
<ul class="info-list">
|
|
||||||
<li><strong>Plan:</strong> {{ $planName }}</li>
|
|
||||||
<li><strong>Email:</strong> {{ $user->email }}</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p>To start using WEBMoney, activate your account by clicking the button below:</p>
|
|
||||||
|
|
||||||
<div class="btn-container">
|
|
||||||
<a href="{{ $activationUrl }}" class="btn btn-primary">ACTIVATE ACCOUNT</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="status-card warning">
|
|
||||||
<table cellpadding="0" cellspacing="0" border="0" width="100%">
|
|
||||||
<tr>
|
|
||||||
<td width="52" valign="top">
|
|
||||||
<div style="width: 40px; height: 40px; background-color: #feebc8; border-radius: 50%; text-align: center; line-height: 40px; font-size: 18px;">!</div>
|
|
||||||
</td>
|
|
||||||
<td valign="top">
|
|
||||||
<p class="status-title">Link Validity</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<p style="margin-top: 12px;">This link is valid for 24 hours. After this period, you will need to request a new activation link.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@else
|
|
||||||
{{-- Spanish (default) --}}
|
|
||||||
<p class="greeting">Hola, {{ $user->name }}</p>
|
|
||||||
|
|
||||||
<div class="content">
|
|
||||||
<p>Tu pago ha sido procesado con éxito y tu suscripción al plan <strong>{{ $planName }}</strong> está activa.</p>
|
|
||||||
|
|
||||||
<div class="status-card success">
|
|
||||||
<table cellpadding="0" cellspacing="0" border="0" width="100%">
|
|
||||||
<tr>
|
|
||||||
<td width="52" valign="top">
|
|
||||||
<div style="width: 40px; height: 40px; background-color: #c6f6d5; border-radius: 50%; text-align: center; line-height: 40px; font-size: 18px;">✓</div>
|
|
||||||
</td>
|
|
||||||
<td valign="top">
|
|
||||||
<p class="status-title">Pago Confirmado</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<p style="margin-top: 16px;">Tu suscripción está lista para ser activada.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="status-card info">
|
|
||||||
<p class="status-title" style="color: #2d3748; margin-bottom: 16px;">Detalles de la Suscripción</p>
|
|
||||||
<ul class="info-list">
|
|
||||||
<li><strong>Plan:</strong> {{ $planName }}</li>
|
|
||||||
<li><strong>Email:</strong> {{ $user->email }}</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p>Para comenzar a usar WEBMoney, activa tu cuenta haciendo clic en el botón:</p>
|
|
||||||
|
|
||||||
<div class="btn-container">
|
|
||||||
<a href="{{ $activationUrl }}" class="btn btn-primary">ACTIVAR CUENTA</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="status-card warning">
|
|
||||||
<table cellpadding="0" cellspacing="0" border="0" width="100%">
|
|
||||||
<tr>
|
|
||||||
<td width="52" valign="top">
|
|
||||||
<div style="width: 40px; height: 40px; background-color: #feebc8; border-radius: 50%; text-align: center; line-height: 40px; font-size: 18px;">!</div>
|
|
||||||
</td>
|
|
||||||
<td valign="top">
|
|
||||||
<p class="status-title">Validez del Enlace</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<p style="margin-top: 12px;">Este enlace es válido por 24 horas. Después de este período, deberás solicitar un nuevo enlace de activación.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
@endsection
|
|
||||||
@ -1,461 +1,388 @@
|
|||||||
@extends('emails.layouts.base')
|
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" lang="pt-BR">
|
||||||
@php $locale = $locale ?? 'pt-BR'; @endphp
|
<head>
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||||
@section('title')
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
@if($locale === 'pt-BR')
|
<meta name="x-apple-disable-message-reformatting" />
|
||||||
Alerta de Pagamentos
|
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no" />
|
||||||
@elseif($locale === 'en')
|
<title>WEBMoney - Alerta de Pagamentos</title>
|
||||||
Payment Alert
|
<!--[if mso]>
|
||||||
@else
|
<style type="text/css">
|
||||||
Alerta de Pagos
|
body, table, td {font-family: Arial, Helvetica, sans-serif !important;}
|
||||||
@endif
|
</style>
|
||||||
@endsection
|
<![endif]-->
|
||||||
|
<style type="text/css">
|
||||||
@section('content')
|
body {
|
||||||
@if($locale === 'pt-BR')
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||||
{{-- Portuguese (Brazil) --}}
|
line-height: 1.6;
|
||||||
<p class="greeting">Olá, {{ $user->name }}</p>
|
color: #333;
|
||||||
|
max-width: 600px;
|
||||||
<div class="content">
|
margin: 0 auto;
|
||||||
<p>Este é o seu resumo de pagamentos para os próximos dias.</p>
|
padding: 20px;
|
||||||
|
background-color: #f5f5f5;
|
||||||
{{-- Summary Box --}}
|
}
|
||||||
<div class="status-card info" style="background-color: #1a1a2e; border-color: #4361ee;">
|
.container {
|
||||||
<p class="status-title" style="color: #ffffff; margin-bottom: 16px;">Resumo Financeiro</p>
|
background-color: #ffffff;
|
||||||
<table cellpadding="0" cellspacing="0" border="0" width="100%" style="color: #ffffff;">
|
border-radius: 8px;
|
||||||
<tr>
|
padding: 30px;
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.1);">
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
<span style="color: #94a3b8;">Saldo Disponível</span>
|
}
|
||||||
</td>
|
.header {
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.1); text-align: right; font-weight: 600;">
|
text-align: center;
|
||||||
{{ $currency }} {{ number_format($totalBalance, 2, ',', '.') }}
|
border-bottom: 2px solid #0f172a;
|
||||||
</td>
|
padding-bottom: 20px;
|
||||||
</tr>
|
margin-bottom: 20px;
|
||||||
<tr>
|
}
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.1);">
|
.header h1 {
|
||||||
<span style="color: #94a3b8;">Total a Pagar</span>
|
color: #0f172a;
|
||||||
</td>
|
margin: 0;
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.1); text-align: right; font-weight: 600; color: #f87171;">
|
font-size: 24px;
|
||||||
{{ $currency }} {{ number_format($totalDue, 2, ',', '.') }}
|
}
|
||||||
</td>
|
.summary-box {
|
||||||
</tr>
|
background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
|
||||||
<tr>
|
color: white;
|
||||||
<td style="padding: 8px 0;">
|
padding: 20px;
|
||||||
<span style="color: #94a3b8;">Pagamentos Pendentes</span>
|
border-radius: 8px;
|
||||||
</td>
|
margin: 20px 0;
|
||||||
<td style="padding: 8px 0; text-align: right; font-weight: 600;">
|
}
|
||||||
{{ $totalPayments }}
|
.summary-row {
|
||||||
</td>
|
display: flex;
|
||||||
</tr>
|
justify-content: space-between;
|
||||||
</table>
|
padding: 8px 0;
|
||||||
</div>
|
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
{{-- Shortage Alert --}}
|
.summary-row:last-child {
|
||||||
@if($shortage > 0)
|
border-bottom: none;
|
||||||
<div class="status-card warning" style="background-color: #fef2f2; border-color: #dc2626;">
|
}
|
||||||
<table cellpadding="0" cellspacing="0" border="0" width="100%">
|
.summary-label {
|
||||||
<tr>
|
color: #94a3b8;
|
||||||
<td width="52" valign="top">
|
}
|
||||||
<div style="width: 40px; height: 40px; background-color: #fecaca; border-radius: 50%; text-align: center; line-height: 40px; font-size: 18px; color: #dc2626;">!</div>
|
.summary-value {
|
||||||
</td>
|
font-weight: bold;
|
||||||
<td valign="top">
|
}
|
||||||
<p class="status-title" style="color: #dc2626;">Saldo Insuficiente</p>
|
.shortage {
|
||||||
</td>
|
background-color: #dc2626;
|
||||||
</tr>
|
color: white;
|
||||||
</table>
|
padding: 15px;
|
||||||
<p style="margin-top: 12px; color: #991b1b; text-align: center;">
|
border-radius: 8px;
|
||||||
<span style="font-size: 28px; font-weight: 700; display: block;">{{ $currency }} {{ number_format($shortage, 2, ',', '.') }}</span>
|
text-align: center;
|
||||||
<span style="font-size: 13px;">em falta para cobrir todos os pagamentos</span>
|
margin: 20px 0;
|
||||||
</p>
|
}
|
||||||
</div>
|
.shortage h3 {
|
||||||
@endif
|
margin: 0 0 5px 0;
|
||||||
|
}
|
||||||
{{-- Overdue Payments --}}
|
.shortage .amount {
|
||||||
@if(count($overduePayments) > 0)
|
font-size: 28px;
|
||||||
<div style="margin-top: 24px;">
|
font-weight: bold;
|
||||||
<p style="font-size: 14px; font-weight: 600; color: #dc2626; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 2px solid #fecaca;">
|
}
|
||||||
Pagamentos em Atraso
|
.section {
|
||||||
</p>
|
margin: 25px 0;
|
||||||
@foreach($overduePayments as $payment)
|
}
|
||||||
<div style="background-color: #fef2f2; border-left: 4px solid #dc2626; padding: 12px 16px; margin: 8px 0; border-radius: 0 4px 4px 0;">
|
.section-title {
|
||||||
<table cellpadding="0" cellspacing="0" border="0" width="100%">
|
font-size: 18px;
|
||||||
<tr>
|
color: #0f172a;
|
||||||
<td>
|
border-bottom: 2px solid #e2e8f0;
|
||||||
<span style="font-weight: 600; color: #1e293b;">{{ $payment['description'] }}</span>
|
padding-bottom: 10px;
|
||||||
</td>
|
margin-bottom: 15px;
|
||||||
<td style="text-align: right;">
|
}
|
||||||
<span style="font-weight: 700; color: #dc2626;">{{ $currency }} {{ number_format($payment['amount'], 2, ',', '.') }}</span>
|
.item {
|
||||||
</td>
|
background-color: #f8fafc;
|
||||||
</tr>
|
border-left: 4px solid #64748b;
|
||||||
<tr>
|
padding: 12px 15px;
|
||||||
<td colspan="2" style="padding-top: 4px;">
|
margin: 10px 0;
|
||||||
<span style="font-size: 12px; color: #64748b;">Venceu em {{ \Carbon\Carbon::parse($payment['due_date'])->format('d/m/Y') }}</span>
|
border-radius: 0 4px 4px 0;
|
||||||
<span style="display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 10px; font-weight: 700; text-transform: uppercase; background-color: #dc2626; color: white; margin-left: 8px;">ATRASADO</span>
|
}
|
||||||
</td>
|
.item.overdue {
|
||||||
</tr>
|
border-left-color: #dc2626;
|
||||||
</table>
|
background-color: #fef2f2;
|
||||||
</div>
|
}
|
||||||
@endforeach
|
.item.tomorrow {
|
||||||
</div>
|
border-left-color: #f59e0b;
|
||||||
@endif
|
background-color: #fffbeb;
|
||||||
|
}
|
||||||
{{-- Tomorrow Payments --}}
|
.item.payable {
|
||||||
@if(count($tomorrowPayments) > 0)
|
border-left-color: #22c55e;
|
||||||
<div style="margin-top: 24px;">
|
background-color: #f0fdf4;
|
||||||
<p style="font-size: 14px; font-weight: 600; color: #f59e0b; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 2px solid #fde68a;">
|
}
|
||||||
Vencem Amanhã
|
.item.unpayable {
|
||||||
</p>
|
border-left-color: #dc2626;
|
||||||
@foreach($tomorrowPayments as $payment)
|
background-color: #fef2f2;
|
||||||
<div style="background-color: #fffbeb; border-left: 4px solid #f59e0b; padding: 12px 16px; margin: 8px 0; border-radius: 0 4px 4px 0;">
|
}
|
||||||
<table cellpadding="0" cellspacing="0" border="0" width="100%">
|
.item-header {
|
||||||
<tr>
|
display: flex;
|
||||||
<td>
|
justify-content: space-between;
|
||||||
<span style="font-weight: 600; color: #1e293b;">{{ $payment['description'] }}</span>
|
align-items: center;
|
||||||
</td>
|
}
|
||||||
<td style="text-align: right;">
|
.item-description {
|
||||||
<span style="font-weight: 700; color: #d97706;">{{ $currency }} {{ number_format($payment['amount'], 2, ',', '.') }}</span>
|
font-weight: 600;
|
||||||
</td>
|
color: #1e293b;
|
||||||
</tr>
|
}
|
||||||
<tr>
|
.item-amount {
|
||||||
<td colspan="2" style="padding-top: 4px;">
|
font-weight: bold;
|
||||||
<span style="font-size: 12px; color: #64748b;">{{ \Carbon\Carbon::parse($payment['due_date'])->format('d/m/Y') }}</span>
|
color: #dc2626;
|
||||||
<span style="display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 10px; font-weight: 700; text-transform: uppercase; background-color: #f59e0b; color: white; margin-left: 8px;">AMANHÃ</span>
|
}
|
||||||
</td>
|
.item-details {
|
||||||
</tr>
|
font-size: 13px;
|
||||||
</table>
|
color: #64748b;
|
||||||
</div>
|
margin-top: 5px;
|
||||||
@endforeach
|
}
|
||||||
</div>
|
.badge {
|
||||||
@endif
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
{{-- Upcoming Payments --}}
|
border-radius: 12px;
|
||||||
@if(count($upcomingPayments) > 0)
|
font-size: 11px;
|
||||||
<div style="margin-top: 24px;">
|
font-weight: bold;
|
||||||
<p style="font-size: 14px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 2px solid #e2e8f0;">
|
text-transform: uppercase;
|
||||||
Próximos Pagamentos
|
}
|
||||||
</p>
|
.badge-overdue {
|
||||||
@foreach($upcomingPayments as $payment)
|
background-color: #dc2626;
|
||||||
<div style="background-color: #f8fafc; border-left: 4px solid #64748b; padding: 12px 16px; margin: 8px 0; border-radius: 0 4px 4px 0;">
|
color: white;
|
||||||
<table cellpadding="0" cellspacing="0" border="0" width="100%">
|
}
|
||||||
<tr>
|
.badge-tomorrow {
|
||||||
<td>
|
background-color: #f59e0b;
|
||||||
<span style="font-weight: 600; color: #1e293b;">{{ $payment['description'] }}</span>
|
color: white;
|
||||||
</td>
|
}
|
||||||
<td style="text-align: right;">
|
.badge-ok {
|
||||||
<span style="font-weight: 700; color: #475569;">{{ $currency }} {{ number_format($payment['amount'], 2, ',', '.') }}</span>
|
background-color: #22c55e;
|
||||||
</td>
|
color: white;
|
||||||
</tr>
|
}
|
||||||
<tr>
|
.account-balance {
|
||||||
<td colspan="2" style="padding-top: 4px;">
|
display: flex;
|
||||||
<span style="font-size: 12px; color: #64748b;">{{ \Carbon\Carbon::parse($payment['due_date'])->format('d/m/Y') }}</span>
|
justify-content: space-between;
|
||||||
</td>
|
padding: 10px 15px;
|
||||||
</tr>
|
background-color: #f8fafc;
|
||||||
</table>
|
margin: 5px 0;
|
||||||
</div>
|
border-radius: 4px;
|
||||||
@endforeach
|
}
|
||||||
</div>
|
.account-name {
|
||||||
@endif
|
font-weight: 500;
|
||||||
|
}
|
||||||
<div class="divider"></div>
|
.balance-positive {
|
||||||
|
color: #22c55e;
|
||||||
<div class="btn-container">
|
font-weight: bold;
|
||||||
<a href="https://webmoney.cnxifly.com/dashboard" class="btn btn-primary">VER DASHBOARD</a>
|
}
|
||||||
</div>
|
.balance-negative {
|
||||||
|
color: #dc2626;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.transfer-suggestion {
|
||||||
|
background-color: #eff6ff;
|
||||||
|
border: 1px solid #3b82f6;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 15px;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
.transfer-arrow {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 20px;
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 30px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.cta-button {
|
||||||
|
display: inline-block;
|
||||||
|
background-color: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
padding: 12px 24px;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.cta-button:hover {
|
||||||
|
background-color: #2563eb;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>💰 WEBMoney - Alerta de Pagamentos</h1>
|
||||||
|
<p>Olá, {{ $userName }}!</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@elseif($locale === 'en')
|
<!-- Summary Box -->
|
||||||
{{-- English --}}
|
<div class="summary-box">
|
||||||
<p class="greeting">Hello, {{ $user->name }}</p>
|
<div class="summary-row">
|
||||||
|
<span class="summary-label">💳 Saldo Total Disponível</span>
|
||||||
<div class="content">
|
<span class="summary-value">{{ number_format($totalAvailable, 2, ',', '.') }} {{ $currency }}</span>
|
||||||
<p>Here is your payment summary for the coming days.</p>
|
</div>
|
||||||
|
<div class="summary-row">
|
||||||
{{-- Summary Box --}}
|
<span class="summary-label">📋 Total a Pagar</span>
|
||||||
<div class="status-card info" style="background-color: #1a1a2e; border-color: #4361ee;">
|
<span class="summary-value">{{ number_format($totalDue, 2, ',', '.') }} {{ $currency }}</span>
|
||||||
<p class="status-title" style="color: #ffffff; margin-bottom: 16px;">Financial Summary</p>
|
|
||||||
<table cellpadding="0" cellspacing="0" border="0" width="100%" style="color: #ffffff;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.1);">
|
|
||||||
<span style="color: #94a3b8;">Available Balance</span>
|
|
||||||
</td>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.1); text-align: right; font-weight: 600;">
|
|
||||||
{{ $currency }} {{ number_format($totalBalance, 2, '.', ',') }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.1);">
|
|
||||||
<span style="color: #94a3b8;">Total Due</span>
|
|
||||||
</td>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.1); text-align: right; font-weight: 600; color: #f87171;">
|
|
||||||
{{ $currency }} {{ number_format($totalDue, 2, '.', ',') }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0;">
|
|
||||||
<span style="color: #94a3b8;">Pending Payments</span>
|
|
||||||
</td>
|
|
||||||
<td style="padding: 8px 0; text-align: right; font-weight: 600;">
|
|
||||||
{{ $totalPayments }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if($shortage > 0)
|
@if($shortage > 0)
|
||||||
<div class="status-card warning" style="background-color: #fef2f2; border-color: #dc2626;">
|
<div class="summary-row" style="color: #fca5a5;">
|
||||||
<table cellpadding="0" cellspacing="0" border="0" width="100%">
|
<span class="summary-label">⚠️ Falta</span>
|
||||||
<tr>
|
<span class="summary-value">{{ number_format($shortage, 2, ',', '.') }} {{ $currency }}</span>
|
||||||
<td width="52" valign="top">
|
|
||||||
<div style="width: 40px; height: 40px; background-color: #fecaca; border-radius: 50%; text-align: center; line-height: 40px; font-size: 18px; color: #dc2626;">!</div>
|
|
||||||
</td>
|
|
||||||
<td valign="top">
|
|
||||||
<p class="status-title" style="color: #dc2626;">Insufficient Balance</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<p style="margin-top: 12px; color: #991b1b; text-align: center;">
|
|
||||||
<span style="font-size: 28px; font-weight: 700; display: block;">{{ $currency }} {{ number_format($shortage, 2, '.', ',') }}</span>
|
|
||||||
<span style="font-size: 13px;">short to cover all payments</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if(count($overduePayments) > 0)
|
|
||||||
<div style="margin-top: 24px;">
|
|
||||||
<p style="font-size: 14px; font-weight: 600; color: #dc2626; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 2px solid #fecaca;">
|
|
||||||
Overdue Payments
|
|
||||||
</p>
|
|
||||||
@foreach($overduePayments as $payment)
|
|
||||||
<div style="background-color: #fef2f2; border-left: 4px solid #dc2626; padding: 12px 16px; margin: 8px 0; border-radius: 0 4px 4px 0;">
|
|
||||||
<table cellpadding="0" cellspacing="0" border="0" width="100%">
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<span style="font-weight: 600; color: #1e293b;">{{ $payment['description'] }}</span>
|
|
||||||
</td>
|
|
||||||
<td style="text-align: right;">
|
|
||||||
<span style="font-weight: 700; color: #dc2626;">{{ $currency }} {{ number_format($payment['amount'], 2, '.', ',') }}</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td colspan="2" style="padding-top: 4px;">
|
|
||||||
<span style="font-size: 12px; color: #64748b;">Due {{ \Carbon\Carbon::parse($payment['due_date'])->format('M d, Y') }}</span>
|
|
||||||
<span style="display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 10px; font-weight: 700; text-transform: uppercase; background-color: #dc2626; color: white; margin-left: 8px;">OVERDUE</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if(count($tomorrowPayments) > 0)
|
|
||||||
<div style="margin-top: 24px;">
|
|
||||||
<p style="font-size: 14px; font-weight: 600; color: #f59e0b; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 2px solid #fde68a;">
|
|
||||||
Due Tomorrow
|
|
||||||
</p>
|
|
||||||
@foreach($tomorrowPayments as $payment)
|
|
||||||
<div style="background-color: #fffbeb; border-left: 4px solid #f59e0b; padding: 12px 16px; margin: 8px 0; border-radius: 0 4px 4px 0;">
|
|
||||||
<table cellpadding="0" cellspacing="0" border="0" width="100%">
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<span style="font-weight: 600; color: #1e293b;">{{ $payment['description'] }}</span>
|
|
||||||
</td>
|
|
||||||
<td style="text-align: right;">
|
|
||||||
<span style="font-weight: 700; color: #d97706;">{{ $currency }} {{ number_format($payment['amount'], 2, '.', ',') }}</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td colspan="2" style="padding-top: 4px;">
|
|
||||||
<span style="font-size: 12px; color: #64748b;">{{ \Carbon\Carbon::parse($payment['due_date'])->format('M d, Y') }}</span>
|
|
||||||
<span style="display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 10px; font-weight: 700; text-transform: uppercase; background-color: #f59e0b; color: white; margin-left: 8px;">TOMORROW</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if(count($upcomingPayments) > 0)
|
|
||||||
<div style="margin-top: 24px;">
|
|
||||||
<p style="font-size: 14px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 2px solid #e2e8f0;">
|
|
||||||
Upcoming Payments
|
|
||||||
</p>
|
|
||||||
@foreach($upcomingPayments as $payment)
|
|
||||||
<div style="background-color: #f8fafc; border-left: 4px solid #64748b; padding: 12px 16px; margin: 8px 0; border-radius: 0 4px 4px 0;">
|
|
||||||
<table cellpadding="0" cellspacing="0" border="0" width="100%">
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<span style="font-weight: 600; color: #1e293b;">{{ $payment['description'] }}</span>
|
|
||||||
</td>
|
|
||||||
<td style="text-align: right;">
|
|
||||||
<span style="font-weight: 700; color: #475569;">{{ $currency }} {{ number_format($payment['amount'], 2, '.', ',') }}</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td colspan="2" style="padding-top: 4px;">
|
|
||||||
<span style="font-size: 12px; color: #64748b;">{{ \Carbon\Carbon::parse($payment['due_date'])->format('M d, Y') }}</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<div class="divider"></div>
|
|
||||||
|
|
||||||
<div class="btn-container">
|
|
||||||
<a href="https://webmoney.cnxifly.com/dashboard" class="btn btn-primary">VIEW DASHBOARD</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="summary-row" style="color: #86efac;">
|
||||||
|
<span class="summary-label">✅ Situação</span>
|
||||||
|
<span class="summary-value">Saldo suficiente!</span>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@else
|
@if($shortage > 0)
|
||||||
{{-- Spanish (default) --}}
|
<div class="shortage">
|
||||||
<p class="greeting">Hola, {{ $user->name }}</p>
|
<h3>⚠️ SALDO INSUFICIENTE</h3>
|
||||||
|
<div class="amount">-{{ number_format($shortage, 2, ',', '.') }} {{ $currency }}</div>
|
||||||
<div class="content">
|
<p style="margin: 10px 0 0 0; font-size: 14px;">Você não tem saldo suficiente para cobrir todos os pagamentos.</p>
|
||||||
<p>Este es tu resumen de pagos para los próximos días.</p>
|
|
||||||
|
|
||||||
{{-- Summary Box --}}
|
|
||||||
<div class="status-card info" style="background-color: #1a1a2e; border-color: #4361ee;">
|
|
||||||
<p class="status-title" style="color: #ffffff; margin-bottom: 16px;">Resumen Financiero</p>
|
|
||||||
<table cellpadding="0" cellspacing="0" border="0" width="100%" style="color: #ffffff;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.1);">
|
|
||||||
<span style="color: #94a3b8;">Saldo Disponible</span>
|
|
||||||
</td>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.1); text-align: right; font-weight: 600;">
|
|
||||||
{{ $currency }} {{ number_format($totalBalance, 2, ',', '.') }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.1);">
|
|
||||||
<span style="color: #94a3b8;">Total a Pagar</span>
|
|
||||||
</td>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.1); text-align: right; font-weight: 600; color: #f87171;">
|
|
||||||
{{ $currency }} {{ number_format($totalDue, 2, ',', '.') }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0;">
|
|
||||||
<span style="color: #94a3b8;">Pagos Pendientes</span>
|
|
||||||
</td>
|
|
||||||
<td style="padding: 8px 0; text-align: right; font-weight: 600;">
|
|
||||||
{{ $totalPayments }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if($shortage > 0)
|
|
||||||
<div class="status-card warning" style="background-color: #fef2f2; border-color: #dc2626;">
|
|
||||||
<table cellpadding="0" cellspacing="0" border="0" width="100%">
|
|
||||||
<tr>
|
|
||||||
<td width="52" valign="top">
|
|
||||||
<div style="width: 40px; height: 40px; background-color: #fecaca; border-radius: 50%; text-align: center; line-height: 40px; font-size: 18px; color: #dc2626;">!</div>
|
|
||||||
</td>
|
|
||||||
<td valign="top">
|
|
||||||
<p class="status-title" style="color: #dc2626;">Saldo Insuficiente</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<p style="margin-top: 12px; color: #991b1b; text-align: center;">
|
|
||||||
<span style="font-size: 28px; font-weight: 700; display: block;">{{ $currency }} {{ number_format($shortage, 2, ',', '.') }}</span>
|
|
||||||
<span style="font-size: 13px;">faltan para cubrir todos los pagos</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if(count($overduePayments) > 0)
|
|
||||||
<div style="margin-top: 24px;">
|
|
||||||
<p style="font-size: 14px; font-weight: 600; color: #dc2626; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 2px solid #fecaca;">
|
|
||||||
Pagos Vencidos
|
|
||||||
</p>
|
|
||||||
@foreach($overduePayments as $payment)
|
|
||||||
<div style="background-color: #fef2f2; border-left: 4px solid #dc2626; padding: 12px 16px; margin: 8px 0; border-radius: 0 4px 4px 0;">
|
|
||||||
<table cellpadding="0" cellspacing="0" border="0" width="100%">
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<span style="font-weight: 600; color: #1e293b;">{{ $payment['description'] }}</span>
|
|
||||||
</td>
|
|
||||||
<td style="text-align: right;">
|
|
||||||
<span style="font-weight: 700; color: #dc2626;">{{ $currency }} {{ number_format($payment['amount'], 2, ',', '.') }}</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td colspan="2" style="padding-top: 4px;">
|
|
||||||
<span style="font-size: 12px; color: #64748b;">Venció el {{ \Carbon\Carbon::parse($payment['due_date'])->format('d/m/Y') }}</span>
|
|
||||||
<span style="display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 10px; font-weight: 700; text-transform: uppercase; background-color: #dc2626; color: white; margin-left: 8px;">VENCIDO</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if(count($tomorrowPayments) > 0)
|
|
||||||
<div style="margin-top: 24px;">
|
|
||||||
<p style="font-size: 14px; font-weight: 600; color: #f59e0b; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 2px solid #fde68a;">
|
|
||||||
Vencen Mañana
|
|
||||||
</p>
|
|
||||||
@foreach($tomorrowPayments as $payment)
|
|
||||||
<div style="background-color: #fffbeb; border-left: 4px solid #f59e0b; padding: 12px 16px; margin: 8px 0; border-radius: 0 4px 4px 0;">
|
|
||||||
<table cellpadding="0" cellspacing="0" border="0" width="100%">
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<span style="font-weight: 600; color: #1e293b;">{{ $payment['description'] }}</span>
|
|
||||||
</td>
|
|
||||||
<td style="text-align: right;">
|
|
||||||
<span style="font-weight: 700; color: #d97706;">{{ $currency }} {{ number_format($payment['amount'], 2, ',', '.') }}</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td colspan="2" style="padding-top: 4px;">
|
|
||||||
<span style="font-size: 12px; color: #64748b;">{{ \Carbon\Carbon::parse($payment['due_date'])->format('d/m/Y') }}</span>
|
|
||||||
<span style="display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 10px; font-weight: 700; text-transform: uppercase; background-color: #f59e0b; color: white; margin-left: 8px;">MAÑANA</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if(count($upcomingPayments) > 0)
|
|
||||||
<div style="margin-top: 24px;">
|
|
||||||
<p style="font-size: 14px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 2px solid #e2e8f0;">
|
|
||||||
Próximos Pagos
|
|
||||||
</p>
|
|
||||||
@foreach($upcomingPayments as $payment)
|
|
||||||
<div style="background-color: #f8fafc; border-left: 4px solid #64748b; padding: 12px 16px; margin: 8px 0; border-radius: 0 4px 4px 0;">
|
|
||||||
<table cellpadding="0" cellspacing="0" border="0" width="100%">
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<span style="font-weight: 600; color: #1e293b;">{{ $payment['description'] }}</span>
|
|
||||||
</td>
|
|
||||||
<td style="text-align: right;">
|
|
||||||
<span style="font-weight: 700; color: #475569;">{{ $currency }} {{ number_format($payment['amount'], 2, ',', '.') }}</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td colspan="2" style="padding-top: 4px;">
|
|
||||||
<span style="font-size: 12px; color: #64748b;">{{ \Carbon\Carbon::parse($payment['due_date'])->format('d/m/Y') }}</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<div class="divider"></div>
|
|
||||||
|
|
||||||
<div class="btn-container">
|
|
||||||
<a href="https://webmoney.cnxifly.com/dashboard" class="btn btn-primary">VER DASHBOARD</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
@endsection
|
|
||||||
|
<!-- Account Balances -->
|
||||||
|
<div class="section">
|
||||||
|
<h2 class="section-title">💳 Saldo das Contas</h2>
|
||||||
|
@foreach($accountBalances as $account)
|
||||||
|
<div class="account-balance">
|
||||||
|
<span class="account-name">{{ $account['name'] }}</span>
|
||||||
|
<span class="{{ $account['balance'] >= 0 ? 'balance-positive' : 'balance-negative' }}">
|
||||||
|
{{ number_format($account['balance'], 2, ',', '.') }} {{ $account['currency'] }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Overdue Items -->
|
||||||
|
@if(count($overdueItems) > 0)
|
||||||
|
<div class="section">
|
||||||
|
<h2 class="section-title">🔴 Pagamentos Vencidos ({{ count($overdueItems) }})</h2>
|
||||||
|
@foreach($overdueItems as $item)
|
||||||
|
<div class="item overdue">
|
||||||
|
<div class="item-header">
|
||||||
|
<span class="item-description">{{ $item['description'] }}</span>
|
||||||
|
<span class="item-amount">{{ number_format($item['amount'], 2, ',', '.') }} {{ $item['currency'] }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="item-details">
|
||||||
|
<span class="badge badge-overdue">{{ $item['days_overdue'] }} dias de atraso</span>
|
||||||
|
• Venceu em {{ \Carbon\Carbon::parse($item['due_date'])->format('d/m/Y') }}
|
||||||
|
@if($item['account_name'])
|
||||||
|
• Conta: {{ $item['account_name'] }}
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<!-- Tomorrow Items -->
|
||||||
|
@if(count($tomorrowItems) > 0)
|
||||||
|
<div class="section">
|
||||||
|
<h2 class="section-title">🟡 Vencem Amanhã ({{ count($tomorrowItems) }})</h2>
|
||||||
|
@foreach($tomorrowItems as $item)
|
||||||
|
<div class="item tomorrow">
|
||||||
|
<div class="item-header">
|
||||||
|
<span class="item-description">{{ $item['description'] }}</span>
|
||||||
|
<span class="item-amount">{{ number_format($item['amount'], 2, ',', '.') }} {{ $item['currency'] }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="item-details">
|
||||||
|
<span class="badge badge-tomorrow">Amanhã</span>
|
||||||
|
• {{ \Carbon\Carbon::parse($item['due_date'])->format('d/m/Y') }}
|
||||||
|
@if($item['account_name'])
|
||||||
|
• Conta: {{ $item['account_name'] }}
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<!-- Payable Items -->
|
||||||
|
@if(count($payableItems) > 0)
|
||||||
|
<div class="section">
|
||||||
|
<h2 class="section-title">✅ Pagamentos Possíveis ({{ count($payableItems) }})</h2>
|
||||||
|
<p style="color: #64748b; font-size: 14px;">Com base no saldo atual, você consegue pagar:</p>
|
||||||
|
@foreach($payableItems as $item)
|
||||||
|
<div class="item payable">
|
||||||
|
<div class="item-header">
|
||||||
|
<span class="item-description">{{ $item['description'] }}</span>
|
||||||
|
<span class="item-amount" style="color: #22c55e;">{{ number_format($item['amount'], 2, ',', '.') }} {{ $item['currency'] }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="item-details">
|
||||||
|
<span class="badge badge-ok">✓ Pode pagar</span>
|
||||||
|
@if($item['account_name'])
|
||||||
|
• Conta: {{ $item['account_name'] }}
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<!-- Unpayable Items -->
|
||||||
|
@if(count($unpayableItems) > 0)
|
||||||
|
<div class="section">
|
||||||
|
<h2 class="section-title">❌ Sem Saldo Suficiente ({{ count($unpayableItems) }})</h2>
|
||||||
|
<p style="color: #64748b; font-size: 14px;">Não há saldo disponível para estes pagamentos:</p>
|
||||||
|
@foreach($unpayableItems as $item)
|
||||||
|
<div class="item unpayable">
|
||||||
|
<div class="item-header">
|
||||||
|
<span class="item-description">{{ $item['description'] }}</span>
|
||||||
|
<span class="item-amount">{{ number_format($item['amount'], 2, ',', '.') }} {{ $item['currency'] }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="item-details">
|
||||||
|
<span class="badge badge-overdue">✗ Sem saldo</span>
|
||||||
|
@if($item['account_name'])
|
||||||
|
• Conta: {{ $item['account_name'] }}
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<!-- Transfer Suggestions -->
|
||||||
|
@if(count($transferSuggestions) > 0)
|
||||||
|
<div class="section">
|
||||||
|
<h2 class="section-title">💱 Sugestões de Transferência</h2>
|
||||||
|
<p style="color: #64748b; font-size: 14px;">Para cobrir os pagamentos, considere transferir entre suas contas:</p>
|
||||||
|
@foreach($transferSuggestions as $transfer)
|
||||||
|
<div class="transfer-suggestion">
|
||||||
|
<div style="display: flex; align-items: center; justify-content: space-between;">
|
||||||
|
<div>
|
||||||
|
<strong>{{ $transfer['from_account'] }}</strong>
|
||||||
|
<div style="font-size: 12px; color: #64748b;">Origem</div>
|
||||||
|
</div>
|
||||||
|
<div class="transfer-arrow">→</div>
|
||||||
|
<div style="text-align: right;">
|
||||||
|
<strong>{{ $transfer['to_account'] }}</strong>
|
||||||
|
<div style="font-size: 12px; color: #64748b;">Destino</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align: center; margin-top: 10px; font-size: 20px; font-weight: bold; color: #3b82f6;">
|
||||||
|
{{ number_format($transfer['amount'], 2, ',', '.') }} {{ $currency }}
|
||||||
|
</div>
|
||||||
|
<div style="text-align: center; font-size: 12px; color: #64748b;">{{ $transfer['reason'] }}</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<a href="https://webmoney.cnxifly.com/transactions" class="cta-button">
|
||||||
|
Acessar WEBMoney
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p>Este email foi enviado automaticamente pelo sistema WEBMoney para {{ $userName }}.</p>
|
||||||
|
<p>Você recebe esta mensagem porque ativou as notificações de pagamentos.</p>
|
||||||
|
<p>Para desativar estas notificações, acesse <a href="https://webmoney.cnxifly.com/preferences" style="color: #3b82f6;">Preferências</a>.</p>
|
||||||
|
<p style="margin-top: 15px; font-size: 11px; color: #94a3b8;">
|
||||||
|
WEBMoney - ConneXiFly<br />
|
||||||
|
Serviço de gestão financeira pessoal<br />
|
||||||
|
Madrid, Espanha
|
||||||
|
</p>
|
||||||
|
<p style="font-size: 11px; color: #94a3b8;">© {{ date('Y') }} WEBMoney - Todos os direitos reservados</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|||||||
@ -1,412 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="{{ $locale ?? 'es' }}">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
||||||
<title>@yield('title') - WEBMoney</title>
|
|
||||||
<!--[if mso]>
|
|
||||||
<noscript>
|
|
||||||
<xml>
|
|
||||||
<o:OfficeDocumentSettings>
|
|
||||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
|
||||||
</o:OfficeDocumentSettings>
|
|
||||||
</xml>
|
|
||||||
</noscript>
|
|
||||||
<![endif]-->
|
|
||||||
<style>
|
|
||||||
/* Reset */
|
|
||||||
body, table, td, p, a, li, blockquote {
|
|
||||||
-webkit-text-size-adjust: 100%;
|
|
||||||
-ms-text-size-adjust: 100%;
|
|
||||||
}
|
|
||||||
table, td {
|
|
||||||
mso-table-lspace: 0pt;
|
|
||||||
mso-table-rspace: 0pt;
|
|
||||||
}
|
|
||||||
img {
|
|
||||||
-ms-interpolation-mode: bicubic;
|
|
||||||
border: 0;
|
|
||||||
height: auto;
|
|
||||||
line-height: 100%;
|
|
||||||
outline: none;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Base styles */
|
|
||||||
body {
|
|
||||||
margin: 0 !important;
|
|
||||||
padding: 0 !important;
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Container */
|
|
||||||
.email-wrapper {
|
|
||||||
width: 100%;
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
padding: 48px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.email-container {
|
|
||||||
max-width: 580px;
|
|
||||||
margin: 0 auto;
|
|
||||||
background-color: #ffffff;
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
|
||||||
border: 1px solid #e9ecef;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Header */
|
|
||||||
.email-header {
|
|
||||||
background-color: #1a1a2e;
|
|
||||||
padding: 32px 40px;
|
|
||||||
text-align: center;
|
|
||||||
border-bottom: 3px solid #4361ee;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #ffffff;
|
|
||||||
letter-spacing: -0.3px;
|
|
||||||
margin: 0;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-accent {
|
|
||||||
color: #4361ee;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Body */
|
|
||||||
.email-body {
|
|
||||||
padding: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.greeting {
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #1a1a2e;
|
|
||||||
margin: 0 0 24px 0;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content p {
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 1.7;
|
|
||||||
color: #495057;
|
|
||||||
margin: 0 0 16px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content strong {
|
|
||||||
color: #1a1a2e;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Status Card */
|
|
||||||
.status-card {
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 24px;
|
|
||||||
margin: 24px 0;
|
|
||||||
border: 1px solid;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-card.success {
|
|
||||||
background-color: #f8fff8;
|
|
||||||
border-color: #c6f6d5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-card.info {
|
|
||||||
background-color: #f7fafc;
|
|
||||||
border-color: #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-card.warning {
|
|
||||||
background-color: #fffaf0;
|
|
||||||
border-color: #feebc8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
padding-bottom: 12px;
|
|
||||||
border-bottom: 1px solid rgba(0,0,0,0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-icon {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 18px;
|
|
||||||
margin-right: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-card.success .status-icon {
|
|
||||||
background-color: #c6f6d5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-card.info .status-icon {
|
|
||||||
background-color: #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-card.warning .status-icon {
|
|
||||||
background-color: #feebc8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-title {
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-card.success .status-title {
|
|
||||||
color: #22543d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-card.info .status-title {
|
|
||||||
color: #2d3748;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-card.warning .status-title {
|
|
||||||
color: #744210;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-card p {
|
|
||||||
font-size: 14px;
|
|
||||||
margin: 0;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-card.success p {
|
|
||||||
color: #276749;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-card.info p {
|
|
||||||
color: #4a5568;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-card.warning p {
|
|
||||||
color: #975a16;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Amount */
|
|
||||||
.amount-value {
|
|
||||||
font-size: 32px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #22543d;
|
|
||||||
display: block;
|
|
||||||
margin: 16px 0;
|
|
||||||
letter-spacing: -0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* List */
|
|
||||||
.info-list {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-list li {
|
|
||||||
position: relative;
|
|
||||||
padding: 8px 0 8px 24px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #4a5568;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-list li::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 14px;
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
background-color: #4361ee;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Buttons */
|
|
||||||
.btn-container {
|
|
||||||
text-align: center;
|
|
||||||
margin: 32px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 14px 32px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-decoration: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background-color: #4361ee;
|
|
||||||
color: #ffffff !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Divider */
|
|
||||||
.divider {
|
|
||||||
height: 1px;
|
|
||||||
background-color: #e9ecef;
|
|
||||||
margin: 32px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Footer */
|
|
||||||
.email-footer {
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
padding: 32px 40px;
|
|
||||||
border-top: 1px solid #e9ecef;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-brand {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-brand-name {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #1a1a2e;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-tagline {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #6c757d;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-links {
|
|
||||||
text-align: center;
|
|
||||||
margin: 16px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-links a {
|
|
||||||
color: #4361ee;
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 12px;
|
|
||||||
margin: 0 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-legal {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 11px;
|
|
||||||
color: #adb5bd;
|
|
||||||
line-height: 1.6;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-legal a {
|
|
||||||
color: #6c757d;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive */
|
|
||||||
@media only screen and (max-width: 600px) {
|
|
||||||
.email-wrapper {
|
|
||||||
padding: 24px 12px;
|
|
||||||
}
|
|
||||||
.email-header {
|
|
||||||
padding: 24px 20px;
|
|
||||||
}
|
|
||||||
.email-body {
|
|
||||||
padding: 28px 20px;
|
|
||||||
}
|
|
||||||
.email-footer {
|
|
||||||
padding: 24px 20px;
|
|
||||||
}
|
|
||||||
.logo {
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
.greeting {
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
.btn {
|
|
||||||
padding: 12px 28px;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
.amount-value {
|
|
||||||
font-size: 28px;
|
|
||||||
}
|
|
||||||
.status-card {
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="email-wrapper">
|
|
||||||
<div class="email-container">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="email-header">
|
|
||||||
<h1 class="logo">WEB<span class="logo-accent">Money</span></h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Body -->
|
|
||||||
<div class="email-body">
|
|
||||||
@yield('content')
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Footer -->
|
|
||||||
<div class="email-footer">
|
|
||||||
<div class="footer-brand">
|
|
||||||
<div class="footer-brand-name">WEBMoney</div>
|
|
||||||
<div class="footer-tagline">
|
|
||||||
@if(($locale ?? 'es') === 'pt-BR')
|
|
||||||
Gestão Financeira Pessoal
|
|
||||||
@elseif(($locale ?? 'es') === 'en')
|
|
||||||
Personal Finance Management
|
|
||||||
@else
|
|
||||||
Gestión Financiera Personal
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="footer-links">
|
|
||||||
<a href="{{ config('app.frontend_url', 'https://webmoney.cnxifly.com') }}">
|
|
||||||
@if(($locale ?? 'es') === 'pt-BR')
|
|
||||||
Acessar Conta
|
|
||||||
@elseif(($locale ?? 'es') === 'en')
|
|
||||||
Access Account
|
|
||||||
@else
|
|
||||||
Acceder a Cuenta
|
|
||||||
@endif
|
|
||||||
</a>
|
|
||||||
<a href="{{ config('app.frontend_url', 'https://webmoney.cnxifly.com') }}/support">
|
|
||||||
@if(($locale ?? 'es') === 'pt-BR')
|
|
||||||
Central de Ajuda
|
|
||||||
@elseif(($locale ?? 'es') === 'en')
|
|
||||||
Help Center
|
|
||||||
@else
|
|
||||||
Centro de Ayuda
|
|
||||||
@endif
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="footer-legal">
|
|
||||||
@if(($locale ?? 'es') === 'pt-BR')
|
|
||||||
Este email foi enviado por WEBMoney, um serviço de ConneXiFly.<br>
|
|
||||||
© {{ date('Y') }} ConneXiFly. Todos os direitos reservados.
|
|
||||||
@elseif(($locale ?? 'es') === 'en')
|
|
||||||
This email was sent by WEBMoney, a ConneXiFly service.<br>
|
|
||||||
© {{ date('Y') }} ConneXiFly. All rights reserved.
|
|
||||||
@else
|
|
||||||
Este correo fue enviado por WEBMoney, un servicio de ConneXiFly.<br>
|
|
||||||
© {{ date('Y') }} ConneXiFly. Todos los derechos reservados.
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,96 +0,0 @@
|
|||||||
@if($locale === 'pt-BR')
|
|
||||||
CANCELAMENTO DE ASSINATURA - WEBMONEY
|
|
||||||
=====================================
|
|
||||||
|
|
||||||
Olá, {{ $userName }}!
|
|
||||||
|
|
||||||
Confirmamos o cancelamento da sua assinatura do plano {{ $planName }}.
|
|
||||||
|
|
||||||
@if($wasRefunded)
|
|
||||||
✅ REEMBOLSO PROCESSADO
|
|
||||||
-----------------------
|
|
||||||
Processamos um reembolso total no valor de: {{ $refundAmount }}
|
|
||||||
|
|
||||||
O reembolso será creditado na sua forma de pagamento original em até 5-10 dias úteis, dependendo do seu banco.
|
|
||||||
@endif
|
|
||||||
|
|
||||||
O QUE ACONTECE AGORA?
|
|
||||||
---------------------
|
|
||||||
• Seu acesso premium foi encerrado imediatamente
|
|
||||||
• Você ainda pode acessar sua conta com funcionalidades básicas
|
|
||||||
• Seus dados foram preservados caso queira voltar
|
|
||||||
|
|
||||||
Sentimos muito vê-lo partir! Se tiver qualquer dúvida ou feedback, não hesite em nos contatar.
|
|
||||||
|
|
||||||
Se mudar de ideia, você sempre pode assinar novamente:
|
|
||||||
{{ config('app.frontend_url', 'https://webmoney.cnxifly.com') }}/pricing
|
|
||||||
|
|
||||||
---
|
|
||||||
Este email foi enviado para {{ $userEmail }}
|
|
||||||
© {{ date('Y') }} WEBMoney - ConneXiFly
|
|
||||||
Precisa de ajuda? Responda este email.
|
|
||||||
|
|
||||||
@elseif($locale === 'en')
|
|
||||||
SUBSCRIPTION CANCELLATION - WEBMONEY
|
|
||||||
====================================
|
|
||||||
|
|
||||||
Hello, {{ $userName }}!
|
|
||||||
|
|
||||||
We confirm the cancellation of your {{ $planName }} subscription.
|
|
||||||
|
|
||||||
@if($wasRefunded)
|
|
||||||
✅ REFUND PROCESSED
|
|
||||||
-------------------
|
|
||||||
We have processed a full refund of: {{ $refundAmount }}
|
|
||||||
|
|
||||||
The refund will be credited to your original payment method within 5-10 business days, depending on your bank.
|
|
||||||
@endif
|
|
||||||
|
|
||||||
WHAT HAPPENS NOW?
|
|
||||||
-----------------
|
|
||||||
• Your premium access has ended immediately
|
|
||||||
• You can still access your account with basic features
|
|
||||||
• Your data has been preserved in case you want to return
|
|
||||||
|
|
||||||
We're sorry to see you go! If you have any questions or feedback, please don't hesitate to contact us.
|
|
||||||
|
|
||||||
If you change your mind, you can always subscribe again:
|
|
||||||
{{ config('app.frontend_url', 'https://webmoney.cnxifly.com') }}/pricing
|
|
||||||
|
|
||||||
---
|
|
||||||
This email was sent to {{ $userEmail }}
|
|
||||||
© {{ date('Y') }} WEBMoney - ConneXiFly
|
|
||||||
Need help? Reply to this email.
|
|
||||||
|
|
||||||
@else
|
|
||||||
CANCELACIÓN DE SUSCRIPCIÓN - WEBMONEY
|
|
||||||
=====================================
|
|
||||||
|
|
||||||
¡Hola, {{ $userName }}!
|
|
||||||
|
|
||||||
Confirmamos la cancelación de tu suscripción al plan {{ $planName }}.
|
|
||||||
|
|
||||||
@if($wasRefunded)
|
|
||||||
✅ REEMBOLSO PROCESADO
|
|
||||||
----------------------
|
|
||||||
Hemos procesado un reembolso total por el valor de: {{ $refundAmount }}
|
|
||||||
|
|
||||||
El reembolso se acreditará en tu método de pago original en un plazo de 5-10 días hábiles, dependiendo de tu banco.
|
|
||||||
@endif
|
|
||||||
|
|
||||||
¿QUÉ SUCEDE AHORA?
|
|
||||||
------------------
|
|
||||||
• Tu acceso premium ha finalizado inmediatamente
|
|
||||||
• Puedes seguir accediendo a tu cuenta con funciones básicas
|
|
||||||
• Tus datos han sido preservados por si deseas volver
|
|
||||||
|
|
||||||
¡Sentimos mucho verte partir! Si tienes alguna pregunta o comentario, no dudes en contactarnos.
|
|
||||||
|
|
||||||
Si cambias de opinión, siempre puedes volver a suscribirte:
|
|
||||||
{{ config('app.frontend_url', 'https://webmoney.cnxifly.com') }}/pricing
|
|
||||||
|
|
||||||
---
|
|
||||||
Este correo fue enviado a {{ $userEmail }}
|
|
||||||
© {{ date('Y') }} WEBMoney - ConneXiFly
|
|
||||||
¿Necesitas ayuda? Responde a este correo.
|
|
||||||
@endif
|
|
||||||
@ -1,164 +0,0 @@
|
|||||||
@extends('emails.layouts.base')
|
|
||||||
|
|
||||||
@section('title')
|
|
||||||
@if($locale === 'pt-BR')
|
|
||||||
Cancelamento de Assinatura
|
|
||||||
@elseif($locale === 'en')
|
|
||||||
Subscription Cancellation
|
|
||||||
@else
|
|
||||||
Cancelación de Suscripción
|
|
||||||
@endif
|
|
||||||
@endsection
|
|
||||||
|
|
||||||
@section('content')
|
|
||||||
@if($locale === 'pt-BR')
|
|
||||||
{{-- Portuguese (Brazil) --}}
|
|
||||||
<p class="greeting">Olá, {{ $userName }}</p>
|
|
||||||
|
|
||||||
<div class="content">
|
|
||||||
<p>Confirmamos o cancelamento da sua assinatura do plano <strong>{{ $planName }}</strong>.</p>
|
|
||||||
|
|
||||||
@if($wasRefunded)
|
|
||||||
<div class="status-card success">
|
|
||||||
<table cellpadding="0" cellspacing="0" border="0" width="100%">
|
|
||||||
<tr>
|
|
||||||
<td width="52" valign="top">
|
|
||||||
<div style="width: 40px; height: 40px; background-color: #c6f6d5; border-radius: 50%; text-align: center; line-height: 40px; font-size: 18px;">✓</div>
|
|
||||||
</td>
|
|
||||||
<td valign="top">
|
|
||||||
<p class="status-title">Reembolso Processado</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<p style="margin-top: 16px;">Valor reembolsado conforme garantia de 7 dias:</p>
|
|
||||||
<span class="amount-value">{{ $refundAmount }}</span>
|
|
||||||
<p style="font-size: 13px; color: #48bb78; margin-top: 8px;">
|
|
||||||
Prazo: 5-10 dias úteis para crédito na forma de pagamento original.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<div class="status-card info">
|
|
||||||
<p class="status-title" style="color: #2d3748; margin-bottom: 16px;">Informações Importantes</p>
|
|
||||||
<ul class="info-list">
|
|
||||||
<li>Acesso premium encerrado</li>
|
|
||||||
<li>Conta disponível com funcionalidades básicas</li>
|
|
||||||
<li>Seus dados foram preservados</li>
|
|
||||||
<li>Reativação disponível a qualquer momento</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="divider"></div>
|
|
||||||
|
|
||||||
<p style="text-align: center; color: #6c757d; font-size: 14px;">
|
|
||||||
Agradecemos por ter sido nosso cliente.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="btn-container">
|
|
||||||
<a href="{{ config('app.frontend_url', 'https://webmoney.cnxifly.com') }}/pricing" class="btn btn-primary">
|
|
||||||
VER PLANOS
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@elseif($locale === 'en')
|
|
||||||
{{-- English --}}
|
|
||||||
<p class="greeting">Hello, {{ $userName }}</p>
|
|
||||||
|
|
||||||
<div class="content">
|
|
||||||
<p>We confirm the cancellation of your <strong>{{ $planName }}</strong> subscription.</p>
|
|
||||||
|
|
||||||
@if($wasRefunded)
|
|
||||||
<div class="status-card success">
|
|
||||||
<table cellpadding="0" cellspacing="0" border="0" width="100%">
|
|
||||||
<tr>
|
|
||||||
<td width="52" valign="top">
|
|
||||||
<div style="width: 40px; height: 40px; background-color: #c6f6d5; border-radius: 50%; text-align: center; line-height: 40px; font-size: 18px;">✓</div>
|
|
||||||
</td>
|
|
||||||
<td valign="top">
|
|
||||||
<p class="status-title">Refund Processed</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<p style="margin-top: 16px;">Amount refunded per our 7-day guarantee:</p>
|
|
||||||
<span class="amount-value">{{ $refundAmount }}</span>
|
|
||||||
<p style="font-size: 13px; color: #48bb78; margin-top: 8px;">
|
|
||||||
Timeline: 5-10 business days to credit your original payment method.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<div class="status-card info">
|
|
||||||
<p class="status-title" style="color: #2d3748; margin-bottom: 16px;">Important Information</p>
|
|
||||||
<ul class="info-list">
|
|
||||||
<li>Premium access ended</li>
|
|
||||||
<li>Account available with basic features</li>
|
|
||||||
<li>Your data has been preserved</li>
|
|
||||||
<li>Reactivation available anytime</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="divider"></div>
|
|
||||||
|
|
||||||
<p style="text-align: center; color: #6c757d; font-size: 14px;">
|
|
||||||
Thank you for being our customer.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="btn-container">
|
|
||||||
<a href="{{ config('app.frontend_url', 'https://webmoney.cnxifly.com') }}/pricing" class="btn btn-primary">
|
|
||||||
VIEW PLANS
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@else
|
|
||||||
{{-- Spanish (default) --}}
|
|
||||||
<p class="greeting">Hola, {{ $userName }}</p>
|
|
||||||
|
|
||||||
<div class="content">
|
|
||||||
<p>Confirmamos la cancelación de tu suscripción al plan <strong>{{ $planName }}</strong>.</p>
|
|
||||||
|
|
||||||
@if($wasRefunded)
|
|
||||||
<div class="status-card success">
|
|
||||||
<table cellpadding="0" cellspacing="0" border="0" width="100%">
|
|
||||||
<tr>
|
|
||||||
<td width="52" valign="top">
|
|
||||||
<div style="width: 40px; height: 40px; background-color: #c6f6d5; border-radius: 50%; text-align: center; line-height: 40px; font-size: 18px;">✓</div>
|
|
||||||
</td>
|
|
||||||
<td valign="top">
|
|
||||||
<p class="status-title">Reembolso Procesado</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<p style="margin-top: 16px;">Monto reembolsado según garantía de 7 días:</p>
|
|
||||||
<span class="amount-value">{{ $refundAmount }}</span>
|
|
||||||
<p style="font-size: 13px; color: #48bb78; margin-top: 8px;">
|
|
||||||
Plazo: 5-10 días hábiles para acreditar en tu método de pago original.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<div class="status-card info">
|
|
||||||
<p class="status-title" style="color: #2d3748; margin-bottom: 16px;">Información Importante</p>
|
|
||||||
<ul class="info-list">
|
|
||||||
<li>Acceso premium finalizado</li>
|
|
||||||
<li>Cuenta disponible con funciones básicas</li>
|
|
||||||
<li>Tus datos han sido preservados</li>
|
|
||||||
<li>Reactivación disponible en cualquier momento</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="divider"></div>
|
|
||||||
|
|
||||||
<p style="text-align: center; color: #6c757d; font-size: 14px;">
|
|
||||||
Gracias por haber sido nuestro cliente.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="btn-container">
|
|
||||||
<a href="{{ config('app.frontend_url', 'https://webmoney.cnxifly.com') }}/pricing" class="btn btn-primary">
|
|
||||||
VER PLANES
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
@endsection
|
|
||||||
@ -1,66 +0,0 @@
|
|||||||
@if($language === 'pt-BR')
|
|
||||||
{{-- PORTUGUÊS --}}
|
|
||||||
WebMoney - Credenciais de Acesso
|
|
||||||
================================
|
|
||||||
|
|
||||||
Olá, {{ $user->name }}!
|
|
||||||
|
|
||||||
Sua conta WebMoney foi criada com sucesso.
|
|
||||||
|
|
||||||
SUAS CREDENCIAIS DE ACESSO:
|
|
||||||
---------------------------
|
|
||||||
Email: {{ $user->email }}
|
|
||||||
Senha: {{ $temporaryPassword }}
|
|
||||||
|
|
||||||
IMPORTANTE: Por motivos de segurança, recomendamos que altere sua senha após o primeiro login.
|
|
||||||
|
|
||||||
Acesse sua conta em: {{ $loginUrl }}
|
|
||||||
|
|
||||||
---
|
|
||||||
Equipe WebMoney
|
|
||||||
https://webmoney.cnxifly.com
|
|
||||||
|
|
||||||
@elseif($language === 'en')
|
|
||||||
{{-- ENGLISH --}}
|
|
||||||
WebMoney - Access Credentials
|
|
||||||
=============================
|
|
||||||
|
|
||||||
Hello, {{ $user->name }}!
|
|
||||||
|
|
||||||
Your WebMoney account has been successfully created.
|
|
||||||
|
|
||||||
YOUR ACCESS CREDENTIALS:
|
|
||||||
------------------------
|
|
||||||
Email: {{ $user->email }}
|
|
||||||
Password: {{ $temporaryPassword }}
|
|
||||||
|
|
||||||
IMPORTANT: For security reasons, we recommend changing your password after your first login.
|
|
||||||
|
|
||||||
Access your account at: {{ $loginUrl }}
|
|
||||||
|
|
||||||
---
|
|
||||||
WebMoney Team
|
|
||||||
https://webmoney.cnxifly.com
|
|
||||||
|
|
||||||
@else
|
|
||||||
{{-- ESPAÑOL --}}
|
|
||||||
WebMoney - Credenciales de Acceso
|
|
||||||
=================================
|
|
||||||
|
|
||||||
Hola, {{ $user->name }}!
|
|
||||||
|
|
||||||
Tu cuenta de WebMoney ha sido creada exitosamente.
|
|
||||||
|
|
||||||
TUS CREDENCIALES DE ACCESO:
|
|
||||||
---------------------------
|
|
||||||
Email: {{ $user->email }}
|
|
||||||
Contraseña: {{ $temporaryPassword }}
|
|
||||||
|
|
||||||
IMPORTANTE: Por motivos de seguridad, te recomendamos cambiar tu contraseña después del primer inicio de sesión.
|
|
||||||
|
|
||||||
Accede a tu cuenta en: {{ $loginUrl }}
|
|
||||||
|
|
||||||
---
|
|
||||||
Equipo WebMoney
|
|
||||||
https://webmoney.cnxifly.com
|
|
||||||
@endif
|
|
||||||
@ -1,181 +0,0 @@
|
|||||||
@extends('emails.layouts.base')
|
|
||||||
|
|
||||||
@php $locale = $language ?? 'es'; @endphp
|
|
||||||
|
|
||||||
@section('title')
|
|
||||||
@if($locale === 'pt-BR')
|
|
||||||
Bem-vindo ao WEBMoney
|
|
||||||
@elseif($locale === 'en')
|
|
||||||
Welcome to WEBMoney
|
|
||||||
@else
|
|
||||||
Bienvenido a WEBMoney
|
|
||||||
@endif
|
|
||||||
@endsection
|
|
||||||
|
|
||||||
@section('content')
|
|
||||||
@if($locale === 'pt-BR')
|
|
||||||
{{-- Portuguese (Brazil) --}}
|
|
||||||
<p class="greeting">Olá, {{ $user->name }}</p>
|
|
||||||
|
|
||||||
<div class="content">
|
|
||||||
<p>Bem-vindo ao WEBMoney! A sua conta foi criada com sucesso.</p>
|
|
||||||
|
|
||||||
<div class="status-card info" style="background-color: #1a1a2e; border-color: #4361ee;">
|
|
||||||
<p class="status-title" style="color: #ffffff; margin-bottom: 16px;">Credenciais de Acesso</p>
|
|
||||||
<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin-top: 12px;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 12px; background: rgba(255,255,255,0.1); border-radius: 4px; margin-bottom: 8px;">
|
|
||||||
<span style="color: #94a3b8; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px;">Email</span><br>
|
|
||||||
<span style="color: #ffffff; font-size: 16px; font-weight: 600; font-family: monospace;">{{ $user->email }}</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr><td style="height: 8px;"></td></tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 12px; background: rgba(255,255,255,0.1); border-radius: 4px;">
|
|
||||||
<span style="color: #94a3b8; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px;">Palavra-passe</span><br>
|
|
||||||
<span style="color: #ffffff; font-size: 16px; font-weight: 600; font-family: monospace;">{{ $password }}</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="status-card warning">
|
|
||||||
<table cellpadding="0" cellspacing="0" border="0" width="100%">
|
|
||||||
<tr>
|
|
||||||
<td width="52" valign="top">
|
|
||||||
<div style="width: 40px; height: 40px; background-color: #feebc8; border-radius: 50%; text-align: center; line-height: 40px; font-size: 18px;">!</div>
|
|
||||||
</td>
|
|
||||||
<td valign="top">
|
|
||||||
<p class="status-title">Segurança</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<p style="margin-top: 12px;">Recomendamos que altere a sua palavra-passe após o primeiro login. Guarde estas credenciais em local seguro.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="status-card info">
|
|
||||||
<p class="status-title" style="color: #2d3748; margin-bottom: 16px;">O que pode fazer</p>
|
|
||||||
<ul class="info-list">
|
|
||||||
<li>Registar receitas e despesas</li>
|
|
||||||
<li>Visualizar relatórios e gráficos</li>
|
|
||||||
<li>Gerir o seu orçamento mensal</li>
|
|
||||||
<li>Exportar dados em múltiplos formatos</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="btn-container">
|
|
||||||
<a href="https://webmoney.cnxifly.com/login" class="btn btn-primary">INICIAR SESSÃO</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@elseif($locale === 'en')
|
|
||||||
{{-- English --}}
|
|
||||||
<p class="greeting">Hello, {{ $user->name }}</p>
|
|
||||||
|
|
||||||
<div class="content">
|
|
||||||
<p>Welcome to WEBMoney! Your account has been successfully created.</p>
|
|
||||||
|
|
||||||
<div class="status-card info" style="background-color: #1a1a2e; border-color: #4361ee;">
|
|
||||||
<p class="status-title" style="color: #ffffff; margin-bottom: 16px;">Access Credentials</p>
|
|
||||||
<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin-top: 12px;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 12px; background: rgba(255,255,255,0.1); border-radius: 4px; margin-bottom: 8px;">
|
|
||||||
<span style="color: #94a3b8; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px;">Email</span><br>
|
|
||||||
<span style="color: #ffffff; font-size: 16px; font-weight: 600; font-family: monospace;">{{ $user->email }}</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr><td style="height: 8px;"></td></tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 12px; background: rgba(255,255,255,0.1); border-radius: 4px;">
|
|
||||||
<span style="color: #94a3b8; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px;">Password</span><br>
|
|
||||||
<span style="color: #ffffff; font-size: 16px; font-weight: 600; font-family: monospace;">{{ $password }}</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="status-card warning">
|
|
||||||
<table cellpadding="0" cellspacing="0" border="0" width="100%">
|
|
||||||
<tr>
|
|
||||||
<td width="52" valign="top">
|
|
||||||
<div style="width: 40px; height: 40px; background-color: #feebc8; border-radius: 50%; text-align: center; line-height: 40px; font-size: 18px;">!</div>
|
|
||||||
</td>
|
|
||||||
<td valign="top">
|
|
||||||
<p class="status-title">Security</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<p style="margin-top: 12px;">We recommend changing your password after your first login. Keep these credentials in a safe place.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="status-card info">
|
|
||||||
<p class="status-title" style="color: #2d3748; margin-bottom: 16px;">What you can do</p>
|
|
||||||
<ul class="info-list">
|
|
||||||
<li>Record income and expenses</li>
|
|
||||||
<li>View reports and charts</li>
|
|
||||||
<li>Manage your monthly budget</li>
|
|
||||||
<li>Export data in multiple formats</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="btn-container">
|
|
||||||
<a href="https://webmoney.cnxifly.com/login" class="btn btn-primary">SIGN IN</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@else
|
|
||||||
{{-- Spanish (default) --}}
|
|
||||||
<p class="greeting">Hola, {{ $user->name }}</p>
|
|
||||||
|
|
||||||
<div class="content">
|
|
||||||
<p>Bienvenido a WEBMoney. Tu cuenta ha sido creada exitosamente.</p>
|
|
||||||
|
|
||||||
<div class="status-card info" style="background-color: #1a1a2e; border-color: #4361ee;">
|
|
||||||
<p class="status-title" style="color: #ffffff; margin-bottom: 16px;">Credenciales de Acceso</p>
|
|
||||||
<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin-top: 12px;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 12px; background: rgba(255,255,255,0.1); border-radius: 4px; margin-bottom: 8px;">
|
|
||||||
<span style="color: #94a3b8; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px;">Email</span><br>
|
|
||||||
<span style="color: #ffffff; font-size: 16px; font-weight: 600; font-family: monospace;">{{ $user->email }}</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr><td style="height: 8px;"></td></tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 12px; background: rgba(255,255,255,0.1); border-radius: 4px;">
|
|
||||||
<span style="color: #94a3b8; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px;">Contraseña</span><br>
|
|
||||||
<span style="color: #ffffff; font-size: 16px; font-weight: 600; font-family: monospace;">{{ $password }}</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="status-card warning">
|
|
||||||
<table cellpadding="0" cellspacing="0" border="0" width="100%">
|
|
||||||
<tr>
|
|
||||||
<td width="52" valign="top">
|
|
||||||
<div style="width: 40px; height: 40px; background-color: #feebc8; border-radius: 50%; text-align: center; line-height: 40px; font-size: 18px;">!</div>
|
|
||||||
</td>
|
|
||||||
<td valign="top">
|
|
||||||
<p class="status-title">Seguridad</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<p style="margin-top: 12px;">Recomendamos cambiar tu contraseña después del primer inicio de sesión. Guarda estas credenciales en un lugar seguro.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="status-card info">
|
|
||||||
<p class="status-title" style="color: #2d3748; margin-bottom: 16px;">Qué puedes hacer</p>
|
|
||||||
<ul class="info-list">
|
|
||||||
<li>Registrar ingresos y gastos</li>
|
|
||||||
<li>Visualizar reportes y gráficos</li>
|
|
||||||
<li>Gestionar tu presupuesto mensual</li>
|
|
||||||
<li>Exportar datos en múltiples formatos</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="btn-container">
|
|
||||||
<a href="https://webmoney.cnxifly.com/login" class="btn btn-primary">INICIAR SESIÓN</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
@endsection
|
|
||||||
@ -1,142 +1,113 @@
|
|||||||
@extends('emails.layouts.base')
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
@php $locale = $locale ?? 'es'; @endphp
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
@section('title')
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
@if($locale === 'pt-BR')
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
Bem-vindo ao WEBMoney
|
<title>Bienvenido a WEBMoney</title>
|
||||||
@elseif($locale === 'en')
|
<!--[if mso]>
|
||||||
Welcome to WEBMoney
|
<style type="text/css">
|
||||||
@else
|
body, table, td {font-family: Arial, Helvetica, sans-serif !important;}
|
||||||
Bienvenido a WEBMoney
|
</style>
|
||||||
@endif
|
<![endif]-->
|
||||||
@endsection
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; background-color: #f4f4f4; font-family: Arial, Helvetica, sans-serif;">
|
||||||
@section('content')
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #f4f4f4;">
|
||||||
@if($locale === 'pt-BR')
|
<tr>
|
||||||
{{-- Portuguese (Brazil) --}}
|
<td style="padding: 20px 0;">
|
||||||
<p class="greeting">Olá, {{ $userName }}</p>
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
|
||||||
|
<!-- Header -->
|
||||||
<div class="content">
|
|
||||||
<p>A sua conta no WEBMoney foi criada com sucesso. Já pode começar a gerir as suas finanças pessoais de forma fácil e segura.</p>
|
|
||||||
|
|
||||||
<div class="status-card success">
|
|
||||||
<table cellpadding="0" cellspacing="0" border="0" width="100%">
|
|
||||||
<tr>
|
<tr>
|
||||||
<td width="52" valign="top">
|
<td style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 40px 30px; text-align: center; border-radius: 8px 8px 0 0;">
|
||||||
<div style="width: 40px; height: 40px; background-color: #c6f6d5; border-radius: 50%; text-align: center; line-height: 40px; font-size: 18px;">✓</div>
|
<h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 700;">
|
||||||
|
🎉 ¡Bienvenido a WEBMoney!
|
||||||
|
</h1>
|
||||||
</td>
|
</td>
|
||||||
<td valign="top">
|
</tr>
|
||||||
<p class="status-title">Conta Registada</p>
|
|
||||||
|
<!-- Content -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 40px 30px;">
|
||||||
|
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
|
||||||
|
Hola <strong>{{ $userName }}</strong>,
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="margin: 0 0 20px; color: #555555; font-size: 16px; line-height: 1.6;">
|
||||||
|
¡Gracias por registrarte en <strong>WEBMoney</strong>! Tu cuenta ha sido creada exitosamente y ya puedes comenzar a gestionar tus finanzas personales de manera fácil y segura.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="background-color: #f8f9fa; border-left: 4px solid #667eea; padding: 20px; margin: 30px 0; border-radius: 4px;">
|
||||||
|
<p style="margin: 0 0 10px; color: #333333; font-size: 14px; font-weight: 600;">
|
||||||
|
📧 Cuenta registrada:
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0; color: #667eea; font-size: 16px; font-weight: 700;">
|
||||||
|
{{ $userEmail }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 style="margin: 30px 0 15px; color: #333333; font-size: 18px; font-weight: 600;">
|
||||||
|
¿Qué puedes hacer ahora?
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<ul style="margin: 0 0 30px; padding-left: 20px; color: #555555; font-size: 15px; line-height: 1.8;">
|
||||||
|
<li>📊 Registrar tus ingresos y gastos</li>
|
||||||
|
<li>📈 Visualizar reportes y gráficos</li>
|
||||||
|
<li>💰 Gestionar tu presupuesto mensual</li>
|
||||||
|
<li>📄 Exportar datos en múltiples formatos</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin: 30px 0;">
|
||||||
|
<tr>
|
||||||
|
<td style="text-align: center;">
|
||||||
|
<a href="https://webmoney.cnxifly.com/login" style="display: inline-block; padding: 15px 40px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #ffffff; text-decoration: none; border-radius: 6px; font-size: 16px; font-weight: 600;">
|
||||||
|
Iniciar Sesión Ahora
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="margin: 30px 0 10px; color: #666666; font-size: 14px; line-height: 1.6;">
|
||||||
|
Si tienes alguna pregunta o necesitas ayuda, no dudes en contactarnos respondiendo a este email.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="margin: 0; color: #666666; font-size: 14px; line-height: 1.6;">
|
||||||
|
¡Gracias por confiar en nosotros!
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #f8f9fa; padding: 30px; text-align: center; border-radius: 0 0 8px 8px;">
|
||||||
|
<p style="margin: 0 0 10px; color: #666666; font-size: 14px;">
|
||||||
|
<strong>WEBMoney</strong> - Tu gestor financiero personal
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0 0 15px; color: #999999; font-size: 12px;">
|
||||||
|
ConneXiFly · webmoney.cnxifly.com
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0; color: #999999; font-size: 11px;">
|
||||||
|
Este es un correo automático. Por favor, no respondas directamente a este mensaje.
|
||||||
|
<br>
|
||||||
|
Para soporte, escríbenos a: <a href="mailto:support@cnxifly.com" style="color: #667eea; text-decoration: none;">support@cnxifly.com</a>
|
||||||
|
</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
<p style="margin-top: 16px;"><strong>Email:</strong> {{ $userEmail }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="status-card info">
|
|
||||||
<p class="status-title" style="color: #2d3748; margin-bottom: 16px;">Funcionalidades Disponíveis</p>
|
|
||||||
<ul class="info-list">
|
|
||||||
<li>Registar receitas e despesas</li>
|
|
||||||
<li>Visualizar relatórios e gráficos</li>
|
|
||||||
<li>Gerir o seu orçamento mensal</li>
|
|
||||||
<li>Exportar dados em múltiplos formatos</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="btn-container">
|
|
||||||
<a href="https://webmoney.cnxifly.com/login" class="btn btn-primary">INICIAR SESSÃO</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="divider"></div>
|
|
||||||
|
|
||||||
<p style="text-align: center; color: #6c757d; font-size: 14px;">
|
|
||||||
Obrigado por confiar em nós.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@elseif($locale === 'en')
|
<!-- Email Footer (outside box) -->
|
||||||
{{-- English --}}
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="margin: 20px auto 0;">
|
||||||
<p class="greeting">Hello, {{ $userName }}</p>
|
|
||||||
|
|
||||||
<div class="content">
|
|
||||||
<p>Your WEBMoney account has been successfully created. You can now start managing your personal finances easily and securely.</p>
|
|
||||||
|
|
||||||
<div class="status-card success">
|
|
||||||
<table cellpadding="0" cellspacing="0" border="0" width="100%">
|
|
||||||
<tr>
|
<tr>
|
||||||
<td width="52" valign="top">
|
<td style="text-align: center; padding: 20px;">
|
||||||
<div style="width: 40px; height: 40px; background-color: #c6f6d5; border-radius: 50%; text-align: center; line-height: 40px; font-size: 18px;">✓</div>
|
<p style="margin: 0; color: #999999; font-size: 11px; line-height: 1.6;">
|
||||||
</td>
|
© 2025 ConneXiFly. Todos los derechos reservados.
|
||||||
<td valign="top">
|
<br>
|
||||||
<p class="status-title">Account Registered</p>
|
Si no solicitaste esta cuenta, ignora este correo.
|
||||||
|
</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
<p style="margin-top: 16px;"><strong>Email:</strong> {{ $userEmail }}</p>
|
</td>
|
||||||
</div>
|
</tr>
|
||||||
|
</table>
|
||||||
<div class="status-card info">
|
</body>
|
||||||
<p class="status-title" style="color: #2d3748; margin-bottom: 16px;">Available Features</p>
|
</html>
|
||||||
<ul class="info-list">
|
|
||||||
<li>Record income and expenses</li>
|
|
||||||
<li>View reports and charts</li>
|
|
||||||
<li>Manage your monthly budget</li>
|
|
||||||
<li>Export data in multiple formats</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="btn-container">
|
|
||||||
<a href="https://webmoney.cnxifly.com/login" class="btn btn-primary">SIGN IN</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="divider"></div>
|
|
||||||
|
|
||||||
<p style="text-align: center; color: #6c757d; font-size: 14px;">
|
|
||||||
Thank you for trusting us.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@else
|
|
||||||
{{-- Spanish (default) --}}
|
|
||||||
<p class="greeting">Hola, {{ $userName }}</p>
|
|
||||||
|
|
||||||
<div class="content">
|
|
||||||
<p>Tu cuenta en WEBMoney ha sido creada exitosamente. Ya puedes comenzar a gestionar tus finanzas personales de manera fácil y segura.</p>
|
|
||||||
|
|
||||||
<div class="status-card success">
|
|
||||||
<table cellpadding="0" cellspacing="0" border="0" width="100%">
|
|
||||||
<tr>
|
|
||||||
<td width="52" valign="top">
|
|
||||||
<div style="width: 40px; height: 40px; background-color: #c6f6d5; border-radius: 50%; text-align: center; line-height: 40px; font-size: 18px;">✓</div>
|
|
||||||
</td>
|
|
||||||
<td valign="top">
|
|
||||||
<p class="status-title">Cuenta Registrada</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<p style="margin-top: 16px;"><strong>Email:</strong> {{ $userEmail }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="status-card info">
|
|
||||||
<p class="status-title" style="color: #2d3748; margin-bottom: 16px;">Funcionalidades Disponibles</p>
|
|
||||||
<ul class="info-list">
|
|
||||||
<li>Registrar ingresos y gastos</li>
|
|
||||||
<li>Visualizar reportes y gráficos</li>
|
|
||||||
<li>Gestionar tu presupuesto mensual</li>
|
|
||||||
<li>Exportar datos en múltiples formatos</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="btn-container">
|
|
||||||
<a href="https://webmoney.cnxifly.com/login" class="btn btn-primary">INICIAR SESIÓN</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="divider"></div>
|
|
||||||
|
|
||||||
<p style="text-align: center; color: #6c757d; font-size: 14px;">
|
|
||||||
Gracias por confiar en nosotros.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
@endsection
|
|
||||||
|
|||||||
@ -8,7 +8,6 @@
|
|||||||
use App\Http\Controllers\Api\CostCenterController;
|
use App\Http\Controllers\Api\CostCenterController;
|
||||||
use App\Http\Controllers\Api\CategoryController;
|
use App\Http\Controllers\Api\CategoryController;
|
||||||
use App\Http\Controllers\Api\LiabilityAccountController;
|
use App\Http\Controllers\Api\LiabilityAccountController;
|
||||||
use App\Http\Controllers\Api\AssetAccountController;
|
|
||||||
use App\Http\Controllers\Api\TransactionController;
|
use App\Http\Controllers\Api\TransactionController;
|
||||||
use App\Http\Controllers\Api\ImportController;
|
use App\Http\Controllers\Api\ImportController;
|
||||||
use App\Http\Controllers\Api\TransferDetectionController;
|
use App\Http\Controllers\Api\TransferDetectionController;
|
||||||
@ -25,18 +24,11 @@
|
|||||||
use App\Http\Controllers\Api\UserPreferenceController;
|
use App\Http\Controllers\Api\UserPreferenceController;
|
||||||
use App\Http\Controllers\Api\PlanController;
|
use App\Http\Controllers\Api\PlanController;
|
||||||
use App\Http\Controllers\Api\SubscriptionController;
|
use App\Http\Controllers\Api\SubscriptionController;
|
||||||
use App\Http\Controllers\Api\UserManagementController;
|
|
||||||
use App\Http\Controllers\Api\SiteSettingsController;
|
|
||||||
|
|
||||||
// Public routes with rate limiting
|
// Public routes with rate limiting
|
||||||
Route::post('/register', [AuthController::class, 'register'])->middleware('throttle:register');
|
Route::post('/register', [AuthController::class, 'register'])->middleware('throttle:register');
|
||||||
Route::post('/login', [AuthController::class, 'login'])->middleware('throttle:login');
|
Route::post('/login', [AuthController::class, 'login'])->middleware('throttle:login');
|
||||||
|
|
||||||
// Account activation (public)
|
|
||||||
Route::post('/activate', [AuthController::class, 'activateAccount']);
|
|
||||||
Route::post('/resend-activation', [AuthController::class, 'resendActivation']);
|
|
||||||
Route::post('/cancel-registration', [AuthController::class, 'cancelRegistration']);
|
|
||||||
|
|
||||||
// Plans (public - for pricing page)
|
// Plans (public - for pricing page)
|
||||||
Route::get('/plans', [PlanController::class, 'index']);
|
Route::get('/plans', [PlanController::class, 'index']);
|
||||||
Route::get('/plans/{slug}', [PlanController::class, 'show']);
|
Route::get('/plans/{slug}', [PlanController::class, 'show']);
|
||||||
@ -47,12 +39,6 @@
|
|||||||
// PayPal webhook (public - called by PayPal)
|
// PayPal webhook (public - called by PayPal)
|
||||||
Route::post('/paypal/webhook', [SubscriptionController::class, 'webhook']);
|
Route::post('/paypal/webhook', [SubscriptionController::class, 'webhook']);
|
||||||
|
|
||||||
// Subscription start for new users (public - used after registration)
|
|
||||||
Route::post('/subscription/start', [SubscriptionController::class, 'startSubscription']);
|
|
||||||
|
|
||||||
// Subscription confirm for new users (public - called after PayPal redirect)
|
|
||||||
Route::post('/subscription/confirm-public', [SubscriptionController::class, 'confirmPublic']);
|
|
||||||
|
|
||||||
// Email testing routes (should be protected in production)
|
// Email testing routes (should be protected in production)
|
||||||
Route::post('/email/send-test', [EmailTestController::class, 'sendTest']);
|
Route::post('/email/send-test', [EmailTestController::class, 'sendTest']);
|
||||||
Route::get('/email/anti-spam-info', [EmailTestController::class, 'getAntiSpamInfo']);
|
Route::get('/email/anti-spam-info', [EmailTestController::class, 'getAntiSpamInfo']);
|
||||||
@ -77,16 +63,15 @@
|
|||||||
Route::get('/subscription/invoices', [SubscriptionController::class, 'invoices']);
|
Route::get('/subscription/invoices', [SubscriptionController::class, 'invoices']);
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Contas (Accounts) - Com limite de plano
|
// Contas (Accounts)
|
||||||
// ============================================
|
// ============================================
|
||||||
// Rotas específicas ANTES do apiResource
|
// Rotas específicas ANTES do apiResource
|
||||||
Route::post('accounts/recalculate-all', [AccountController::class, 'recalculateBalances']);
|
Route::post('accounts/recalculate-all', [AccountController::class, 'recalculateBalances']);
|
||||||
Route::post('accounts/{id}/recalculate', [AccountController::class, 'recalculateBalance']);
|
Route::post('accounts/{id}/recalculate', [AccountController::class, 'recalculateBalance']);
|
||||||
Route::post('accounts/{id}/adjust-balance', [AccountController::class, 'adjustBalance']);
|
Route::post('accounts/{id}/adjust-balance', [AccountController::class, 'adjustBalance']);
|
||||||
|
|
||||||
// Resource principal com middleware de limite no store
|
// Resource principal
|
||||||
Route::post('accounts', [AccountController::class, 'store'])->middleware('plan.limits:accounts');
|
Route::apiResource('accounts', AccountController::class);
|
||||||
Route::apiResource('accounts', AccountController::class)->except(['store']);
|
|
||||||
Route::get('accounts-total', [AccountController::class, 'totalBalance']);
|
Route::get('accounts-total', [AccountController::class, 'totalBalance']);
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@ -98,7 +83,7 @@
|
|||||||
Route::post('cost-centers/match', [CostCenterController::class, 'matchByText']);
|
Route::post('cost-centers/match', [CostCenterController::class, 'matchByText']);
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Categorias (Categories) - Com limite de plano
|
// Categorias (Categories)
|
||||||
// ============================================
|
// ============================================
|
||||||
// Rotas específicas ANTES do apiResource
|
// Rotas específicas ANTES do apiResource
|
||||||
Route::post('categories/categorize-batch/preview', [CategoryController::class, 'categorizeBatchPreview']);
|
Route::post('categories/categorize-batch/preview', [CategoryController::class, 'categorizeBatchPreview']);
|
||||||
@ -107,9 +92,8 @@
|
|||||||
Route::post('categories/match', [CategoryController::class, 'matchByText']);
|
Route::post('categories/match', [CategoryController::class, 'matchByText']);
|
||||||
Route::post('categories/reorder', [CategoryController::class, 'reorder']);
|
Route::post('categories/reorder', [CategoryController::class, 'reorder']);
|
||||||
|
|
||||||
// Resource principal com middleware de limite no store
|
// Resource principal
|
||||||
Route::post('categories', [CategoryController::class, 'store'])->middleware('plan.limits:categories');
|
Route::apiResource('categories', CategoryController::class);
|
||||||
Route::apiResource('categories', CategoryController::class)->except(['store']);
|
|
||||||
|
|
||||||
// Rotas com parâmetros (depois do apiResource)
|
// Rotas com parâmetros (depois do apiResource)
|
||||||
Route::post('categories/{id}/keywords', [CategoryController::class, 'addKeyword']);
|
Route::post('categories/{id}/keywords', [CategoryController::class, 'addKeyword']);
|
||||||
@ -120,10 +104,7 @@
|
|||||||
// ============================================
|
// ============================================
|
||||||
// Rotas específicas ANTES do apiResource (para evitar conflito com {id})
|
// Rotas específicas ANTES do apiResource (para evitar conflito com {id})
|
||||||
Route::get('liability-accounts/pending-reconciliation', [LiabilityAccountController::class, 'pendingReconciliation']);
|
Route::get('liability-accounts/pending-reconciliation', [LiabilityAccountController::class, 'pendingReconciliation']);
|
||||||
Route::get('liability-accounts/template', [LiabilityAccountController::class, 'downloadTemplate']);
|
|
||||||
Route::get('liability-accounts/contract-types', [LiabilityAccountController::class, 'contractTypes']);
|
|
||||||
Route::post('liability-accounts/import', [LiabilityAccountController::class, 'import']);
|
Route::post('liability-accounts/import', [LiabilityAccountController::class, 'import']);
|
||||||
Route::post('liability-accounts/wizard', [LiabilityAccountController::class, 'storeWithWizard']);
|
|
||||||
Route::get('liability-summary', [LiabilityAccountController::class, 'summary']);
|
Route::get('liability-summary', [LiabilityAccountController::class, 'summary']);
|
||||||
|
|
||||||
// Resource principal
|
// Resource principal
|
||||||
@ -139,25 +120,9 @@
|
|||||||
Route::delete('liability-accounts/{accountId}/installments/{installmentId}/reconcile', [LiabilityAccountController::class, 'unreconcile']);
|
Route::delete('liability-accounts/{accountId}/installments/{installmentId}/reconcile', [LiabilityAccountController::class, 'unreconcile']);
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Contas Ativo (Asset Accounts)
|
// Transações (Transactions)
|
||||||
// ============================================
|
// ============================================
|
||||||
// Rotas específicas ANTES do apiResource
|
Route::apiResource('transactions', TransactionController::class);
|
||||||
Route::get('asset-accounts/asset-types', [AssetAccountController::class, 'assetTypes']);
|
|
||||||
Route::post('asset-accounts/wizard', [AssetAccountController::class, 'storeWithWizard']);
|
|
||||||
Route::get('asset-summary', [AssetAccountController::class, 'summary']);
|
|
||||||
|
|
||||||
// Resource principal
|
|
||||||
Route::apiResource('asset-accounts', AssetAccountController::class);
|
|
||||||
|
|
||||||
// Rotas com parâmetros
|
|
||||||
Route::put('asset-accounts/{assetAccount}/value', [AssetAccountController::class, 'updateValue']);
|
|
||||||
Route::post('asset-accounts/{assetAccount}/dispose', [AssetAccountController::class, 'dispose']);
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Transações (Transactions) - Com limite de plano
|
|
||||||
// ============================================
|
|
||||||
Route::post('transactions', [TransactionController::class, 'store'])->middleware('plan.limits:transactions');
|
|
||||||
Route::apiResource('transactions', TransactionController::class)->except(['store']);
|
|
||||||
Route::get('transactions-by-week', [TransactionController::class, 'byWeek']);
|
Route::get('transactions-by-week', [TransactionController::class, 'byWeek']);
|
||||||
Route::get('transactions-summary', [TransactionController::class, 'summary']);
|
Route::get('transactions-summary', [TransactionController::class, 'summary']);
|
||||||
|
|
||||||
@ -257,54 +222,43 @@
|
|||||||
Route::put('recurring-instances/{recurringInstance}', [RecurringTemplateController::class, 'updateInstance']);
|
Route::put('recurring-instances/{recurringInstance}', [RecurringTemplateController::class, 'updateInstance']);
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Business - Precificação de Produtos (Admin Only)
|
// Business - Precificação de Produtos
|
||||||
// ============================================
|
// ============================================
|
||||||
Route::middleware('admin.only')->group(function () {
|
|
||||||
// User Management (Admin Only)
|
|
||||||
Route::get('admin/users/summary', [UserManagementController::class, 'summary']);
|
|
||||||
Route::get('admin/users', [UserManagementController::class, 'index']);
|
|
||||||
Route::post('admin/users', [UserManagementController::class, 'store']);
|
|
||||||
Route::get('admin/users/{id}', [UserManagementController::class, 'show']);
|
|
||||||
Route::put('admin/users/{id}', [UserManagementController::class, 'update']);
|
|
||||||
Route::post('admin/users/{id}/reset-password', [UserManagementController::class, 'resetPassword']);
|
|
||||||
Route::post('admin/users/{id}/change-plan', [UserManagementController::class, 'changePlan']);
|
|
||||||
Route::delete('admin/users/{id}', [UserManagementController::class, 'destroy']);
|
|
||||||
|
|
||||||
// Configurações de Negócio (Markup)
|
|
||||||
Route::get('business-settings/default', [BusinessSettingController::class, 'getDefault']);
|
|
||||||
Route::apiResource('business-settings', BusinessSettingController::class);
|
|
||||||
Route::post('business-settings/{id}/recalculate-markup', [BusinessSettingController::class, 'recalculateMarkup']);
|
|
||||||
Route::post('business-settings/{id}/simulate-price', [BusinessSettingController::class, 'simulatePrice']);
|
|
||||||
|
|
||||||
// Fichas Técnicas de Produtos (CMV)
|
|
||||||
Route::get('product-sheets/categories', [ProductSheetController::class, 'categories']);
|
|
||||||
Route::get('product-sheets/item-types', [ProductSheetController::class, 'itemTypes']);
|
|
||||||
Route::apiResource('product-sheets', ProductSheetController::class);
|
|
||||||
Route::post('product-sheets/{id}/items', [ProductSheetController::class, 'addItem']);
|
|
||||||
Route::put('product-sheets/{sheetId}/items/{itemId}', [ProductSheetController::class, 'updateItem']);
|
|
||||||
Route::delete('product-sheets/{sheetId}/items/{itemId}', [ProductSheetController::class, 'removeItem']);
|
|
||||||
Route::post('product-sheets/{id}/recalculate-price', [ProductSheetController::class, 'recalculatePrice']);
|
|
||||||
Route::post('product-sheets/{id}/duplicate', [ProductSheetController::class, 'duplicate']);
|
|
||||||
|
|
||||||
// Fichas Técnicas de Serviços (CSV - Custo do Serviço Vendido)
|
// Configurações de Negócio (Markup)
|
||||||
Route::get('service-sheets/categories', [ServiceSheetController::class, 'categories']);
|
Route::get('business-settings/default', [BusinessSettingController::class, 'getDefault']);
|
||||||
Route::get('service-sheets/item-types', [ServiceSheetController::class, 'itemTypes']);
|
Route::apiResource('business-settings', BusinessSettingController::class);
|
||||||
Route::post('service-sheets/simulate', [ServiceSheetController::class, 'simulate']);
|
Route::post('business-settings/{id}/recalculate-markup', [BusinessSettingController::class, 'recalculateMarkup']);
|
||||||
Route::apiResource('service-sheets', ServiceSheetController::class);
|
Route::post('business-settings/{id}/simulate-price', [BusinessSettingController::class, 'simulatePrice']);
|
||||||
Route::post('service-sheets/{id}/items', [ServiceSheetController::class, 'addItem']);
|
|
||||||
Route::put('service-sheets/{sheetId}/items/{itemId}', [ServiceSheetController::class, 'updateItem']);
|
|
||||||
Route::delete('service-sheets/{sheetId}/items/{itemId}', [ServiceSheetController::class, 'removeItem']);
|
|
||||||
Route::post('service-sheets/{id}/duplicate', [ServiceSheetController::class, 'duplicate']);
|
|
||||||
|
|
||||||
// Campanhas Promocionais (Black Friday, etc.)
|
// Fichas Técnicas de Produtos (CMV)
|
||||||
Route::get('campaigns/presets', [PromotionalCampaignController::class, 'presets']);
|
Route::get('product-sheets/categories', [ProductSheetController::class, 'categories']);
|
||||||
Route::post('campaigns/preview', [PromotionalCampaignController::class, 'preview']);
|
Route::get('product-sheets/item-types', [ProductSheetController::class, 'itemTypes']);
|
||||||
Route::apiResource('campaigns', PromotionalCampaignController::class);
|
Route::apiResource('product-sheets', ProductSheetController::class);
|
||||||
Route::post('campaigns/{id}/duplicate', [PromotionalCampaignController::class, 'duplicate']);
|
Route::post('product-sheets/{id}/items', [ProductSheetController::class, 'addItem']);
|
||||||
Route::post('campaigns/{id}/products', [PromotionalCampaignController::class, 'addProducts']);
|
Route::put('product-sheets/{sheetId}/items/{itemId}', [ProductSheetController::class, 'updateItem']);
|
||||||
Route::delete('campaigns/{id}/products', [PromotionalCampaignController::class, 'removeProducts']);
|
Route::delete('product-sheets/{sheetId}/items/{itemId}', [ProductSheetController::class, 'removeItem']);
|
||||||
Route::put('campaigns/{campaignId}/products/{productId}', [PromotionalCampaignController::class, 'updateProductDiscount']);
|
Route::post('product-sheets/{id}/recalculate-price', [ProductSheetController::class, 'recalculatePrice']);
|
||||||
}); // End admin.only group
|
Route::post('product-sheets/{id}/duplicate', [ProductSheetController::class, 'duplicate']);
|
||||||
|
|
||||||
|
// Fichas Técnicas de Serviços (CSV - Custo do Serviço Vendido)
|
||||||
|
Route::get('service-sheets/categories', [ServiceSheetController::class, 'categories']);
|
||||||
|
Route::get('service-sheets/item-types', [ServiceSheetController::class, 'itemTypes']);
|
||||||
|
Route::post('service-sheets/simulate', [ServiceSheetController::class, 'simulate']);
|
||||||
|
Route::apiResource('service-sheets', ServiceSheetController::class);
|
||||||
|
Route::post('service-sheets/{id}/items', [ServiceSheetController::class, 'addItem']);
|
||||||
|
Route::put('service-sheets/{sheetId}/items/{itemId}', [ServiceSheetController::class, 'updateItem']);
|
||||||
|
Route::delete('service-sheets/{sheetId}/items/{itemId}', [ServiceSheetController::class, 'removeItem']);
|
||||||
|
Route::post('service-sheets/{id}/duplicate', [ServiceSheetController::class, 'duplicate']);
|
||||||
|
|
||||||
|
// Campanhas Promocionais (Black Friday, etc.)
|
||||||
|
Route::get('campaigns/presets', [PromotionalCampaignController::class, 'presets']);
|
||||||
|
Route::post('campaigns/preview', [PromotionalCampaignController::class, 'preview']);
|
||||||
|
Route::apiResource('campaigns', PromotionalCampaignController::class);
|
||||||
|
Route::post('campaigns/{id}/duplicate', [PromotionalCampaignController::class, 'duplicate']);
|
||||||
|
Route::post('campaigns/{id}/products', [PromotionalCampaignController::class, 'addProducts']);
|
||||||
|
Route::delete('campaigns/{id}/products', [PromotionalCampaignController::class, 'removeProducts']);
|
||||||
|
Route::put('campaigns/{campaignId}/products/{productId}', [PromotionalCampaignController::class, 'updateProductDiscount']);
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Metas Financieras (Financial Goals)
|
// Metas Financieras (Financial Goals)
|
||||||
@ -314,13 +268,12 @@
|
|||||||
Route::delete('financial-goals/{goalId}/contributions/{contributionId}', [FinancialGoalController::class, 'removeContribution']);
|
Route::delete('financial-goals/{goalId}/contributions/{contributionId}', [FinancialGoalController::class, 'removeContribution']);
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Presupuestos (Budgets) - Com limite de plano
|
// Presupuestos (Budgets)
|
||||||
// ============================================
|
// ============================================
|
||||||
Route::get('budgets/available-categories', [BudgetController::class, 'availableCategories']);
|
Route::get('budgets/available-categories', [BudgetController::class, 'availableCategories']);
|
||||||
Route::get('budgets/year-summary', [BudgetController::class, 'yearSummary']);
|
Route::get('budgets/year-summary', [BudgetController::class, 'yearSummary']);
|
||||||
Route::post('budgets/copy-to-next-month', [BudgetController::class, 'copyToNextMonth']);
|
Route::post('budgets/copy-to-next-month', [BudgetController::class, 'copyToNextMonth']);
|
||||||
Route::post('budgets', [BudgetController::class, 'store'])->middleware('plan.limits:budgets');
|
Route::apiResource('budgets', BudgetController::class);
|
||||||
Route::apiResource('budgets', BudgetController::class)->except(['store']);
|
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Reportes (Reports)
|
// Reportes (Reports)
|
||||||
@ -355,17 +308,5 @@
|
|||||||
Route::get('preferences', [UserPreferenceController::class, 'index']);
|
Route::get('preferences', [UserPreferenceController::class, 'index']);
|
||||||
Route::put('preferences', [UserPreferenceController::class, 'update']);
|
Route::put('preferences', [UserPreferenceController::class, 'update']);
|
||||||
Route::post('preferences/test-notification', [UserPreferenceController::class, 'testNotification']);
|
Route::post('preferences/test-notification', [UserPreferenceController::class, 'testNotification']);
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Site Settings (Admin only - Configurações do Site)
|
|
||||||
// ============================================
|
|
||||||
Route::middleware('admin.only')->prefix('admin/site-settings')->group(function () {
|
|
||||||
Route::get('/', [SiteSettingsController::class, 'index']);
|
|
||||||
Route::get('/cnxifly/status', [SiteSettingsController::class, 'getCnxiflyStatus']);
|
|
||||||
Route::post('/cnxifly/toggle', [SiteSettingsController::class, 'toggleCnxiflyPage']);
|
|
||||||
Route::post('/cnxifly/deploy', [SiteSettingsController::class, 'deployCnxiflyPage']);
|
|
||||||
Route::get('/{key}', [SiteSettingsController::class, 'show']);
|
|
||||||
Route::put('/{key}', [SiteSettingsController::class, 'update']);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,39 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Deploy landing page to cnxifly.com
|
|
||||||
# Usage: ./deploy-landing.sh [live|maintenance]
|
|
||||||
|
|
||||||
MODE=${1:-live}
|
|
||||||
SERVER="root@213.165.93.60"
|
|
||||||
PASSWORD="Master9354"
|
|
||||||
DEST="/var/www/cnxifly"
|
|
||||||
|
|
||||||
echo "🚀 Deploying cnxifly.com landing page in mode: $MODE"
|
|
||||||
|
|
||||||
if [ "$MODE" = "live" ]; then
|
|
||||||
SOURCE="landing/index.html"
|
|
||||||
elif [ "$MODE" = "maintenance" ]; then
|
|
||||||
SOURCE="landing/maintenance.html"
|
|
||||||
else
|
|
||||||
echo "❌ Invalid mode. Use: live or maintenance"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if source file exists
|
|
||||||
if [ ! -f "$SOURCE" ]; then
|
|
||||||
echo "❌ Source file not found: $SOURCE"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "📁 Copying $SOURCE to server..."
|
|
||||||
|
|
||||||
# Deploy the file
|
|
||||||
sshpass -p "$PASSWORD" scp -o StrictHostKeyChecking=no "$SOURCE" "$SERVER:$DEST/index.html"
|
|
||||||
|
|
||||||
if [ $? -eq 0 ]; then
|
|
||||||
echo "✅ Landing page deployed successfully!"
|
|
||||||
echo "🌐 Visit: https://cnxifly.com"
|
|
||||||
else
|
|
||||||
echo "❌ Deploy failed!"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
@ -1,129 +0,0 @@
|
|||||||
# WebMoney SaaS - Status de Implementação
|
|
||||||
|
|
||||||
> Última atualização: 17 de Dezembro de 2025
|
|
||||||
|
|
||||||
## ✅ Concluído
|
|
||||||
|
|
||||||
### Fase 1: Estrutura de Planos
|
|
||||||
- [x] Tabela `plans` com 3 planos (Free, Pro Mensual, Pro Anual)
|
|
||||||
- [x] Tabela `subscriptions` para gerenciar assinaturas
|
|
||||||
- [x] Model Plan com relacionamentos
|
|
||||||
- [x] Model Subscription com métodos auxiliares
|
|
||||||
|
|
||||||
### Fase 2: Integração PayPal
|
|
||||||
- [x] PayPalService completo (criar/cancelar/reativar assinaturas)
|
|
||||||
- [x] Credenciais PayPal Sandbox configuradas no servidor
|
|
||||||
- [x] Planos criados no PayPal com IDs salvos no banco
|
|
||||||
- [x] FRONTEND_URL configurado para redirects
|
|
||||||
|
|
||||||
### Fase 3: Fluxo de Assinatura
|
|
||||||
- [x] Endpoint `GET /api/plans` - listar planos
|
|
||||||
- [x] Endpoint `POST /api/subscription/create` - criar assinatura
|
|
||||||
- [x] Endpoint `GET /api/subscription/status` - status atual
|
|
||||||
- [x] Endpoint `POST /api/subscription/cancel` - cancelar
|
|
||||||
- [x] Endpoint `POST /api/subscription/reactivate` - reativar
|
|
||||||
- [x] Webhooks PayPal funcionando (ID: 4UM53122EW59785)
|
|
||||||
- [x] Frontend: página de planos e checkout
|
|
||||||
- [x] Assinatura de teste criada: I-RHE4CFSL3T3N (Pro Mensual)
|
|
||||||
|
|
||||||
### Fase 4: Enforcement de Limites
|
|
||||||
- [x] Limites do plano Free definidos:
|
|
||||||
- 1 conta bancária
|
|
||||||
- 10 categorias
|
|
||||||
- 3 orçamentos
|
|
||||||
- 100 transações
|
|
||||||
- [x] Middleware `CheckPlanLimits` criado
|
|
||||||
- [x] Middleware aplicado nas rotas de criação
|
|
||||||
- [x] Endpoint `/subscription/status` retorna uso atual
|
|
||||||
- [x] Widget `PlanUsageWidget` no Dashboard
|
|
||||||
- [x] Translations (es.json, pt-BR.json)
|
|
||||||
|
|
||||||
## ✅ Testes Realizados (17/12/2025)
|
|
||||||
|
|
||||||
### Testar com Usuário Free
|
|
||||||
- [x] Criar usuário sem assinatura ✅ **FUNCIONA**
|
|
||||||
- Usuário de teste: `testfree2@webmoney.test` (ID: 4)
|
|
||||||
- Plano Free atribuído automaticamente
|
|
||||||
- [x] Validar que middleware bloqueia ao atingir limite ✅ **FUNCIONA**
|
|
||||||
- Contas: 1/1 → Bloqueia 2ª conta ✅
|
|
||||||
- Categorias: 10/10 → Bloqueia 11ª categoria ✅
|
|
||||||
- Budgets: 3/3 → Bloqueia 4º orçamento ✅
|
|
||||||
- [x] Validar widget mostra progresso corretamente ✅ **FUNCIONA**
|
|
||||||
- API retorna `usage` e `usage_percentages` corretos
|
|
||||||
- [x] Testar mensagens de erro amigáveis ✅ **FUNCIONA**
|
|
||||||
- Mensagem: "Has alcanzado el límite de X de tu plan. Actualiza a Pro para X ilimitados."
|
|
||||||
- Retorna `error: plan_limit_exceeded` com dados do limite
|
|
||||||
|
|
||||||
### Testar Usuário Pro
|
|
||||||
- [x] Admin Pro tem limites `null` (ilimitados) ✅
|
|
||||||
- [x] 173 categorias, 1204 transações sem bloqueio ✅
|
|
||||||
|
|
||||||
### Testar Criação de Usuários Admin
|
|
||||||
- [x] Endpoint POST `/api/admin/users` ✅ **FUNCIONA**
|
|
||||||
- [x] Tipos de usuário: Free, Pro, Admin ✅
|
|
||||||
- [x] Pro/Admin recebem assinatura automática de 100 anos ✅
|
|
||||||
|
|
||||||
### ⏳ Pendente - Testes de Cancelamento
|
|
||||||
- [ ] Cancelar assinatura via PayPal
|
|
||||||
- [ ] Verificar grace period funciona
|
|
||||||
- [ ] Verificar downgrade para Free após expirar
|
|
||||||
|
|
||||||
### ⏳ Pendente - Testes de Reativação
|
|
||||||
- [ ] Reativar assinatura cancelada via PayPal
|
|
||||||
- [ ] Verificar status volta para ativo
|
|
||||||
|
|
||||||
## 🔧 Melhorias Futuras (Produção)
|
|
||||||
|
|
||||||
| Prioridade | Item | Descrição |
|
|
||||||
|------------|------|-----------|
|
|
||||||
| **Alta** | PayPal Live | Trocar credenciais sandbox para produção |
|
|
||||||
| **Alta** | Histórico de pagamentos | Tabela `payments` para registrar transações |
|
|
||||||
| **Média** | Emails transacionais | Notificar renovação, falha de pagamento, etc. |
|
|
||||||
| **Média** | Página de faturamento | Frontend com histórico e faturas |
|
|
||||||
| **Baixa** | Upgrade mid-cycle | Trocar de plano durante o ciclo |
|
|
||||||
| **Baixa** | Cupons de desconto | Sistema de cupons promocionais |
|
|
||||||
|
|
||||||
## 📋 Credenciais e Configurações
|
|
||||||
|
|
||||||
### PayPal Sandbox
|
|
||||||
```
|
|
||||||
Client ID: AU-E_dptCQSa_xGUmU--0pTPuZ25AWKOFP6uvamzPQZHrg1nfRaVhEebtpJ1jU_8OKyMocglbesAwIpR
|
|
||||||
Webhook ID: 4UM53122EW59785
|
|
||||||
Mode: sandbox
|
|
||||||
```
|
|
||||||
|
|
||||||
### Planos no Banco de Dados
|
|
||||||
| ID | Slug | Preço | PayPal Plan ID |
|
|
||||||
|----|------|-------|----------------|
|
|
||||||
| 1 | free | 0.00 | - |
|
|
||||||
| 2 | pro-monthly | 9.99 | P-3FJ50989UN7098919M7A752Y |
|
|
||||||
| 3 | pro-yearly | 99.99 | P-9FN08839NE7915003M7A76JY |
|
|
||||||
|
|
||||||
### Assinatura de Teste
|
|
||||||
```
|
|
||||||
User: marco@cnxifly.com (ID: 1)
|
|
||||||
Plan: Pro Mensual (ID: 2)
|
|
||||||
PayPal ID: I-RHE4CFSL3T3N
|
|
||||||
Status: active
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🚀 Como Continuar
|
|
||||||
|
|
||||||
1. **Para testar limites Free:**
|
|
||||||
```bash
|
|
||||||
# Criar usuário de teste
|
|
||||||
curl -X POST https://webmoney.cnxifly.com/api/register \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"name":"Test Free","email":"testfree@test.com","password":"Test1234!"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Para ir para produção:**
|
|
||||||
- Criar conta PayPal Business
|
|
||||||
- Obter credenciais Live
|
|
||||||
- Atualizar .env no servidor
|
|
||||||
- Criar planos no PayPal Live
|
|
||||||
- Atualizar IDs no banco
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Documento criado para referência futura. Voltar aqui quando retomar o desenvolvimento SaaS.*
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
// WebMoney Service Worker - PWA Support
|
// WebMoney Service Worker - PWA Support
|
||||||
const CACHE_VERSION = 'webmoney-v1.40.0';
|
const CACHE_VERSION = 'webmoney-v1.39.0';
|
||||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||||
const CACHE_DYNAMIC = `${CACHE_VERSION}-dynamic`;
|
const CACHE_DYNAMIC = `${CACHE_VERSION}-dynamic`;
|
||||||
const CACHE_IMMUTABLE = `${CACHE_VERSION}-immutable`;
|
const CACHE_IMMUTABLE = `${CACHE_VERSION}-immutable`;
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import ProtectedRoute from './components/ProtectedRoute';
|
|||||||
import Layout from './components/Layout';
|
import Layout from './components/Layout';
|
||||||
import CookieConsent from './components/CookieConsent';
|
import CookieConsent from './components/CookieConsent';
|
||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
import Landing from './pages/Landing';
|
|
||||||
import Dashboard from './pages/Dashboard';
|
import Dashboard from './pages/Dashboard';
|
||||||
import Accounts from './pages/Accounts';
|
import Accounts from './pages/Accounts';
|
||||||
import CostCenters from './pages/CostCenters';
|
import CostCenters from './pages/CostCenters';
|
||||||
@ -26,11 +25,6 @@ import Preferences from './pages/Preferences';
|
|||||||
import Profile from './pages/Profile';
|
import Profile from './pages/Profile';
|
||||||
import Pricing from './pages/Pricing';
|
import Pricing from './pages/Pricing';
|
||||||
import Billing from './pages/Billing';
|
import Billing from './pages/Billing';
|
||||||
import Users from './pages/Users';
|
|
||||||
import SiteSettings from './pages/SiteSettings';
|
|
||||||
import Register from './pages/Register';
|
|
||||||
import ActivateAccount from './pages/ActivateAccount';
|
|
||||||
import PaymentSuccess from './pages/PaymentSuccess';
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@ -39,9 +33,6 @@ function App() {
|
|||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/register" element={<Register />} />
|
|
||||||
<Route path="/activate" element={<ActivateAccount />} />
|
|
||||||
<Route path="/payment-success" element={<PaymentSuccess />} />
|
|
||||||
<Route
|
<Route
|
||||||
path="/dashboard"
|
path="/dashboard"
|
||||||
element={
|
element={
|
||||||
@ -230,27 +221,7 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route path="/" element={<Navigate to="/dashboard" />} />
|
||||||
path="/users"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<Layout>
|
|
||||||
<Users />
|
|
||||||
</Layout>
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/site-settings"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<Layout>
|
|
||||||
<SiteSettings />
|
|
||||||
</Layout>
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route path="/" element={<Landing />} />
|
|
||||||
</Routes>
|
</Routes>
|
||||||
<CookieConsent />
|
<CookieConsent />
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
|
|||||||
@ -1,832 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { accountService, liabilityAccountService, assetAccountService } from '../services/api';
|
|
||||||
import { useToast } from './Toast';
|
|
||||||
|
|
||||||
const AccountWizard = ({ isOpen, onClose, onSuccess, account = null }) => {
|
|
||||||
const toast = useToast();
|
|
||||||
const [step, setStep] = useState(1);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
|
||||||
const isEditMode = !!account;
|
|
||||||
|
|
||||||
// Tipo de destino: 'account', 'asset' ou 'liability'
|
|
||||||
const [destinationType, setDestinationType] = useState('account');
|
|
||||||
|
|
||||||
// Form data unificado
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
// Tipo de conta (etapa 1)
|
|
||||||
account_type: '', // checking, savings, credit_card, cash
|
|
||||||
|
|
||||||
// Dados básicos (etapa 2)
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
currency: 'EUR',
|
|
||||||
color: '#3B82F6',
|
|
||||||
icon: 'bi-bank',
|
|
||||||
|
|
||||||
// Dados financeiros (etapa 3)
|
|
||||||
initial_balance: '',
|
|
||||||
credit_limit: '', // Para cartão de crédito
|
|
||||||
|
|
||||||
// Dados bancários (etapa 4) - opcional
|
|
||||||
bank_name: '',
|
|
||||||
account_number: '',
|
|
||||||
|
|
||||||
// Configurações
|
|
||||||
is_active: true,
|
|
||||||
include_in_total: true,
|
|
||||||
|
|
||||||
// Para poupança (ativo)
|
|
||||||
interest_rate: '',
|
|
||||||
|
|
||||||
// Para cartão de crédito (passivo)
|
|
||||||
closing_day: '',
|
|
||||||
due_day: '',
|
|
||||||
annual_interest_rate: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Definição dos tipos de conta
|
|
||||||
const accountTypes = {
|
|
||||||
checking: {
|
|
||||||
name: 'Cuenta Corriente',
|
|
||||||
description: 'Cuenta bancaria para operaciones diarias',
|
|
||||||
icon: 'bi-bank',
|
|
||||||
color: '#3B82F6',
|
|
||||||
destination: 'account',
|
|
||||||
},
|
|
||||||
savings: {
|
|
||||||
name: 'Cuenta de Ahorro',
|
|
||||||
description: 'Dinero guardado que genera intereses',
|
|
||||||
icon: 'bi-piggy-bank',
|
|
||||||
color: '#10B981',
|
|
||||||
destination: 'asset', // Poupança vira ativo
|
|
||||||
},
|
|
||||||
credit_card: {
|
|
||||||
name: 'Tarjeta de Crédito',
|
|
||||||
description: 'Línea de crédito rotativo',
|
|
||||||
icon: 'bi-credit-card',
|
|
||||||
color: '#EF4444',
|
|
||||||
destination: 'liability', // Cartão vira passivo
|
|
||||||
},
|
|
||||||
cash: {
|
|
||||||
name: 'Efectivo',
|
|
||||||
description: 'Dinero en mano o caja chica',
|
|
||||||
icon: 'bi-cash-stack',
|
|
||||||
color: '#F59E0B',
|
|
||||||
destination: 'account',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleResize = () => setIsMobile(window.innerWidth < 768);
|
|
||||||
window.addEventListener('resize', handleResize);
|
|
||||||
return () => window.removeEventListener('resize', handleResize);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen) {
|
|
||||||
if (account) {
|
|
||||||
loadAccountData(account);
|
|
||||||
} else {
|
|
||||||
resetForm();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [isOpen, account]);
|
|
||||||
|
|
||||||
// Atualizar destino quando tipo muda
|
|
||||||
useEffect(() => {
|
|
||||||
if (formData.account_type && accountTypes[formData.account_type]) {
|
|
||||||
const typeConfig = accountTypes[formData.account_type];
|
|
||||||
setDestinationType(typeConfig.destination);
|
|
||||||
|
|
||||||
// Atualizar ícone e cor padrão
|
|
||||||
if (!isEditMode) {
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
icon: typeConfig.icon,
|
|
||||||
color: typeConfig.color,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [formData.account_type]);
|
|
||||||
|
|
||||||
const loadAccountData = (accountData) => {
|
|
||||||
// Determinar o tipo baseado nos dados
|
|
||||||
let accountType = accountData.type || accountData.account_type || 'checking';
|
|
||||||
|
|
||||||
// Se for um ativo de investimento/poupança
|
|
||||||
if (accountData.asset_type === 'cash' || accountData.investment_type === 'savings') {
|
|
||||||
accountType = 'savings';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Se for um passivo de cartão
|
|
||||||
if (accountData.contract_type === 'credit_card') {
|
|
||||||
accountType = 'credit_card';
|
|
||||||
}
|
|
||||||
|
|
||||||
setStep(2);
|
|
||||||
setFormData({
|
|
||||||
account_type: accountType,
|
|
||||||
name: accountData.name || '',
|
|
||||||
description: accountData.description || '',
|
|
||||||
currency: accountData.currency || 'EUR',
|
|
||||||
color: accountData.color || '#3B82F6',
|
|
||||||
icon: accountData.icon || 'bi-bank',
|
|
||||||
initial_balance: accountData.initial_balance || accountData.current_balance || accountData.current_value || '',
|
|
||||||
credit_limit: accountData.credit_limit || accountData.principal_amount || '',
|
|
||||||
bank_name: accountData.bank_name || accountData.creditor || '',
|
|
||||||
account_number: accountData.account_number || accountData.contract_number || '',
|
|
||||||
is_active: accountData.is_active !== false,
|
|
||||||
include_in_total: accountData.include_in_total !== false,
|
|
||||||
interest_rate: accountData.interest_rate || '',
|
|
||||||
closing_day: accountData.closing_day || '',
|
|
||||||
due_day: accountData.due_day || '',
|
|
||||||
annual_interest_rate: accountData.annual_interest_rate || '',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetForm = () => {
|
|
||||||
setStep(1);
|
|
||||||
setDestinationType('account');
|
|
||||||
setFormData({
|
|
||||||
account_type: '',
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
currency: 'EUR',
|
|
||||||
color: '#3B82F6',
|
|
||||||
icon: 'bi-bank',
|
|
||||||
initial_balance: '',
|
|
||||||
credit_limit: '',
|
|
||||||
bank_name: '',
|
|
||||||
account_number: '',
|
|
||||||
is_active: true,
|
|
||||||
include_in_total: true,
|
|
||||||
interest_rate: '',
|
|
||||||
closing_day: '',
|
|
||||||
due_day: '',
|
|
||||||
annual_interest_rate: '',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChange = (e) => {
|
|
||||||
const { name, value, type, checked } = e.target;
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
[name]: type === 'checkbox' ? checked : value,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectAccountType = (type) => {
|
|
||||||
setFormData(prev => ({ ...prev, account_type: type }));
|
|
||||||
setStep(2);
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateStep = () => {
|
|
||||||
switch (step) {
|
|
||||||
case 1:
|
|
||||||
return !!formData.account_type;
|
|
||||||
case 2:
|
|
||||||
return !!formData.name?.trim();
|
|
||||||
case 3:
|
|
||||||
if (destinationType === 'liability') {
|
|
||||||
return formData.credit_limit > 0;
|
|
||||||
}
|
|
||||||
return true; // Saldo inicial pode ser 0
|
|
||||||
case 4:
|
|
||||||
return true; // Dados bancários são opcionais
|
|
||||||
default:
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const nextStep = () => {
|
|
||||||
if (validateStep()) {
|
|
||||||
setStep(prev => Math.min(prev + 1, 4));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const prevStep = () => {
|
|
||||||
setStep(prev => Math.max(prev - 1, 1));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
let response;
|
|
||||||
|
|
||||||
if (destinationType === 'asset') {
|
|
||||||
// Criar como Ativo (Poupança)
|
|
||||||
const assetData = {
|
|
||||||
asset_type: 'cash', // Tipo cash para poupança
|
|
||||||
investment_type: 'savings',
|
|
||||||
name: formData.name,
|
|
||||||
description: formData.description,
|
|
||||||
currency: formData.currency,
|
|
||||||
color: formData.color,
|
|
||||||
acquisition_value: parseFloat(formData.initial_balance) || 0,
|
|
||||||
current_value: parseFloat(formData.initial_balance) || 0,
|
|
||||||
acquisition_date: new Date().toISOString().split('T')[0],
|
|
||||||
institution: formData.bank_name,
|
|
||||||
account_number: formData.account_number,
|
|
||||||
interest_rate: parseFloat(formData.interest_rate) || 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isEditMode && account) {
|
|
||||||
response = await assetAccountService.update(account.id, assetData);
|
|
||||||
} else {
|
|
||||||
response = await assetAccountService.createWithWizard(assetData);
|
|
||||||
}
|
|
||||||
} else if (destinationType === 'liability') {
|
|
||||||
// Criar como Passivo (Cartão de Crédito)
|
|
||||||
const liabilityData = {
|
|
||||||
contract_type: 'credit_card',
|
|
||||||
name: formData.name,
|
|
||||||
description: formData.description,
|
|
||||||
currency: formData.currency,
|
|
||||||
color: formData.color,
|
|
||||||
icon: formData.icon,
|
|
||||||
creditor: formData.bank_name,
|
|
||||||
contract_number: formData.account_number,
|
|
||||||
principal_amount: parseFloat(formData.credit_limit) || 0,
|
|
||||||
total_pending: parseFloat(formData.initial_balance) || 0,
|
|
||||||
annual_interest_rate: parseFloat(formData.annual_interest_rate) || 0,
|
|
||||||
amortization_system: 'revolving',
|
|
||||||
start_date: new Date().toISOString().split('T')[0],
|
|
||||||
closing_day: parseInt(formData.closing_day) || null,
|
|
||||||
due_day: parseInt(formData.due_day) || null,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isEditMode && account) {
|
|
||||||
response = await liabilityAccountService.update(account.id, liabilityData);
|
|
||||||
} else {
|
|
||||||
response = await liabilityAccountService.storeWithWizard(liabilityData);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Criar como Conta Normal
|
|
||||||
const accountData = {
|
|
||||||
type: formData.account_type,
|
|
||||||
name: formData.name,
|
|
||||||
description: formData.description,
|
|
||||||
currency: formData.currency,
|
|
||||||
color: formData.color,
|
|
||||||
icon: formData.icon,
|
|
||||||
bank_name: formData.bank_name,
|
|
||||||
account_number: formData.account_number,
|
|
||||||
initial_balance: parseFloat(formData.initial_balance) || 0,
|
|
||||||
credit_limit: formData.account_type === 'credit_card' ? parseFloat(formData.credit_limit) || null : null,
|
|
||||||
is_active: formData.is_active,
|
|
||||||
include_in_total: formData.include_in_total,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isEditMode && account) {
|
|
||||||
response = await accountService.update(account.id, accountData);
|
|
||||||
} else {
|
|
||||||
response = await accountService.create(accountData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.success) {
|
|
||||||
onSuccess?.(response.data, destinationType);
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving account:', error);
|
|
||||||
toast.error(error.response?.data?.message || 'Error al guardar la cuenta');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTotalSteps = () => {
|
|
||||||
return 4;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="modal fade show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.8)' }}>
|
|
||||||
<div className={`modal-dialog ${isMobile ? 'modal-fullscreen' : 'modal-lg'} modal-dialog-centered modal-dialog-scrollable`}>
|
|
||||||
<div className="modal-content" style={{ background: isMobile ? '#0f172a' : '#1e293b', border: '1px solid #334155' }}>
|
|
||||||
{/* Header */}
|
|
||||||
<div className={`modal-header border-0 ${isMobile ? 'py-2' : ''}`} style={{ backgroundColor: '#334155' }}>
|
|
||||||
<h5 className={`modal-title text-white ${isMobile ? 'fs-6' : ''}`}>
|
|
||||||
<i className={`bi ${isEditMode ? 'bi-pencil' : 'bi-plus-circle'} me-2`}></i>
|
|
||||||
{isEditMode ? 'Editar Cuenta' : 'Nueva Cuenta'} - Paso {step}/{getTotalSteps()}
|
|
||||||
</h5>
|
|
||||||
<button type="button" className="btn-close btn-close-white" onClick={onClose}></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress */}
|
|
||||||
<div className="progress" style={{ height: '4px', borderRadius: 0 }}>
|
|
||||||
<div
|
|
||||||
className="progress-bar bg-primary"
|
|
||||||
style={{ width: `${(step / getTotalSteps()) * 100}%` }}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Body */}
|
|
||||||
<div className={`modal-body ${isMobile ? 'p-3' : 'p-4'}`} style={{ color: '#fff' }}>
|
|
||||||
|
|
||||||
{/* Step 1: Tipo de Conta */}
|
|
||||||
{step === 1 && (
|
|
||||||
<div>
|
|
||||||
<h5 className="mb-4">
|
|
||||||
<i className="bi bi-wallet2 me-2"></i>
|
|
||||||
¿Qué tipo de cuenta deseas crear?
|
|
||||||
</h5>
|
|
||||||
|
|
||||||
<div className="row g-3">
|
|
||||||
{Object.entries(accountTypes).map(([key, config]) => (
|
|
||||||
<div key={key} className="col-md-6">
|
|
||||||
<div
|
|
||||||
className={`card h-100 cursor-pointer ${formData.account_type === key ? 'border-primary' : 'border-secondary'}`}
|
|
||||||
style={{
|
|
||||||
backgroundColor: formData.account_type === key ? config.color + '20' : '#0f172a',
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'all 0.2s',
|
|
||||||
}}
|
|
||||||
onClick={() => selectAccountType(key)}
|
|
||||||
>
|
|
||||||
<div className="card-body text-center p-4">
|
|
||||||
<div
|
|
||||||
className="rounded-circle d-inline-flex align-items-center justify-content-center mb-3"
|
|
||||||
style={{
|
|
||||||
width: '60px',
|
|
||||||
height: '60px',
|
|
||||||
backgroundColor: config.color + '30',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<i className={`bi ${config.icon} fs-3`} style={{ color: config.color }}></i>
|
|
||||||
</div>
|
|
||||||
<h6 className="text-white mb-2">{config.name}</h6>
|
|
||||||
<small className="text-slate-400">{config.description}</small>
|
|
||||||
|
|
||||||
{/* Badge indicando destino */}
|
|
||||||
<div className="mt-3">
|
|
||||||
{config.destination === 'asset' && (
|
|
||||||
<span className="badge bg-success">
|
|
||||||
<i className="bi bi-graph-up me-1"></i>
|
|
||||||
Se registra como Activo
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{config.destination === 'liability' && (
|
|
||||||
<span className="badge bg-danger">
|
|
||||||
<i className="bi bi-graph-down me-1"></i>
|
|
||||||
Se registra como Pasivo
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{config.destination === 'account' && (
|
|
||||||
<span className="badge bg-primary">
|
|
||||||
<i className="bi bi-wallet2 me-1"></i>
|
|
||||||
Cuenta Estándar
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Step 2: Dados Básicos */}
|
|
||||||
{step === 2 && (
|
|
||||||
<div>
|
|
||||||
<h5 className="mb-4">
|
|
||||||
<i className={`bi ${accountTypes[formData.account_type]?.icon || 'bi-info-circle'} me-2`}></i>
|
|
||||||
Información Básica
|
|
||||||
</h5>
|
|
||||||
|
|
||||||
<div className="row g-3">
|
|
||||||
<div className="col-12">
|
|
||||||
<label className="form-label">
|
|
||||||
Nombre de la Cuenta <span className="text-danger">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="name"
|
|
||||||
className="form-control bg-dark text-white border-secondary"
|
|
||||||
value={formData.name}
|
|
||||||
onChange={handleChange}
|
|
||||||
placeholder={`Ej: ${accountTypes[formData.account_type]?.name || 'Mi cuenta'}`}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="col-md-6">
|
|
||||||
<label className="form-label">Moneda</label>
|
|
||||||
<select
|
|
||||||
name="currency"
|
|
||||||
className="form-select bg-dark text-white border-secondary"
|
|
||||||
value={formData.currency}
|
|
||||||
onChange={handleChange}
|
|
||||||
>
|
|
||||||
<option value="EUR">EUR - Euro</option>
|
|
||||||
<option value="USD">USD - Dólar</option>
|
|
||||||
<option value="BRL">BRL - Real</option>
|
|
||||||
<option value="GBP">GBP - Libra</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="col-md-6">
|
|
||||||
<label className="form-label">Color</label>
|
|
||||||
<div className="d-flex gap-2 flex-wrap">
|
|
||||||
{['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#06B6D4', '#6B7280'].map(color => (
|
|
||||||
<div
|
|
||||||
key={color}
|
|
||||||
className={`rounded-circle ${formData.color === color ? 'ring ring-white' : ''}`}
|
|
||||||
style={{
|
|
||||||
width: '32px',
|
|
||||||
height: '32px',
|
|
||||||
backgroundColor: color,
|
|
||||||
cursor: 'pointer',
|
|
||||||
border: formData.color === color ? '3px solid white' : '2px solid transparent',
|
|
||||||
}}
|
|
||||||
onClick={() => setFormData(prev => ({ ...prev, color }))}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="col-12">
|
|
||||||
<label className="form-label">Descripción (opcional)</label>
|
|
||||||
<textarea
|
|
||||||
name="description"
|
|
||||||
className="form-control bg-dark text-white border-secondary"
|
|
||||||
value={formData.description}
|
|
||||||
onChange={handleChange}
|
|
||||||
rows={2}
|
|
||||||
placeholder="Descripción o notas adicionales..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Step 3: Dados Financeiros */}
|
|
||||||
{step === 3 && (
|
|
||||||
<div>
|
|
||||||
<h5 className="mb-4">
|
|
||||||
<i className="bi bi-currency-euro me-2"></i>
|
|
||||||
Información Financiera
|
|
||||||
</h5>
|
|
||||||
|
|
||||||
<div className="row g-3">
|
|
||||||
{/* Saldo/Valor Inicial */}
|
|
||||||
<div className="col-md-6">
|
|
||||||
<label className="form-label">
|
|
||||||
{destinationType === 'liability' ? 'Deuda Actual' : 'Saldo Inicial'}
|
|
||||||
{destinationType === 'liability' && <span className="text-danger">*</span>}
|
|
||||||
</label>
|
|
||||||
<div className="input-group">
|
|
||||||
<span className="input-group-text bg-dark text-white border-secondary">
|
|
||||||
{formData.currency === 'EUR' ? '€' : formData.currency === 'USD' ? '$' : 'R$'}
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
name="initial_balance"
|
|
||||||
className="form-control bg-dark text-white border-secondary"
|
|
||||||
value={formData.initial_balance}
|
|
||||||
onChange={handleChange}
|
|
||||||
placeholder="0.00"
|
|
||||||
step="0.01"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<small className="text-slate-400">
|
|
||||||
{destinationType === 'liability'
|
|
||||||
? 'Monto que debes actualmente en esta tarjeta'
|
|
||||||
: 'Saldo actual de la cuenta'}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Limite de Crédito (só para cartão) */}
|
|
||||||
{(formData.account_type === 'credit_card' || destinationType === 'liability') && (
|
|
||||||
<div className="col-md-6">
|
|
||||||
<label className="form-label">
|
|
||||||
Límite de Crédito <span className="text-danger">*</span>
|
|
||||||
</label>
|
|
||||||
<div className="input-group">
|
|
||||||
<span className="input-group-text bg-dark text-white border-secondary">
|
|
||||||
{formData.currency === 'EUR' ? '€' : formData.currency === 'USD' ? '$' : 'R$'}
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
name="credit_limit"
|
|
||||||
className="form-control bg-dark text-white border-secondary"
|
|
||||||
value={formData.credit_limit}
|
|
||||||
onChange={handleChange}
|
|
||||||
placeholder="5000.00"
|
|
||||||
step="0.01"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Taxa de Juros (para poupança ou cartão) */}
|
|
||||||
{(destinationType === 'asset' || destinationType === 'liability') && (
|
|
||||||
<div className="col-md-6">
|
|
||||||
<label className="form-label">
|
|
||||||
Tasa de Interés Anual (%)
|
|
||||||
</label>
|
|
||||||
<div className="input-group">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
name={destinationType === 'asset' ? 'interest_rate' : 'annual_interest_rate'}
|
|
||||||
className="form-control bg-dark text-white border-secondary"
|
|
||||||
value={destinationType === 'asset' ? formData.interest_rate : formData.annual_interest_rate}
|
|
||||||
onChange={handleChange}
|
|
||||||
placeholder={destinationType === 'asset' ? '2.5' : '18.99'}
|
|
||||||
step="0.01"
|
|
||||||
/>
|
|
||||||
<span className="input-group-text bg-dark text-white border-secondary">%</span>
|
|
||||||
</div>
|
|
||||||
<small className="text-slate-400">
|
|
||||||
{destinationType === 'asset'
|
|
||||||
? 'Rendimiento anual de la cuenta'
|
|
||||||
: 'Tasa de interés por financiamiento'}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Dias de fechamento e vencimento (cartão) */}
|
|
||||||
{destinationType === 'liability' && (
|
|
||||||
<>
|
|
||||||
<div className="col-md-6">
|
|
||||||
<label className="form-label">Día de Cierre</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
name="closing_day"
|
|
||||||
className="form-control bg-dark text-white border-secondary"
|
|
||||||
value={formData.closing_day}
|
|
||||||
onChange={handleChange}
|
|
||||||
placeholder="15"
|
|
||||||
min="1"
|
|
||||||
max="31"
|
|
||||||
/>
|
|
||||||
<small className="text-slate-400">Día del mes que cierra la factura</small>
|
|
||||||
</div>
|
|
||||||
<div className="col-md-6">
|
|
||||||
<label className="form-label">Día de Vencimiento</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
name="due_day"
|
|
||||||
className="form-control bg-dark text-white border-secondary"
|
|
||||||
value={formData.due_day}
|
|
||||||
onChange={handleChange}
|
|
||||||
placeholder="25"
|
|
||||||
min="1"
|
|
||||||
max="31"
|
|
||||||
/>
|
|
||||||
<small className="text-slate-400">Día de pago de la factura</small>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Step 4: Dados Bancários */}
|
|
||||||
{step === 4 && (
|
|
||||||
<div>
|
|
||||||
<h5 className="mb-4">
|
|
||||||
<i className="bi bi-building me-2"></i>
|
|
||||||
Información Bancaria (Opcional)
|
|
||||||
</h5>
|
|
||||||
|
|
||||||
<div className="row g-3">
|
|
||||||
<div className="col-md-6">
|
|
||||||
<label className="form-label">
|
|
||||||
{destinationType === 'liability' ? 'Emisor de la Tarjeta' : 'Nombre del Banco'}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="bank_name"
|
|
||||||
className="form-control bg-dark text-white border-secondary"
|
|
||||||
value={formData.bank_name}
|
|
||||||
onChange={handleChange}
|
|
||||||
placeholder={destinationType === 'liability' ? 'Ej: BBVA, Santander' : 'Ej: Santander, BBVA'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="col-md-6">
|
|
||||||
<label className="form-label">
|
|
||||||
{destinationType === 'liability' ? 'Últimos 4 Dígitos' : 'Número de Cuenta'}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="account_number"
|
|
||||||
className="form-control bg-dark text-white border-secondary"
|
|
||||||
value={formData.account_number}
|
|
||||||
onChange={handleChange}
|
|
||||||
placeholder={destinationType === 'liability' ? '****1234' : 'ES00 0000 0000 0000'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{destinationType === 'account' && (
|
|
||||||
<>
|
|
||||||
<div className="col-12">
|
|
||||||
<hr className="border-secondary my-4" />
|
|
||||||
<h6 className="text-white mb-3">
|
|
||||||
<i className="bi bi-gear me-2"></i>
|
|
||||||
Configuración
|
|
||||||
</h6>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="col-md-6">
|
|
||||||
<div className="form-check form-switch">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name="is_active"
|
|
||||||
className="form-check-input"
|
|
||||||
checked={formData.is_active}
|
|
||||||
onChange={handleChange}
|
|
||||||
id="is_active"
|
|
||||||
/>
|
|
||||||
<label className="form-check-label text-white" htmlFor="is_active">
|
|
||||||
Cuenta Activa
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<small className="text-slate-400">Cuentas inactivas no aparecen en transacciones</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="col-md-6">
|
|
||||||
<div className="form-check form-switch">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name="include_in_total"
|
|
||||||
className="form-check-input"
|
|
||||||
checked={formData.include_in_total}
|
|
||||||
onChange={handleChange}
|
|
||||||
id="include_in_total"
|
|
||||||
/>
|
|
||||||
<label className="form-check-label text-white" htmlFor="include_in_total">
|
|
||||||
Incluir en Total
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<small className="text-slate-400">Suma el saldo al patrimonio total</small>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Resumen */}
|
|
||||||
<div className="mt-4 p-3 rounded" style={{ backgroundColor: '#0f172a' }}>
|
|
||||||
<h6 className="text-white mb-3">
|
|
||||||
<i className="bi bi-check-circle me-2"></i>
|
|
||||||
Resumen
|
|
||||||
</h6>
|
|
||||||
<div className="row g-2">
|
|
||||||
<div className="col-6">
|
|
||||||
<small className="text-slate-400">Tipo:</small>
|
|
||||||
<div className="text-white">{accountTypes[formData.account_type]?.name}</div>
|
|
||||||
</div>
|
|
||||||
<div className="col-6">
|
|
||||||
<small className="text-slate-400">Nombre:</small>
|
|
||||||
<div className="text-white">{formData.name || '-'}</div>
|
|
||||||
</div>
|
|
||||||
<div className="col-6">
|
|
||||||
<small className="text-slate-400">
|
|
||||||
{destinationType === 'liability' ? 'Deuda:' : 'Saldo:'}
|
|
||||||
</small>
|
|
||||||
<div className="text-white">
|
|
||||||
{formData.currency === 'EUR' ? '€' : formData.currency === 'USD' ? '$' : 'R$'}
|
|
||||||
{parseFloat(formData.initial_balance || 0).toFixed(2)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{formData.credit_limit && (
|
|
||||||
<div className="col-6">
|
|
||||||
<small className="text-slate-400">Límite:</small>
|
|
||||||
<div className="text-white">
|
|
||||||
{formData.currency === 'EUR' ? '€' : formData.currency === 'USD' ? '$' : 'R$'}
|
|
||||||
{parseFloat(formData.credit_limit).toFixed(2)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="col-12">
|
|
||||||
<small className="text-slate-400">Se guardará como:</small>
|
|
||||||
<div>
|
|
||||||
{destinationType === 'asset' && (
|
|
||||||
<span className="badge bg-success">
|
|
||||||
<i className="bi bi-graph-up me-1"></i>
|
|
||||||
Activo Financiero
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{destinationType === 'liability' && (
|
|
||||||
<span className="badge bg-danger">
|
|
||||||
<i className="bi bi-graph-down me-1"></i>
|
|
||||||
Pasivo (Deuda)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{destinationType === 'account' && (
|
|
||||||
<span className="badge bg-primary">
|
|
||||||
<i className="bi bi-wallet2 me-1"></i>
|
|
||||||
Cuenta Estándar
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className={`modal-footer border-0 ${isMobile ? 'flex-column' : ''}`}>
|
|
||||||
{isMobile ? (
|
|
||||||
<>
|
|
||||||
{step === getTotalSteps() && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-primary w-100 mb-2"
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={loading || !validateStep()}
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<>
|
|
||||||
<span className="spinner-border spinner-border-sm me-2"></span>
|
|
||||||
Guardando...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<i className="bi bi-check-lg me-1"></i>
|
|
||||||
{isEditMode ? 'Guardar Cambios' : 'Crear Cuenta'}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<div className="d-flex gap-2 w-100">
|
|
||||||
{step > 1 && (
|
|
||||||
<button type="button" className="btn btn-outline-secondary flex-fill" onClick={prevStep}>
|
|
||||||
<i className="bi bi-arrow-left me-1"></i>
|
|
||||||
Anterior
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{step < getTotalSteps() && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-primary flex-fill"
|
|
||||||
onClick={nextStep}
|
|
||||||
disabled={!validateStep()}
|
|
||||||
>
|
|
||||||
Siguiente
|
|
||||||
<i className="bi bi-arrow-right ms-1"></i>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button type="button" className="btn btn-outline-secondary flex-fill" onClick={onClose}>
|
|
||||||
Cancelar
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
|
||||||
Cancelar
|
|
||||||
</button>
|
|
||||||
{step > 1 && (
|
|
||||||
<button type="button" className="btn btn-outline-light" onClick={prevStep}>
|
|
||||||
<i className="bi bi-arrow-left me-1"></i>
|
|
||||||
Anterior
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{step < getTotalSteps() ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-primary"
|
|
||||||
onClick={nextStep}
|
|
||||||
disabled={!validateStep()}
|
|
||||||
>
|
|
||||||
Siguiente
|
|
||||||
<i className="bi bi-arrow-right ms-1"></i>
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-success"
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={loading || !validateStep()}
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<>
|
|
||||||
<span className="spinner-border spinner-border-sm me-2"></span>
|
|
||||||
Guardando...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<i className="bi bi-check-lg me-1"></i>
|
|
||||||
{isEditMode ? 'Guardar Cambios' : 'Crear Cuenta'}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AccountWizard;
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -13,10 +13,6 @@ const Layout = ({ children }) => {
|
|||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { date } = useFormatters();
|
const { date } = useFormatters();
|
||||||
|
|
||||||
// Admin email - only this user can see business module
|
|
||||||
const ADMIN_EMAIL = 'marco@cnxifly.com';
|
|
||||||
const isAdmin = user?.email === ADMIN_EMAIL;
|
|
||||||
|
|
||||||
// Mobile: sidebar oculta por padrão | Desktop: expandida
|
// Mobile: sidebar oculta por padrão | Desktop: expandida
|
||||||
const isMobile = () => window.innerWidth < 768;
|
const isMobile = () => window.innerWidth < 768;
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false); // Mobile: inicia fechada
|
const [sidebarOpen, setSidebarOpen] = useState(false); // Mobile: inicia fechada
|
||||||
@ -51,9 +47,8 @@ const Layout = ({ children }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const [expandedGroups, setExpandedGroups] = useState({
|
const [expandedGroups, setExpandedGroups] = useState({
|
||||||
registrations: false,
|
movements: true,
|
||||||
movements: false,
|
planning: true,
|
||||||
planning: false,
|
|
||||||
settings: false,
|
settings: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -73,16 +68,6 @@ const Layout = ({ children }) => {
|
|||||||
const menuStructure = [
|
const menuStructure = [
|
||||||
{ type: 'item', path: '/dashboard', icon: 'bi-speedometer2', label: t('nav.dashboard') },
|
{ type: 'item', path: '/dashboard', icon: 'bi-speedometer2', label: t('nav.dashboard') },
|
||||||
{ type: 'item', path: '/accounts', icon: 'bi-wallet2', label: t('nav.accounts') },
|
{ type: 'item', path: '/accounts', icon: 'bi-wallet2', label: t('nav.accounts') },
|
||||||
{
|
|
||||||
type: 'group',
|
|
||||||
id: 'registrations',
|
|
||||||
icon: 'bi-folder',
|
|
||||||
label: t('nav.registrations'),
|
|
||||||
items: [
|
|
||||||
{ path: '/categories', icon: 'bi-tags', label: t('nav.categories') },
|
|
||||||
{ path: '/cost-centers', icon: 'bi-building', label: t('nav.costCenters') },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
type: 'group',
|
type: 'group',
|
||||||
id: 'movements',
|
id: 'movements',
|
||||||
@ -96,8 +81,8 @@ const Layout = ({ children }) => {
|
|||||||
{ path: '/refunds', icon: 'bi-receipt-cutoff', label: t('nav.refunds') },
|
{ path: '/refunds', icon: 'bi-receipt-cutoff', label: t('nav.refunds') },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
// Business module - only visible to admin
|
{ type: 'item', path: '/liabilities', icon: 'bi-bank', label: t('nav.liabilities') },
|
||||||
...(isAdmin ? [{ type: 'item', path: '/business', icon: 'bi-briefcase', label: t('nav.business') }] : []),
|
{ type: 'item', path: '/business', icon: 'bi-briefcase', label: t('nav.business') },
|
||||||
{
|
{
|
||||||
type: 'group',
|
type: 'group',
|
||||||
id: 'planning',
|
id: 'planning',
|
||||||
@ -116,15 +101,12 @@ const Layout = ({ children }) => {
|
|||||||
icon: 'bi-gear',
|
icon: 'bi-gear',
|
||||||
label: t('nav.settings'),
|
label: t('nav.settings'),
|
||||||
items: [
|
items: [
|
||||||
|
{ path: '/categories', icon: 'bi-tags', label: t('nav.categories') },
|
||||||
|
{ path: '/cost-centers', icon: 'bi-building', label: t('nav.costCenters') },
|
||||||
{ path: '/preferences', icon: 'bi-sliders', label: t('nav.preferences') },
|
{ path: '/preferences', icon: 'bi-sliders', label: t('nav.preferences') },
|
||||||
{ path: '/profile', icon: 'bi-person-circle', label: t('nav.profile') },
|
{ path: '/profile', icon: 'bi-person-circle', label: t('nav.profile') },
|
||||||
{ path: '/billing', icon: 'bi-credit-card', label: t('nav.billing') },
|
{ path: '/billing', icon: 'bi-credit-card', label: t('nav.billing') },
|
||||||
{ path: '/pricing', icon: 'bi-tags-fill', label: t('nav.pricing') },
|
{ path: '/pricing', icon: 'bi-tags-fill', label: t('nav.pricing') },
|
||||||
// Admin only: User management and Site Settings
|
|
||||||
...(isAdmin ? [
|
|
||||||
{ path: '/users', icon: 'bi-people', label: t('nav.users') },
|
|
||||||
{ path: '/site-settings', icon: 'bi-globe', label: t('nav.siteSettings', 'Sitio Web') },
|
|
||||||
] : []),
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -4,9 +4,6 @@ import { useAuth } from '../context/AuthContext';
|
|||||||
|
|
||||||
const ProtectedRoute = ({ children }) => {
|
const ProtectedRoute = ({ children }) => {
|
||||||
const { isAuthenticated, loading } = useAuth();
|
const { isAuthenticated, loading } = useAuth();
|
||||||
|
|
||||||
// Verificar também o token no localStorage (para casos de navegação imediata após registro)
|
|
||||||
const hasToken = !!localStorage.getItem('token');
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@ -18,7 +15,7 @@ const ProtectedRoute = ({ children }) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (isAuthenticated || hasToken) ? children : <Navigate to="/login" />;
|
return isAuthenticated ? children : <Navigate to="/login" />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ProtectedRoute;
|
export default ProtectedRoute;
|
||||||
|
|||||||
@ -15,9 +15,7 @@ export const ToastProvider = ({ children }) => {
|
|||||||
|
|
||||||
const addToast = useCallback((message, type = 'info', duration = 5000) => {
|
const addToast = useCallback((message, type = 'info', duration = 5000) => {
|
||||||
const id = Date.now();
|
const id = Date.now();
|
||||||
// Map 'error' to 'danger' for backward compatibility
|
const toast = { id, message, type, duration };
|
||||||
const mappedType = type === 'error' ? 'danger' : type;
|
|
||||||
const toast = { id, message, type: mappedType, duration };
|
|
||||||
|
|
||||||
setToasts((prev) => [...prev, toast]);
|
setToasts((prev) => [...prev, toast]);
|
||||||
|
|
||||||
@ -50,11 +48,8 @@ export const ToastProvider = ({ children }) => {
|
|||||||
return addToast(message, 'info', duration);
|
return addToast(message, 'info', duration);
|
||||||
}, [addToast]);
|
}, [addToast]);
|
||||||
|
|
||||||
// Alias for backward compatibility
|
|
||||||
const showToast = addToast;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToastContext.Provider value={{ addToast, showToast, removeToast, success, error, warning, info }}>
|
<ToastContext.Provider value={{ addToast, removeToast, success, error, warning, info }}>
|
||||||
{children}
|
{children}
|
||||||
<ToastContainer toasts={toasts} removeToast={removeToast} />
|
<ToastContainer toasts={toasts} removeToast={removeToast} />
|
||||||
</ToastContext.Provider>
|
</ToastContext.Provider>
|
||||||
|
|||||||
@ -1,129 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import api from '../../services/api';
|
|
||||||
|
|
||||||
export default function PlanUsageWidget() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [data, setData] = useState(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadUsage();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadUsage = async () => {
|
|
||||||
try {
|
|
||||||
const response = await api.get('/subscription/status');
|
|
||||||
if (response.data.success) {
|
|
||||||
setData(response.data.data);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading plan usage:', error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="card mb-3">
|
|
||||||
<div className="card-body text-center py-3">
|
|
||||||
<div className="spinner-border spinner-border-sm text-primary" role="status">
|
|
||||||
<span className="visually-hidden">Loading...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data || !data.plan) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { plan, usage, usage_percentages } = data;
|
|
||||||
const limits = plan.limits || {};
|
|
||||||
|
|
||||||
// Only show for free plan or plans with limits
|
|
||||||
const hasLimits = Object.values(limits).some(v => v !== null);
|
|
||||||
if (!hasLimits) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const resources = [
|
|
||||||
{ key: 'accounts', icon: 'bi-wallet2', label: t('dashboard.accounts', 'Cuentas') },
|
|
||||||
{ key: 'categories', icon: 'bi-tags', label: t('dashboard.categories', 'Categorías') },
|
|
||||||
{ key: 'budgets', icon: 'bi-pie-chart', label: t('dashboard.budgets', 'Presupuestos') },
|
|
||||||
{ key: 'transactions', icon: 'bi-receipt', label: t('dashboard.transactions', 'Transacciones') },
|
|
||||||
];
|
|
||||||
|
|
||||||
const getProgressColor = (percentage) => {
|
|
||||||
if (percentage === null) return 'bg-success';
|
|
||||||
if (percentage >= 100) return 'bg-danger';
|
|
||||||
if (percentage >= 80) return 'bg-warning';
|
|
||||||
return 'bg-primary';
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasWarning = Object.values(usage_percentages).some(p => p !== null && p >= 80);
|
|
||||||
const hasLimit = Object.values(usage_percentages).some(p => p !== null && p >= 100);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`card mb-3 ${hasLimit ? 'border-danger' : hasWarning ? 'border-warning' : ''}`}>
|
|
||||||
<div className="card-header d-flex justify-content-between align-items-center py-2">
|
|
||||||
<span className="fw-semibold">
|
|
||||||
<i className="bi bi-speedometer2 me-2"></i>
|
|
||||||
{t('planUsage.title', 'Uso del Plan')} - {plan.name}
|
|
||||||
</span>
|
|
||||||
{plan.is_free && (
|
|
||||||
<Link to="/pricing" className="btn btn-sm btn-primary">
|
|
||||||
<i className="bi bi-arrow-up-circle me-1"></i>
|
|
||||||
{t('planUsage.upgrade', 'Mejorar')}
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="card-body py-2">
|
|
||||||
{hasLimit && (
|
|
||||||
<div className="alert alert-danger py-2 mb-3">
|
|
||||||
<i className="bi bi-exclamation-triangle-fill me-2"></i>
|
|
||||||
{t('planUsage.limitReached', 'Has alcanzado el límite de tu plan. Actualiza a Pro para continuar.')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="row g-2">
|
|
||||||
{resources.map(({ key, icon, label }) => {
|
|
||||||
const current = usage[key] || 0;
|
|
||||||
const limit = limits[key];
|
|
||||||
const percentage = usage_percentages[key];
|
|
||||||
|
|
||||||
if (limit === null || limit === undefined) {
|
|
||||||
return null; // Don't show unlimited resources
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={key} className="col-6 col-md-3">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="d-flex justify-content-between align-items-center mb-1">
|
|
||||||
<small className="text-muted">
|
|
||||||
<i className={`bi ${icon} me-1`}></i>
|
|
||||||
{label}
|
|
||||||
</small>
|
|
||||||
<small className={percentage >= 100 ? 'text-danger fw-bold' : percentage >= 80 ? 'text-warning' : ''}>
|
|
||||||
{current}/{limit}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
<div className="progress" style={{ height: '6px' }}>
|
|
||||||
<div
|
|
||||||
className={`progress-bar ${getProgressColor(percentage)}`}
|
|
||||||
role="progressbar"
|
|
||||||
style={{ width: `${Math.min(percentage || 0, 100)}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -25,9 +25,10 @@ export const AuthProvider = ({ children }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const register = async (userData) => {
|
const register = async (userData) => {
|
||||||
// Register but don't set user - user needs PayPal payment + email activation first
|
|
||||||
const response = await authService.register(userData);
|
const response = await authService.register(userData);
|
||||||
// Don't set user here - wait for activation
|
if (response.success) {
|
||||||
|
setUser(response.data.user);
|
||||||
|
}
|
||||||
return response;
|
return response;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -44,8 +44,6 @@
|
|||||||
"details": "Details",
|
"details": "Details",
|
||||||
"clearFilters": "Clear Filters",
|
"clearFilters": "Clear Filters",
|
||||||
"select": "Select",
|
"select": "Select",
|
||||||
"selection": "Selection",
|
|
||||||
"selectIcon": "Select Icon",
|
|
||||||
"refresh": "Refresh",
|
"refresh": "Refresh",
|
||||||
"filters": "Filters",
|
"filters": "Filters",
|
||||||
"processing": "Processing...",
|
"processing": "Processing...",
|
||||||
@ -67,10 +65,7 @@
|
|||||||
"incomes": "Income",
|
"incomes": "Income",
|
||||||
"expenses": "Expenses",
|
"expenses": "Expenses",
|
||||||
"balance": "Balance",
|
"balance": "Balance",
|
||||||
"current": "Current",
|
"current": "Current"
|
||||||
"continue": "Continue",
|
|
||||||
"creating": "Creating...",
|
|
||||||
"remove": "Remove"
|
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"login": "Login",
|
"login": "Login",
|
||||||
@ -82,56 +77,7 @@
|
|||||||
"loginSuccess": "Successfully logged in",
|
"loginSuccess": "Successfully logged in",
|
||||||
"loginError": "Login error",
|
"loginError": "Login error",
|
||||||
"logoutSuccess": "Successfully logged out",
|
"logoutSuccess": "Successfully logged out",
|
||||||
"invalidCredentials": "Invalid credentials",
|
"invalidCredentials": "Invalid credentials"
|
||||||
"createAccount": "Create your account",
|
|
||||||
"backToLogin": "Back to login",
|
|
||||||
"goToLogin": "Go to login"
|
|
||||||
},
|
|
||||||
"login": {
|
|
||||||
"noAccount": "Don't have an account?",
|
|
||||||
"createAccount": "Create one here",
|
|
||||||
"noSubscription": "You don't have an active subscription. Please complete the payment."
|
|
||||||
},
|
|
||||||
"errors": {
|
|
||||||
"connection": "Connection error. Please try again.",
|
|
||||||
"resendFailed": "Error resending email",
|
|
||||||
"subscriptionFailed": "Error creating subscription. Please try again."
|
|
||||||
},
|
|
||||||
"register": {
|
|
||||||
"selectPlan": "Select a plan",
|
|
||||||
"repeatPassword": "Repeat your password",
|
|
||||||
"continueToPayment": "Continue to payment",
|
|
||||||
"createAccount": "Create Account",
|
|
||||||
"alreadyHaveAccount": "Already have an account?",
|
|
||||||
"loginHere": "Login here",
|
|
||||||
"paymentCanceled": "Payment was canceled. You can try again."
|
|
||||||
},
|
|
||||||
"activate": {
|
|
||||||
"activating": "Activating your account...",
|
|
||||||
"pleaseWait": "Please wait while we activate your account.",
|
|
||||||
"successTitle": "Account Activated!",
|
|
||||||
"success": "Your account has been successfully activated. You can now use WEBMoney!",
|
|
||||||
"errorTitle": "Activation Error",
|
|
||||||
"error": "Could not activate your account. The link may have expired.",
|
|
||||||
"invalidLink": "Invalid activation link.",
|
|
||||||
"redirecting": "Redirecting in {{seconds}} seconds...",
|
|
||||||
"goToDashboard": "Go to Dashboard",
|
|
||||||
"checkEmail": "Check your email",
|
|
||||||
"checkEmailMessage": "We sent an activation email to {{email}}. Click the link to activate your account.",
|
|
||||||
"didntReceive": "Didn't receive the email?",
|
|
||||||
"resend": "Resend email",
|
|
||||||
"resendSuccess": "Email resent successfully!"
|
|
||||||
},
|
|
||||||
"payment": {
|
|
||||||
"confirming": "Confirming payment...",
|
|
||||||
"pleaseWait": "Please wait while we process your payment.",
|
|
||||||
"successTitle": "Payment Confirmed!",
|
|
||||||
"successMessage": "Your subscription has been successfully confirmed.",
|
|
||||||
"checkYourEmail": "Check your email!",
|
|
||||||
"activationSent": "We sent an activation email to {{email}}. Click the link to activate your account and start using WEBMoney.",
|
|
||||||
"errorTitle": "Payment Error",
|
|
||||||
"error": "Error confirming payment",
|
|
||||||
"noSubscriptionId": "Subscription ID not found"
|
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
@ -323,18 +269,12 @@
|
|||||||
"deleteWarning": "All subcategories will also be deleted.",
|
"deleteWarning": "All subcategories will also be deleted.",
|
||||||
"categoryName": "Category Name",
|
"categoryName": "Category Name",
|
||||||
"parentCategory": "Parent Category",
|
"parentCategory": "Parent Category",
|
||||||
"noParent": "No parent category",
|
"noParent": "No parent category (root)",
|
||||||
"selectParent": "More categories...",
|
|
||||||
"subcategories": "Subcategories",
|
"subcategories": "Subcategories",
|
||||||
"keywords": "Keywords",
|
"keywords": "Keywords",
|
||||||
"addKeyword": "Add Keyword",
|
"addKeyword": "Add Keyword",
|
||||||
"keywordPlaceholder": "Type and press Enter...",
|
"keywordPlaceholder": "Type a keyword",
|
||||||
"keywordHelp": "E.g.: \"RESTAURANT\", \"PIZZA\" - Transactions with these words are auto-categorized",
|
"keywordHelp": "Keywords help automatically categorize transactions",
|
||||||
"noKeywords": "No keywords. Transactions will be categorized manually.",
|
|
||||||
"namePlaceholder": "E.g.: Food, Transport...",
|
|
||||||
"descPlaceholder": "Describe this category...",
|
|
||||||
"visualSettings": "Appearance",
|
|
||||||
"autoCategorizationLabel": "Auto-categorization",
|
|
||||||
"types": {
|
"types": {
|
||||||
"income": "Income",
|
"income": "Income",
|
||||||
"expense": "Expense",
|
"expense": "Expense",
|
||||||
@ -378,23 +318,20 @@
|
|||||||
"expand": "Expand",
|
"expand": "Expand",
|
||||||
"collapse": "Collapse",
|
"collapse": "Collapse",
|
||||||
"createSubcategory": "Create Subcategory",
|
"createSubcategory": "Create Subcategory",
|
||||||
"batchCategorize": "Auto Categorize",
|
"batchCategorize": "Batch Categorize",
|
||||||
"batchDescription": "Automatically categorize transactions using keywords",
|
|
||||||
"analyzingTransactions": "Analyzing transactions...",
|
|
||||||
"uncategorized": "Uncategorized",
|
"uncategorized": "Uncategorized",
|
||||||
"willCategorize": "Will Be Categorized",
|
"willCategorize": "Will Be Categorized",
|
||||||
"willSkip": "No Match",
|
"willSkip": "Will Be Skipped",
|
||||||
"totalKeywords": "Keywords",
|
"totalKeywords": "Active Keywords",
|
||||||
"previewTitle": "Categorization Preview",
|
"previewTitle": "Categorization Preview",
|
||||||
"matchedKeyword": "Keyword",
|
"matchedKeyword": "Matched Keyword",
|
||||||
"executeBatch": "Categorize",
|
"executeBatch": "Execute Categorization",
|
||||||
"batchSuccess": "transactions categorized successfully",
|
"batchSuccess": "transactions categorized successfully",
|
||||||
"batchError": "Error categorizing transactions",
|
"batchError": "Error categorizing transactions",
|
||||||
"nothingToCategorize": "No transactions to categorize",
|
"nothingToCategorize": "No transactions to categorize",
|
||||||
"batchPreviewError": "Error loading preview",
|
"batchPreviewError": "Error loading preview",
|
||||||
"previewError": "Error loading preview",
|
"previewError": "Error loading preview",
|
||||||
"noMatchesFound": "Add keywords to categories to enable auto-categorization",
|
"noMatchesFound": "No transactions match the configured keywords",
|
||||||
"noMatchesFoundTitle": "No matches found",
|
|
||||||
"categorized": "categorized",
|
"categorized": "categorized",
|
||||||
"category": "Category"
|
"category": "Category"
|
||||||
},
|
},
|
||||||
@ -412,13 +349,8 @@
|
|||||||
"budget": "Budget",
|
"budget": "Budget",
|
||||||
"keywords": "Keywords",
|
"keywords": "Keywords",
|
||||||
"addKeyword": "Add Keyword",
|
"addKeyword": "Add Keyword",
|
||||||
"keywordPlaceholder": "Type and press Enter...",
|
"keywordPlaceholder": "Type a keyword",
|
||||||
"keywordHelp": "E.g.: \"UBER\", \"iFood\" - Transactions with these words are auto-assigned",
|
"keywordHelp": "Keywords help automatically assign transactions",
|
||||||
"noKeywords": "No keywords. Transactions will be assigned manually.",
|
|
||||||
"namePlaceholder": "E.g.: Project Alpha, Marketing Dept...",
|
|
||||||
"descPlaceholder": "Describe the purpose of this cost center...",
|
|
||||||
"visualSettings": "Appearance",
|
|
||||||
"autoAssignLabel": "Auto-assign",
|
|
||||||
"createSuccess": "Cost center created successfully",
|
"createSuccess": "Cost center created successfully",
|
||||||
"updateSuccess": "Cost center updated successfully",
|
"updateSuccess": "Cost center updated successfully",
|
||||||
"deleteSuccess": "Cost center deleted successfully",
|
"deleteSuccess": "Cost center deleted successfully",
|
||||||
@ -577,8 +509,6 @@
|
|||||||
"status": {
|
"status": {
|
||||||
"label": "Status",
|
"label": "Status",
|
||||||
"pending": "Pending",
|
"pending": "Pending",
|
||||||
"effective": "Effective",
|
|
||||||
"scheduled": "Scheduled",
|
|
||||||
"completed": "Completed",
|
"completed": "Completed",
|
||||||
"cancelled": "Cancelled"
|
"cancelled": "Cancelled"
|
||||||
},
|
},
|
||||||
@ -1774,7 +1704,6 @@
|
|||||||
"subcategory": "Subcategory",
|
"subcategory": "Subcategory",
|
||||||
"allCategory": "All category",
|
"allCategory": "All category",
|
||||||
"selectCategory": "Select a category",
|
"selectCategory": "Select a category",
|
||||||
"general": "General",
|
|
||||||
"amount": "Amount",
|
"amount": "Amount",
|
||||||
"month": "Month",
|
"month": "Month",
|
||||||
"budgeted": "Budgeted",
|
"budgeted": "Budgeted",
|
||||||
@ -1798,57 +1727,6 @@
|
|||||||
"yearly": "Yearly",
|
"yearly": "Yearly",
|
||||||
"isCumulative": "Cumulative Budget",
|
"isCumulative": "Cumulative Budget",
|
||||||
"isCumulativeHelp": "Accumulates expenses from the beginning of the year to the current period",
|
"isCumulativeHelp": "Accumulates expenses from the beginning of the year to the current period",
|
||||||
"total": "Total",
|
|
||||||
"wizard": {
|
|
||||||
"title": "Budget Wizard",
|
|
||||||
"button": "Wizard",
|
|
||||||
"step1": "Mode",
|
|
||||||
"step2": "Categories",
|
|
||||||
"step3": "Values",
|
|
||||||
"step4": "Confirm",
|
|
||||||
"quickStart": "Quick Start with Templates",
|
|
||||||
"manual": "Create Manually",
|
|
||||||
"manualDesc": "Choose categories and amounts",
|
|
||||||
"copy": "Copy from Another Month",
|
|
||||||
"copyDesc": "Reuse existing budgets",
|
|
||||||
"loadBudgets": "Load Budgets",
|
|
||||||
"noSourceBudgets": "No budgets in this period to copy",
|
|
||||||
"selectCategories": "Select Categories",
|
|
||||||
"categoriesSelected": "categories selected",
|
|
||||||
"setAmounts": "Set Amounts",
|
|
||||||
"history": "History",
|
|
||||||
"useHistory": "Use historical average",
|
|
||||||
"confirm": "Confirm Budgets",
|
|
||||||
"budgets": "Budgets",
|
|
||||||
"periods": "Periods",
|
|
||||||
"periodHelp": "Defines the automatic creation frequency for budgets",
|
|
||||||
"createBudgets": "Create Budgets",
|
|
||||||
"createBudget": "Create Budget",
|
|
||||||
"successCount": "{{count}} budget(s) created successfully",
|
|
||||||
"errorCount": "{{count}} budget(s) could not be created (already exist)",
|
|
||||||
"fillRequired": "Please fill in the required fields",
|
|
||||||
"updated": "Budget updated successfully",
|
|
||||||
"created": "Budget created successfully",
|
|
||||||
"selectAtLeast": "Select at least one category",
|
|
||||||
"templates": {
|
|
||||||
"basic": {
|
|
||||||
"name": "Basic Budget",
|
|
||||||
"desc": "Essential for monthly control"
|
|
||||||
},
|
|
||||||
"family": {
|
|
||||||
"name": "Family Budget",
|
|
||||||
"desc": "Complete for families"
|
|
||||||
},
|
|
||||||
"individual": {
|
|
||||||
"name": "Individual Budget",
|
|
||||||
"desc": "For single person"
|
|
||||||
},
|
|
||||||
"complete": {
|
|
||||||
"name": "Complete Budget",
|
|
||||||
"desc": "All categories"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"alert": {
|
"alert": {
|
||||||
"exceeded": "Budget exceeded!",
|
"exceeded": "Budget exceeded!",
|
||||||
"warning": "Warning: near limit",
|
"warning": "Warning: near limit",
|
||||||
@ -2141,7 +2019,6 @@
|
|||||||
"lastNamePlaceholder": "Your last name",
|
"lastNamePlaceholder": "Your last name",
|
||||||
"lastNameRequired": "Last name is required",
|
"lastNameRequired": "Last name is required",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"namePlaceholder": "Your name",
|
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
"phone": "Phone",
|
"phone": "Phone",
|
||||||
"phoneRequired": "Phone number is required",
|
"phoneRequired": "Phone number is required",
|
||||||
@ -2176,7 +2053,6 @@
|
|||||||
"billedAnnually": "Billed annually €{{price}}",
|
"billedAnnually": "Billed annually €{{price}}",
|
||||||
"save": "Save {{percent}}%",
|
"save": "Save {{percent}}%",
|
||||||
"trialDays": "{{days}}-day free trial",
|
"trialDays": "{{days}}-day free trial",
|
||||||
"trial": "trial",
|
|
||||||
"mostPopular": "Most Popular",
|
"mostPopular": "Most Popular",
|
||||||
"currentPlan": "Current Plan",
|
"currentPlan": "Current Plan",
|
||||||
"startFree": "Start Free",
|
"startFree": "Start Free",
|
||||||
@ -2189,16 +2065,6 @@
|
|||||||
"securePayment": "Secure payment",
|
"securePayment": "Secure payment",
|
||||||
"cancelAnytime": "Cancel anytime",
|
"cancelAnytime": "Cancel anytime",
|
||||||
"paypalSecure": "Secure payment with PayPal",
|
"paypalSecure": "Secure payment with PayPal",
|
||||||
"comingSoon": "Coming Soon",
|
|
||||||
"forPymes": "Tools for SMEs",
|
|
||||||
"features": {
|
|
||||||
"multiUsers": "Multiple users",
|
|
||||||
"integratedBilling": "Integrated billing",
|
|
||||||
"advancedReports": "Advanced reports",
|
|
||||||
"apiAccess": "API access",
|
|
||||||
"prioritySupport": "Priority support",
|
|
||||||
"dedicatedManager": "Dedicated account manager"
|
|
||||||
},
|
|
||||||
"faq": {
|
"faq": {
|
||||||
"title": "Frequently Asked Questions",
|
"title": "Frequently Asked Questions",
|
||||||
"q1": "Can I change plans at any time?",
|
"q1": "Can I change plans at any time?",
|
||||||
@ -2236,8 +2102,7 @@
|
|||||||
"trialing": "Trial",
|
"trialing": "Trial",
|
||||||
"canceled": "Canceled",
|
"canceled": "Canceled",
|
||||||
"expired": "Expired",
|
"expired": "Expired",
|
||||||
"past_due": "Past Due",
|
"past_due": "Past Due"
|
||||||
"pending": "Pending"
|
|
||||||
},
|
},
|
||||||
"invoiceStatus": {
|
"invoiceStatus": {
|
||||||
"paid": "Paid",
|
"paid": "Paid",
|
||||||
@ -2251,120 +2116,12 @@
|
|||||||
"subscriptionConfirmed": "Subscription confirmed successfully!",
|
"subscriptionConfirmed": "Subscription confirmed successfully!",
|
||||||
"confirmError": "Error confirming subscription",
|
"confirmError": "Error confirming subscription",
|
||||||
"subscriptionCanceled": "Subscription canceled",
|
"subscriptionCanceled": "Subscription canceled",
|
||||||
"subscriptionCanceledRefunded": "Subscription canceled and refund processed",
|
|
||||||
"cancelError": "Error canceling subscription",
|
"cancelError": "Error canceling subscription",
|
||||||
"cancelConfirmTitle": "Cancel subscription?",
|
"cancelConfirmTitle": "Cancel subscription?",
|
||||||
"cancelConfirmMessage": "Are you sure you want to cancel your subscription?",
|
"cancelConfirmMessage": "Are you sure you want to cancel your subscription?",
|
||||||
"cancelNote1": "You'll keep access until the end of the current period",
|
"cancelNote1": "You'll keep access until the end of the current period",
|
||||||
"cancelNote2": "Your data will not be deleted",
|
"cancelNote2": "Your data will not be deleted",
|
||||||
"cancelNote3": "You can reactivate your subscription at any time",
|
"cancelNote3": "You can reactivate your subscription at any time",
|
||||||
"confirmCancel": "Yes, Cancel",
|
"confirmCancel": "Yes, Cancel"
|
||||||
"guaranteePeriod": "Guarantee Period",
|
|
||||||
"guaranteeMessage": "You have {{days}} day(s) remaining in the 7-day guarantee period. You can cancel and receive a full refund.",
|
|
||||||
"guaranteeBadge": "Guarantee: {{days}} days",
|
|
||||||
"requestRefund": "Request full refund",
|
|
||||||
"refundNote": "The refund will be processed by PayPal within 5-10 business days.",
|
|
||||||
"cancelAndRefund": "Cancel and Refund"
|
|
||||||
},
|
|
||||||
"landing": {
|
|
||||||
"nav": {
|
|
||||||
"features": "Features",
|
|
||||||
"pricing": "Pricing",
|
|
||||||
"faq": "FAQ",
|
|
||||||
"login": "Login",
|
|
||||||
"register": "Start Now"
|
|
||||||
},
|
|
||||||
"hero": {
|
|
||||||
"title": "Take Control of Your Finances",
|
|
||||||
"subtitle": "Intelligent financial management for individuals and businesses. Track income, expenses, and achieve your financial goals.",
|
|
||||||
"cta": "Start Now",
|
|
||||||
"learnMore": "Learn More",
|
|
||||||
"secure": "100% Secure"
|
|
||||||
},
|
|
||||||
"features": {
|
|
||||||
"title": "Everything You Need",
|
|
||||||
"subtitle": "Powerful tools to manage your money",
|
|
||||||
"accounts": {
|
|
||||||
"title": "Multiple Accounts",
|
|
||||||
"description": "Manage bank accounts, cards, and cash in one place"
|
|
||||||
},
|
|
||||||
"analytics": {
|
|
||||||
"title": "Detailed Reports",
|
|
||||||
"description": "Charts and analysis to understand your spending"
|
|
||||||
},
|
|
||||||
"categories": {
|
|
||||||
"title": "Smart Categories",
|
|
||||||
"description": "Automatic categorization with keywords and subcategories"
|
|
||||||
},
|
|
||||||
"import": {
|
|
||||||
"title": "Bank Import",
|
|
||||||
"description": "Import statements from Excel, CSV, OFX, and PDF"
|
|
||||||
},
|
|
||||||
"recurring": {
|
|
||||||
"title": "Recurring Transactions",
|
|
||||||
"description": "Automate bills, subscriptions and recurring income"
|
|
||||||
},
|
|
||||||
"security": {
|
|
||||||
"title": "Total Security",
|
|
||||||
"description": "Bank-level encryption to protect your data"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"pricing": {
|
|
||||||
"title": "Simple Plans, Fair Prices",
|
|
||||||
"subtitle": "Choose the plan that fits your needs",
|
|
||||||
"monthly": "Monthly",
|
|
||||||
"annual": "Annual",
|
|
||||||
"popular": "Most Popular",
|
|
||||||
"month": "month",
|
|
||||||
"year": "year",
|
|
||||||
"free": "Free",
|
|
||||||
"startFree": "Start Free",
|
|
||||||
"subscribe": "Subscribe Now",
|
|
||||||
"billedAnnually": "Billed annually €{{price}}",
|
|
||||||
"comingSoon": "Coming Soon",
|
|
||||||
"forPymes": "Tools for SMEs",
|
|
||||||
"features": {
|
|
||||||
"oneAccount": "1 bank account",
|
|
||||||
"tenCategories": "10 categories",
|
|
||||||
"hundredSubcategories": "100 subcategories",
|
|
||||||
"thousandTransactions": "1,000 transactions",
|
|
||||||
"unlimitedAccounts": "Unlimited accounts",
|
|
||||||
"unlimitedCategories": "Unlimited categories",
|
|
||||||
"unlimitedTransactions": "Unlimited transactions",
|
|
||||||
"multiUsers": "Multiple users",
|
|
||||||
"integratedBilling": "Integrated billing",
|
|
||||||
"advancedReports": "Advanced reports",
|
|
||||||
"cashFlow": "Cash flow management",
|
|
||||||
"budgetControl": "Budget control by project",
|
|
||||||
"businessModule": "Business module",
|
|
||||||
"prioritySupport": "Priority support"
|
|
||||||
},
|
|
||||||
"goldTeaser": {
|
|
||||||
"title": "GOLD Plan Coming Soon",
|
|
||||||
"description": "Direct online synchronization with your bank. Connect your accounts and see your transactions automatically updated in real-time."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"faq": {
|
|
||||||
"title": "Frequently Asked Questions",
|
|
||||||
"q1": "Is my data secure?",
|
|
||||||
"a1": "Yes! We use bank-level encryption (SSL/TLS) and your data is stored on secure servers with regular backups. We never share your information with third parties.",
|
|
||||||
"q2": "Can I cancel anytime?",
|
|
||||||
"a2": "Yes, you can cancel your subscription at any time without fees. You'll keep access until the end of the period you already paid.",
|
|
||||||
"q3": "Which banks are compatible?",
|
|
||||||
"a3": "You can import statements from any bank that exports to Excel, CSV, OFX, or PDF. We have predefined mappings for major banks.",
|
|
||||||
"q4": "How does the 7-day guarantee work?",
|
|
||||||
"a4": "You pay via PayPal and get immediate full access to all features. If you're not satisfied, cancel within 7 days and receive a full refund, no questions asked."
|
|
||||||
},
|
|
||||||
"cta": {
|
|
||||||
"title": "Ready to Transform Your Finances?",
|
|
||||||
"subtitle": "Join thousands of users who have already taken control of their money.",
|
|
||||||
"button": "Create Free Account"
|
|
||||||
},
|
|
||||||
"footer": {
|
|
||||||
"rights": "All rights reserved.",
|
|
||||||
"privacy": "Privacy Policy",
|
|
||||||
"terms": "Terms of Use",
|
|
||||||
"contact": "Contact"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -45,8 +45,6 @@
|
|||||||
"details": "Detalles",
|
"details": "Detalles",
|
||||||
"clearFilters": "Limpiar Filtros",
|
"clearFilters": "Limpiar Filtros",
|
||||||
"select": "Seleccionar",
|
"select": "Seleccionar",
|
||||||
"selection": "Selección",
|
|
||||||
"selectIcon": "Seleccionar Icono",
|
|
||||||
"refresh": "Actualizar",
|
"refresh": "Actualizar",
|
||||||
"filters": "Filtros",
|
"filters": "Filtros",
|
||||||
"processing": "Procesando...",
|
"processing": "Procesando...",
|
||||||
@ -68,10 +66,7 @@
|
|||||||
"incomes": "Ingresos",
|
"incomes": "Ingresos",
|
||||||
"expenses": "Gastos",
|
"expenses": "Gastos",
|
||||||
"balance": "Balance",
|
"balance": "Balance",
|
||||||
"current": "Actual",
|
"current": "Actual"
|
||||||
"continue": "Continuar",
|
|
||||||
"creating": "Creando...",
|
|
||||||
"remove": "Eliminar"
|
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"login": "Iniciar Sesión",
|
"login": "Iniciar Sesión",
|
||||||
@ -83,56 +78,7 @@
|
|||||||
"loginSuccess": "Sesión iniciada correctamente",
|
"loginSuccess": "Sesión iniciada correctamente",
|
||||||
"loginError": "Error al iniciar sesión",
|
"loginError": "Error al iniciar sesión",
|
||||||
"logoutSuccess": "Sesión cerrada correctamente",
|
"logoutSuccess": "Sesión cerrada correctamente",
|
||||||
"invalidCredentials": "Credenciales inválidas",
|
"invalidCredentials": "Credenciales inválidas"
|
||||||
"createAccount": "Crea tu cuenta",
|
|
||||||
"backToLogin": "Volver al login",
|
|
||||||
"goToLogin": "Ir al login"
|
|
||||||
},
|
|
||||||
"login": {
|
|
||||||
"noAccount": "¿No tienes cuenta?",
|
|
||||||
"createAccount": "Crea una aquí",
|
|
||||||
"noSubscription": "No tienes una suscripción activa. Por favor, completa el pago."
|
|
||||||
},
|
|
||||||
"errors": {
|
|
||||||
"connection": "Error de conexión. Intenta de nuevo.",
|
|
||||||
"resendFailed": "Error al reenviar email",
|
|
||||||
"subscriptionFailed": "Error al crear suscripción. Intenta de nuevo."
|
|
||||||
},
|
|
||||||
"register": {
|
|
||||||
"selectPlan": "Selecciona un plan",
|
|
||||||
"repeatPassword": "Repite tu contraseña",
|
|
||||||
"continueToPayment": "Continuar al pago",
|
|
||||||
"createAccount": "Crear Cuenta",
|
|
||||||
"alreadyHaveAccount": "¿Ya tienes cuenta?",
|
|
||||||
"loginHere": "Inicia sesión aquí",
|
|
||||||
"paymentCanceled": "El pago fue cancelado. Puedes intentarlo de nuevo."
|
|
||||||
},
|
|
||||||
"activate": {
|
|
||||||
"activating": "Activando tu cuenta...",
|
|
||||||
"pleaseWait": "Por favor, espera mientras activamos tu cuenta.",
|
|
||||||
"successTitle": "¡Cuenta Activada!",
|
|
||||||
"success": "Tu cuenta ha sido activada exitosamente. ¡Ya puedes usar WEBMoney!",
|
|
||||||
"errorTitle": "Error de Activación",
|
|
||||||
"error": "No se pudo activar tu cuenta. El enlace puede haber expirado.",
|
|
||||||
"invalidLink": "Enlace de activación inválido.",
|
|
||||||
"redirecting": "Redirigiendo en {{seconds}} segundos...",
|
|
||||||
"goToDashboard": "Ir al Panel",
|
|
||||||
"checkEmail": "Revisa tu email",
|
|
||||||
"checkEmailMessage": "Hemos enviado un email de activación a {{email}}. Haz clic en el enlace para activar tu cuenta.",
|
|
||||||
"didntReceive": "¿No recibiste el email?",
|
|
||||||
"resend": "Reenviar email",
|
|
||||||
"resendSuccess": "¡Email reenviado exitosamente!"
|
|
||||||
},
|
|
||||||
"payment": {
|
|
||||||
"confirming": "Confirmando pago...",
|
|
||||||
"pleaseWait": "Por favor, espera mientras procesamos tu pago.",
|
|
||||||
"successTitle": "¡Pago Confirmado!",
|
|
||||||
"successMessage": "Tu suscripción ha sido confirmada exitosamente.",
|
|
||||||
"checkYourEmail": "¡Revisa tu email!",
|
|
||||||
"activationSent": "Hemos enviado un email de activación a {{email}}. Haz clic en el enlace para activar tu cuenta y comenzar a usar WEBMoney.",
|
|
||||||
"errorTitle": "Error de Pago",
|
|
||||||
"error": "Error al confirmar el pago",
|
|
||||||
"noSubscriptionId": "ID de suscripción no encontrado"
|
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Panel",
|
"dashboard": "Panel",
|
||||||
@ -140,7 +86,6 @@
|
|||||||
"liabilities": "Pasivos",
|
"liabilities": "Pasivos",
|
||||||
"transactions": "Transacciones",
|
"transactions": "Transacciones",
|
||||||
"movements": "Movimientos",
|
"movements": "Movimientos",
|
||||||
"registrations": "Registros",
|
|
||||||
"import": "Importar",
|
"import": "Importar",
|
||||||
"duplicates": "Duplicados",
|
"duplicates": "Duplicados",
|
||||||
"transfers": "Transferencias",
|
"transfers": "Transferencias",
|
||||||
@ -158,8 +103,7 @@
|
|||||||
"goals": "Metas",
|
"goals": "Metas",
|
||||||
"budgets": "Presupuestos",
|
"budgets": "Presupuestos",
|
||||||
"billing": "Facturación",
|
"billing": "Facturación",
|
||||||
"pricing": "Planes",
|
"pricing": "Planes"
|
||||||
"users": "Usuarios"
|
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Panel de Control",
|
"title": "Panel de Control",
|
||||||
@ -326,18 +270,12 @@
|
|||||||
"deleteWarning": "Se eliminarán también todas las subcategorías.",
|
"deleteWarning": "Se eliminarán también todas las subcategorías.",
|
||||||
"categoryName": "Nombre de la Categoría",
|
"categoryName": "Nombre de la Categoría",
|
||||||
"parentCategory": "Categoría Padre",
|
"parentCategory": "Categoría Padre",
|
||||||
"noParent": "Sin categoría padre",
|
"noParent": "Sin categoría padre (raíz)",
|
||||||
"selectParent": "Más categorías...",
|
|
||||||
"subcategories": "Subcategorías",
|
"subcategories": "Subcategorías",
|
||||||
"keywords": "Palabras Clave",
|
"keywords": "Palabras Clave",
|
||||||
"addKeyword": "Agregar Palabra Clave",
|
"addKeyword": "Agregar Palabra Clave",
|
||||||
"keywordPlaceholder": "Escribe y presiona Enter...",
|
"keywordPlaceholder": "Escribe una palabra clave",
|
||||||
"keywordHelp": "Ej: \"RESTAURANTE\", \"PIZZA\" - Transacciones con estas palabras se categorizan automáticamente",
|
"keywordHelp": "Las palabras clave ayudan a categorizar transacciones automáticamente",
|
||||||
"noKeywords": "Sin palabras clave. Las transacciones se categorizarán manualmente.",
|
|
||||||
"namePlaceholder": "Ej: Alimentación, Transporte...",
|
|
||||||
"descPlaceholder": "Describe esta categoría...",
|
|
||||||
"visualSettings": "Apariencia",
|
|
||||||
"autoCategorizationLabel": "Auto-categorización",
|
|
||||||
"types": {
|
"types": {
|
||||||
"income": "Ingreso",
|
"income": "Ingreso",
|
||||||
"expense": "Gasto",
|
"expense": "Gasto",
|
||||||
@ -381,23 +319,20 @@
|
|||||||
"expand": "Expandir",
|
"expand": "Expandir",
|
||||||
"collapse": "Contraer",
|
"collapse": "Contraer",
|
||||||
"createSubcategory": "Crear Subcategoría",
|
"createSubcategory": "Crear Subcategoría",
|
||||||
"batchCategorize": "Categorización Automática",
|
"batchCategorize": "Categorizar en Lote",
|
||||||
"batchDescription": "Categoriza transacciones automáticamente usando palabras clave",
|
|
||||||
"analyzingTransactions": "Analizando transacciones...",
|
|
||||||
"uncategorized": "Sin Categoría",
|
"uncategorized": "Sin Categoría",
|
||||||
"willCategorize": "Serán Categorizadas",
|
"willCategorize": "Serán Categorizadas",
|
||||||
"willSkip": "Sin Correspondencia",
|
"willSkip": "Serán Ignoradas",
|
||||||
"totalKeywords": "Palabras Clave",
|
"totalKeywords": "Keywords Activas",
|
||||||
"previewTitle": "Vista Previa de Categorización",
|
"previewTitle": "Vista Previa de Categorización",
|
||||||
"matchedKeyword": "Keyword",
|
"matchedKeyword": "Keyword Encontrada",
|
||||||
"executeBatch": "Categorizar",
|
"executeBatch": "Ejecutar Categorización",
|
||||||
"batchSuccess": "transacciones categorizadas con éxito",
|
"batchSuccess": "transacciones categorizadas con éxito",
|
||||||
"batchError": "Error al categorizar transacciones",
|
"batchError": "Error al categorizar transacciones",
|
||||||
"nothingToCategorize": "No hay transacciones para categorizar",
|
"nothingToCategorize": "No hay transacciones para categorizar",
|
||||||
"batchPreviewError": "Error al cargar preview",
|
"batchPreviewError": "Error al cargar preview",
|
||||||
"previewError": "Error al cargar preview",
|
"previewError": "Error al cargar preview",
|
||||||
"noMatchesFound": "Añade palabras clave a las categorías para permitir categorización automática",
|
"noMatchesFound": "Ninguna transacción corresponde a las palabras clave configuradas",
|
||||||
"noMatchesFoundTitle": "Ninguna correspondencia encontrada",
|
|
||||||
"categorized": "categorizadas",
|
"categorized": "categorizadas",
|
||||||
"category": "Categoría"
|
"category": "Categoría"
|
||||||
},
|
},
|
||||||
@ -415,13 +350,8 @@
|
|||||||
"budget": "Presupuesto",
|
"budget": "Presupuesto",
|
||||||
"keywords": "Palabras Clave",
|
"keywords": "Palabras Clave",
|
||||||
"addKeyword": "Agregar Palabra Clave",
|
"addKeyword": "Agregar Palabra Clave",
|
||||||
"keywordPlaceholder": "Escribe y presiona Enter...",
|
"keywordPlaceholder": "Escribe una palabra clave",
|
||||||
"keywordHelp": "Ej: \"UBER\", \"iFood\" - Transacciones con estas palabras se asignan automáticamente",
|
"keywordHelp": "Las palabras clave ayudan a asignar transacciones automáticamente",
|
||||||
"noKeywords": "Sin palabras clave. Las transacciones se asignarán manualmente.",
|
|
||||||
"namePlaceholder": "Ej: Proyecto Alpha, Dpto. Marketing...",
|
|
||||||
"descPlaceholder": "Describe el propósito de este centro de costo...",
|
|
||||||
"visualSettings": "Apariencia",
|
|
||||||
"autoAssignLabel": "Auto-asignación",
|
|
||||||
"createSuccess": "Centro de costo creado correctamente",
|
"createSuccess": "Centro de costo creado correctamente",
|
||||||
"updateSuccess": "Centro de costo actualizado correctamente",
|
"updateSuccess": "Centro de costo actualizado correctamente",
|
||||||
"deleteSuccess": "Centro de costo eliminado correctamente",
|
"deleteSuccess": "Centro de costo eliminado correctamente",
|
||||||
@ -435,8 +365,6 @@
|
|||||||
"liabilities": {
|
"liabilities": {
|
||||||
"title": "Cuentas Pasivo",
|
"title": "Cuentas Pasivo",
|
||||||
"subtitle": "Gestión de préstamos y financiamientos",
|
"subtitle": "Gestión de préstamos y financiamientos",
|
||||||
"manage": "Gestionar Pasivos",
|
|
||||||
"noLiabilities": "No hay pasivos registrados",
|
|
||||||
"importContract": "Importar Contrato",
|
"importContract": "Importar Contrato",
|
||||||
"import": "Importar",
|
"import": "Importar",
|
||||||
"importInfo": "Selecciona un archivo Excel (.xlsx) con la tabla de cuotas. El archivo debe contener columnas para: Número de Cuota, Fecha de Vencimiento, Valor de Cuota, Intereses, Capital y Estado.",
|
"importInfo": "Selecciona un archivo Excel (.xlsx) con la tabla de cuotas. El archivo debe contener columnas para: Número de Cuota, Fecha de Vencimiento, Valor de Cuota, Intereses, Capital y Estado.",
|
||||||
@ -585,8 +513,6 @@
|
|||||||
"status": {
|
"status": {
|
||||||
"label": "Estado",
|
"label": "Estado",
|
||||||
"pending": "Pendiente",
|
"pending": "Pendiente",
|
||||||
"effective": "Efectiva",
|
|
||||||
"scheduled": "Programada",
|
|
||||||
"completed": "Completada",
|
"completed": "Completada",
|
||||||
"cancelled": "Cancelada"
|
"cancelled": "Cancelada"
|
||||||
},
|
},
|
||||||
@ -1830,7 +1756,6 @@
|
|||||||
"subcategory": "Subcategoría",
|
"subcategory": "Subcategoría",
|
||||||
"allCategory": "Toda la categoría",
|
"allCategory": "Toda la categoría",
|
||||||
"selectCategory": "Seleccionar categoría",
|
"selectCategory": "Seleccionar categoría",
|
||||||
"general": "General",
|
|
||||||
"amount": "Monto",
|
"amount": "Monto",
|
||||||
"spent": "Gastado",
|
"spent": "Gastado",
|
||||||
"budgeted": "Presupuestado",
|
"budgeted": "Presupuestado",
|
||||||
@ -1855,57 +1780,6 @@
|
|||||||
"yearly": "Anual",
|
"yearly": "Anual",
|
||||||
"isCumulative": "Presupuesto Acumulativo",
|
"isCumulative": "Presupuesto Acumulativo",
|
||||||
"isCumulativeHelp": "Acumula gastos desde inicio de año hasta el período actual",
|
"isCumulativeHelp": "Acumula gastos desde inicio de año hasta el período actual",
|
||||||
"total": "Total",
|
|
||||||
"wizard": {
|
|
||||||
"title": "Asistente de Presupuestos",
|
|
||||||
"button": "Asistente",
|
|
||||||
"step1": "Modo",
|
|
||||||
"step2": "Categorías",
|
|
||||||
"step3": "Valores",
|
|
||||||
"step4": "Confirmar",
|
|
||||||
"quickStart": "Inicio Rápido con Plantillas",
|
|
||||||
"manual": "Crear Manualmente",
|
|
||||||
"manualDesc": "Elige categorías y valores",
|
|
||||||
"copy": "Copiar de Otro Mes",
|
|
||||||
"copyDesc": "Reutiliza presupuestos existentes",
|
|
||||||
"loadBudgets": "Cargar Presupuestos",
|
|
||||||
"noSourceBudgets": "No hay presupuestos en este período para copiar",
|
|
||||||
"selectCategories": "Selecciona las Categorías",
|
|
||||||
"categoriesSelected": "categorías seleccionadas",
|
|
||||||
"setAmounts": "Define los Valores",
|
|
||||||
"history": "Hist.",
|
|
||||||
"useHistory": "Usar promedio histórico",
|
|
||||||
"confirm": "Confirma los Presupuestos",
|
|
||||||
"budgets": "Presupuestos",
|
|
||||||
"periods": "Períodos",
|
|
||||||
"periodHelp": "Define la frecuencia de creación automática de presupuestos",
|
|
||||||
"createBudgets": "Crear Presupuestos",
|
|
||||||
"createBudget": "Crear Presupuesto",
|
|
||||||
"successCount": "{{count}} presupuesto(s) creado(s) con éxito",
|
|
||||||
"errorCount": "{{count}} presupuesto(s) no pudieron ser creados (ya existen)",
|
|
||||||
"fillRequired": "Complete los campos obligatorios",
|
|
||||||
"updated": "Presupuesto actualizado con éxito",
|
|
||||||
"created": "Presupuesto creado con éxito",
|
|
||||||
"selectAtLeast": "Seleccione al menos una categoría",
|
|
||||||
"templates": {
|
|
||||||
"basic": {
|
|
||||||
"name": "Presupuesto Básico",
|
|
||||||
"desc": "Esencial para control mensual"
|
|
||||||
},
|
|
||||||
"family": {
|
|
||||||
"name": "Presupuesto Familiar",
|
|
||||||
"desc": "Completo para familias"
|
|
||||||
},
|
|
||||||
"individual": {
|
|
||||||
"name": "Presupuesto Individual",
|
|
||||||
"desc": "Para persona soltera"
|
|
||||||
},
|
|
||||||
"complete": {
|
|
||||||
"name": "Presupuesto Completo",
|
|
||||||
"desc": "Todas las categorías"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"summary": {
|
"summary": {
|
||||||
"totalBudget": "Presupuesto Total",
|
"totalBudget": "Presupuesto Total",
|
||||||
"totalSpent": "Gastado",
|
"totalSpent": "Gastado",
|
||||||
@ -2133,7 +2007,6 @@
|
|||||||
"lastNamePlaceholder": "Tus apellidos",
|
"lastNamePlaceholder": "Tus apellidos",
|
||||||
"lastNameRequired": "Los apellidos son obligatorios",
|
"lastNameRequired": "Los apellidos son obligatorios",
|
||||||
"name": "Nombre",
|
"name": "Nombre",
|
||||||
"namePlaceholder": "Tu nombre",
|
|
||||||
"email": "Correo electrónico",
|
"email": "Correo electrónico",
|
||||||
"phone": "Teléfono",
|
"phone": "Teléfono",
|
||||||
"phoneRequired": "El teléfono es obligatorio",
|
"phoneRequired": "El teléfono es obligatorio",
|
||||||
@ -2168,7 +2041,6 @@
|
|||||||
"billedAnnually": "Facturado anualmente €{{price}}",
|
"billedAnnually": "Facturado anualmente €{{price}}",
|
||||||
"save": "Ahorra {{percent}}%",
|
"save": "Ahorra {{percent}}%",
|
||||||
"trialDays": "{{days}} días de prueba gratis",
|
"trialDays": "{{days}} días de prueba gratis",
|
||||||
"trial": "de prueba",
|
|
||||||
"mostPopular": "Más Popular",
|
"mostPopular": "Más Popular",
|
||||||
"currentPlan": "Plan Actual",
|
"currentPlan": "Plan Actual",
|
||||||
"startFree": "Comenzar Gratis",
|
"startFree": "Comenzar Gratis",
|
||||||
@ -2181,16 +2053,6 @@
|
|||||||
"securePayment": "Pago seguro",
|
"securePayment": "Pago seguro",
|
||||||
"cancelAnytime": "Cancela cuando quieras",
|
"cancelAnytime": "Cancela cuando quieras",
|
||||||
"paypalSecure": "Pago seguro con PayPal",
|
"paypalSecure": "Pago seguro con PayPal",
|
||||||
"comingSoon": "Próximamente",
|
|
||||||
"forPymes": "Herramientas para PyMEs",
|
|
||||||
"features": {
|
|
||||||
"multiUsers": "Múltiples usuarios",
|
|
||||||
"integratedBilling": "Facturación integrada",
|
|
||||||
"advancedReports": "Reportes avanzados",
|
|
||||||
"apiAccess": "Acceso a API",
|
|
||||||
"prioritySupport": "Soporte prioritario",
|
|
||||||
"dedicatedManager": "Gestor de cuenta dedicado"
|
|
||||||
},
|
|
||||||
"faq": {
|
"faq": {
|
||||||
"title": "Preguntas Frecuentes",
|
"title": "Preguntas Frecuentes",
|
||||||
"q1": "¿Puedo cambiar de plan en cualquier momento?",
|
"q1": "¿Puedo cambiar de plan en cualquier momento?",
|
||||||
@ -2201,16 +2063,6 @@
|
|||||||
"a3": "Ofrecemos una garantía de devolución de 30 días. Si no estás satisfecho, contáctanos para un reembolso completo."
|
"a3": "Ofrecemos una garantía de devolución de 30 días. Si no estás satisfecho, contáctanos para un reembolso completo."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"planUsage": {
|
|
||||||
"title": "Uso del Plan",
|
|
||||||
"upgrade": "Mejorar",
|
|
||||||
"limitReached": "Has alcanzado el límite de tu plan. Actualiza a Pro para continuar.",
|
|
||||||
"limitWarning": "Estás cerca del límite de tu plan.",
|
|
||||||
"accounts": "Cuentas",
|
|
||||||
"categories": "Categorías",
|
|
||||||
"budgets": "Presupuestos",
|
|
||||||
"transactions": "Transacciones"
|
|
||||||
},
|
|
||||||
"billing": {
|
"billing": {
|
||||||
"title": "Facturación",
|
"title": "Facturación",
|
||||||
"currentPlan": "Plan Actual",
|
"currentPlan": "Plan Actual",
|
||||||
@ -2238,8 +2090,7 @@
|
|||||||
"trialing": "En Prueba",
|
"trialing": "En Prueba",
|
||||||
"canceled": "Cancelada",
|
"canceled": "Cancelada",
|
||||||
"expired": "Expirada",
|
"expired": "Expirada",
|
||||||
"past_due": "Pago Pendiente",
|
"past_due": "Pago Pendiente"
|
||||||
"pending": "Pendiente"
|
|
||||||
},
|
},
|
||||||
"invoiceStatus": {
|
"invoiceStatus": {
|
||||||
"paid": "Pagada",
|
"paid": "Pagada",
|
||||||
@ -2253,120 +2104,12 @@
|
|||||||
"subscriptionConfirmed": "¡Suscripción confirmada con éxito!",
|
"subscriptionConfirmed": "¡Suscripción confirmada con éxito!",
|
||||||
"confirmError": "Error al confirmar la suscripción",
|
"confirmError": "Error al confirmar la suscripción",
|
||||||
"subscriptionCanceled": "Suscripción cancelada",
|
"subscriptionCanceled": "Suscripción cancelada",
|
||||||
"subscriptionCanceledRefunded": "Suscripción cancelada y reembolso procesado",
|
|
||||||
"cancelError": "Error al cancelar la suscripción",
|
"cancelError": "Error al cancelar la suscripción",
|
||||||
"cancelConfirmTitle": "¿Cancelar suscripción?",
|
"cancelConfirmTitle": "¿Cancelar suscripción?",
|
||||||
"cancelConfirmMessage": "¿Estás seguro de que deseas cancelar tu suscripción?",
|
"cancelConfirmMessage": "¿Estás seguro de que deseas cancelar tu suscripción?",
|
||||||
"cancelNote1": "Mantendrás acceso hasta el final del período actual",
|
"cancelNote1": "Mantendrás acceso hasta el final del período actual",
|
||||||
"cancelNote2": "Tus datos no se eliminarán",
|
"cancelNote2": "Tus datos no se eliminarán",
|
||||||
"cancelNote3": "Puedes reactivar tu suscripción en cualquier momento",
|
"cancelNote3": "Puedes reactivar tu suscripción en cualquier momento",
|
||||||
"confirmCancel": "Sí, Cancelar",
|
"confirmCancel": "Sí, Cancelar"
|
||||||
"guaranteePeriod": "Período de Garantía",
|
|
||||||
"guaranteeMessage": "Te quedan {{days}} día(s) en el período de garantía de 7 días. Puedes cancelar y recibir un reembolso total.",
|
|
||||||
"guaranteeBadge": "Garantía: {{days}} días",
|
|
||||||
"requestRefund": "Solicitar reembolso total",
|
|
||||||
"refundNote": "El reembolso será procesado por PayPal en 5-10 días hábiles.",
|
|
||||||
"cancelAndRefund": "Cancelar y Reembolsar"
|
|
||||||
},
|
|
||||||
"landing": {
|
|
||||||
"nav": {
|
|
||||||
"features": "Funcionalidades",
|
|
||||||
"pricing": "Precios",
|
|
||||||
"faq": "FAQ",
|
|
||||||
"login": "Iniciar Sesión",
|
|
||||||
"register": "Empezar Ahora"
|
|
||||||
},
|
|
||||||
"hero": {
|
|
||||||
"title": "Toma el Control de tus Finanzas",
|
|
||||||
"subtitle": "Gestión financiera inteligente para personas y empresas. Controla ingresos, gastos y alcanza tus metas financieras.",
|
|
||||||
"cta": "Comenzar Ahora",
|
|
||||||
"learnMore": "Saber Más",
|
|
||||||
"secure": "100% Seguro"
|
|
||||||
},
|
|
||||||
"features": {
|
|
||||||
"title": "Todo lo que Necesitas",
|
|
||||||
"subtitle": "Herramientas potentes para gestionar tu dinero",
|
|
||||||
"accounts": {
|
|
||||||
"title": "Múltiples Cuentas",
|
|
||||||
"description": "Gestiona cuentas bancarias, tarjetas y efectivo en un solo lugar"
|
|
||||||
},
|
|
||||||
"analytics": {
|
|
||||||
"title": "Reportes Detallados",
|
|
||||||
"description": "Gráficos y análisis para entender tus gastos"
|
|
||||||
},
|
|
||||||
"categories": {
|
|
||||||
"title": "Categorías Inteligentes",
|
|
||||||
"description": "Categorización automática con palabras clave y subcategorías"
|
|
||||||
},
|
|
||||||
"import": {
|
|
||||||
"title": "Importación Bancaria",
|
|
||||||
"description": "Importa extractos de Excel, CSV, OFX y PDF"
|
|
||||||
},
|
|
||||||
"recurring": {
|
|
||||||
"title": "Transacciones Recurrentes",
|
|
||||||
"description": "Automatiza facturas, suscripciones e ingresos recurrentes"
|
|
||||||
},
|
|
||||||
"security": {
|
|
||||||
"title": "Seguridad Total",
|
|
||||||
"description": "Cifrado de nivel bancario para proteger tus datos"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"pricing": {
|
|
||||||
"title": "Planes Simples, Precios Justos",
|
|
||||||
"subtitle": "Elige el plan que se adapte a tus necesidades",
|
|
||||||
"monthly": "Mensual",
|
|
||||||
"annual": "Anual",
|
|
||||||
"popular": "Más Popular",
|
|
||||||
"month": "mes",
|
|
||||||
"year": "año",
|
|
||||||
"free": "Gratis",
|
|
||||||
"startFree": "Comenzar Ahora",
|
|
||||||
"subscribe": "Suscribirse Ahora",
|
|
||||||
"billedAnnually": "Facturado anualmente €{{price}}",
|
|
||||||
"comingSoon": "Próximamente",
|
|
||||||
"forPymes": "Herramientas para PyMEs",
|
|
||||||
"features": {
|
|
||||||
"oneAccount": "1 cuenta bancaria",
|
|
||||||
"tenCategories": "10 categorías",
|
|
||||||
"hundredSubcategories": "100 subcategorías",
|
|
||||||
"thousandTransactions": "1.000 transacciones",
|
|
||||||
"unlimitedAccounts": "Cuentas ilimitadas",
|
|
||||||
"unlimitedCategories": "Categorías ilimitadas",
|
|
||||||
"unlimitedTransactions": "Transacciones ilimitadas",
|
|
||||||
"multiUsers": "Múltiples usuarios",
|
|
||||||
"integratedBilling": "Facturación integrada",
|
|
||||||
"advancedReports": "Reportes avanzados",
|
|
||||||
"cashFlow": "Gestión de flujo de caja",
|
|
||||||
"budgetControl": "Control de presupuesto por proyecto",
|
|
||||||
"businessModule": "Módulo de negocios",
|
|
||||||
"prioritySupport": "Soporte prioritario"
|
|
||||||
},
|
|
||||||
"goldTeaser": {
|
|
||||||
"title": "Plan GOLD Próximamente",
|
|
||||||
"description": "Sincronización online directa con tu banco. Conecta tus cuentas y mira tus transacciones actualizadas automáticamente en tiempo real."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"faq": {
|
|
||||||
"title": "Preguntas Frecuentes",
|
|
||||||
"q1": "¿Mis datos están seguros?",
|
|
||||||
"a1": "¡Sí! Utilizamos cifrado de nivel bancario (SSL/TLS) y tus datos se almacenan en servidores seguros con copias de seguridad regulares. Nunca compartimos tu información con terceros.",
|
|
||||||
"q2": "¿Puedo cancelar cuando quiera?",
|
|
||||||
"a2": "Sí, puedes cancelar tu suscripción en cualquier momento sin cargos. Mantendrás el acceso hasta el final del período que ya pagaste.",
|
|
||||||
"q3": "¿Qué bancos son compatibles?",
|
|
||||||
"a3": "Puedes importar extractos de cualquier banco que exporte a Excel, CSV, OFX o PDF. Tenemos mapeos predefinidos para los principales bancos.",
|
|
||||||
"q4": "¿Cómo funciona la garantía de 7 días?",
|
|
||||||
"a4": "Pagas con PayPal y obtienes acceso completo inmediato a todas las funcionalidades. Si no estás satisfecho, cancela en los primeros 7 días y recibirás un reembolso total, sin preguntas."
|
|
||||||
},
|
|
||||||
"cta": {
|
|
||||||
"title": "¿Listo para Transformar tus Finanzas?",
|
|
||||||
"subtitle": "Únete a miles de usuarios que ya tomaron el control de su dinero.",
|
|
||||||
"button": "Comenzar Ahora"
|
|
||||||
},
|
|
||||||
"footer": {
|
|
||||||
"rights": "Todos los derechos reservados.",
|
|
||||||
"privacy": "Política de Privacidad",
|
|
||||||
"terms": "Términos de Uso",
|
|
||||||
"contact": "Contacto"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -45,8 +45,6 @@
|
|||||||
"details": "Detalhes",
|
"details": "Detalhes",
|
||||||
"clearFilters": "Limpar Filtros",
|
"clearFilters": "Limpar Filtros",
|
||||||
"select": "Selecionar",
|
"select": "Selecionar",
|
||||||
"selection": "Seleção",
|
|
||||||
"selectIcon": "Selecionar Ícone",
|
|
||||||
"refresh": "Atualizar",
|
"refresh": "Atualizar",
|
||||||
"filters": "Filtros",
|
"filters": "Filtros",
|
||||||
"processing": "Processando...",
|
"processing": "Processando...",
|
||||||
@ -69,10 +67,7 @@
|
|||||||
"incomes": "Receitas",
|
"incomes": "Receitas",
|
||||||
"expenses": "Despesas",
|
"expenses": "Despesas",
|
||||||
"balance": "Saldo",
|
"balance": "Saldo",
|
||||||
"current": "Atual",
|
"current": "Atual"
|
||||||
"continue": "Continuar",
|
|
||||||
"creating": "Criando...",
|
|
||||||
"remove": "Remover"
|
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"login": "Entrar",
|
"login": "Entrar",
|
||||||
@ -84,56 +79,7 @@
|
|||||||
"loginSuccess": "Login realizado com sucesso",
|
"loginSuccess": "Login realizado com sucesso",
|
||||||
"loginError": "Erro ao fazer login",
|
"loginError": "Erro ao fazer login",
|
||||||
"logoutSuccess": "Logout realizado com sucesso",
|
"logoutSuccess": "Logout realizado com sucesso",
|
||||||
"invalidCredentials": "Credenciais inválidas",
|
"invalidCredentials": "Credenciais inválidas"
|
||||||
"createAccount": "Crie sua conta",
|
|
||||||
"backToLogin": "Voltar para login",
|
|
||||||
"goToLogin": "Ir para login"
|
|
||||||
},
|
|
||||||
"login": {
|
|
||||||
"noAccount": "Não tem uma conta?",
|
|
||||||
"createAccount": "Crie uma aqui",
|
|
||||||
"noSubscription": "Você não possui uma assinatura ativa. Por favor, complete o pagamento."
|
|
||||||
},
|
|
||||||
"errors": {
|
|
||||||
"connection": "Erro de conexão. Tente novamente.",
|
|
||||||
"resendFailed": "Erro ao reenviar email",
|
|
||||||
"subscriptionFailed": "Erro ao criar assinatura. Tente novamente."
|
|
||||||
},
|
|
||||||
"register": {
|
|
||||||
"selectPlan": "Selecione um plano",
|
|
||||||
"repeatPassword": "Repita sua senha",
|
|
||||||
"continueToPayment": "Continuar para pagamento",
|
|
||||||
"createAccount": "Criar Conta",
|
|
||||||
"alreadyHaveAccount": "Já tem uma conta?",
|
|
||||||
"loginHere": "Entre aqui",
|
|
||||||
"paymentCanceled": "O pagamento foi cancelado. Você pode tentar novamente."
|
|
||||||
},
|
|
||||||
"activate": {
|
|
||||||
"activating": "Ativando sua conta...",
|
|
||||||
"pleaseWait": "Por favor, aguarde enquanto ativamos sua conta.",
|
|
||||||
"successTitle": "Conta Ativada!",
|
|
||||||
"success": "Sua conta foi ativada com sucesso. Agora você pode usar o WEBMoney!",
|
|
||||||
"errorTitle": "Erro na Ativação",
|
|
||||||
"error": "Não foi possível ativar sua conta. O link pode ter expirado.",
|
|
||||||
"invalidLink": "Link de ativação inválido.",
|
|
||||||
"redirecting": "Redirecionando em {{seconds}} segundos...",
|
|
||||||
"goToDashboard": "Ir para o Painel",
|
|
||||||
"checkEmail": "Verifique seu email",
|
|
||||||
"checkEmailMessage": "Enviamos um email de ativação para {{email}}. Clique no link para ativar sua conta.",
|
|
||||||
"didntReceive": "Não recebeu o email?",
|
|
||||||
"resend": "Reenviar email",
|
|
||||||
"resendSuccess": "Email reenviado com sucesso!"
|
|
||||||
},
|
|
||||||
"payment": {
|
|
||||||
"confirming": "Confirmando pagamento...",
|
|
||||||
"pleaseWait": "Por favor, aguarde enquanto processamos seu pagamento.",
|
|
||||||
"successTitle": "Pagamento Confirmado!",
|
|
||||||
"successMessage": "Sua assinatura foi confirmada com sucesso.",
|
|
||||||
"checkYourEmail": "Verifique seu email!",
|
|
||||||
"activationSent": "Enviamos um email de ativação para {{email}}. Clique no link para ativar sua conta e começar a usar o WEBMoney.",
|
|
||||||
"errorTitle": "Erro no Pagamento",
|
|
||||||
"error": "Erro ao confirmar pagamento",
|
|
||||||
"noSubscriptionId": "ID da assinatura não encontrado"
|
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Painel",
|
"dashboard": "Painel",
|
||||||
@ -141,7 +87,6 @@
|
|||||||
"liabilities": "Passivos",
|
"liabilities": "Passivos",
|
||||||
"transactions": "Transações",
|
"transactions": "Transações",
|
||||||
"movements": "Movimentações",
|
"movements": "Movimentações",
|
||||||
"registrations": "Cadastros",
|
|
||||||
"import": "Importar",
|
"import": "Importar",
|
||||||
"duplicates": "Duplicatas",
|
"duplicates": "Duplicatas",
|
||||||
"transfers": "Transferências",
|
"transfers": "Transferências",
|
||||||
@ -159,8 +104,7 @@
|
|||||||
"goals": "Metas",
|
"goals": "Metas",
|
||||||
"budgets": "Orçamentos",
|
"budgets": "Orçamentos",
|
||||||
"billing": "Faturamento",
|
"billing": "Faturamento",
|
||||||
"pricing": "Planos",
|
"pricing": "Planos"
|
||||||
"users": "Usuários"
|
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Painel de Controle",
|
"title": "Painel de Controle",
|
||||||
@ -328,18 +272,12 @@
|
|||||||
"deleteWarning": "Todas as subcategorias também serão excluídas.",
|
"deleteWarning": "Todas as subcategorias também serão excluídas.",
|
||||||
"categoryName": "Nome da Categoria",
|
"categoryName": "Nome da Categoria",
|
||||||
"parentCategory": "Categoria Pai",
|
"parentCategory": "Categoria Pai",
|
||||||
"noParent": "Sem categoria pai",
|
"noParent": "Sem categoria pai (raiz)",
|
||||||
"selectParent": "Mais categorias...",
|
|
||||||
"subcategories": "Subcategorias",
|
"subcategories": "Subcategorias",
|
||||||
"keywords": "Palavras-chave",
|
"keywords": "Palavras-chave",
|
||||||
"addKeyword": "Adicionar Palavra-chave",
|
"addKeyword": "Adicionar Palavra-chave",
|
||||||
"keywordPlaceholder": "Digite e pressione Enter...",
|
"keywordPlaceholder": "Digite uma palavra-chave",
|
||||||
"keywordHelp": "Ex: \"RESTAURANTE\", \"PIZZA\" - Transações com essas palavras são categorizadas automaticamente",
|
"keywordHelp": "Palavras-chave ajudam a categorizar transações automaticamente",
|
||||||
"noKeywords": "Sem palavras-chave. Transações serão categorizadas manualmente.",
|
|
||||||
"namePlaceholder": "Ex: Alimentação, Transporte...",
|
|
||||||
"descPlaceholder": "Descreva esta categoria...",
|
|
||||||
"visualSettings": "Aparência",
|
|
||||||
"autoCategorizationLabel": "Auto-categorização",
|
|
||||||
"types": {
|
"types": {
|
||||||
"income": "Receita",
|
"income": "Receita",
|
||||||
"expense": "Despesa",
|
"expense": "Despesa",
|
||||||
@ -383,23 +321,20 @@
|
|||||||
"expand": "Expandir",
|
"expand": "Expandir",
|
||||||
"collapse": "Recolher",
|
"collapse": "Recolher",
|
||||||
"createSubcategory": "Criar Subcategoria",
|
"createSubcategory": "Criar Subcategoria",
|
||||||
"batchCategorize": "Categorização Automática",
|
"batchCategorize": "Categorizar em Lote",
|
||||||
"batchDescription": "Categorize transações automaticamente usando palavras-chave",
|
|
||||||
"analyzingTransactions": "Analisando transações...",
|
|
||||||
"uncategorized": "Sem Categoria",
|
"uncategorized": "Sem Categoria",
|
||||||
"willCategorize": "Serão Categorizadas",
|
"willCategorize": "Serão Categorizadas",
|
||||||
"willSkip": "Sem Correspondência",
|
"willSkip": "Serão Ignoradas",
|
||||||
"totalKeywords": "Palavras-chave",
|
"totalKeywords": "Keywords Ativas",
|
||||||
"previewTitle": "Prévia da Categorização",
|
"previewTitle": "Prévia da Categorização",
|
||||||
"matchedKeyword": "Keyword",
|
"matchedKeyword": "Keyword Encontrada",
|
||||||
"executeBatch": "Categorizar",
|
"executeBatch": "Executar Categorização",
|
||||||
"batchSuccess": "transações categorizadas com sucesso",
|
"batchSuccess": "transações categorizadas com sucesso",
|
||||||
"batchError": "Erro ao categorizar transações",
|
"batchError": "Erro ao categorizar transações",
|
||||||
"nothingToCategorize": "Nenhuma transação para categorizar",
|
"nothingToCategorize": "Nenhuma transação para categorizar",
|
||||||
"batchPreviewError": "Erro ao carregar preview",
|
"batchPreviewError": "Erro ao carregar preview",
|
||||||
"previewError": "Erro ao carregar preview",
|
"previewError": "Erro ao carregar preview",
|
||||||
"noMatchesFound": "Adicione palavras-chave às categorias para permitir categorização automática",
|
"noMatchesFound": "Nenhuma transação corresponde às palavras-chave configuradas",
|
||||||
"noMatchesFoundTitle": "Nenhuma correspondência encontrada",
|
|
||||||
"categorized": "categorizadas",
|
"categorized": "categorizadas",
|
||||||
"category": "Categoria"
|
"category": "Categoria"
|
||||||
},
|
},
|
||||||
@ -417,13 +352,8 @@
|
|||||||
"budget": "Orçamento",
|
"budget": "Orçamento",
|
||||||
"keywords": "Palavras-chave",
|
"keywords": "Palavras-chave",
|
||||||
"addKeyword": "Adicionar Palavra-chave",
|
"addKeyword": "Adicionar Palavra-chave",
|
||||||
"keywordPlaceholder": "Digite e pressione Enter...",
|
"keywordPlaceholder": "Digite uma palavra-chave",
|
||||||
"keywordHelp": "Ex: \"UBER\", \"iFood\" - Transações com essas palavras são atribuídas automaticamente",
|
"keywordHelp": "Palavras-chave ajudam a atribuir transações automaticamente",
|
||||||
"noKeywords": "Sem palavras-chave. Transações serão atribuídas manualmente.",
|
|
||||||
"namePlaceholder": "Ex: Projeto Alpha, Dpto. Marketing...",
|
|
||||||
"descPlaceholder": "Descreva o propósito deste centro de custo...",
|
|
||||||
"visualSettings": "Aparência",
|
|
||||||
"autoAssignLabel": "Auto-atribuição",
|
|
||||||
"createSuccess": "Centro de custo criado com sucesso",
|
"createSuccess": "Centro de custo criado com sucesso",
|
||||||
"updateSuccess": "Centro de custo atualizado com sucesso",
|
"updateSuccess": "Centro de custo atualizado com sucesso",
|
||||||
"deleteSuccess": "Centro de custo excluído com sucesso",
|
"deleteSuccess": "Centro de custo excluído com sucesso",
|
||||||
@ -437,8 +367,6 @@
|
|||||||
"liabilities": {
|
"liabilities": {
|
||||||
"title": "Contas Passivo",
|
"title": "Contas Passivo",
|
||||||
"subtitle": "Gerenciamento de empréstimos e financiamentos",
|
"subtitle": "Gerenciamento de empréstimos e financiamentos",
|
||||||
"manage": "Gerenciar Passivos",
|
|
||||||
"noLiabilities": "Nenhum passivo cadastrado",
|
|
||||||
"importContract": "Importar Contrato",
|
"importContract": "Importar Contrato",
|
||||||
"import": "Importar",
|
"import": "Importar",
|
||||||
"importInfo": "Selecione um arquivo Excel (.xlsx) com a tabela de parcelas. O arquivo deve conter colunas para: Número da Parcela, Data de Vencimento, Valor da Cota, Juros, Capital e Estado.",
|
"importInfo": "Selecione um arquivo Excel (.xlsx) com a tabela de parcelas. O arquivo deve conter colunas para: Número da Parcela, Data de Vencimento, Valor da Cota, Juros, Capital e Estado.",
|
||||||
@ -587,8 +515,6 @@
|
|||||||
"status": {
|
"status": {
|
||||||
"label": "Status",
|
"label": "Status",
|
||||||
"pending": "Pendente",
|
"pending": "Pendente",
|
||||||
"effective": "Efetivada",
|
|
||||||
"scheduled": "Agendada",
|
|
||||||
"completed": "Concluída",
|
"completed": "Concluída",
|
||||||
"cancelled": "Cancelada"
|
"cancelled": "Cancelada"
|
||||||
},
|
},
|
||||||
@ -1784,7 +1710,6 @@
|
|||||||
"subcategory": "Subcategoria",
|
"subcategory": "Subcategoria",
|
||||||
"allCategory": "Toda a categoria",
|
"allCategory": "Toda a categoria",
|
||||||
"selectCategory": "Selecione uma categoria",
|
"selectCategory": "Selecione uma categoria",
|
||||||
"general": "Geral",
|
|
||||||
"amount": "Valor",
|
"amount": "Valor",
|
||||||
"month": "Mês",
|
"month": "Mês",
|
||||||
"budgeted": "Orçado",
|
"budgeted": "Orçado",
|
||||||
@ -1806,57 +1731,6 @@
|
|||||||
"yearly": "Anual",
|
"yearly": "Anual",
|
||||||
"isCumulative": "Orçamento Cumulativo",
|
"isCumulative": "Orçamento Cumulativo",
|
||||||
"isCumulativeHelp": "Acumula gastos desde o início do ano até o período atual",
|
"isCumulativeHelp": "Acumula gastos desde o início do ano até o período atual",
|
||||||
"total": "Total",
|
|
||||||
"wizard": {
|
|
||||||
"title": "Assistente de Orçamentos",
|
|
||||||
"button": "Assistente",
|
|
||||||
"step1": "Modo",
|
|
||||||
"step2": "Categorias",
|
|
||||||
"step3": "Valores",
|
|
||||||
"step4": "Confirmar",
|
|
||||||
"quickStart": "Início Rápido com Templates",
|
|
||||||
"manual": "Criar Manualmente",
|
|
||||||
"manualDesc": "Escolha categorias e valores",
|
|
||||||
"copy": "Copiar de Outro Mês",
|
|
||||||
"copyDesc": "Reutilize orçamentos existentes",
|
|
||||||
"loadBudgets": "Carregar Orçamentos",
|
|
||||||
"noSourceBudgets": "Não há orçamentos neste período para copiar",
|
|
||||||
"selectCategories": "Selecione as Categorias",
|
|
||||||
"categoriesSelected": "categorias selecionadas",
|
|
||||||
"setAmounts": "Defina os Valores",
|
|
||||||
"history": "Hist.",
|
|
||||||
"useHistory": "Usar média histórica",
|
|
||||||
"confirm": "Confirme os Orçamentos",
|
|
||||||
"budgets": "Orçamentos",
|
|
||||||
"periods": "Períodos",
|
|
||||||
"periodHelp": "Define a frequência de criação automática dos orçamentos",
|
|
||||||
"createBudgets": "Criar Orçamentos",
|
|
||||||
"createBudget": "Criar Orçamento",
|
|
||||||
"successCount": "{{count}} orçamento(s) criado(s) com sucesso",
|
|
||||||
"errorCount": "{{count}} orçamento(s) não puderam ser criados (já existem)",
|
|
||||||
"fillRequired": "Preencha os campos obrigatórios",
|
|
||||||
"updated": "Orçamento atualizado com sucesso",
|
|
||||||
"created": "Orçamento criado com sucesso",
|
|
||||||
"selectAtLeast": "Selecione pelo menos uma categoria",
|
|
||||||
"templates": {
|
|
||||||
"basic": {
|
|
||||||
"name": "Orçamento Básico",
|
|
||||||
"desc": "Essencial para controle mensal"
|
|
||||||
},
|
|
||||||
"family": {
|
|
||||||
"name": "Orçamento Familiar",
|
|
||||||
"desc": "Completo para famílias"
|
|
||||||
},
|
|
||||||
"individual": {
|
|
||||||
"name": "Orçamento Individual",
|
|
||||||
"desc": "Para pessoa solteira"
|
|
||||||
},
|
|
||||||
"complete": {
|
|
||||||
"name": "Orçamento Completo",
|
|
||||||
"desc": "Todas as categorias"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"alert": {
|
"alert": {
|
||||||
"exceeded": "Orçamento excedido!",
|
"exceeded": "Orçamento excedido!",
|
||||||
"warning": "Atenção: próximo do limite",
|
"warning": "Atenção: próximo do limite",
|
||||||
@ -2151,7 +2025,6 @@
|
|||||||
"lastNamePlaceholder": "Seu sobrenome",
|
"lastNamePlaceholder": "Seu sobrenome",
|
||||||
"lastNameRequired": "O sobrenome é obrigatório",
|
"lastNameRequired": "O sobrenome é obrigatório",
|
||||||
"name": "Nome",
|
"name": "Nome",
|
||||||
"namePlaceholder": "Seu nome",
|
|
||||||
"email": "E-mail",
|
"email": "E-mail",
|
||||||
"phone": "Telefone",
|
"phone": "Telefone",
|
||||||
"phoneRequired": "O telefone é obrigatório",
|
"phoneRequired": "O telefone é obrigatório",
|
||||||
@ -2186,7 +2059,6 @@
|
|||||||
"billedAnnually": "Cobrado anualmente €{{price}}",
|
"billedAnnually": "Cobrado anualmente €{{price}}",
|
||||||
"save": "Economize {{percent}}%",
|
"save": "Economize {{percent}}%",
|
||||||
"trialDays": "{{days}} dias de teste grátis",
|
"trialDays": "{{days}} dias de teste grátis",
|
||||||
"trial": "de teste",
|
|
||||||
"mostPopular": "Mais Popular",
|
"mostPopular": "Mais Popular",
|
||||||
"currentPlan": "Plano Atual",
|
"currentPlan": "Plano Atual",
|
||||||
"startFree": "Começar Grátis",
|
"startFree": "Começar Grátis",
|
||||||
@ -2199,16 +2071,6 @@
|
|||||||
"securePayment": "Pagamento seguro",
|
"securePayment": "Pagamento seguro",
|
||||||
"cancelAnytime": "Cancele quando quiser",
|
"cancelAnytime": "Cancele quando quiser",
|
||||||
"paypalSecure": "Pagamento seguro com PayPal",
|
"paypalSecure": "Pagamento seguro com PayPal",
|
||||||
"comingSoon": "Em Breve",
|
|
||||||
"forPymes": "Ferramentas para PMEs",
|
|
||||||
"features": {
|
|
||||||
"multiUsers": "Múltiplos usuários",
|
|
||||||
"integratedBilling": "Faturamento integrado",
|
|
||||||
"advancedReports": "Relatórios avançados",
|
|
||||||
"apiAccess": "Acesso à API",
|
|
||||||
"prioritySupport": "Suporte prioritário",
|
|
||||||
"dedicatedManager": "Gerente de conta dedicado"
|
|
||||||
},
|
|
||||||
"faq": {
|
"faq": {
|
||||||
"title": "Perguntas Frequentes",
|
"title": "Perguntas Frequentes",
|
||||||
"q1": "Posso mudar de plano a qualquer momento?",
|
"q1": "Posso mudar de plano a qualquer momento?",
|
||||||
@ -2219,16 +2081,6 @@
|
|||||||
"a3": "Oferecemos garantia de devolução de 30 dias. Se não estiver satisfeito, entre em contato para reembolso completo."
|
"a3": "Oferecemos garantia de devolução de 30 dias. Se não estiver satisfeito, entre em contato para reembolso completo."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"planUsage": {
|
|
||||||
"title": "Uso do Plano",
|
|
||||||
"upgrade": "Fazer Upgrade",
|
|
||||||
"limitReached": "Você atingiu o limite do seu plano. Atualize para Pro para continuar.",
|
|
||||||
"limitWarning": "Você está perto do limite do seu plano.",
|
|
||||||
"accounts": "Contas",
|
|
||||||
"categories": "Categorias",
|
|
||||||
"budgets": "Orçamentos",
|
|
||||||
"transactions": "Transações"
|
|
||||||
},
|
|
||||||
"billing": {
|
"billing": {
|
||||||
"title": "Faturamento",
|
"title": "Faturamento",
|
||||||
"currentPlan": "Plano Atual",
|
"currentPlan": "Plano Atual",
|
||||||
@ -2256,8 +2108,7 @@
|
|||||||
"trialing": "Em Teste",
|
"trialing": "Em Teste",
|
||||||
"canceled": "Cancelada",
|
"canceled": "Cancelada",
|
||||||
"expired": "Expirada",
|
"expired": "Expirada",
|
||||||
"past_due": "Pagamento Pendente",
|
"past_due": "Pagamento Pendente"
|
||||||
"pending": "Pendente"
|
|
||||||
},
|
},
|
||||||
"invoiceStatus": {
|
"invoiceStatus": {
|
||||||
"paid": "Paga",
|
"paid": "Paga",
|
||||||
@ -2271,120 +2122,12 @@
|
|||||||
"subscriptionConfirmed": "Assinatura confirmada com sucesso!",
|
"subscriptionConfirmed": "Assinatura confirmada com sucesso!",
|
||||||
"confirmError": "Erro ao confirmar assinatura",
|
"confirmError": "Erro ao confirmar assinatura",
|
||||||
"subscriptionCanceled": "Assinatura cancelada",
|
"subscriptionCanceled": "Assinatura cancelada",
|
||||||
"subscriptionCanceledRefunded": "Assinatura cancelada e reembolso processado",
|
|
||||||
"cancelError": "Erro ao cancelar assinatura",
|
"cancelError": "Erro ao cancelar assinatura",
|
||||||
"cancelConfirmTitle": "Cancelar assinatura?",
|
"cancelConfirmTitle": "Cancelar assinatura?",
|
||||||
"cancelConfirmMessage": "Tem certeza que deseja cancelar sua assinatura?",
|
"cancelConfirmMessage": "Tem certeza que deseja cancelar sua assinatura?",
|
||||||
"cancelNote1": "Você manterá acesso até o final do período atual",
|
"cancelNote1": "Você manterá acesso até o final do período atual",
|
||||||
"cancelNote2": "Seus dados não serão excluídos",
|
"cancelNote2": "Seus dados não serão excluídos",
|
||||||
"cancelNote3": "Você pode reativar sua assinatura a qualquer momento",
|
"cancelNote3": "Você pode reativar sua assinatura a qualquer momento",
|
||||||
"confirmCancel": "Sim, Cancelar",
|
"confirmCancel": "Sim, Cancelar"
|
||||||
"guaranteePeriod": "Período de Garantia",
|
|
||||||
"guaranteeMessage": "Você ainda tem {{days}} dia(s) restantes no período de garantia de 7 dias. Você pode cancelar e receber um reembolso total.",
|
|
||||||
"guaranteeBadge": "Garantia: {{days}} dias",
|
|
||||||
"requestRefund": "Solicitar reembolso total",
|
|
||||||
"refundNote": "O reembolso será processado pelo PayPal em 5-10 dias úteis.",
|
|
||||||
"cancelAndRefund": "Cancelar e Reembolsar"
|
|
||||||
},
|
|
||||||
"landing": {
|
|
||||||
"nav": {
|
|
||||||
"features": "Recursos",
|
|
||||||
"pricing": "Preços",
|
|
||||||
"faq": "FAQ",
|
|
||||||
"login": "Entrar",
|
|
||||||
"register": "Começar Agora"
|
|
||||||
},
|
|
||||||
"hero": {
|
|
||||||
"title": "Assuma o Controle das suas Finanças",
|
|
||||||
"subtitle": "Gestão financeira inteligente para pessoas e empresas. Acompanhe receitas, despesas e alcance seus objetivos financeiros.",
|
|
||||||
"cta": "Começar Agora",
|
|
||||||
"learnMore": "Saiba Mais",
|
|
||||||
"secure": "100% Seguro"
|
|
||||||
},
|
|
||||||
"features": {
|
|
||||||
"title": "Tudo que Você Precisa",
|
|
||||||
"subtitle": "Ferramentas poderosas para gerenciar seu dinheiro",
|
|
||||||
"accounts": {
|
|
||||||
"title": "Múltiplas Contas",
|
|
||||||
"description": "Gerencie contas bancárias, cartões e dinheiro em um só lugar"
|
|
||||||
},
|
|
||||||
"analytics": {
|
|
||||||
"title": "Relatórios Detalhados",
|
|
||||||
"description": "Gráficos e análises para entender seus gastos"
|
|
||||||
},
|
|
||||||
"categories": {
|
|
||||||
"title": "Categorias Inteligentes",
|
|
||||||
"description": "Categorização automática com palavras-chave e subcategorias"
|
|
||||||
},
|
|
||||||
"import": {
|
|
||||||
"title": "Importação Bancária",
|
|
||||||
"description": "Importe extratos de Excel, CSV, OFX e PDF"
|
|
||||||
},
|
|
||||||
"recurring": {
|
|
||||||
"title": "Transações Recorrentes",
|
|
||||||
"description": "Automatize contas, assinaturas e receitas recorrentes"
|
|
||||||
},
|
|
||||||
"security": {
|
|
||||||
"title": "Segurança Total",
|
|
||||||
"description": "Criptografia de nível bancário para proteger seus dados"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"pricing": {
|
|
||||||
"title": "Planos Simples, Preços Justos",
|
|
||||||
"subtitle": "Escolha o plano que atende às suas necessidades",
|
|
||||||
"monthly": "Mensal",
|
|
||||||
"annual": "Anual",
|
|
||||||
"popular": "Mais Popular",
|
|
||||||
"month": "mês",
|
|
||||||
"year": "ano",
|
|
||||||
"free": "Grátis",
|
|
||||||
"startFree": "Começar Grátis",
|
|
||||||
"subscribe": "Assinar Agora",
|
|
||||||
"billedAnnually": "Cobrado anualmente €{{price}}",
|
|
||||||
"comingSoon": "Em Breve",
|
|
||||||
"forPymes": "Ferramentas para PMEs",
|
|
||||||
"features": {
|
|
||||||
"oneAccount": "1 conta bancária",
|
|
||||||
"tenCategories": "10 categorias",
|
|
||||||
"hundredSubcategories": "100 subcategorias",
|
|
||||||
"thousandTransactions": "1.000 transações",
|
|
||||||
"unlimitedAccounts": "Contas ilimitadas",
|
|
||||||
"unlimitedCategories": "Categorias ilimitadas",
|
|
||||||
"unlimitedTransactions": "Transações ilimitadas",
|
|
||||||
"multiUsers": "Múltiplos usuários",
|
|
||||||
"integratedBilling": "Faturamento integrado",
|
|
||||||
"advancedReports": "Relatórios avançados",
|
|
||||||
"cashFlow": "Gestão de fluxo de caixa",
|
|
||||||
"budgetControl": "Controle de orçamento por projeto",
|
|
||||||
"businessModule": "Módulo de negócios",
|
|
||||||
"prioritySupport": "Suporte prioritário"
|
|
||||||
},
|
|
||||||
"goldTeaser": {
|
|
||||||
"title": "Plano GOLD Em Breve",
|
|
||||||
"description": "Sincronização online direta com seu banco. Conecte suas contas e veja suas transações atualizadas automaticamente em tempo real."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"faq": {
|
|
||||||
"title": "Perguntas Frequentes",
|
|
||||||
"q1": "Meus dados estão seguros?",
|
|
||||||
"a1": "Sim! Usamos criptografia de nível bancário (SSL/TLS) e seus dados são armazenados em servidores seguros com backups regulares. Nunca compartilhamos suas informações com terceiros.",
|
|
||||||
"q2": "Posso cancelar quando quiser?",
|
|
||||||
"a2": "Sim, você pode cancelar sua assinatura a qualquer momento sem taxas. Você manterá o acesso até o final do período que já pagou.",
|
|
||||||
"q3": "Quais bancos são compatíveis?",
|
|
||||||
"a3": "Você pode importar extratos de qualquer banco que exporte para Excel, CSV, OFX ou PDF. Temos mapeamentos predefinidos para os principais bancos.",
|
|
||||||
"q4": "Como funciona a garantia de 7 dias?",
|
|
||||||
"a4": "Você paga via PayPal e obtém acesso completo imediato a todos os recursos. Se não ficar satisfeito, cancele em até 7 dias e receba reembolso total, sem perguntas."
|
|
||||||
},
|
|
||||||
"cta": {
|
|
||||||
"title": "Pronto para Transformar suas Finanças?",
|
|
||||||
"subtitle": "Junte-se a milhares de usuários que já assumiram o controle do seu dinheiro.",
|
|
||||||
"button": "Criar Conta Grátis"
|
|
||||||
},
|
|
||||||
"footer": {
|
|
||||||
"rights": "Todos os direitos reservados.",
|
|
||||||
"privacy": "Política de Privacidade",
|
|
||||||
"terms": "Termos de Uso",
|
|
||||||
"contact": "Contato"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1899,16 +1899,6 @@ input[type="color"]::-webkit-color-swatch {
|
|||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.txn-status-badge.effective {
|
|
||||||
background: rgba(16, 185, 129, 0.15);
|
|
||||||
color: #10b981;
|
|
||||||
}
|
|
||||||
|
|
||||||
.txn-status-badge.scheduled {
|
|
||||||
background: rgba(59, 130, 246, 0.15);
|
|
||||||
color: #3b82f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Actions dropdown */
|
/* Actions dropdown */
|
||||||
.txn-actions-btn {
|
.txn-actions-btn {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
|
|||||||
@ -1,14 +1,12 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { accountService, liabilityAccountService, assetAccountService } from '../services/api';
|
import { accountService, liabilityAccountService } from '../services/api';
|
||||||
import { useToast } from '../components/Toast';
|
import { useToast } from '../components/Toast';
|
||||||
import { ConfirmModal } from '../components/Modal';
|
import { ConfirmModal } from '../components/Modal';
|
||||||
import IconSelector from '../components/IconSelector';
|
import IconSelector from '../components/IconSelector';
|
||||||
import CurrencySelector from '../components/CurrencySelector';
|
import CurrencySelector from '../components/CurrencySelector';
|
||||||
import { useFormatters } from '../hooks';
|
import { useFormatters } from '../hooks';
|
||||||
import AssetWizard from '../components/AssetWizard';
|
|
||||||
import AccountWizard from '../components/AccountWizard';
|
|
||||||
|
|
||||||
const Accounts = () => {
|
const Accounts = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -17,17 +15,10 @@ const Accounts = () => {
|
|||||||
const { currency: formatCurrencyHook } = useFormatters();
|
const { currency: formatCurrencyHook } = useFormatters();
|
||||||
const [accounts, setAccounts] = useState([]);
|
const [accounts, setAccounts] = useState([]);
|
||||||
const [liabilityAccounts, setLiabilityAccounts] = useState([]);
|
const [liabilityAccounts, setLiabilityAccounts] = useState([]);
|
||||||
const [assetAccounts, setAssetAccounts] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||||
const [showAdjustModal, setShowAdjustModal] = useState(false);
|
const [showAdjustModal, setShowAdjustModal] = useState(false);
|
||||||
const [showAssetWizard, setShowAssetWizard] = useState(false);
|
|
||||||
const [showAccountWizard, setShowAccountWizard] = useState(false);
|
|
||||||
const [editingAccount, setEditingAccount] = useState(null);
|
|
||||||
const [showAssetDetail, setShowAssetDetail] = useState(false);
|
|
||||||
const [selectedAsset, setSelectedAsset] = useState(null);
|
|
||||||
const [editingAsset, setEditingAsset] = useState(null);
|
|
||||||
const [adjustAccount, setAdjustAccount] = useState(null);
|
const [adjustAccount, setAdjustAccount] = useState(null);
|
||||||
const [targetBalance, setTargetBalance] = useState('');
|
const [targetBalance, setTargetBalance] = useState('');
|
||||||
const [adjusting, setAdjusting] = useState(false);
|
const [adjusting, setAdjusting] = useState(false);
|
||||||
@ -36,7 +27,6 @@ const Accounts = () => {
|
|||||||
const [recalculating, setRecalculating] = useState(false);
|
const [recalculating, setRecalculating] = useState(false);
|
||||||
const [filter, setFilter] = useState({ type: '', is_active: '' });
|
const [filter, setFilter] = useState({ type: '', is_active: '' });
|
||||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||||
const [activeTab, setActiveTab] = useState('accounts'); // 'accounts', 'liabilities' ou 'assets'
|
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
@ -78,10 +68,9 @@ const Accounts = () => {
|
|||||||
if (filter.is_active !== '') params.is_active = filter.is_active;
|
if (filter.is_active !== '') params.is_active = filter.is_active;
|
||||||
|
|
||||||
// Carregar contas normais e passivas em paralelo
|
// Carregar contas normais e passivas em paralelo
|
||||||
const [accountsResponse, liabilityResponse, assetResponse] = await Promise.all([
|
const [accountsResponse, liabilityResponse] = await Promise.all([
|
||||||
accountService.getAll(params),
|
accountService.getAll(params),
|
||||||
liabilityAccountService.getAll({ is_active: filter.is_active || undefined }),
|
liabilityAccountService.getAll({ is_active: filter.is_active || undefined })
|
||||||
assetAccountService.getAll({ status: filter.is_active === '1' ? 'active' : undefined })
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (accountsResponse.success) {
|
if (accountsResponse.success) {
|
||||||
@ -90,9 +79,6 @@ const Accounts = () => {
|
|||||||
if (liabilityResponse.success) {
|
if (liabilityResponse.success) {
|
||||||
setLiabilityAccounts(liabilityResponse.data);
|
setLiabilityAccounts(liabilityResponse.data);
|
||||||
}
|
}
|
||||||
if (assetResponse.success) {
|
|
||||||
setAssetAccounts(assetResponse.data);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(t('accounts.loadError'));
|
toast.error(t('accounts.loadError'));
|
||||||
} finally {
|
} finally {
|
||||||
@ -267,56 +253,7 @@ const Accounts = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Abrir modal de detalhes do ativo
|
// Calcula totais agrupados por moeda (incluindo passivos como valor negativo)
|
||||||
const handleOpenAssetDetail = async (asset) => {
|
|
||||||
try {
|
|
||||||
const response = await assetAccountService.getById(asset.id);
|
|
||||||
if (response.success) {
|
|
||||||
setSelectedAsset(response.data);
|
|
||||||
setShowAssetDetail(true);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast.error('Erro ao carregar detalhes do ativo');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fechar modal de detalhes do ativo
|
|
||||||
const handleCloseAssetDetail = () => {
|
|
||||||
setShowAssetDetail(false);
|
|
||||||
setSelectedAsset(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Editar ativo
|
|
||||||
const handleEditAsset = () => {
|
|
||||||
setEditingAsset(selectedAsset);
|
|
||||||
setShowAssetDetail(false);
|
|
||||||
setShowAssetWizard(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Callback após salvar/criar ativo
|
|
||||||
const handleAssetSuccess = (assetData) => {
|
|
||||||
loadAccounts();
|
|
||||||
setEditingAsset(null);
|
|
||||||
toast.success(editingAsset ? 'Activo actualizado con éxito' : 'Activo creado con éxito');
|
|
||||||
};
|
|
||||||
|
|
||||||
// Callback após salvar/criar conta via wizard
|
|
||||||
const handleAccountWizardSuccess = (data, destinationType) => {
|
|
||||||
loadAccounts();
|
|
||||||
setEditingAccount(null);
|
|
||||||
|
|
||||||
const isEditing = !!editingAccount;
|
|
||||||
|
|
||||||
if (destinationType === 'asset') {
|
|
||||||
toast.success(isEditing ? 'Cuenta de ahorro actualizada' : 'Cuenta de ahorro creada como activo');
|
|
||||||
} else if (destinationType === 'liability') {
|
|
||||||
toast.success(isEditing ? 'Tarjeta de crédito actualizada' : 'Tarjeta de crédito creada como pasivo');
|
|
||||||
} else {
|
|
||||||
toast.success(isEditing ? t('accounts.updateSuccess') : t('accounts.createSuccess'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Calcula totais agrupados por moeda (incluindo passivos como valor negativo e ativos como positivo)
|
|
||||||
const getTotalsByCurrency = () => {
|
const getTotalsByCurrency = () => {
|
||||||
const totals = {};
|
const totals = {};
|
||||||
|
|
||||||
@ -343,49 +280,19 @@ const Accounts = () => {
|
|||||||
totals[currency] -= parseFloat(acc.remaining_balance || 0);
|
totals[currency] -= parseFloat(acc.remaining_balance || 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Ativos (como valor positivo - current_value)
|
|
||||||
assetAccounts
|
|
||||||
.filter(acc => acc.status === 'active')
|
|
||||||
.forEach(acc => {
|
|
||||||
const currency = acc.currency || 'EUR';
|
|
||||||
if (!totals[currency]) {
|
|
||||||
totals[currency] = 0;
|
|
||||||
}
|
|
||||||
// Somar o valor atual do ativo
|
|
||||||
totals[currency] += parseFloat(acc.current_value || 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
return totals;
|
return totals;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Total de contas ativas (normais + passivas + ativos)
|
// Total de contas ativas (normais + passivas)
|
||||||
const getTotalActiveAccounts = () => {
|
const getTotalActiveAccounts = () => {
|
||||||
const normalActive = accounts.filter(a => a.is_active).length;
|
const normalActive = accounts.filter(a => a.is_active).length;
|
||||||
const liabilityActive = liabilityAccounts.filter(a => a.is_active).length;
|
const liabilityActive = liabilityAccounts.filter(a => a.is_active).length;
|
||||||
const assetActive = assetAccounts.filter(a => a.status === 'active').length;
|
return normalActive + liabilityActive;
|
||||||
return normalActive + liabilityActive + assetActive;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Total de todas as contas
|
// Total de todas as contas
|
||||||
const getTotalAccounts = () => {
|
const getTotalAccounts = () => {
|
||||||
return accounts.length + liabilityAccounts.length + assetAccounts.length;
|
return accounts.length + liabilityAccounts.length;
|
||||||
};
|
|
||||||
|
|
||||||
// Total de ativos por moeda
|
|
||||||
const getAssetTotalsByCurrency = () => {
|
|
||||||
const totals = {};
|
|
||||||
assetAccounts
|
|
||||||
.filter(acc => acc.status === 'active')
|
|
||||||
.forEach(acc => {
|
|
||||||
const currency = acc.currency || 'EUR';
|
|
||||||
if (!totals[currency]) {
|
|
||||||
totals[currency] = { current: 0, acquisition: 0, count: 0 };
|
|
||||||
}
|
|
||||||
totals[currency].current += parseFloat(acc.current_value || 0);
|
|
||||||
totals[currency].acquisition += parseFloat(acc.acquisition_value || 0);
|
|
||||||
totals[currency].count++;
|
|
||||||
});
|
|
||||||
return totals;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -400,7 +307,7 @@ const Accounts = () => {
|
|||||||
{!isMobile && <p className="text-slate-400 mb-0">{t('accounts.title')}</p>}
|
{!isMobile && <p className="text-slate-400 mb-0">{t('accounts.title')}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="d-flex gap-2">
|
<div className="d-flex gap-2">
|
||||||
{!isMobile && activeTab === 'accounts' && (
|
{!isMobile && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-outline-secondary"
|
className="btn btn-outline-secondary"
|
||||||
onClick={handleRecalculateBalances}
|
onClick={handleRecalculateBalances}
|
||||||
@ -415,82 +322,14 @@ const Accounts = () => {
|
|||||||
{t('accounts.recalculate')}
|
{t('accounts.recalculate')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{activeTab === 'accounts' && (
|
<button className={`btn btn-primary ${isMobile ? 'btn-sm' : ''}`} onClick={() => handleOpenModal()}>
|
||||||
<button className={`btn btn-primary ${isMobile ? 'btn-sm' : ''}`} onClick={() => setShowAccountWizard(true)}>
|
<i className="bi bi-plus-lg me-2"></i>
|
||||||
<i className="bi bi-plus-lg me-2"></i>
|
{isMobile ? t('common.add') : t('accounts.newAccount')}
|
||||||
{isMobile ? t('common.add') : t('accounts.newAccount')}
|
</button>
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{activeTab === 'liabilities' && (
|
|
||||||
<button className={`btn btn-warning ${isMobile ? 'btn-sm' : ''}`} onClick={() => navigate('/liabilities')}>
|
|
||||||
<i className="bi bi-gear me-2"></i>
|
|
||||||
{isMobile ? t('common.manage') : t('liabilities.manage')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{activeTab === 'assets' && (
|
|
||||||
<button className={`btn btn-success ${isMobile ? 'btn-sm' : ''}`} onClick={() => setShowAssetWizard(true)}>
|
|
||||||
<i className="bi bi-plus-lg me-2"></i>
|
|
||||||
{isMobile ? t('common.add') : 'Nuevo Activo'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs - Mobile Optimized */}
|
{/* Summary Cards */}
|
||||||
<ul className={`nav nav-tabs mb-4 ${isMobile ? 'nav-fill flex-nowrap overflow-auto' : ''}`} style={{ borderBottom: '1px solid #334155' }}>
|
|
||||||
<li className="nav-item">
|
|
||||||
<button
|
|
||||||
className={`nav-link ${activeTab === 'accounts' ? 'active bg-primary text-white' : 'text-slate-400'} ${isMobile ? 'px-2 py-2' : ''}`}
|
|
||||||
onClick={() => setActiveTab('accounts')}
|
|
||||||
style={{
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '0.5rem 0.5rem 0 0',
|
|
||||||
backgroundColor: activeTab === 'accounts' ? undefined : 'transparent',
|
|
||||||
fontSize: isMobile ? '0.75rem' : undefined,
|
|
||||||
whiteSpace: 'nowrap'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<i className={`bi bi-wallet2 ${isMobile ? '' : 'me-2'}`}></i>
|
|
||||||
{isMobile ? '' : t('nav.accounts')} ({accounts.length})
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li className="nav-item">
|
|
||||||
<button
|
|
||||||
className={`nav-link ${activeTab === 'liabilities' ? 'active bg-warning text-dark' : 'text-slate-400'} ${isMobile ? 'px-2 py-2' : ''}`}
|
|
||||||
onClick={() => setActiveTab('liabilities')}
|
|
||||||
style={{
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '0.5rem 0.5rem 0 0',
|
|
||||||
backgroundColor: activeTab === 'liabilities' ? undefined : 'transparent',
|
|
||||||
fontSize: isMobile ? '0.75rem' : undefined,
|
|
||||||
whiteSpace: 'nowrap'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<i className={`bi bi-bank ${isMobile ? '' : 'me-2'}`}></i>
|
|
||||||
{isMobile ? '' : t('nav.liabilities')} ({liabilityAccounts.length})
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li className="nav-item">
|
|
||||||
<button
|
|
||||||
className={`nav-link ${activeTab === 'assets' ? 'active bg-success text-white' : 'text-slate-400'} ${isMobile ? 'px-2 py-2' : ''}`}
|
|
||||||
onClick={() => setActiveTab('assets')}
|
|
||||||
style={{
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '0.5rem 0.5rem 0 0',
|
|
||||||
backgroundColor: activeTab === 'assets' ? undefined : 'transparent',
|
|
||||||
fontSize: isMobile ? '0.75rem' : undefined,
|
|
||||||
whiteSpace: 'nowrap'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<i className={`bi bi-graph-up-arrow ${isMobile ? '' : 'me-2'}`}></i>
|
|
||||||
{isMobile ? '' : 'Activos'} ({assetAccounts.length})
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
{activeTab === 'accounts' && (
|
|
||||||
<>
|
|
||||||
{/* Summary Cards */}
|
|
||||||
<div className={`row ${isMobile ? 'g-2 mb-3' : 'mb-4'}`}>
|
<div className={`row ${isMobile ? 'g-2 mb-3' : 'mb-4'}`}>
|
||||||
{/* Total por Moeda */}
|
{/* Total por Moeda */}
|
||||||
<div className="col-md-6">
|
<div className="col-md-6">
|
||||||
@ -603,7 +442,7 @@ const Accounts = () => {
|
|||||||
<div className="text-center py-5">
|
<div className="text-center py-5">
|
||||||
<i className="bi bi-wallet2 display-1 text-slate-600"></i>
|
<i className="bi bi-wallet2 display-1 text-slate-600"></i>
|
||||||
<p className="text-slate-400 mt-3">{t('accounts.noAccounts')}</p>
|
<p className="text-slate-400 mt-3">{t('accounts.noAccounts')}</p>
|
||||||
<button className="btn btn-primary" onClick={() => setShowAccountWizard(true)}>
|
<button className="btn btn-primary" onClick={() => handleOpenModal()}>
|
||||||
<i className="bi bi-plus-lg me-2"></i>
|
<i className="bi bi-plus-lg me-2"></i>
|
||||||
{t('accounts.newAccount')}
|
{t('accounts.newAccount')}
|
||||||
</button>
|
</button>
|
||||||
@ -649,10 +488,7 @@ const Accounts = () => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-link text-info p-1"
|
className="btn btn-link text-info p-1"
|
||||||
onClick={() => {
|
onClick={() => handleOpenModal(account)}
|
||||||
setEditingAccount(account);
|
|
||||||
setShowAccountWizard(true);
|
|
||||||
}}
|
|
||||||
style={{ fontSize: '1rem' }}
|
style={{ fontSize: '1rem' }}
|
||||||
>
|
>
|
||||||
<i className="bi bi-pencil"></i>
|
<i className="bi bi-pencil"></i>
|
||||||
@ -754,10 +590,7 @@ const Accounts = () => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-link text-info p-1 me-1"
|
className="btn btn-link text-info p-1 me-1"
|
||||||
onClick={() => {
|
onClick={() => handleOpenModal(account)}
|
||||||
setEditingAccount(account);
|
|
||||||
setShowAccountWizard(true);
|
|
||||||
}}
|
|
||||||
title={t('common.edit')}
|
title={t('common.edit')}
|
||||||
>
|
>
|
||||||
<i className="bi bi-pencil"></i>
|
<i className="bi bi-pencil"></i>
|
||||||
@ -778,15 +611,23 @@ const Accounts = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tab de Passivos */}
|
{/* Liability Accounts Section */}
|
||||||
{activeTab === 'liabilities' && (
|
{liabilityAccounts.length > 0 && (filter.type === '' || filter.type === 'liability') && (
|
||||||
<>
|
<div className="card border-0 mt-4" style={{ background: '#1e293b' }}>
|
||||||
{/* Liability Accounts Section */}
|
<div className={`card-header border-0 d-flex justify-content-between align-items-center ${isMobile ? 'py-2 px-3' : ''}`} style={{ background: '#1e293b', borderBottom: '1px solid #334155' }}>
|
||||||
{liabilityAccounts.length > 0 ? (
|
<h5 className={`mb-0 text-white ${isMobile ? 'fs-6' : ''}`}>
|
||||||
<div className="card border-0" style={{ background: '#1e293b' }}>
|
<i className="bi bi-file-earmark-text me-2 text-danger"></i>
|
||||||
|
{t('liabilities.title')}
|
||||||
|
</h5>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-outline-light"
|
||||||
|
onClick={() => navigate('/liabilities')}
|
||||||
|
>
|
||||||
|
<i className="bi bi-box-arrow-up-right me-1"></i>
|
||||||
|
{t('common.details')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div className={`card-body ${isMobile ? 'p-2' : 'p-0'}`}>
|
<div className={`card-body ${isMobile ? 'p-2' : 'p-0'}`}>
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
// Mobile: Cards Layout
|
// Mobile: Cards Layout
|
||||||
@ -957,244 +798,7 @@ const Accounts = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="card border-0" style={{ background: '#1e293b' }}>
|
|
||||||
<div className="card-body text-center py-5">
|
|
||||||
<i className="bi bi-bank display-1 text-slate-600"></i>
|
|
||||||
<p className="text-slate-400 mt-3">{t('liabilities.noLiabilities')}</p>
|
|
||||||
<button className="btn btn-primary" onClick={() => navigate('/liabilities')}>
|
|
||||||
<i className="bi bi-plus-lg me-2"></i>
|
|
||||||
{t('liabilities.import')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tab de Ativos */}
|
|
||||||
{activeTab === 'assets' && (
|
|
||||||
<>
|
|
||||||
{/* Summary Cards de Ativos */}
|
|
||||||
<div className={`row ${isMobile ? 'g-2 mb-3' : 'mb-4'}`}>
|
|
||||||
{Object.entries(getAssetTotalsByCurrency()).map(([currency, data]) => (
|
|
||||||
<div className="col-md-4" key={currency}>
|
|
||||||
<div className={`card border-0 ${!isMobile ? 'h-100' : ''}`} style={{ background: '#1e293b' }}>
|
|
||||||
<div className={`card-body ${isMobile ? 'p-3' : ''}`}>
|
|
||||||
<div className="d-flex justify-content-between align-items-start">
|
|
||||||
<div>
|
|
||||||
<p className={`text-slate-400 mb-1 ${isMobile ? '' : 'small'}`} style={isMobile ? { fontSize: '0.7rem' } : undefined}>
|
|
||||||
Activos ({currency}) - {data.count} items
|
|
||||||
</p>
|
|
||||||
<h4 className={`mb-0 text-success ${isMobile ? 'fs-5' : ''}`}>
|
|
||||||
{formatCurrency(data.current, currency)}
|
|
||||||
</h4>
|
|
||||||
{data.current !== data.acquisition && (
|
|
||||||
<small className={`${data.current > data.acquisition ? 'text-success' : 'text-danger'}`}>
|
|
||||||
<i className={`bi ${data.current > data.acquisition ? 'bi-arrow-up' : 'bi-arrow-down'} me-1`}></i>
|
|
||||||
{((data.current - data.acquisition) / data.acquisition * 100).toFixed(1)}% desde compra
|
|
||||||
</small>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className={`rounded-circle bg-success bg-opacity-25 ${isMobile ? 'p-2' : 'p-3'}`}>
|
|
||||||
<i className={`bi bi-graph-up-arrow text-success ${isMobile ? 'fs-5' : 'fs-4'}`}></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{Object.keys(getAssetTotalsByCurrency()).length === 0 && (
|
|
||||||
<div className="col-12">
|
|
||||||
<div className="card border-0" style={{ background: '#1e293b' }}>
|
|
||||||
<div className={`card-body ${isMobile ? 'p-3' : ''} text-center`}>
|
|
||||||
<i className="bi bi-graph-up-arrow text-slate-600 fs-1 mb-2"></i>
|
|
||||||
<p className="text-slate-400 mb-0">No hay activos registrados</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Lista de Ativos */}
|
|
||||||
<div className="card border-0" style={{ background: '#1e293b' }}>
|
|
||||||
<div className={`card-body ${isMobile ? 'p-2' : 'p-0'}`}>
|
|
||||||
{assetAccounts.length === 0 ? (
|
|
||||||
<div className="text-center py-5">
|
|
||||||
<i className="bi bi-graph-up-arrow display-1 text-slate-600"></i>
|
|
||||||
<p className="text-slate-400 mt-3">No hay activos registrados</p>
|
|
||||||
<button className="btn btn-success" onClick={() => setShowAssetWizard(true)}>
|
|
||||||
<i className="bi bi-plus-lg me-2"></i>
|
|
||||||
Crear Activo
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : isMobile ? (
|
|
||||||
// Mobile: Cards Layout para Ativos
|
|
||||||
<div className="d-flex flex-column gap-2">
|
|
||||||
{assetAccounts.map((asset) => (
|
|
||||||
<div
|
|
||||||
key={`asset-${asset.id}`}
|
|
||||||
className="card border-secondary"
|
|
||||||
style={{ cursor: 'pointer', background: '#0f172a' }}
|
|
||||||
>
|
|
||||||
<div className="card-body p-3">
|
|
||||||
{/* Header with Icon and Name */}
|
|
||||||
<div className="d-flex align-items-start gap-2 mb-2">
|
|
||||||
<div
|
|
||||||
className="rounded-circle d-flex align-items-center justify-content-center flex-shrink-0"
|
|
||||||
style={{
|
|
||||||
width: '35px',
|
|
||||||
height: '35px',
|
|
||||||
backgroundColor: (asset.color || '#10B981') + '25',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<i className={`bi bi-${asset.asset_type === 'real_estate' ? 'house' : asset.asset_type === 'vehicle' ? 'truck' : asset.asset_type === 'investment' ? 'graph-up' : 'box'} fs-6`} style={{ color: asset.color || '#10B981' }}></i>
|
|
||||||
</div>
|
|
||||||
<div className="flex-grow-1" style={{ minWidth: 0 }}>
|
|
||||||
<div className="text-white fw-medium mb-1" style={{ fontSize: '0.85rem' }}>
|
|
||||||
{asset.name}
|
|
||||||
</div>
|
|
||||||
<div className="text-slate-400" style={{ fontSize: '0.65rem' }}>
|
|
||||||
{assetAccountService.statuses[asset.asset_type] || asset.asset_type}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className={`badge ${asset.status === 'active' ? 'bg-success' : 'bg-secondary'}`} style={{ fontSize: '0.65rem' }}>
|
|
||||||
{asset.status === 'active' ? 'Activo' : asset.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Values */}
|
|
||||||
<div className="d-flex justify-content-between align-items-center pt-2" style={{ borderTop: '1px solid #334155' }}>
|
|
||||||
<div>
|
|
||||||
<div className="text-slate-400" style={{ fontSize: '0.65rem' }}>Valor Actual</div>
|
|
||||||
<div className="text-success fw-bold" style={{ fontSize: '0.9rem' }}>
|
|
||||||
{formatCurrency(asset.current_value, asset.currency)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-end">
|
|
||||||
<div className="text-slate-400" style={{ fontSize: '0.65rem' }}>Rentabilidad</div>
|
|
||||||
<div className={`fw-medium ${parseFloat(asset.current_value) >= parseFloat(asset.acquisition_value) ? 'text-success' : 'text-danger'}`} style={{ fontSize: '0.8rem' }}>
|
|
||||||
{asset.acquisition_value > 0 ? (
|
|
||||||
<>
|
|
||||||
<i className={`bi ${parseFloat(asset.current_value) >= parseFloat(asset.acquisition_value) ? 'bi-arrow-up' : 'bi-arrow-down'} me-1`}></i>
|
|
||||||
{(((parseFloat(asset.current_value) - parseFloat(asset.acquisition_value)) / parseFloat(asset.acquisition_value)) * 100).toFixed(1)}%
|
|
||||||
</>
|
|
||||||
) : '-'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
// Desktop: Table Layout para Ativos
|
|
||||||
<div className="table-responsive">
|
|
||||||
<table className="table table-hover mb-0" style={{ '--bs-table-bg': 'transparent', backgroundColor: 'transparent' }}>
|
|
||||||
<thead style={{ backgroundColor: 'transparent' }}>
|
|
||||||
<tr style={{ borderBottom: '1px solid #334155', backgroundColor: 'transparent' }}>
|
|
||||||
<th className="text-slate-400 fw-normal py-3 ps-4" style={{ backgroundColor: 'transparent' }}>Nombre</th>
|
|
||||||
<th className="text-slate-400 fw-normal py-3" style={{ backgroundColor: 'transparent' }}>Tipo</th>
|
|
||||||
<th className="text-slate-400 fw-normal py-3 text-end" style={{ backgroundColor: 'transparent' }}>Adquisición</th>
|
|
||||||
<th className="text-slate-400 fw-normal py-3 text-end" style={{ backgroundColor: 'transparent' }}>Valor Actual</th>
|
|
||||||
<th className="text-slate-400 fw-normal py-3 text-center" style={{ backgroundColor: 'transparent' }}>Rentabilidad</th>
|
|
||||||
<th className="text-slate-400 fw-normal py-3 text-center" style={{ backgroundColor: 'transparent' }}>{t('common.status')}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody style={{ backgroundColor: 'transparent' }}>
|
|
||||||
{assetAccounts.map((asset) => {
|
|
||||||
const gainLoss = parseFloat(asset.current_value) - parseFloat(asset.acquisition_value);
|
|
||||||
const gainLossPercent = asset.acquisition_value > 0 ? (gainLoss / parseFloat(asset.acquisition_value)) * 100 : 0;
|
|
||||||
return (
|
|
||||||
<tr
|
|
||||||
key={`asset-${asset.id}`}
|
|
||||||
style={{ borderBottom: '1px solid #334155', backgroundColor: 'transparent', cursor: 'pointer' }}
|
|
||||||
onClick={() => handleOpenAssetDetail(asset)}
|
|
||||||
className="asset-row-hover"
|
|
||||||
>
|
|
||||||
<td className="py-3 ps-4" style={{ backgroundColor: 'transparent' }}>
|
|
||||||
<div className="d-flex align-items-center">
|
|
||||||
<div
|
|
||||||
className="rounded-circle d-flex align-items-center justify-content-center me-3"
|
|
||||||
style={{
|
|
||||||
width: '40px',
|
|
||||||
height: '40px',
|
|
||||||
backgroundColor: (asset.color || '#10B981') + '25',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<i className={`bi bi-${asset.asset_type === 'real_estate' ? 'house' : asset.asset_type === 'vehicle' ? 'truck' : asset.asset_type === 'investment' ? 'graph-up' : 'box'}`} style={{ color: asset.color || '#10B981' }}></i>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-white fw-medium">{asset.name}</div>
|
|
||||||
{asset.description && (
|
|
||||||
<small className="text-slate-400">{asset.description.substring(0, 30)}</small>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="py-3" style={{ backgroundColor: 'transparent' }}>
|
|
||||||
<span className="badge" style={{ backgroundColor: '#334155' }}>
|
|
||||||
{asset.asset_type === 'real_estate' ? 'Inmueble' :
|
|
||||||
asset.asset_type === 'vehicle' ? 'Vehículo' :
|
|
||||||
asset.asset_type === 'investment' ? 'Inversión' :
|
|
||||||
asset.asset_type === 'equipment' ? 'Equipamiento' :
|
|
||||||
asset.asset_type === 'receivable' ? 'Crédito' :
|
|
||||||
asset.asset_type === 'cash' ? 'Efectivo' :
|
|
||||||
asset.asset_type}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-3 text-end text-slate-300" style={{ backgroundColor: 'transparent' }}>
|
|
||||||
{formatCurrency(asset.acquisition_value, asset.currency)}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 text-end fw-medium text-success" style={{ backgroundColor: 'transparent' }}>
|
|
||||||
{formatCurrency(asset.current_value, asset.currency)}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 text-center" style={{ backgroundColor: 'transparent' }}>
|
|
||||||
<span className={`fw-medium ${gainLoss >= 0 ? 'text-success' : 'text-danger'}`}>
|
|
||||||
<i className={`bi ${gainLoss >= 0 ? 'bi-arrow-up' : 'bi-arrow-down'} me-1`}></i>
|
|
||||||
{gainLossPercent.toFixed(1)}%
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-3 text-center" style={{ backgroundColor: 'transparent' }}>
|
|
||||||
<span className={`badge ${asset.status === 'active' ? 'bg-success' : 'bg-secondary'}`}>
|
|
||||||
{asset.status === 'active' ? 'Activo' : asset.status === 'disposed' ? 'Vendido' : asset.status}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Asset Wizard Modal */}
|
|
||||||
<AssetWizard
|
|
||||||
isOpen={showAssetWizard}
|
|
||||||
onClose={() => {
|
|
||||||
setShowAssetWizard(false);
|
|
||||||
setEditingAsset(null);
|
|
||||||
}}
|
|
||||||
onSuccess={handleAssetSuccess}
|
|
||||||
asset={editingAsset}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Account Wizard Modal */}
|
|
||||||
<AccountWizard
|
|
||||||
isOpen={showAccountWizard}
|
|
||||||
onClose={() => {
|
|
||||||
setShowAccountWizard(false);
|
|
||||||
setEditingAccount(null);
|
|
||||||
}}
|
|
||||||
onSuccess={handleAccountWizardSuccess}
|
|
||||||
account={editingAccount}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Modal de Criar/Editar */}
|
{/* Modal de Criar/Editar */}
|
||||||
{showModal && (
|
{showModal && (
|
||||||
@ -1484,259 +1088,6 @@ const Accounts = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Modal de Detalhes do Ativo */}
|
|
||||||
{showAssetDetail && selectedAsset && (
|
|
||||||
<div className="modal fade show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.8)' }}>
|
|
||||||
<div className="modal-dialog modal-lg modal-dialog-centered">
|
|
||||||
<div className="modal-content" style={{ backgroundColor: '#1e293b', border: '1px solid #334155' }}>
|
|
||||||
<div className="modal-header border-0">
|
|
||||||
<div className="d-flex align-items-center">
|
|
||||||
<div
|
|
||||||
className="rounded-circle d-flex align-items-center justify-content-center me-3"
|
|
||||||
style={{
|
|
||||||
width: '48px',
|
|
||||||
height: '48px',
|
|
||||||
backgroundColor: (selectedAsset.color || '#10B981') + '25',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<i className={`bi bi-${selectedAsset.asset_type === 'real_estate' ? 'house' : selectedAsset.asset_type === 'vehicle' ? 'truck' : selectedAsset.asset_type === 'investment' ? 'graph-up' : 'box'} fs-4`} style={{ color: selectedAsset.color || '#10B981' }}></i>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h5 className="modal-title text-white mb-0">{selectedAsset.name}</h5>
|
|
||||||
{selectedAsset.description && (
|
|
||||||
<small className="text-slate-400">{selectedAsset.description}</small>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button type="button" className="btn-close btn-close-white" onClick={handleCloseAssetDetail}></button>
|
|
||||||
</div>
|
|
||||||
<div className="modal-body">
|
|
||||||
{/* Valores principais */}
|
|
||||||
<div className="row g-3 mb-4">
|
|
||||||
<div className="col-md-4">
|
|
||||||
<div className="p-3 rounded" style={{ backgroundColor: '#0f172a' }}>
|
|
||||||
<div className="text-slate-400 small mb-1">Valor de Adquisición</div>
|
|
||||||
<div className="text-white fs-5 fw-bold">
|
|
||||||
{formatCurrency(selectedAsset.acquisition_value, selectedAsset.currency)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="col-md-4">
|
|
||||||
<div className="p-3 rounded" style={{ backgroundColor: '#0f172a' }}>
|
|
||||||
<div className="text-slate-400 small mb-1">Valor Actual</div>
|
|
||||||
<div className="text-success fs-5 fw-bold">
|
|
||||||
{formatCurrency(selectedAsset.current_value, selectedAsset.currency)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="col-md-4">
|
|
||||||
<div className="p-3 rounded" style={{ backgroundColor: '#0f172a' }}>
|
|
||||||
<div className="text-slate-400 small mb-1">Rentabilidad</div>
|
|
||||||
{(() => {
|
|
||||||
const gainLoss = parseFloat(selectedAsset.current_value) - parseFloat(selectedAsset.acquisition_value);
|
|
||||||
const gainLossPercent = selectedAsset.acquisition_value > 0 ? (gainLoss / parseFloat(selectedAsset.acquisition_value)) * 100 : 0;
|
|
||||||
return (
|
|
||||||
<div className={`fs-5 fw-bold ${gainLoss >= 0 ? 'text-success' : 'text-danger'}`}>
|
|
||||||
<i className={`bi ${gainLoss >= 0 ? 'bi-arrow-up' : 'bi-arrow-down'} me-1`}></i>
|
|
||||||
{formatCurrency(Math.abs(gainLoss), selectedAsset.currency)} ({gainLossPercent.toFixed(1)}%)
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Informações detalhadas */}
|
|
||||||
<div className="row g-3">
|
|
||||||
<div className="col-12">
|
|
||||||
<h6 className="text-white mb-3">
|
|
||||||
<i className="bi bi-info-circle me-2"></i>
|
|
||||||
Información del Activo
|
|
||||||
</h6>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="col-md-6">
|
|
||||||
<div className="mb-3">
|
|
||||||
<span className="text-slate-400 small">Tipo de Activo</span>
|
|
||||||
<div className="text-white">
|
|
||||||
{selectedAsset.asset_type === 'real_estate' ? 'Inmueble' :
|
|
||||||
selectedAsset.asset_type === 'vehicle' ? 'Vehículo' :
|
|
||||||
selectedAsset.asset_type === 'investment' ? 'Inversión' :
|
|
||||||
selectedAsset.asset_type === 'equipment' ? 'Equipamiento' :
|
|
||||||
selectedAsset.asset_type === 'receivable' ? 'Crédito por Cobrar' :
|
|
||||||
selectedAsset.asset_type === 'cash' ? 'Efectivo' :
|
|
||||||
selectedAsset.asset_type}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="col-md-6">
|
|
||||||
<div className="mb-3">
|
|
||||||
<span className="text-slate-400 small">Estado</span>
|
|
||||||
<div>
|
|
||||||
<span className={`badge ${selectedAsset.status === 'active' ? 'bg-success' : 'bg-secondary'}`}>
|
|
||||||
{selectedAsset.status === 'active' ? 'Activo' : selectedAsset.status === 'disposed' ? 'Vendido' : selectedAsset.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedAsset.acquisition_date && (
|
|
||||||
<div className="col-md-6">
|
|
||||||
<div className="mb-3">
|
|
||||||
<span className="text-slate-400 small">Fecha de Adquisición</span>
|
|
||||||
<div className="text-white">
|
|
||||||
{new Date(selectedAsset.acquisition_date).toLocaleDateString('es-ES', { day: '2-digit', month: 'long', year: 'numeric' })}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Campos específicos por tipo */}
|
|
||||||
{selectedAsset.asset_type === 'real_estate' && (
|
|
||||||
<>
|
|
||||||
{selectedAsset.address && (
|
|
||||||
<div className="col-md-6">
|
|
||||||
<div className="mb-3">
|
|
||||||
<span className="text-slate-400 small">Dirección</span>
|
|
||||||
<div className="text-white">{selectedAsset.address}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{selectedAsset.city && (
|
|
||||||
<div className="col-md-6">
|
|
||||||
<div className="mb-3">
|
|
||||||
<span className="text-slate-400 small">Ciudad</span>
|
|
||||||
<div className="text-white">{selectedAsset.city}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{selectedAsset.property_area_m2 && (
|
|
||||||
<div className="col-md-6">
|
|
||||||
<div className="mb-3">
|
|
||||||
<span className="text-slate-400 small">Área</span>
|
|
||||||
<div className="text-white">{selectedAsset.property_area_m2} m²</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedAsset.asset_type === 'vehicle' && (
|
|
||||||
<>
|
|
||||||
{selectedAsset.vehicle_brand && (
|
|
||||||
<div className="col-md-6">
|
|
||||||
<div className="mb-3">
|
|
||||||
<span className="text-slate-400 small">Marca/Modelo</span>
|
|
||||||
<div className="text-white">{selectedAsset.vehicle_brand} {selectedAsset.vehicle_model}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{selectedAsset.vehicle_year && (
|
|
||||||
<div className="col-md-6">
|
|
||||||
<div className="mb-3">
|
|
||||||
<span className="text-slate-400 small">Año</span>
|
|
||||||
<div className="text-white">{selectedAsset.vehicle_year}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{selectedAsset.vehicle_plate && (
|
|
||||||
<div className="col-md-6">
|
|
||||||
<div className="mb-3">
|
|
||||||
<span className="text-slate-400 small">Matrícula</span>
|
|
||||||
<div className="text-white">{selectedAsset.vehicle_plate}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{selectedAsset.vehicle_mileage && (
|
|
||||||
<div className="col-md-6">
|
|
||||||
<div className="mb-3">
|
|
||||||
<span className="text-slate-400 small">Kilometraje</span>
|
|
||||||
<div className="text-white">{selectedAsset.vehicle_mileage.toLocaleString()} km</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedAsset.asset_type === 'investment' && (
|
|
||||||
<>
|
|
||||||
{selectedAsset.investment_type && (
|
|
||||||
<div className="col-md-6">
|
|
||||||
<div className="mb-3">
|
|
||||||
<span className="text-slate-400 small">Tipo de Inversión</span>
|
|
||||||
<div className="text-white">
|
|
||||||
{selectedAsset.investment_type === 'stocks' ? 'Acciones' :
|
|
||||||
selectedAsset.investment_type === 'bonds' ? 'Bonos' :
|
|
||||||
selectedAsset.investment_type === 'etf' ? 'ETF' :
|
|
||||||
selectedAsset.investment_type === 'mutual_fund' ? 'Fondo Mutuo' :
|
|
||||||
selectedAsset.investment_type === 'crypto' ? 'Criptomoneda' :
|
|
||||||
selectedAsset.investment_type === 'fixed_deposit' ? 'Depósito a Plazo' :
|
|
||||||
selectedAsset.investment_type}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{selectedAsset.institution && (
|
|
||||||
<div className="col-md-6">
|
|
||||||
<div className="mb-3">
|
|
||||||
<span className="text-slate-400 small">Institución</span>
|
|
||||||
<div className="text-white">{selectedAsset.institution}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{selectedAsset.ticker && (
|
|
||||||
<div className="col-md-6">
|
|
||||||
<div className="mb-3">
|
|
||||||
<span className="text-slate-400 small">Ticker/Símbolo</span>
|
|
||||||
<div className="text-white">{selectedAsset.ticker}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{selectedAsset.quantity && (
|
|
||||||
<div className="col-md-6">
|
|
||||||
<div className="mb-3">
|
|
||||||
<span className="text-slate-400 small">Cantidad</span>
|
|
||||||
<div className="text-white">{selectedAsset.quantity}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{selectedAsset.interest_rate && (
|
|
||||||
<div className="col-md-6">
|
|
||||||
<div className="mb-3">
|
|
||||||
<span className="text-slate-400 small">Tasa de Interés</span>
|
|
||||||
<div className="text-white">{selectedAsset.interest_rate}%</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{selectedAsset.maturity_date && (
|
|
||||||
<div className="col-md-6">
|
|
||||||
<div className="mb-3">
|
|
||||||
<span className="text-slate-400 small">Fecha de Vencimiento</span>
|
|
||||||
<div className="text-white">
|
|
||||||
{new Date(selectedAsset.maturity_date).toLocaleDateString('es-ES', { day: '2-digit', month: 'long', year: 'numeric' })}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="modal-footer border-0">
|
|
||||||
<button type="button" className="btn btn-primary me-2" onClick={handleEditAsset}>
|
|
||||||
<i className="bi bi-pencil me-2"></i>
|
|
||||||
Editar
|
|
||||||
</button>
|
|
||||||
<button type="button" className="btn btn-secondary" onClick={handleCloseAssetDetail}>
|
|
||||||
Cerrar
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Modal de Confirmação de Exclusão */}
|
{/* Modal de Confirmação de Exclusão */}
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
show={showDeleteModal}
|
show={showDeleteModal}
|
||||||
|
|||||||
@ -1,125 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { useSearchParams, useNavigate, Link } from 'react-router-dom';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import api from '../services/api';
|
|
||||||
import logo from '../assets/logo-white.png';
|
|
||||||
|
|
||||||
const ActivateAccount = () => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [status, setStatus] = useState('loading'); // loading, success, error
|
|
||||||
const [message, setMessage] = useState('');
|
|
||||||
const [countdown, setCountdown] = useState(5);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const token = searchParams.get('token');
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
setStatus('error');
|
|
||||||
setMessage(t('activate.invalidLink'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
activateAccount(token);
|
|
||||||
}, [searchParams]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (status === 'success' && countdown > 0) {
|
|
||||||
const timer = setTimeout(() => setCountdown(countdown - 1), 1000);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
} else if (status === 'success' && countdown === 0) {
|
|
||||||
navigate('/dashboard');
|
|
||||||
}
|
|
||||||
}, [status, countdown, navigate]);
|
|
||||||
|
|
||||||
const activateAccount = async (token) => {
|
|
||||||
try {
|
|
||||||
const response = await api.post('/activate', { token });
|
|
||||||
|
|
||||||
if (response.data.success) {
|
|
||||||
// Save token and user to localStorage
|
|
||||||
localStorage.setItem('token', response.data.data.token);
|
|
||||||
localStorage.setItem('user', JSON.stringify(response.data.data.user));
|
|
||||||
|
|
||||||
setStatus('success');
|
|
||||||
setMessage(t('activate.success'));
|
|
||||||
} else {
|
|
||||||
setStatus('error');
|
|
||||||
setMessage(response.data.message || t('activate.error'));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
setStatus('error');
|
|
||||||
setMessage(error.response?.data?.message || t('activate.error'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-vh-100 d-flex align-items-center justify-content-center"
|
|
||||||
style={{ background: 'linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)' }}>
|
|
||||||
<div className="container">
|
|
||||||
<div className="row justify-content-center">
|
|
||||||
<div className="col-md-6 col-lg-5">
|
|
||||||
<div className="card shadow-lg border-0">
|
|
||||||
<div className="card-body p-5 text-center">
|
|
||||||
<Link to="/">
|
|
||||||
<img src={logo} alt="WEBMoney" className="mb-4" style={{ height: '80px', width: 'auto' }} />
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{status === 'loading' && (
|
|
||||||
<>
|
|
||||||
<div className="spinner-border text-primary mb-4" role="status">
|
|
||||||
<span className="visually-hidden">{t('common.loading')}</span>
|
|
||||||
</div>
|
|
||||||
<h4 className="mb-3">{t('activate.activating')}</h4>
|
|
||||||
<p className="text-muted">{t('activate.pleaseWait')}</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status === 'success' && (
|
|
||||||
<>
|
|
||||||
<div className="mb-4">
|
|
||||||
<i className="bi bi-check-circle-fill text-success" style={{ fontSize: '80px' }}></i>
|
|
||||||
</div>
|
|
||||||
<h4 className="mb-3 text-success">{t('activate.successTitle')}</h4>
|
|
||||||
<p className="text-muted mb-4">{message}</p>
|
|
||||||
<div className="alert alert-info">
|
|
||||||
<i className="bi bi-clock me-2"></i>
|
|
||||||
{t('activate.redirecting', { seconds: countdown })}
|
|
||||||
</div>
|
|
||||||
<Link to="/dashboard" className="btn btn-primary btn-lg w-100">
|
|
||||||
<i className="bi bi-speedometer2 me-2"></i>
|
|
||||||
{t('activate.goToDashboard')}
|
|
||||||
</Link>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status === 'error' && (
|
|
||||||
<>
|
|
||||||
<div className="mb-4">
|
|
||||||
<i className="bi bi-x-circle-fill text-danger" style={{ fontSize: '80px' }}></i>
|
|
||||||
</div>
|
|
||||||
<h4 className="mb-3 text-danger">{t('activate.errorTitle')}</h4>
|
|
||||||
<p className="text-muted mb-4">{message}</p>
|
|
||||||
<div className="d-grid gap-2">
|
|
||||||
<Link to="/login" className="btn btn-primary btn-lg">
|
|
||||||
<i className="bi bi-box-arrow-in-right me-2"></i>
|
|
||||||
{t('auth.login')}
|
|
||||||
</Link>
|
|
||||||
<Link to="/" className="btn btn-outline-secondary">
|
|
||||||
<i className="bi bi-house me-2"></i>
|
|
||||||
{t('common.back')}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ActivateAccount;
|
|
||||||
@ -16,9 +16,6 @@ export default function Billing() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [canceling, setCanceling] = useState(false);
|
const [canceling, setCanceling] = useState(false);
|
||||||
const [showCancelModal, setShowCancelModal] = useState(false);
|
const [showCancelModal, setShowCancelModal] = useState(false);
|
||||||
const [requestRefund, setRequestRefund] = useState(false);
|
|
||||||
const [withinGuaranteePeriod, setWithinGuaranteePeriod] = useState(false);
|
|
||||||
const [guaranteeDaysRemaining, setGuaranteeDaysRemaining] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Handle subscription confirmation from PayPal return
|
// Handle subscription confirmation from PayPal return
|
||||||
@ -62,8 +59,6 @@ export default function Billing() {
|
|||||||
if (statusResponse.data.success) {
|
if (statusResponse.data.success) {
|
||||||
setSubscription(statusResponse.data.data.subscription);
|
setSubscription(statusResponse.data.data.subscription);
|
||||||
setPlan(statusResponse.data.data.plan);
|
setPlan(statusResponse.data.data.plan);
|
||||||
setWithinGuaranteePeriod(statusResponse.data.data.within_guarantee_period || false);
|
|
||||||
setGuaranteeDaysRemaining(statusResponse.data.data.guarantee_days_remaining || 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load invoices
|
// Load invoices
|
||||||
@ -84,17 +79,11 @@ export default function Billing() {
|
|||||||
const handleCancelSubscription = async () => {
|
const handleCancelSubscription = async () => {
|
||||||
try {
|
try {
|
||||||
setCanceling(true);
|
setCanceling(true);
|
||||||
const response = await api.post('/subscription/cancel', {
|
const response = await api.post('/subscription/cancel');
|
||||||
request_refund: requestRefund && withinGuaranteePeriod,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
const message = response.data.data?.refunded
|
showToast(t('billing.subscriptionCanceled'), 'success');
|
||||||
? t('billing.subscriptionCanceledRefunded', 'Assinatura cancelada e reembolso processado')
|
|
||||||
: t('billing.subscriptionCanceled');
|
|
||||||
showToast(message, 'success');
|
|
||||||
setShowCancelModal(false);
|
setShowCancelModal(false);
|
||||||
setRequestRefund(false);
|
|
||||||
loadData();
|
loadData();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -173,13 +162,6 @@ export default function Billing() {
|
|||||||
{t(`billing.status.${subscription.status}`)}
|
{t(`billing.status.${subscription.status}`)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{withinGuaranteePeriod && (
|
|
||||||
<span className="badge bg-info me-2">
|
|
||||||
<i className="bi bi-shield-check me-1"></i>
|
|
||||||
{t('billing.guaranteeBadge', { days: guaranteeDaysRemaining, defaultValue: `Garantia: ${guaranteeDaysRemaining} dias` })}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{subscription.status === 'trialing' && subscription.trial_ends_at && (
|
{subscription.status === 'trialing' && subscription.trial_ends_at && (
|
||||||
<small className="text-muted">
|
<small className="text-muted">
|
||||||
{t('billing.trialEnds', { date: formatDate(subscription.trial_ends_at) })}
|
{t('billing.trialEnds', { date: formatDate(subscription.trial_ends_at) })}
|
||||||
@ -260,7 +242,7 @@ export default function Billing() {
|
|||||||
{plan.features?.slice(0, Math.ceil(plan.features.length / 2)).map((feature, idx) => (
|
{plan.features?.slice(0, Math.ceil(plan.features.length / 2)).map((feature, idx) => (
|
||||||
<li key={idx} className="mb-2">
|
<li key={idx} className="mb-2">
|
||||||
<i className="bi bi-check-circle-fill text-success me-2"></i>
|
<i className="bi bi-check-circle-fill text-success me-2"></i>
|
||||||
{t(feature, feature)}
|
{feature}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@ -270,7 +252,7 @@ export default function Billing() {
|
|||||||
{plan.features?.slice(Math.ceil(plan.features.length / 2)).map((feature, idx) => (
|
{plan.features?.slice(Math.ceil(plan.features.length / 2)).map((feature, idx) => (
|
||||||
<li key={idx} className="mb-2">
|
<li key={idx} className="mb-2">
|
||||||
<i className="bi bi-check-circle-fill text-success me-2"></i>
|
<i className="bi bi-check-circle-fill text-success me-2"></i>
|
||||||
{t(feature, feature)}
|
{feature}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@ -336,7 +318,7 @@ export default function Billing() {
|
|||||||
<th>{t('billing.date')}</th>
|
<th>{t('billing.date')}</th>
|
||||||
<th>{t('billing.description')}</th>
|
<th>{t('billing.description')}</th>
|
||||||
<th className="text-end">{t('billing.amount')}</th>
|
<th className="text-end">{t('billing.amount')}</th>
|
||||||
<th>{t('common.status')}</th>
|
<th>{t('billing.status')}</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -344,12 +326,12 @@ export default function Billing() {
|
|||||||
{invoices.map((invoice) => (
|
{invoices.map((invoice) => (
|
||||||
<tr key={invoice.id}>
|
<tr key={invoice.id}>
|
||||||
<td>
|
<td>
|
||||||
<code>{invoice.number || '-'}</code>
|
<code>{invoice.invoice_number}</code>
|
||||||
</td>
|
</td>
|
||||||
<td>{formatDate(invoice.paid_at || invoice.created_at)}</td>
|
<td>{formatDate(invoice.invoice_date)}</td>
|
||||||
<td>{invoice.description || '-'}</td>
|
<td>{invoice.description || '-'}</td>
|
||||||
<td className="text-end">
|
<td className="text-end">
|
||||||
{invoice.formatted_total || formatCurrency(invoice.total, invoice.currency)}
|
{formatCurrency(invoice.total_amount, invoice.currency)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span className={`badge ${getInvoiceStatusBadge(invoice.status)}`}>
|
<span className={`badge ${getInvoiceStatusBadge(invoice.status)}`}>
|
||||||
@ -387,60 +369,28 @@ export default function Billing() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn-close"
|
className="btn-close"
|
||||||
onClick={() => { setShowCancelModal(false); setRequestRefund(false); }}
|
onClick={() => setShowCancelModal(false)}
|
||||||
></button>
|
></button>
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-body">
|
<div className="modal-body">
|
||||||
{withinGuaranteePeriod ? (
|
<p>{t('billing.cancelConfirmMessage')}</p>
|
||||||
<>
|
<ul className="text-muted">
|
||||||
<div className="alert alert-info">
|
|
||||||
<i className="bi bi-info-circle me-2"></i>
|
|
||||||
<strong>{t('billing.guaranteePeriod', 'Período de garantia')}</strong>
|
|
||||||
<p className="mb-0 mt-1">
|
|
||||||
{t('billing.guaranteeMessage', {
|
|
||||||
days: guaranteeDaysRemaining,
|
|
||||||
defaultValue: `Você ainda tem ${guaranteeDaysRemaining} dia(s) restantes no período de garantia de 7 dias. Você pode cancelar e receber um reembolso total.`
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-check mb-3">
|
|
||||||
<input
|
|
||||||
className="form-check-input"
|
|
||||||
type="checkbox"
|
|
||||||
id="requestRefund"
|
|
||||||
checked={requestRefund}
|
|
||||||
onChange={(e) => setRequestRefund(e.target.checked)}
|
|
||||||
/>
|
|
||||||
<label className="form-check-label" htmlFor="requestRefund">
|
|
||||||
<strong>{t('billing.requestRefund', 'Solicitar reembolso total')}</strong>
|
|
||||||
<small className="d-block text-muted">
|
|
||||||
{t('billing.refundNote', 'O reembolso será processado pela PayPal em até 5-10 dias úteis.')}
|
|
||||||
</small>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p>{t('billing.cancelConfirmMessage')}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ul className="text-muted small">
|
|
||||||
<li>{t('billing.cancelNote1')}</li>
|
<li>{t('billing.cancelNote1')}</li>
|
||||||
<li>{t('billing.cancelNote2')}</li>
|
<li>{t('billing.cancelNote2')}</li>
|
||||||
{!requestRefund && <li>{t('billing.cancelNote3')}</li>}
|
<li>{t('billing.cancelNote3')}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-footer">
|
<div className="modal-footer">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
onClick={() => { setShowCancelModal(false); setRequestRefund(false); }}
|
onClick={() => setShowCancelModal(false)}
|
||||||
>
|
>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`btn ${requestRefund ? 'btn-warning' : 'btn-danger'}`}
|
className="btn btn-danger"
|
||||||
onClick={handleCancelSubscription}
|
onClick={handleCancelSubscription}
|
||||||
disabled={canceling}
|
disabled={canceling}
|
||||||
>
|
>
|
||||||
@ -449,8 +399,6 @@ export default function Billing() {
|
|||||||
<span className="spinner-border spinner-border-sm me-2"></span>
|
<span className="spinner-border spinner-border-sm me-2"></span>
|
||||||
{t('common.processing')}
|
{t('common.processing')}
|
||||||
</>
|
</>
|
||||||
) : requestRefund ? (
|
|
||||||
t('billing.cancelAndRefund', 'Cancelar e Reembolsar')
|
|
||||||
) : (
|
) : (
|
||||||
t('billing.confirmCancel')
|
t('billing.confirmCancel')
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import { budgetService, categoryService, costCenterService } from '../services/a
|
|||||||
import useFormatters from '../hooks/useFormatters';
|
import useFormatters from '../hooks/useFormatters';
|
||||||
import { getCurrencyByCode } from '../config/currencies';
|
import { getCurrencyByCode } from '../config/currencies';
|
||||||
import ConfirmModal from '../components/ConfirmModal';
|
import ConfirmModal from '../components/ConfirmModal';
|
||||||
import BudgetWizard from '../components/BudgetWizard';
|
|
||||||
|
|
||||||
const Budgets = () => {
|
const Budgets = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -22,7 +21,14 @@ const Budgets = () => {
|
|||||||
const [deleteBudget, setDeleteBudget] = useState(null);
|
const [deleteBudget, setDeleteBudget] = useState(null);
|
||||||
const [yearSummary, setYearSummary] = useState(null);
|
const [yearSummary, setYearSummary] = useState(null);
|
||||||
const [primaryCurrency, setPrimaryCurrency] = useState('EUR');
|
const [primaryCurrency, setPrimaryCurrency] = useState('EUR');
|
||||||
const [showWizard, setShowWizard] = useState(false);
|
const [formData, setFormData] = useState({
|
||||||
|
category_id: '',
|
||||||
|
subcategory_id: '',
|
||||||
|
cost_center_id: '',
|
||||||
|
amount: '',
|
||||||
|
period_type: 'monthly',
|
||||||
|
is_cumulative: false,
|
||||||
|
});
|
||||||
|
|
||||||
// Meses con i18n
|
// Meses con i18n
|
||||||
const getMonths = () => [
|
const getMonths = () => [
|
||||||
@ -86,6 +92,29 @@ const Budgets = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
...formData,
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingBudget) {
|
||||||
|
await budgetService.update(editingBudget.id, data);
|
||||||
|
} else {
|
||||||
|
await budgetService.create(data);
|
||||||
|
}
|
||||||
|
setShowModal(false);
|
||||||
|
setEditingBudget(null);
|
||||||
|
resetForm();
|
||||||
|
loadData();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving budget:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!deleteBudget) return;
|
if (!deleteBudget) return;
|
||||||
try {
|
try {
|
||||||
@ -99,6 +128,11 @@ const Budgets = () => {
|
|||||||
|
|
||||||
const handleEdit = (budget) => {
|
const handleEdit = (budget) => {
|
||||||
setEditingBudget(budget);
|
setEditingBudget(budget);
|
||||||
|
setFormData({
|
||||||
|
category_id: budget.category_id,
|
||||||
|
subcategory_id: budget.subcategory_id || '',
|
||||||
|
amount: budget.amount,
|
||||||
|
});
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -117,8 +151,20 @@ const Budgets = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setFormData({
|
||||||
|
category_id: '',
|
||||||
|
subcategory_id: '',
|
||||||
|
cost_center_id: '',
|
||||||
|
amount: '',
|
||||||
|
period_type: 'monthly',
|
||||||
|
is_cumulative: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const openNewBudget = () => {
|
const openNewBudget = () => {
|
||||||
setEditingBudget(null);
|
setEditingBudget(null);
|
||||||
|
resetForm();
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -200,14 +246,6 @@ const Budgets = () => {
|
|||||||
<i className="bi bi-copy me-1"></i>
|
<i className="bi bi-copy me-1"></i>
|
||||||
{t('budgets.copyToNext')}
|
{t('budgets.copyToNext')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
className="btn btn-outline-primary"
|
|
||||||
onClick={() => setShowWizard(true)}
|
|
||||||
title={t('budgets.wizard.title') || 'Assistente de Orçamentos'}
|
|
||||||
>
|
|
||||||
<i className="bi bi-magic me-1"></i>
|
|
||||||
{t('budgets.wizard.button') || 'Assistente'}
|
|
||||||
</button>
|
|
||||||
<button className="btn btn-primary" onClick={openNewBudget}>
|
<button className="btn btn-primary" onClick={openNewBudget}>
|
||||||
<i className="bi bi-plus-lg me-1"></i>
|
<i className="bi bi-plus-lg me-1"></i>
|
||||||
{t('budgets.addBudget')}
|
{t('budgets.addBudget')}
|
||||||
@ -548,19 +586,280 @@ const Budgets = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Budget Form Modal - Using BudgetWizard with mode='single' */}
|
{/* Budget Form Modal */}
|
||||||
<BudgetWizard
|
{showModal && (
|
||||||
isOpen={showModal}
|
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.7)' }}>
|
||||||
onClose={() => {
|
<div className="modal-dialog modal-dialog-centered">
|
||||||
setShowModal(false);
|
<div className="modal-content border-0" style={{ background: '#1e293b' }}>
|
||||||
setEditingBudget(null);
|
<div className="modal-header border-0">
|
||||||
}}
|
<h5 className="modal-title text-white">
|
||||||
onSuccess={loadData}
|
<i className={`bi ${editingBudget ? 'bi-pencil' : 'bi-plus-circle'} me-2`}></i>
|
||||||
year={year}
|
{editingBudget ? t('budgets.editBudget') : t('budgets.newBudget')}
|
||||||
month={month}
|
</h5>
|
||||||
mode="single"
|
<button
|
||||||
editBudget={editingBudget}
|
type="button"
|
||||||
/>
|
className="btn-close btn-close-white"
|
||||||
|
onClick={() => setShowModal(false)}
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="modal-body" style={{ maxHeight: '70vh', overflowY: 'auto' }}>
|
||||||
|
<p className="text-slate-400 small mb-3">
|
||||||
|
<i className="bi bi-calendar3 me-1"></i>
|
||||||
|
{months.find(m => m.value === month)?.label} {year}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Category Selection - Grid style like transactions */}
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label text-slate-400">{t('budgets.category')} *</label>
|
||||||
|
{editingBudget ? (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control bg-dark border-secondary text-white"
|
||||||
|
value={editingBudget.category?.name || ''}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{availableCategories.length === 0 ? (
|
||||||
|
<div className="alert alert-warning py-2 mb-0">
|
||||||
|
<i className="bi bi-info-circle me-2"></i>
|
||||||
|
{t('budgets.allCategoriesUsed')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="category-grid" style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
|
||||||
|
gap: '8px',
|
||||||
|
maxHeight: '200px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
padding: '4px'
|
||||||
|
}}>
|
||||||
|
{availableCategories.map(cat => (
|
||||||
|
<div
|
||||||
|
key={cat.id}
|
||||||
|
onClick={() => setFormData({...formData, category_id: cat.id, subcategory_id: ''})}
|
||||||
|
className={`p-2 rounded text-center cursor-pointer ${
|
||||||
|
formData.category_id == cat.id
|
||||||
|
? 'border border-primary'
|
||||||
|
: 'border border-secondary'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
background: formData.category_id == cat.id ? 'rgba(59, 130, 246, 0.2)' : '#0f172a',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className={`bi ${cat.icon || 'bi-tag'} d-block mb-1`}
|
||||||
|
style={{
|
||||||
|
fontSize: '1.5rem',
|
||||||
|
color: cat.color || '#6b7280'
|
||||||
|
}}
|
||||||
|
></i>
|
||||||
|
<small className="text-white d-block text-truncate" title={cat.name}>
|
||||||
|
{cat.name}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Subcategory Selection - Only if category selected and has subcategories */}
|
||||||
|
{formData.category_id && !editingBudget && (() => {
|
||||||
|
const selectedCategory = availableCategories.find(c => c.id == formData.category_id);
|
||||||
|
const subcategories = selectedCategory?.subcategories || [];
|
||||||
|
|
||||||
|
if (subcategories.length > 0) {
|
||||||
|
return (
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label text-slate-400">
|
||||||
|
{t('budgets.subcategory')}
|
||||||
|
<small className="text-muted ms-2">({t('common.optional')})</small>
|
||||||
|
</label>
|
||||||
|
<div className="category-grid" style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
|
||||||
|
gap: '8px',
|
||||||
|
maxHeight: '200px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
padding: '4px'
|
||||||
|
}}>
|
||||||
|
{/* Option: All category (no subcategory) */}
|
||||||
|
<div
|
||||||
|
onClick={() => setFormData({...formData, subcategory_id: ''})}
|
||||||
|
className={`p-2 rounded text-center cursor-pointer ${
|
||||||
|
!formData.subcategory_id
|
||||||
|
? 'border border-primary'
|
||||||
|
: 'border border-secondary'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
background: !formData.subcategory_id ? 'rgba(59, 130, 246, 0.2)' : '#0f172a',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className={`bi ${selectedCategory?.icon || 'bi-tag'} d-block mb-1`}
|
||||||
|
style={{
|
||||||
|
fontSize: '1.5rem',
|
||||||
|
color: selectedCategory?.color || '#6b7280'
|
||||||
|
}}
|
||||||
|
></i>
|
||||||
|
<small className="text-white d-block text-truncate">
|
||||||
|
{t('budgets.allCategory')}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Subcategories */}
|
||||||
|
{subcategories.map(sub => (
|
||||||
|
<div
|
||||||
|
key={sub.id}
|
||||||
|
onClick={() => setFormData({...formData, subcategory_id: sub.id})}
|
||||||
|
className={`p-2 rounded text-center cursor-pointer ${
|
||||||
|
formData.subcategory_id == sub.id
|
||||||
|
? 'border border-primary'
|
||||||
|
: 'border border-secondary'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
background: formData.subcategory_id == sub.id ? 'rgba(59, 130, 246, 0.2)' : '#0f172a',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className={`bi ${sub.icon || 'bi-tag'} d-block mb-1`}
|
||||||
|
style={{
|
||||||
|
fontSize: '1.5rem',
|
||||||
|
color: sub.color || '#6b7280'
|
||||||
|
}}
|
||||||
|
></i>
|
||||||
|
<small className="text-white d-block text-truncate" title={sub.name}>
|
||||||
|
{sub.name}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Cost Center Selection - Only if editing or creating new */}
|
||||||
|
{!editingBudget && costCenters.length > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label text-slate-400">
|
||||||
|
{t('budgets.costCenter')}
|
||||||
|
<small className="text-muted ms-2">({t('common.optional')})</small>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="form-select bg-dark border-secondary text-white"
|
||||||
|
value={formData.cost_center_id || ''}
|
||||||
|
onChange={(e) => setFormData({...formData, cost_center_id: e.target.value})}
|
||||||
|
>
|
||||||
|
<option value="">{t('budgets.noCostCenter')}</option>
|
||||||
|
{costCenters.map(cc => (
|
||||||
|
<option key={cc.id} value={cc.id}>
|
||||||
|
{cc.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Amount */}
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label text-slate-400">{t('budgets.amount')} *</label>
|
||||||
|
<div className="input-group">
|
||||||
|
<span className="input-group-text bg-dark border-secondary text-white">
|
||||||
|
{getCurrencyByCode(primaryCurrency)?.symbol || '€'}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0.01"
|
||||||
|
className="form-control bg-dark border-secondary text-white"
|
||||||
|
value={formData.amount}
|
||||||
|
onChange={(e) => setFormData({...formData, amount: e.target.value})}
|
||||||
|
placeholder="0.00"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Period Type */}
|
||||||
|
{!editingBudget && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label text-slate-400">{t('budgets.periodType')}</label>
|
||||||
|
<select
|
||||||
|
className="form-select bg-dark border-secondary text-white"
|
||||||
|
value={formData.period_type || 'monthly'}
|
||||||
|
onChange={(e) => setFormData({...formData, period_type: e.target.value})}
|
||||||
|
>
|
||||||
|
<option value="monthly">{t('budgets.monthly')}</option>
|
||||||
|
<option value="bimestral">{t('budgets.bimestral')}</option>
|
||||||
|
<option value="trimestral">{t('budgets.trimestral')}</option>
|
||||||
|
<option value="semestral">{t('budgets.semestral')}</option>
|
||||||
|
<option value="yearly">{t('budgets.yearly')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cumulative Option */}
|
||||||
|
{!editingBudget && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="form-check form-switch">
|
||||||
|
<input
|
||||||
|
className="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
id="isCumulativeCheck"
|
||||||
|
checked={formData.is_cumulative || false}
|
||||||
|
onChange={(e) => setFormData({...formData, is_cumulative: e.target.checked})}
|
||||||
|
/>
|
||||||
|
<label className="form-check-label text-slate-400" htmlFor="isCumulativeCheck">
|
||||||
|
{t('budgets.isCumulative')}
|
||||||
|
<small className="d-block text-muted">{t('budgets.isCumulativeHelp')}</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Info about auto-propagation */}
|
||||||
|
{!editingBudget && (
|
||||||
|
<div className="alert alert-info py-2 mb-0">
|
||||||
|
<small>
|
||||||
|
<i className="bi bi-info-circle me-1"></i>
|
||||||
|
{t('budgets.autoPropagateInfo')}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer border-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-secondary"
|
||||||
|
onClick={() => setShowModal(false)}
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={!editingBudget && (!formData.category_id || !formData.amount)}
|
||||||
|
>
|
||||||
|
<i className="bi bi-check-lg me-1"></i>
|
||||||
|
{t('common.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Delete Confirmation */}
|
{/* Delete Confirmation */}
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
@ -572,15 +871,6 @@ const Budgets = () => {
|
|||||||
confirmText={t('common.delete')}
|
confirmText={t('common.delete')}
|
||||||
variant="danger"
|
variant="danger"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Budget Wizard */}
|
|
||||||
<BudgetWizard
|
|
||||||
isOpen={showWizard}
|
|
||||||
onClose={() => setShowWizard(false)}
|
|
||||||
onSuccess={loadData}
|
|
||||||
year={year}
|
|
||||||
month={month}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -473,84 +473,40 @@ const Categories = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Modal de Criar/Editar - Design Elegante */}
|
{/* Modal de Criar/Editar */}
|
||||||
{showModal && (
|
{showModal && (
|
||||||
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.8)' }}>
|
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.7)' }}>
|
||||||
<div className="modal-dialog modal-dialog-centered modal-lg">
|
<div className="modal-dialog modal-lg modal-dialog-centered">
|
||||||
<div className="modal-content border-0" style={{ background: '#1e293b', maxHeight: '90vh' }}>
|
<div className="modal-content" style={{ background: '#1e293b' }}>
|
||||||
{/* Header elegante */}
|
<div className="modal-header border-bottom" style={{ borderColor: '#334155 !important' }}>
|
||||||
<div className="modal-header border-0 pb-0">
|
<h5 className="modal-title text-white">
|
||||||
<div>
|
<i className={`bi ${selectedItem ? 'bi-pencil' : 'bi-plus-circle'} me-2`}></i>
|
||||||
<h5 className="modal-title text-white mb-1">
|
{selectedItem ? t('categories.editCategory') : formData.parent_id ? t('categories.createSubcategory') : t('categories.newCategory')}
|
||||||
<i className={`bi ${selectedItem ? 'bi-pencil-square' : formData.parent_id ? 'bi-diagram-3' : 'bi-plus-circle-dotted'} me-2 text-info`}></i>
|
</h5>
|
||||||
{selectedItem ? t('categories.editCategory') : formData.parent_id ? t('categories.createSubcategory') : t('categories.newCategory')}
|
|
||||||
</h5>
|
|
||||||
<p className="text-slate-400 mb-0 small">
|
|
||||||
{formData.parent_id
|
|
||||||
? `${t('categories.parentCategory')}: ${flatCategories.find(c => c.id == formData.parent_id)?.name || ''}`
|
|
||||||
: t('categories.title')
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button type="button" className="btn-close btn-close-white" onClick={handleCloseModal}></button>
|
<button type="button" className="btn-close btn-close-white" onClick={handleCloseModal}></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="modal-body pt-3" style={{ maxHeight: '65vh', overflowY: 'auto' }}>
|
<div className="modal-body">
|
||||||
|
<div className="row g-3">
|
||||||
{/* Preview Card */}
|
{/* Nome */}
|
||||||
<div className="mb-4 p-3 rounded-3" style={{ background: '#0f172a' }}>
|
<div className="col-md-6">
|
||||||
<div className="d-flex align-items-center">
|
<label className="form-label text-slate-300">{t('common.name')} *</label>
|
||||||
<div
|
|
||||||
className="rounded-circle d-flex align-items-center justify-content-center me-3"
|
|
||||||
style={{
|
|
||||||
width: 50,
|
|
||||||
height: 50,
|
|
||||||
background: `${formData.color}25`,
|
|
||||||
border: `2px solid ${formData.color}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<i className={`bi ${formData.icon}`} style={{ fontSize: '1.3rem', color: formData.color }}></i>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h6 className="text-white mb-0">{formData.name || t('categories.newCategory')}</h6>
|
|
||||||
<small className="text-slate-400">{formData.description || t('common.description')}</small>
|
|
||||||
</div>
|
|
||||||
<div className="ms-auto">
|
|
||||||
<span className={`badge bg-${getTypeColor(formData.type)} bg-opacity-25 text-${getTypeColor(formData.type)}`}>
|
|
||||||
{categoryTypes[formData.type]}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Nome e Tipo - Linha principal */}
|
|
||||||
<div className="row g-3 mb-4">
|
|
||||||
<div className="col-md-8">
|
|
||||||
<label className="form-label text-white fw-medium mb-2">
|
|
||||||
<i className="bi bi-type me-2 text-primary"></i>
|
|
||||||
{t('common.name')} *
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="form-control bg-dark text-white border-0"
|
className="form-control bg-dark text-white border-secondary"
|
||||||
style={{ background: '#0f172a' }}
|
|
||||||
name="name"
|
name="name"
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
placeholder={t('categories.namePlaceholder') || 'Ex: Alimentación, Transporte...'}
|
placeholder="Ex: Alimentação, Moradia..."
|
||||||
required
|
required
|
||||||
autoFocus
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-4">
|
|
||||||
<label className="form-label text-white fw-medium mb-2">
|
{/* Tipo */}
|
||||||
<i className="bi bi-arrow-left-right me-2 text-warning"></i>
|
<div className="col-md-3">
|
||||||
{t('common.type')} *
|
<label className="form-label text-slate-300">{t('common.type')} *</label>
|
||||||
</label>
|
|
||||||
<select
|
<select
|
||||||
className="form-select bg-dark text-white border-0"
|
className="form-select bg-dark text-white border-secondary"
|
||||||
style={{ background: '#0f172a' }}
|
|
||||||
name="type"
|
name="type"
|
||||||
value={formData.type}
|
value={formData.type}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
@ -561,218 +517,145 @@ const Categories = () => {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Visual - Cor e Ícone */}
|
{/* Categoria Pai */}
|
||||||
<div className="mb-4">
|
<div className="col-md-3">
|
||||||
<label className="form-label text-white fw-medium mb-2">
|
<label className="form-label text-slate-300">{t('categories.parentCategory')}</label>
|
||||||
<i className="bi bi-palette me-2 text-success"></i>
|
<select
|
||||||
{t('categories.visualSettings') || 'Aparência'}
|
className="form-select bg-dark text-white border-secondary"
|
||||||
</label>
|
name="parent_id"
|
||||||
<div className="row g-3">
|
value={formData.parent_id}
|
||||||
<div className="col-4">
|
onChange={handleChange}
|
||||||
<div className="p-3 rounded text-center" style={{ background: '#0f172a' }}>
|
>
|
||||||
<label className="text-slate-400 small d-block mb-2">{t('common.color')}</label>
|
<option value="">{t('categories.noParent')}</option>
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
className="form-control form-control-color mx-auto border-0"
|
|
||||||
style={{ width: 50, height: 50, cursor: 'pointer', background: 'transparent' }}
|
|
||||||
name="color"
|
|
||||||
value={formData.color}
|
|
||||||
onChange={handleChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="col-8">
|
|
||||||
<div className="p-3 rounded h-100" style={{ background: '#0f172a' }}>
|
|
||||||
<label className="text-slate-400 small d-block mb-2">{t('common.icon')}</label>
|
|
||||||
<IconSelector
|
|
||||||
value={formData.icon}
|
|
||||||
onChange={(icon) => setFormData(prev => ({ ...prev, icon }))}
|
|
||||||
type="category"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Categoria Pai (se não for edição de uma categoria raiz) */}
|
|
||||||
{!selectedItem?.children?.length && (
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="form-label text-white fw-medium mb-2">
|
|
||||||
<i className="bi bi-diagram-2 me-2 text-info"></i>
|
|
||||||
{t('categories.parentCategory')}
|
|
||||||
<span className="badge bg-secondary ms-2" style={{ fontSize: '0.65rem' }}>{t('common.optional')}</span>
|
|
||||||
</label>
|
|
||||||
<div className="row g-2">
|
|
||||||
<div className="col-4 col-md-3">
|
|
||||||
<div
|
|
||||||
onClick={() => setFormData({...formData, parent_id: ''})}
|
|
||||||
className="p-2 rounded text-center h-100 d-flex flex-column justify-content-center"
|
|
||||||
style={{
|
|
||||||
background: !formData.parent_id ? 'rgba(59, 130, 246, 0.15)' : '#0f172a',
|
|
||||||
cursor: 'pointer',
|
|
||||||
border: !formData.parent_id ? '2px solid #3b82f6' : '2px solid transparent',
|
|
||||||
minHeight: 60
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<i className="bi bi-app d-block mb-1 text-slate-400"></i>
|
|
||||||
<small className="text-white">{t('categories.noParent')}</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{flatCategories
|
{flatCategories
|
||||||
.filter(c => c.id !== selectedItem?.id && (c.type === formData.type || c.type === 'both'))
|
.filter(c => c.id !== selectedItem?.id)
|
||||||
.slice(0, 7)
|
.map((cat) => (
|
||||||
.map(cat => (
|
<option key={cat.id} value={cat.id}>{cat.name}</option>
|
||||||
<div key={cat.id} className="col-4 col-md-3">
|
|
||||||
<div
|
|
||||||
onClick={() => setFormData({...formData, parent_id: cat.id.toString()})}
|
|
||||||
className="p-2 rounded text-center h-100 d-flex flex-column justify-content-center"
|
|
||||||
style={{
|
|
||||||
background: formData.parent_id == cat.id ? 'rgba(59, 130, 246, 0.15)' : '#0f172a',
|
|
||||||
cursor: 'pointer',
|
|
||||||
border: formData.parent_id == cat.id ? '2px solid #3b82f6' : '2px solid transparent',
|
|
||||||
minHeight: 60
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<i className={`bi ${cat.icon || 'bi-tag'} d-block mb-1`} style={{ color: cat.color || '#6b7280' }}></i>
|
|
||||||
<small className="text-white text-truncate" title={cat.name}>{cat.name}</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</select>
|
||||||
{flatCategories.filter(c => c.id !== selectedItem?.id && (c.type === formData.type || c.type === 'both')).length > 7 && (
|
|
||||||
<select
|
|
||||||
className="form-select bg-dark text-white border-0 mt-2"
|
|
||||||
style={{ background: '#0f172a' }}
|
|
||||||
name="parent_id"
|
|
||||||
value={formData.parent_id}
|
|
||||||
onChange={handleChange}
|
|
||||||
>
|
|
||||||
<option value="">{t('categories.selectParent') || 'Mais categorias...'}</option>
|
|
||||||
{flatCategories
|
|
||||||
.filter(c => c.id !== selectedItem?.id && (c.type === formData.type || c.type === 'both'))
|
|
||||||
.map((cat) => (
|
|
||||||
<option key={cat.id} value={cat.id}>{cat.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Descrição */}
|
{/* Cor */}
|
||||||
<div className="mb-4">
|
<div className="col-md-3">
|
||||||
<label className="form-label text-white fw-medium mb-2">
|
<label className="form-label text-slate-300">{t('common.color')}</label>
|
||||||
<i className="bi bi-text-paragraph me-2 text-secondary"></i>
|
<input
|
||||||
{t('common.description')}
|
type="color"
|
||||||
<span className="badge bg-secondary ms-2" style={{ fontSize: '0.65rem' }}>{t('common.optional')}</span>
|
className="form-control form-control-color bg-dark border-secondary w-100"
|
||||||
</label>
|
name="color"
|
||||||
<textarea
|
value={formData.color}
|
||||||
className="form-control bg-dark text-white border-0"
|
onChange={handleChange}
|
||||||
style={{ background: '#0f172a' }}
|
/>
|
||||||
name="description"
|
</div>
|
||||||
value={formData.description}
|
|
||||||
onChange={handleChange}
|
|
||||||
rows="2"
|
|
||||||
placeholder={t('categories.descPlaceholder') || 'Descreva esta categoria...'}
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Palavras-chave - Seção destacada */}
|
{/* Ícone */}
|
||||||
<div className="mb-3">
|
<div className="col-md-5">
|
||||||
<label className="form-label text-white fw-medium mb-2">
|
<label className="form-label text-slate-300">{t('common.icon')}</label>
|
||||||
<i className="bi bi-key me-2 text-warning"></i>
|
<IconSelector
|
||||||
{t('categories.keywords')}
|
value={formData.icon}
|
||||||
<span className="badge bg-warning text-dark ms-2" style={{ fontSize: '0.65rem' }}>
|
onChange={(icon) => setFormData(prev => ({ ...prev, icon }))}
|
||||||
{t('categories.autoCategorizationLabel') || 'Auto-categorização'}
|
type="category"
|
||||||
</span>
|
/>
|
||||||
</label>
|
</div>
|
||||||
<div className="p-3 rounded" style={{ background: '#0f172a' }}>
|
|
||||||
|
{/* Status */}
|
||||||
|
<div className="col-md-4">
|
||||||
|
<label className="form-label text-slate-300"> </label>
|
||||||
|
<div className="form-check mt-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="form-check-input"
|
||||||
|
id="is_active"
|
||||||
|
name="is_active"
|
||||||
|
checked={formData.is_active}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
<label className="form-check-label text-slate-300" htmlFor="is_active">
|
||||||
|
{t('common.active')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Descrição */}
|
||||||
|
<div className="col-12">
|
||||||
|
<label className="form-label text-slate-300">{t('common.description')}</label>
|
||||||
|
<textarea
|
||||||
|
className="form-control bg-dark text-white border-secondary"
|
||||||
|
name="description"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={handleChange}
|
||||||
|
rows="2"
|
||||||
|
placeholder="Descreva esta categoria..."
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Palavras-chave */}
|
||||||
|
<div className="col-12">
|
||||||
|
<label className="form-label text-slate-300">
|
||||||
|
<i className="bi bi-key me-1"></i>
|
||||||
|
{t('categories.keywordHelp')}
|
||||||
|
</label>
|
||||||
<div className="input-group mb-2">
|
<div className="input-group mb-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="form-control bg-dark text-white border-0"
|
className="form-control bg-dark text-white border-secondary"
|
||||||
style={{ background: '#1e293b' }}
|
|
||||||
value={newKeyword}
|
value={newKeyword}
|
||||||
onChange={(e) => setNewKeyword(e.target.value)}
|
onChange={(e) => setNewKeyword(e.target.value)}
|
||||||
onKeyPress={handleKeywordKeyPress}
|
onKeyPress={handleKeywordKeyPress}
|
||||||
placeholder={t('categories.keywordPlaceholder') || 'Digite e pressione Enter...'}
|
placeholder="Digite uma palavra-chave e pressione Enter..."
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-info px-3"
|
className="btn btn-outline-info"
|
||||||
onClick={handleAddKeyword}
|
onClick={handleAddKeyword}
|
||||||
>
|
>
|
||||||
<i className="bi bi-plus-lg"></i>
|
<i className="bi bi-plus"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="d-flex flex-wrap gap-2">
|
<div className="d-flex flex-wrap gap-2">
|
||||||
{formData.keywords.map((keyword, index) => (
|
{formData.keywords.map((keyword, index) => (
|
||||||
<span
|
<span
|
||||||
key={index}
|
key={index}
|
||||||
className="badge d-flex align-items-center py-2 px-3"
|
className="badge d-flex align-items-center"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: formData.color + '25',
|
backgroundColor: formData.color + '25',
|
||||||
color: formData.color,
|
color: formData.color,
|
||||||
fontSize: '0.85rem'
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{keyword}
|
{keyword}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn-close ms-2"
|
className="btn-close btn-close-white ms-2"
|
||||||
style={{ fontSize: '8px', filter: 'brightness(1.5)' }}
|
style={{ fontSize: '8px' }}
|
||||||
onClick={() => handleRemoveKeyword(keyword)}
|
onClick={() => handleRemoveKeyword(keyword)}
|
||||||
></button>
|
></button>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
{formData.keywords.length === 0 && (
|
{formData.keywords.length === 0 && (
|
||||||
<small className="text-slate-500">
|
<small className="text-slate-500">
|
||||||
<i className="bi bi-info-circle me-1"></i>
|
{t('common.noData')}
|
||||||
{t('categories.noKeywords') || 'Nenhuma palavra-chave. Transações serão categorizadas manualmente.'}
|
|
||||||
</small>
|
</small>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<small className="text-slate-500 mt-2 d-block">
|
||||||
|
Ex: "RESTAURANTE", "PIZZA", "HAMBURGUER" - Para a categoria Alimentação
|
||||||
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<small className="text-slate-500 mt-2 d-block">
|
|
||||||
<i className="bi bi-lightbulb me-1"></i>
|
|
||||||
{t('categories.keywordHelp') || 'Ex: "RESTAURANTE", "PIZZA" - Transações com essas palavras serão categorizadas automaticamente'}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status */}
|
|
||||||
<div className="form-check form-switch">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="form-check-input"
|
|
||||||
id="is_active"
|
|
||||||
name="is_active"
|
|
||||||
checked={formData.is_active}
|
|
||||||
onChange={handleChange}
|
|
||||||
role="switch"
|
|
||||||
/>
|
|
||||||
<label className="form-check-label text-white" htmlFor="is_active">
|
|
||||||
<i className={`bi ${formData.is_active ? 'bi-check-circle text-success' : 'bi-x-circle text-secondary'} me-2`}></i>
|
|
||||||
{formData.is_active ? t('common.active') : t('common.inactive')}
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="modal-footer border-top" style={{ borderColor: '#334155 !important' }}>
|
||||||
{/* Footer elegante */}
|
<button type="button" className="btn btn-outline-light" onClick={handleCloseModal}>
|
||||||
<div className="modal-footer border-0">
|
|
||||||
<button type="button" className="btn btn-outline-secondary px-4" onClick={handleCloseModal}>
|
|
||||||
<i className="bi bi-x-lg me-2"></i>
|
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" className="btn btn-info px-4" disabled={saving || !formData.name.trim()}>
|
<button type="submit" className="btn btn-info" disabled={saving}>
|
||||||
{saving ? (
|
{saving ? (
|
||||||
<>
|
<>
|
||||||
<span className="spinner-border spinner-border-sm me-2"></span>
|
<span className="spinner-border spinner-border-sm me-2"></span>
|
||||||
{t('common.saving')}
|
{t('common.loading')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<i className={`bi ${selectedItem ? 'bi-check-lg' : 'bi-plus-lg'} me-2`}></i>
|
<i className="bi bi-check-lg me-2"></i>
|
||||||
{selectedItem ? t('common.save') : t('common.create')}
|
{selectedItem ? t('common.save') : t('common.create')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@ -795,145 +678,116 @@ const Categories = () => {
|
|||||||
loading={saving}
|
loading={saving}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Modal de Categorização em Lote - Design Elegante */}
|
{/* Modal de Categorização em Lote */}
|
||||||
{showBatchModal && (
|
{showBatchModal && (
|
||||||
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.8)' }}>
|
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.7)' }}>
|
||||||
<div className="modal-dialog modal-lg modal-dialog-centered" style={{ maxWidth: '700px' }}>
|
<div className="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
|
||||||
<div className="modal-content border-0" style={{ background: '#1e293b', maxHeight: '90vh' }}>
|
<div className="modal-content border-0" style={{ background: '#1e293b' }}>
|
||||||
{/* Header elegante */}
|
<div className="modal-header border-secondary">
|
||||||
<div className="modal-header border-0 pb-0">
|
<h5 className="modal-title text-white">
|
||||||
<div>
|
<i className="bi bi-lightning-charge me-2 text-warning"></i>
|
||||||
<h5 className="modal-title text-white mb-1">
|
{t('categories.batchCategorize') || 'Categorização em Lote'}
|
||||||
<i className="bi bi-lightning-charge-fill me-2 text-warning"></i>
|
</h5>
|
||||||
{t('categories.batchCategorize') || 'Categorização Automática'}
|
|
||||||
</h5>
|
|
||||||
<p className="text-slate-400 mb-0 small">
|
|
||||||
{t('categories.batchDescription') || 'Categorize transações automaticamente usando palavras-chave'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button type="button" className="btn-close btn-close-white" onClick={handleCloseBatchModal}></button>
|
<button type="button" className="btn-close btn-close-white" onClick={handleCloseBatchModal}></button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
<div className="modal-body pt-3" style={{ maxHeight: '65vh', overflowY: 'auto' }}>
|
|
||||||
{loadingBatch ? (
|
{loadingBatch ? (
|
||||||
<div className="text-center py-5">
|
<div className="text-center py-5">
|
||||||
<div className="spinner-border text-warning" role="status" style={{ width: '3rem', height: '3rem' }}>
|
<div className="spinner-border text-info" role="status">
|
||||||
<span className="visually-hidden">Loading...</span>
|
<span className="visually-hidden">Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-slate-400 mt-3 mb-0">{t('categories.analyzingTransactions') || 'Analisando transações...'}</p>
|
<p className="text-slate-400 mt-3">{t('common.loading') || 'Carregando...'}</p>
|
||||||
</div>
|
</div>
|
||||||
) : batchPreview ? (
|
) : batchPreview ? (
|
||||||
<>
|
<>
|
||||||
{/* Cards de Resumo */}
|
{/* Resumo */}
|
||||||
<div className="row g-3 mb-4">
|
<div className="row mb-4">
|
||||||
<div className="col-6 col-md-3">
|
<div className="col-md-3">
|
||||||
<div className="p-3 rounded-3 text-center" style={{ background: '#0f172a' }}>
|
<div className="card border-0" style={{ background: '#0f172a' }}>
|
||||||
<div className="rounded-circle d-inline-flex align-items-center justify-content-center mb-2"
|
<div className="card-body text-center">
|
||||||
style={{ width: 40, height: 40, background: 'rgba(234, 179, 8, 0.2)' }}>
|
<h3 className="text-warning mb-1">{batchPreview.total_uncategorized}</h3>
|
||||||
<i className="bi bi-question-circle text-warning"></i>
|
<small className="text-slate-400">{t('categories.uncategorized') || 'Sem categoria'}</small>
|
||||||
</div>
|
</div>
|
||||||
<h4 className="text-warning mb-0">{batchPreview.total_uncategorized}</h4>
|
|
||||||
<small className="text-slate-500">{t('categories.uncategorized') || 'Sem categoria'}</small>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-6 col-md-3">
|
<div className="col-md-3">
|
||||||
<div className="p-3 rounded-3 text-center" style={{ background: '#0f172a' }}>
|
<div className="card border-0" style={{ background: '#0f172a' }}>
|
||||||
<div className="rounded-circle d-inline-flex align-items-center justify-content-center mb-2"
|
<div className="card-body text-center">
|
||||||
style={{ width: 40, height: 40, background: 'rgba(34, 197, 94, 0.2)' }}>
|
<h3 className="text-success mb-1">{batchPreview.would_categorize}</h3>
|
||||||
<i className="bi bi-check-circle text-success"></i>
|
<small className="text-slate-400">{t('categories.willCategorize') || 'Serão categorizadas'}</small>
|
||||||
</div>
|
</div>
|
||||||
<h4 className="text-success mb-0">{batchPreview.would_categorize}</h4>
|
|
||||||
<small className="text-slate-500">{t('categories.willCategorize') || 'Serão categorizadas'}</small>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-6 col-md-3">
|
<div className="col-md-3">
|
||||||
<div className="p-3 rounded-3 text-center" style={{ background: '#0f172a' }}>
|
<div className="card border-0" style={{ background: '#0f172a' }}>
|
||||||
<div className="rounded-circle d-inline-flex align-items-center justify-content-center mb-2"
|
<div className="card-body text-center">
|
||||||
style={{ width: 40, height: 40, background: 'rgba(148, 163, 184, 0.2)' }}>
|
<h3 className="text-secondary mb-1">{batchPreview.would_skip}</h3>
|
||||||
<i className="bi bi-dash-circle text-slate-400"></i>
|
<small className="text-slate-400">{t('categories.willSkip') || 'Sem correspondência'}</small>
|
||||||
</div>
|
</div>
|
||||||
<h4 className="text-slate-400 mb-0">{batchPreview.would_skip}</h4>
|
|
||||||
<small className="text-slate-500">{t('categories.willSkip') || 'Sem correspondência'}</small>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-6 col-md-3">
|
<div className="col-md-3">
|
||||||
<div className="p-3 rounded-3 text-center" style={{ background: '#0f172a' }}>
|
<div className="card border-0" style={{ background: '#0f172a' }}>
|
||||||
<div className="rounded-circle d-inline-flex align-items-center justify-content-center mb-2"
|
<div className="card-body text-center">
|
||||||
style={{ width: 40, height: 40, background: 'rgba(59, 130, 246, 0.2)' }}>
|
<h3 className="text-info mb-1">{batchPreview.total_keywords}</h3>
|
||||||
<i className="bi bi-key text-info"></i>
|
<small className="text-slate-400">{t('categories.totalKeywords') || 'Palavras-chave'}</small>
|
||||||
</div>
|
</div>
|
||||||
<h4 className="text-info mb-0">{batchPreview.total_keywords}</h4>
|
|
||||||
<small className="text-slate-500">{t('categories.totalKeywords') || 'Palavras-chave'}</small>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Preview Table */}
|
{/* Preview */}
|
||||||
{batchPreview.preview.length > 0 ? (
|
{batchPreview.preview.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className="d-flex align-items-center mb-3">
|
<h6 className="text-white mb-3">
|
||||||
<i className="bi bi-eye me-2 text-info"></i>
|
<i className="bi bi-eye me-2"></i>
|
||||||
<h6 className="text-white mb-0">
|
{t('categories.previewTitle') || 'Preview das categorizações'}
|
||||||
{t('categories.previewTitle') || 'Preview das categorizações'}
|
</h6>
|
||||||
</h6>
|
<div className="table-responsive" style={{ maxHeight: '300px' }}>
|
||||||
<span className="badge bg-info bg-opacity-25 text-info ms-2">
|
<table className="table table-dark table-striped table-hover mb-0">
|
||||||
{batchPreview.preview.length} {t('common.items') || 'itens'}
|
<thead style={{ position: 'sticky', top: 0, background: '#1e293b' }}>
|
||||||
</span>
|
<tr>
|
||||||
</div>
|
<th>{t('transactions.description') || 'Descrição'}</th>
|
||||||
<div className="rounded-3 overflow-hidden" style={{ background: '#0f172a' }}>
|
<th>{t('categories.matchedKeyword') || 'Keyword'}</th>
|
||||||
<div style={{ maxHeight: '250px', overflowY: 'auto' }}>
|
<th>{t('categories.category') || 'Categoria'}</th>
|
||||||
<table className="table table-dark mb-0" style={{ background: 'transparent' }}>
|
</tr>
|
||||||
<thead style={{ position: 'sticky', top: 0, background: '#0f172a', zIndex: 1 }}>
|
</thead>
|
||||||
<tr>
|
<tbody>
|
||||||
<th className="border-0 text-slate-400 fw-normal small">{t('transactions.description') || 'Descrição'}</th>
|
{batchPreview.preview.map((item, index) => (
|
||||||
<th className="border-0 text-slate-400 fw-normal small text-center" style={{ width: '120px' }}>{t('categories.matchedKeyword') || 'Keyword'}</th>
|
<tr key={index}>
|
||||||
<th className="border-0 text-slate-400 fw-normal small" style={{ width: '140px' }}>{t('categories.category') || 'Categoria'}</th>
|
<td className="text-truncate" style={{ maxWidth: '300px' }}>
|
||||||
|
{item.description}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className="badge bg-warning">{item.matched_keyword}</span>
|
||||||
|
</td>
|
||||||
|
<td className="text-info">{item.category_name}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
))}
|
||||||
<tbody>
|
</tbody>
|
||||||
{batchPreview.preview.map((item, index) => (
|
</table>
|
||||||
<tr key={index} style={{ borderColor: '#334155' }}>
|
|
||||||
<td className="text-white text-truncate border-secondary" style={{ maxWidth: '200px' }}>
|
|
||||||
{item.description}
|
|
||||||
</td>
|
|
||||||
<td className="text-center border-secondary">
|
|
||||||
<span className="badge bg-warning bg-opacity-25 text-warning">{item.matched_keyword}</span>
|
|
||||||
</td>
|
|
||||||
<td className="text-info border-secondary">{item.category_name}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-4 rounded-3 text-center" style={{ background: '#0f172a' }}>
|
<div className="alert alert-warning">
|
||||||
<i className="bi bi-search display-4 text-slate-600 mb-3 d-block"></i>
|
<i className="bi bi-exclamation-triangle me-2"></i>
|
||||||
<h6 className="text-white mb-2">{t('categories.noMatchesFoundTitle') || 'Nenhuma correspondência encontrada'}</h6>
|
{t('categories.noMatchesFound') || 'Nenhuma transação corresponde às palavras-chave configuradas'}
|
||||||
<p className="text-slate-400 mb-0 small">
|
|
||||||
{t('categories.noMatchesFound') || 'Adicione palavras-chave às categorias para permitir categorização automática'}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-4 rounded-3 text-center" style={{ background: '#0f172a' }}>
|
<div className="alert alert-danger">
|
||||||
<i className="bi bi-exclamation-triangle display-4 text-danger mb-3 d-block"></i>
|
{t('categories.previewError') || 'Erro ao carregar preview'}
|
||||||
<p className="text-slate-400 mb-0">{t('categories.previewError') || 'Erro ao carregar preview'}</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="modal-footer border-secondary">
|
||||||
{/* Footer elegante */}
|
<button type="button" className="btn btn-outline-secondary" onClick={handleCloseBatchModal}>
|
||||||
<div className="modal-footer border-0">
|
|
||||||
<button type="button" className="btn btn-outline-secondary px-4" onClick={handleCloseBatchModal}>
|
|
||||||
<i className="bi bi-x-lg me-2"></i>
|
|
||||||
{t('common.cancel') || 'Cancelar'}
|
{t('common.cancel') || 'Cancelar'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-warning px-4"
|
className="btn btn-warning"
|
||||||
onClick={handleExecuteBatch}
|
onClick={handleExecuteBatch}
|
||||||
disabled={executingBatch || !batchPreview || batchPreview.would_categorize === 0}
|
disabled={executingBatch || !batchPreview || batchPreview.would_categorize === 0}
|
||||||
>
|
>
|
||||||
@ -944,8 +798,8 @@ const Categories = () => {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<i className="bi bi-lightning-charge-fill me-2"></i>
|
<i className="bi bi-lightning-charge me-2"></i>
|
||||||
{t('categories.executeBatch') || 'Categorizar'} ({batchPreview?.would_categorize || 0})
|
{t('categories.executeBatch') || 'Executar Categorização'}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -357,242 +357,170 @@ const CostCenters = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Modal de Criar/Editar - Design Elegante */}
|
{/* Modal de Criar/Editar */}
|
||||||
{showModal && (
|
{showModal && (
|
||||||
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.8)' }}>
|
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.7)' }}>
|
||||||
<div className="modal-dialog modal-dialog-centered modal-lg">
|
<div className="modal-dialog modal-lg modal-dialog-centered">
|
||||||
<div className="modal-content border-0" style={{ background: '#1e293b', maxHeight: '90vh' }}>
|
<div className="modal-content" style={{ background: '#1e293b' }}>
|
||||||
{/* Header elegante */}
|
<div className="modal-header border-bottom" style={{ borderColor: '#334155 !important' }}>
|
||||||
<div className="modal-header border-0 pb-0">
|
<h5 className="modal-title text-white">
|
||||||
<div>
|
<i className={`bi ${selectedItem ? 'bi-pencil' : 'bi-plus-circle'} me-2`}></i>
|
||||||
<h5 className="modal-title text-white mb-1">
|
{selectedItem ? t('costCenters.editCostCenter') : t('costCenters.newCostCenter')}
|
||||||
<i className={`bi ${selectedItem ? 'bi-pencil-square' : 'bi-plus-circle-dotted'} me-2 text-success`}></i>
|
</h5>
|
||||||
{selectedItem ? t('costCenters.editCostCenter') : t('costCenters.newCostCenter')}
|
|
||||||
</h5>
|
|
||||||
<p className="text-slate-400 mb-0 small">
|
|
||||||
{t('costCenters.title')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button type="button" className="btn-close btn-close-white" onClick={handleCloseModal}></button>
|
<button type="button" className="btn-close btn-close-white" onClick={handleCloseModal}></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="modal-body pt-3" style={{ maxHeight: '65vh', overflowY: 'auto' }}>
|
<div className="modal-body">
|
||||||
|
<div className="row g-3">
|
||||||
{/* Preview Card */}
|
{/* Nome */}
|
||||||
<div className="mb-4 p-3 rounded-3" style={{ background: '#0f172a' }}>
|
|
||||||
<div className="d-flex align-items-center">
|
|
||||||
<div
|
|
||||||
className="rounded-circle d-flex align-items-center justify-content-center me-3"
|
|
||||||
style={{
|
|
||||||
width: 50,
|
|
||||||
height: 50,
|
|
||||||
background: `${formData.color}25`,
|
|
||||||
border: `2px solid ${formData.color}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<i className={`bi ${formData.icon}`} style={{ fontSize: '1.3rem', color: formData.color }}></i>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h6 className="text-white mb-0">{formData.name || t('costCenters.newCostCenter')}</h6>
|
|
||||||
<small className="text-slate-400">
|
|
||||||
{formData.code ? `${t('costCenters.code')}: ${formData.code}` : t('common.description')}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
<div className="ms-auto">
|
|
||||||
{formData.is_active ? (
|
|
||||||
<span className="badge bg-success bg-opacity-25 text-success">{t('common.active')}</span>
|
|
||||||
) : (
|
|
||||||
<span className="badge bg-secondary bg-opacity-25 text-secondary">{t('common.inactive')}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Nome e Código - Linha principal */}
|
|
||||||
<div className="row g-3 mb-4">
|
|
||||||
<div className="col-md-8">
|
<div className="col-md-8">
|
||||||
<label className="form-label text-white fw-medium mb-2">
|
<label className="form-label text-slate-300">{t('common.name')} *</label>
|
||||||
<i className="bi bi-type me-2 text-primary"></i>
|
|
||||||
{t('common.name')} *
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="form-control bg-dark text-white border-0"
|
className="form-control bg-dark text-white border-secondary"
|
||||||
style={{ background: '#0f172a' }}
|
|
||||||
name="name"
|
name="name"
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
placeholder={t('costCenters.namePlaceholder') || 'Ej: Proyecto Alpha, Dpto. Marketing...'}
|
placeholder="Ex: Projeto Alpha, Departamento RH..."
|
||||||
required
|
required
|
||||||
autoFocus
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Código */}
|
||||||
<div className="col-md-4">
|
<div className="col-md-4">
|
||||||
<label className="form-label text-white fw-medium mb-2">
|
<label className="form-label text-slate-300">{t('costCenters.code')}</label>
|
||||||
<i className="bi bi-hash me-2 text-warning"></i>
|
|
||||||
{t('costCenters.code')}
|
|
||||||
<span className="badge bg-secondary ms-2" style={{ fontSize: '0.65rem' }}>{t('common.optional')}</span>
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="form-control bg-dark text-white border-0"
|
className="form-control bg-dark text-white border-secondary"
|
||||||
style={{ background: '#0f172a' }}
|
|
||||||
name="code"
|
name="code"
|
||||||
value={formData.code}
|
value={formData.code}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
placeholder="CC001"
|
placeholder="Ex: CC001"
|
||||||
maxLength="20"
|
maxLength="20"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Visual - Cor e Ícone */}
|
{/* Cor */}
|
||||||
<div className="mb-4">
|
<div className="col-md-3">
|
||||||
<label className="form-label text-white fw-medium mb-2">
|
<label className="form-label text-slate-300">{t('common.color')}</label>
|
||||||
<i className="bi bi-palette me-2 text-success"></i>
|
<input
|
||||||
{t('costCenters.visualSettings') || t('categories.visualSettings') || 'Apariencia'}
|
type="color"
|
||||||
</label>
|
className="form-control form-control-color bg-dark border-secondary w-100"
|
||||||
<div className="row g-3">
|
name="color"
|
||||||
<div className="col-4">
|
value={formData.color}
|
||||||
<div className="p-3 rounded text-center" style={{ background: '#0f172a' }}>
|
onChange={handleChange}
|
||||||
<label className="text-slate-400 small d-block mb-2">{t('common.color')}</label>
|
/>
|
||||||
<input
|
</div>
|
||||||
type="color"
|
|
||||||
className="form-control form-control-color mx-auto border-0"
|
{/* Ícone */}
|
||||||
style={{ width: 50, height: 50, cursor: 'pointer', background: 'transparent' }}
|
<div className="col-md-5">
|
||||||
name="color"
|
<label className="form-label text-slate-300">{t('common.icon')}</label>
|
||||||
value={formData.color}
|
<IconSelector
|
||||||
onChange={handleChange}
|
value={formData.icon}
|
||||||
/>
|
onChange={(icon) => setFormData(prev => ({ ...prev, icon }))}
|
||||||
</div>
|
type="costCenter"
|
||||||
</div>
|
/>
|
||||||
<div className="col-8">
|
</div>
|
||||||
<div className="p-3 rounded h-100" style={{ background: '#0f172a' }}>
|
|
||||||
<label className="text-slate-400 small d-block mb-2">{t('common.icon')}</label>
|
{/* Status */}
|
||||||
<IconSelector
|
<div className="col-md-4">
|
||||||
value={formData.icon}
|
<label className="form-label text-slate-300"> </label>
|
||||||
onChange={(icon) => setFormData(prev => ({ ...prev, icon }))}
|
<div className="form-check mt-2">
|
||||||
type="costCenter"
|
<input
|
||||||
/>
|
type="checkbox"
|
||||||
</div>
|
className="form-check-input"
|
||||||
|
id="is_active"
|
||||||
|
name="is_active"
|
||||||
|
checked={formData.is_active}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
<label className="form-check-label text-slate-300" htmlFor="is_active">
|
||||||
|
{t('common.active')}
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Descrição */}
|
{/* Descrição */}
|
||||||
<div className="mb-4">
|
<div className="col-12">
|
||||||
<label className="form-label text-white fw-medium mb-2">
|
<label className="form-label text-slate-300">{t('common.description')}</label>
|
||||||
<i className="bi bi-text-paragraph me-2 text-secondary"></i>
|
<textarea
|
||||||
{t('common.description')}
|
className="form-control bg-dark text-white border-secondary"
|
||||||
<span className="badge bg-secondary ms-2" style={{ fontSize: '0.65rem' }}>{t('common.optional')}</span>
|
name="description"
|
||||||
</label>
|
value={formData.description}
|
||||||
<textarea
|
onChange={handleChange}
|
||||||
className="form-control bg-dark text-white border-0"
|
rows="2"
|
||||||
style={{ background: '#0f172a' }}
|
placeholder="Descreva o propósito deste centro de custo..."
|
||||||
name="description"
|
></textarea>
|
||||||
value={formData.description}
|
</div>
|
||||||
onChange={handleChange}
|
|
||||||
rows="2"
|
|
||||||
placeholder={t('costCenters.descPlaceholder') || 'Describe el propósito de este centro de costo...'}
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Palavras-chave - Seção destacada */}
|
{/* Palavras-chave */}
|
||||||
<div className="mb-3">
|
<div className="col-12">
|
||||||
<label className="form-label text-white fw-medium mb-2">
|
<label className="form-label text-slate-300">
|
||||||
<i className="bi bi-key me-2 text-warning"></i>
|
<i className="bi bi-key me-1"></i>
|
||||||
{t('costCenters.keywords')}
|
{t('costCenters.keywordHelp')}
|
||||||
<span className="badge bg-warning text-dark ms-2" style={{ fontSize: '0.65rem' }}>
|
</label>
|
||||||
{t('costCenters.autoAssignLabel') || 'Auto-asignación'}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<div className="p-3 rounded" style={{ background: '#0f172a' }}>
|
|
||||||
<div className="input-group mb-2">
|
<div className="input-group mb-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="form-control bg-dark text-white border-0"
|
className="form-control bg-dark text-white border-secondary"
|
||||||
style={{ background: '#1e293b' }}
|
|
||||||
value={newKeyword}
|
value={newKeyword}
|
||||||
onChange={(e) => setNewKeyword(e.target.value)}
|
onChange={(e) => setNewKeyword(e.target.value)}
|
||||||
onKeyPress={handleKeywordKeyPress}
|
onKeyPress={handleKeywordKeyPress}
|
||||||
placeholder={t('costCenters.keywordPlaceholder') || 'Escribe y presiona Enter...'}
|
placeholder="Digite uma palavra-chave e pressione Enter..."
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-success px-3"
|
className="btn btn-outline-success"
|
||||||
onClick={handleAddKeyword}
|
onClick={handleAddKeyword}
|
||||||
>
|
>
|
||||||
<i className="bi bi-plus-lg"></i>
|
<i className="bi bi-plus"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="d-flex flex-wrap gap-2">
|
<div className="d-flex flex-wrap gap-2">
|
||||||
{formData.keywords.map((keyword, index) => (
|
{formData.keywords.map((keyword, index) => (
|
||||||
<span
|
<span
|
||||||
key={index}
|
key={index}
|
||||||
className="badge d-flex align-items-center py-2 px-3"
|
className="badge d-flex align-items-center"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: formData.color + '25',
|
backgroundColor: formData.color + '25',
|
||||||
color: formData.color,
|
color: formData.color,
|
||||||
fontSize: '0.85rem'
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{keyword}
|
{keyword}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn-close ms-2"
|
className="btn-close btn-close-white ms-2"
|
||||||
style={{ fontSize: '8px', filter: 'brightness(1.5)' }}
|
style={{ fontSize: '8px' }}
|
||||||
onClick={() => handleRemoveKeyword(keyword)}
|
onClick={() => handleRemoveKeyword(keyword)}
|
||||||
></button>
|
></button>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
{formData.keywords.length === 0 && (
|
{formData.keywords.length === 0 && (
|
||||||
<small className="text-slate-500">
|
<small className="text-slate-500">
|
||||||
<i className="bi bi-info-circle me-1"></i>
|
{t('common.noData')}
|
||||||
{t('costCenters.noKeywords') || 'Sin palabras clave. Las transacciones se asignarán manualmente.'}
|
|
||||||
</small>
|
</small>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<small className="text-slate-500 mt-2 d-block">
|
||||||
|
Ex: "UBER", "iFood", "Supermercado XYZ" - Quando estas palavras aparecerem na
|
||||||
|
descrição de uma transação, este centro de custo será sugerido automaticamente.
|
||||||
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<small className="text-slate-500 mt-2 d-block">
|
|
||||||
<i className="bi bi-lightbulb me-1"></i>
|
|
||||||
{t('costCenters.keywordHelp')}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status */}
|
|
||||||
<div className="form-check form-switch">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="form-check-input"
|
|
||||||
id="is_active"
|
|
||||||
name="is_active"
|
|
||||||
checked={formData.is_active}
|
|
||||||
onChange={handleChange}
|
|
||||||
role="switch"
|
|
||||||
/>
|
|
||||||
<label className="form-check-label text-white" htmlFor="is_active">
|
|
||||||
<i className={`bi ${formData.is_active ? 'bi-check-circle text-success' : 'bi-x-circle text-secondary'} me-2`}></i>
|
|
||||||
{formData.is_active ? t('common.active') : t('common.inactive')}
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="modal-footer border-top" style={{ borderColor: '#334155 !important' }}>
|
||||||
{/* Footer elegante */}
|
<button type="button" className="btn btn-outline-light" onClick={handleCloseModal}>
|
||||||
<div className="modal-footer border-0">
|
|
||||||
<button type="button" className="btn btn-outline-secondary px-4" onClick={handleCloseModal}>
|
|
||||||
<i className="bi bi-x-lg me-2"></i>
|
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" className="btn btn-success px-4" disabled={saving || !formData.name.trim()}>
|
<button type="submit" className="btn btn-success" disabled={saving}>
|
||||||
{saving ? (
|
{saving ? (
|
||||||
<>
|
<>
|
||||||
<span className="spinner-border spinner-border-sm me-2"></span>
|
<span className="spinner-border spinner-border-sm me-2"></span>
|
||||||
{t('common.saving')}
|
{t('common.loading')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<i className={`bi ${selectedItem ? 'bi-check-lg' : 'bi-plus-lg'} me-2`}></i>
|
<i className="bi bi-check-lg me-2"></i>
|
||||||
{selectedItem ? t('common.save') : t('common.create')}
|
{selectedItem ? t('common.save') : t('common.create')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -10,7 +10,6 @@ import OverpaymentsAnalysis from '../components/dashboard/OverpaymentsAnalysis';
|
|||||||
import CalendarWidget from '../components/dashboard/CalendarWidget';
|
import CalendarWidget from '../components/dashboard/CalendarWidget';
|
||||||
import UpcomingWidget from '../components/dashboard/UpcomingWidget';
|
import UpcomingWidget from '../components/dashboard/UpcomingWidget';
|
||||||
import OverdueWidget from '../components/dashboard/OverdueWidget';
|
import OverdueWidget from '../components/dashboard/OverdueWidget';
|
||||||
import PlanUsageWidget from '../components/dashboard/PlanUsageWidget';
|
|
||||||
|
|
||||||
const Dashboard = () => {
|
const Dashboard = () => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
@ -231,9 +230,6 @@ const Dashboard = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dashboard-container">
|
<div className="dashboard-container">
|
||||||
{/* Plan Usage Widget - Show for free plan */}
|
|
||||||
<PlanUsageWidget />
|
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="d-flex justify-content-between align-items-center mb-4">
|
<div className="d-flex justify-content-between align-items-center mb-4">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -228,26 +228,6 @@ const FinancialHealth = () => {
|
|||||||
}],
|
}],
|
||||||
} : null;
|
} : null;
|
||||||
|
|
||||||
// Data sufficiency status
|
|
||||||
const dataStatus = data.data_status;
|
|
||||||
const showDataWarning = dataStatus && !dataStatus.has_sufficient_data;
|
|
||||||
|
|
||||||
// Helper para obtener ícono y cor do alerta baseado no nível
|
|
||||||
const getDataWarningStyle = (level) => {
|
|
||||||
switch (level) {
|
|
||||||
case 'no_data':
|
|
||||||
return { icon: 'bi-database-x', color: 'danger', bg: 'rgba(239, 68, 68, 0.1)' };
|
|
||||||
case 'insufficient':
|
|
||||||
return { icon: 'bi-exclamation-triangle', color: 'warning', bg: 'rgba(245, 158, 11, 0.1)' };
|
|
||||||
case 'limited':
|
|
||||||
return { icon: 'bi-info-circle', color: 'info', bg: 'rgba(59, 130, 246, 0.1)' };
|
|
||||||
case 'outdated':
|
|
||||||
return { icon: 'bi-clock-history', color: 'secondary', bg: 'rgba(100, 116, 139, 0.1)' };
|
|
||||||
default:
|
|
||||||
return { icon: 'bi-info-circle', color: 'info', bg: 'rgba(59, 130, 246, 0.1)' };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="financial-health-container">
|
<div className="financial-health-container">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@ -270,46 +250,6 @@ const FinancialHealth = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Data Sufficiency Warning */}
|
|
||||||
{showDataWarning && (
|
|
||||||
<div
|
|
||||||
className={`alert alert-${getDataWarningStyle(dataStatus.level).color} d-flex align-items-start mb-4`}
|
|
||||||
style={{
|
|
||||||
background: getDataWarningStyle(dataStatus.level).bg,
|
|
||||||
border: 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<i className={`bi ${getDataWarningStyle(dataStatus.level).icon} me-3 fs-4`}></i>
|
|
||||||
<div className="flex-grow-1">
|
|
||||||
<h6 className="alert-heading mb-1">
|
|
||||||
{dataStatus.level === 'no_data' && t('financialHealth.dataWarning.noData', 'Datos Insuficientes')}
|
|
||||||
{dataStatus.level === 'insufficient' && t('financialHealth.dataWarning.insufficient', 'Análisis Limitado')}
|
|
||||||
{dataStatus.level === 'limited' && t('financialHealth.dataWarning.limited', 'Pocos Datos')}
|
|
||||||
{dataStatus.level === 'outdated' && t('financialHealth.dataWarning.outdated', 'Datos Desactualizados')}
|
|
||||||
</h6>
|
|
||||||
<p className="mb-2 small">{dataStatus.message}</p>
|
|
||||||
<div className="d-flex gap-3 small">
|
|
||||||
<span>
|
|
||||||
<i className="bi bi-wallet2 me-1"></i>
|
|
||||||
{dataStatus.counts?.accounts || 0} {t('financialHealth.dataWarning.accounts', 'cuentas')}
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
<i className="bi bi-arrow-left-right me-1"></i>
|
|
||||||
{dataStatus.counts?.transactions || 0} {t('financialHealth.dataWarning.transactions', 'transacciones')}
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
<i className="bi bi-calendar-check me-1"></i>
|
|
||||||
{dataStatus.counts?.recent_transactions || 0} {t('financialHealth.dataWarning.recentTransactions', 'últimos 30 días')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<a href="/accounts" className="btn btn-sm btn-outline-light ms-3">
|
|
||||||
<i className="bi bi-plus-lg me-1"></i>
|
|
||||||
{t('financialHealth.dataWarning.addData', 'Añadir Datos')}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<ul className="nav nav-pills mb-4 gap-2">
|
<ul className="nav nav-pills mb-4 gap-2">
|
||||||
{tabs.map(tab => (
|
{tabs.map(tab => (
|
||||||
|
|||||||
@ -1,443 +0,0 @@
|
|||||||
/* Landing Page Styles */
|
|
||||||
|
|
||||||
/* Variables */
|
|
||||||
:root {
|
|
||||||
--landing-primary: #3b82f6;
|
|
||||||
--landing-primary-dark: #1e40af;
|
|
||||||
--landing-secondary: #10b981;
|
|
||||||
--landing-dark: #0f172a;
|
|
||||||
--landing-dark-lighter: #1e293b;
|
|
||||||
--landing-text: #f1f5f9;
|
|
||||||
--landing-text-muted: #94a3b8;
|
|
||||||
--landing-border: #334155;
|
|
||||||
}
|
|
||||||
|
|
||||||
.landing-page {
|
|
||||||
background: var(--landing-dark);
|
|
||||||
color: var(--landing-text);
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Navbar */
|
|
||||||
.landing-navbar {
|
|
||||||
background: rgba(15, 23, 42, 0.95);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
border-bottom: 1px solid var(--landing-border);
|
|
||||||
padding: 1rem 0;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.landing-navbar.scrolled {
|
|
||||||
padding: 0.5rem 0;
|
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.landing-navbar .nav-link {
|
|
||||||
color: var(--landing-text-muted) !important;
|
|
||||||
font-weight: 500;
|
|
||||||
padding: 0.5rem 1rem !important;
|
|
||||||
transition: color 0.3s ease;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.landing-navbar .nav-link:hover {
|
|
||||||
color: var(--landing-text) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hero Section */
|
|
||||||
.hero-section {
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-bg {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: radial-gradient(ellipse at 20% 20%, rgba(59, 130, 246, 0.15), transparent 50%),
|
|
||||||
radial-gradient(ellipse at 80% 80%, rgba(16, 185, 129, 0.1), transparent 50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-section .container {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hero Title Animation */
|
|
||||||
.hero-title-wrapper {
|
|
||||||
line-height: 1.1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-title-wrapper h1 {
|
|
||||||
line-height: 1.1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-title-wrapper h1:first-child {
|
|
||||||
padding-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-title-wrapper h1:last-child {
|
|
||||||
padding-left: 3.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.hero-title-wrapper h1:last-child {
|
|
||||||
padding-left: 5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-title-highlight {
|
|
||||||
background: linear-gradient(135deg, var(--landing-primary), var(--landing-secondary));
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
background-clip: text;
|
|
||||||
animation: glow 3s ease-in-out infinite, colorShift 5s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes glow {
|
|
||||||
0%, 100% {
|
|
||||||
filter: drop-shadow(0 0 5px rgba(59, 130, 246, 0.3));
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
filter: drop-shadow(0 0 20px rgba(16, 185, 129, 0.5)) drop-shadow(0 0 40px rgba(59, 130, 246, 0.3));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes colorShift {
|
|
||||||
0%, 100% {
|
|
||||||
background: linear-gradient(135deg, var(--landing-primary), var(--landing-secondary));
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
background-clip: text;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
background: linear-gradient(135deg, var(--landing-secondary), var(--landing-primary));
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
background-clip: text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dashboard Preview */
|
|
||||||
.hero-image {
|
|
||||||
perspective: 1000px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-preview {
|
|
||||||
background: var(--landing-dark-lighter);
|
|
||||||
border-radius: 16px;
|
|
||||||
border: 1px solid var(--landing-border);
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
|
||||||
transform: rotateY(-5deg) rotateX(5deg);
|
|
||||||
transition: transform 0.5s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-preview:hover {
|
|
||||||
transform: rotateY(0) rotateX(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-header {
|
|
||||||
background: var(--landing-dark);
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-bottom: 1px solid var(--landing-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-dots {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-dots .dot {
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-dots .dot.red { background: #ef4444; }
|
|
||||||
.preview-dots .dot.yellow { background: #f59e0b; }
|
|
||||||
.preview-dots .dot.green { background: #22c55e; }
|
|
||||||
|
|
||||||
.preview-content {
|
|
||||||
padding: 24px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-card {
|
|
||||||
background: var(--landing-dark);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 20px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-card i {
|
|
||||||
font-size: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-card.balance i { color: var(--landing-primary); }
|
|
||||||
.preview-card.income i { color: #22c55e; }
|
|
||||||
.preview-card.expense i { color: #ef4444; }
|
|
||||||
|
|
||||||
.preview-card.income span { color: #22c55e; }
|
|
||||||
.preview-card.expense span { color: #ef4444; }
|
|
||||||
|
|
||||||
/* Features Section */
|
|
||||||
.features-section {
|
|
||||||
background: var(--landing-dark-lighter);
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-card {
|
|
||||||
background: var(--landing-dark);
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 32px;
|
|
||||||
border: 1px solid var(--landing-border);
|
|
||||||
transition: transform 0.3s ease, border-color 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-card:hover {
|
|
||||||
transform: translateY(-8px);
|
|
||||||
border-color: var(--landing-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-icon {
|
|
||||||
width: 64px;
|
|
||||||
height: 64px;
|
|
||||||
background: linear-gradient(135deg, var(--landing-primary), var(--landing-secondary));
|
|
||||||
border-radius: 16px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-icon i {
|
|
||||||
font-size: 28px;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-card h4 {
|
|
||||||
margin-bottom: 12px;
|
|
||||||
color: var(--landing-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pricing Section */
|
|
||||||
.pricing-section {
|
|
||||||
background: var(--landing-dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pricing-card {
|
|
||||||
background: var(--landing-dark-lighter);
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 32px;
|
|
||||||
border: 1px solid var(--landing-border);
|
|
||||||
transition: transform 0.3s ease, border-color 0.3s ease;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pricing-card:hover {
|
|
||||||
transform: translateY(-8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pricing-card.featured {
|
|
||||||
border-color: var(--landing-primary);
|
|
||||||
box-shadow: 0 0 40px rgba(59, 130, 246, 0.2);
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.featured-badge {
|
|
||||||
position: absolute;
|
|
||||||
top: -12px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
background: linear-gradient(135deg, var(--landing-primary), var(--landing-primary-dark));
|
|
||||||
color: white;
|
|
||||||
padding: 6px 20px;
|
|
||||||
border-radius: 20px;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.coming-soon-badge {
|
|
||||||
position: absolute;
|
|
||||||
top: -12px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
|
||||||
color: white;
|
|
||||||
padding: 6px 20px;
|
|
||||||
border-radius: 20px;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pricing-card.coming-soon {
|
|
||||||
opacity: 0.9;
|
|
||||||
border: 2px dashed rgba(99, 102, 241, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pricing-card.coming-soon .coming-soon-text {
|
|
||||||
font-size: 3rem;
|
|
||||||
color: #6366f1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pricing-card.coming-soon .pricing-features li {
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Gold Plan Teaser */
|
|
||||||
.gold-teaser {
|
|
||||||
background: linear-gradient(135deg, rgba(234, 179, 8, 0.1) 0%, rgba(161, 98, 7, 0.2) 100%);
|
|
||||||
border: 2px solid rgba(234, 179, 8, 0.3);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.gold-teaser h4 {
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pricing-header {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
padding-bottom: 24px;
|
|
||||||
border-bottom: 1px solid var(--landing-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pricing-header h3 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.price {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.price .currency {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--landing-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.price .amount {
|
|
||||||
font-size: 3rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--landing-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.price .period {
|
|
||||||
font-size: 1rem;
|
|
||||||
color: var(--landing-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.billing-note {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--landing-text-muted);
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pricing-features {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0 0 24px 0;
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pricing-features li {
|
|
||||||
padding: 12px 0;
|
|
||||||
border-bottom: 1px solid var(--landing-border);
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pricing-features li:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pricing-footer {
|
|
||||||
margin-top: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* FAQ Section */
|
|
||||||
.faq-section {
|
|
||||||
background: var(--landing-dark-lighter);
|
|
||||||
}
|
|
||||||
|
|
||||||
.faq-section .accordion-item {
|
|
||||||
background: var(--landing-dark);
|
|
||||||
border: 1px solid var(--landing-border);
|
|
||||||
margin-bottom: 12px;
|
|
||||||
border-radius: 12px !important;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.faq-section .accordion-button {
|
|
||||||
background: var(--landing-dark);
|
|
||||||
color: var(--landing-text);
|
|
||||||
font-weight: 500;
|
|
||||||
padding: 20px 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.faq-section .accordion-button:not(.collapsed) {
|
|
||||||
background: var(--landing-dark);
|
|
||||||
color: var(--landing-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.faq-section .accordion-button::after {
|
|
||||||
filter: invert(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.faq-section .accordion-button:focus {
|
|
||||||
box-shadow: none;
|
|
||||||
border-color: var(--landing-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.faq-section .accordion-body {
|
|
||||||
background: var(--landing-dark);
|
|
||||||
color: var(--landing-text-muted);
|
|
||||||
padding: 0 24px 20px 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* CTA Section */
|
|
||||||
.cta-section {
|
|
||||||
background: linear-gradient(135deg, var(--landing-primary), var(--landing-primary-dark));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Footer */
|
|
||||||
.landing-footer {
|
|
||||||
background: var(--landing-dark);
|
|
||||||
border-top: 1px solid var(--landing-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.landing-footer a {
|
|
||||||
text-decoration: none;
|
|
||||||
transition: color 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.landing-footer a:hover {
|
|
||||||
color: var(--landing-primary) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.hero-section .row {
|
|
||||||
min-height: auto;
|
|
||||||
padding-top: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.price .amount {
|
|
||||||
font-size: 2.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-card {
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,428 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { useAuth } from '../context/AuthContext';
|
|
||||||
import api from '../services/api';
|
|
||||||
import logo from '../assets/logo-white.png';
|
|
||||||
import './Landing.css';
|
|
||||||
|
|
||||||
export default function Landing() {
|
|
||||||
const { t, i18n } = useTranslation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { user, isAuthenticated } = useAuth();
|
|
||||||
const [plans, setPlans] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
// Se já está autenticado, vai para dashboard
|
|
||||||
useEffect(() => {
|
|
||||||
if (isAuthenticated && user) {
|
|
||||||
navigate('/dashboard');
|
|
||||||
}
|
|
||||||
}, [isAuthenticated, user, navigate]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadPlans();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadPlans = async () => {
|
|
||||||
try {
|
|
||||||
const response = await api.get('/plans');
|
|
||||||
if (response.data.success) {
|
|
||||||
setPlans(response.data.data.plans);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading plans:', error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const scrollToSection = (id) => {
|
|
||||||
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' });
|
|
||||||
};
|
|
||||||
|
|
||||||
const features = [
|
|
||||||
{
|
|
||||||
icon: 'bi-wallet2',
|
|
||||||
title: t('landing.features.accounts.title'),
|
|
||||||
description: t('landing.features.accounts.description'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: 'bi-graph-up-arrow',
|
|
||||||
title: t('landing.features.analytics.title'),
|
|
||||||
description: t('landing.features.analytics.description'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: 'bi-tags',
|
|
||||||
title: t('landing.features.categories.title'),
|
|
||||||
description: t('landing.features.categories.description'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: 'bi-cloud-upload',
|
|
||||||
title: t('landing.features.import.title'),
|
|
||||||
description: t('landing.features.import.description'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: 'bi-arrow-repeat',
|
|
||||||
title: t('landing.features.recurring.title'),
|
|
||||||
description: t('landing.features.recurring.description'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: 'bi-shield-check',
|
|
||||||
title: t('landing.features.security.title'),
|
|
||||||
description: t('landing.features.security.description'),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const faqs = [
|
|
||||||
{
|
|
||||||
question: t('landing.faq.q1'),
|
|
||||||
answer: t('landing.faq.a1'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
question: t('landing.faq.q2'),
|
|
||||||
answer: t('landing.faq.a2'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
question: t('landing.faq.q3'),
|
|
||||||
answer: t('landing.faq.a3'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
question: t('landing.faq.q4'),
|
|
||||||
answer: t('landing.faq.a4'),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="landing-page">
|
|
||||||
{/* Navigation */}
|
|
||||||
<nav className="navbar navbar-expand-lg navbar-dark fixed-top landing-navbar">
|
|
||||||
<div className="container">
|
|
||||||
<Link to="/" className="navbar-brand d-flex align-items-center">
|
|
||||||
<img src={logo} alt="WebMoney" height="40" className="me-2" />
|
|
||||||
<span className="fw-bold">WebMoney</span>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="navbar-toggler"
|
|
||||||
type="button"
|
|
||||||
data-bs-toggle="collapse"
|
|
||||||
data-bs-target="#navbarNav"
|
|
||||||
>
|
|
||||||
<span className="navbar-toggler-icon"></span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="collapse navbar-collapse" id="navbarNav">
|
|
||||||
<ul className="navbar-nav mx-auto">
|
|
||||||
<li className="nav-item">
|
|
||||||
<button className="nav-link btn btn-link" onClick={() => scrollToSection('features')}>
|
|
||||||
{t('landing.nav.features')}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li className="nav-item">
|
|
||||||
<button className="nav-link btn btn-link" onClick={() => scrollToSection('pricing')}>
|
|
||||||
{t('landing.nav.pricing')}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li className="nav-item">
|
|
||||||
<button className="nav-link btn btn-link" onClick={() => scrollToSection('faq')}>
|
|
||||||
{t('landing.nav.faq')}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div className="d-flex align-items-center gap-3">
|
|
||||||
<Link to="/login" className="btn btn-outline-light">
|
|
||||||
{t('landing.nav.login')}
|
|
||||||
</Link>
|
|
||||||
<Link to="/register" className="btn btn-primary">
|
|
||||||
{t('landing.nav.register')}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Hero Section */}
|
|
||||||
<section className="hero-section">
|
|
||||||
<div className="hero-bg"></div>
|
|
||||||
<div className="container">
|
|
||||||
<div className="row align-items-center min-vh-100 py-5">
|
|
||||||
<div className="col-lg-6">
|
|
||||||
<div className="hero-title-wrapper mb-4">
|
|
||||||
<h1 className="display-3 fw-bold text-white mb-0">
|
|
||||||
{i18n.language === 'es' ? 'Toma el Control' : i18n.language === 'pt-BR' ? 'Assuma o Controle' : 'Take Control'}
|
|
||||||
</h1>
|
|
||||||
<h1 className="display-3 fw-bold mb-0 hero-title-highlight">
|
|
||||||
{i18n.language === 'es' ? 'de tus Finanzas' : i18n.language === 'pt-BR' ? 'das suas Finanças' : 'of Your Finances'}
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<p className="lead text-white-50 mb-4">
|
|
||||||
{t('landing.hero.subtitle')}
|
|
||||||
</p>
|
|
||||||
<div className="d-flex gap-3 flex-wrap">
|
|
||||||
<Link to="/register" className="btn btn-primary btn-lg">
|
|
||||||
<i className="bi bi-rocket-takeoff me-2"></i>
|
|
||||||
{t('landing.hero.cta')}
|
|
||||||
</Link>
|
|
||||||
<button
|
|
||||||
className="btn btn-outline-light btn-lg"
|
|
||||||
onClick={() => scrollToSection('features')}
|
|
||||||
>
|
|
||||||
{t('landing.hero.learnMore')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Trust badges */}
|
|
||||||
<div className="mt-5 d-flex align-items-center gap-4 text-white-50">
|
|
||||||
<div className="d-flex align-items-center">
|
|
||||||
<i className="bi bi-shield-check fs-4 me-2 text-success"></i>
|
|
||||||
<span>{t('landing.hero.secure')}</span>
|
|
||||||
</div>
|
|
||||||
<div className="d-flex align-items-center">
|
|
||||||
<i className="bi bi-credit-card fs-4 me-2 text-primary"></i>
|
|
||||||
<span>PayPal</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="col-lg-6 d-none d-lg-block">
|
|
||||||
<div className="hero-image">
|
|
||||||
<div className="dashboard-preview">
|
|
||||||
<div className="preview-header">
|
|
||||||
<div className="preview-dots">
|
|
||||||
<span className="dot red"></span>
|
|
||||||
<span className="dot yellow"></span>
|
|
||||||
<span className="dot green"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="preview-content">
|
|
||||||
<div className="preview-card balance">
|
|
||||||
<i className="bi bi-wallet2"></i>
|
|
||||||
<span>€12,450.00</span>
|
|
||||||
</div>
|
|
||||||
<div className="preview-card income">
|
|
||||||
<i className="bi bi-arrow-down-circle"></i>
|
|
||||||
<span>+€3,200</span>
|
|
||||||
</div>
|
|
||||||
<div className="preview-card expense">
|
|
||||||
<i className="bi bi-arrow-up-circle"></i>
|
|
||||||
<span>-€1,850</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Features Section */}
|
|
||||||
<section id="features" className="features-section py-5">
|
|
||||||
<div className="container py-5">
|
|
||||||
<div className="text-center mb-5">
|
|
||||||
<h2 className="display-5 fw-bold">{t('landing.features.title')}</h2>
|
|
||||||
<p className="lead text-muted">{t('landing.features.subtitle')}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="row g-4">
|
|
||||||
{features.map((feature, index) => (
|
|
||||||
<div key={index} className="col-md-6 col-lg-4">
|
|
||||||
<div className="feature-card h-100">
|
|
||||||
<div className="feature-icon">
|
|
||||||
<i className={`bi ${feature.icon}`}></i>
|
|
||||||
</div>
|
|
||||||
<h4>{feature.title}</h4>
|
|
||||||
<p className="text-muted">{feature.description}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Pricing Section */}
|
|
||||||
<section id="pricing" className="pricing-section py-5">
|
|
||||||
<div className="container py-5">
|
|
||||||
<div className="text-center mb-5">
|
|
||||||
<h2 className="display-5 fw-bold">{t('landing.pricing.title')}</h2>
|
|
||||||
<p className="lead text-muted">{t('landing.pricing.subtitle')}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="text-center py-5">
|
|
||||||
<div className="spinner-border text-primary" role="status">
|
|
||||||
<span className="visually-hidden">Loading...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="row justify-content-center g-4">
|
|
||||||
{plans.map((plan) => (
|
|
||||||
<div key={plan.id} className="col-lg-3 col-md-6">
|
|
||||||
<div className={`pricing-card h-100 ${plan.is_featured ? 'featured' : ''} ${plan.coming_soon ? 'coming-soon' : ''}`}>
|
|
||||||
{plan.is_featured && (
|
|
||||||
<div className="featured-badge">
|
|
||||||
<i className="bi bi-star-fill me-1"></i>
|
|
||||||
{t('landing.pricing.popular')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{plan.coming_soon && (
|
|
||||||
<div className="coming-soon-badge">
|
|
||||||
<i className="bi bi-clock me-1"></i>
|
|
||||||
{t('landing.pricing.comingSoon')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="pricing-header">
|
|
||||||
<h3>{plan.name}</h3>
|
|
||||||
<div className="price">
|
|
||||||
{plan.is_free ? (
|
|
||||||
<span className="amount">{t('landing.pricing.free')}</span>
|
|
||||||
) : plan.coming_soon ? (
|
|
||||||
<span className="amount coming-soon-text">
|
|
||||||
<i className="bi bi-rocket-takeoff"></i>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span className="currency">€</span>
|
|
||||||
<span className="amount">{plan.monthly_price?.toFixed(2) || plan.price}</span>
|
|
||||||
<span className="period">/{t('landing.pricing.month')}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{plan.billing_period === 'annual' && !plan.is_free && !plan.coming_soon && (
|
|
||||||
<p className="billing-note">
|
|
||||||
{t('landing.pricing.billedAnnually', { price: plan.price })}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{plan.coming_soon && (
|
|
||||||
<p className="billing-note">
|
|
||||||
{t('landing.pricing.forPymes')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul className="pricing-features">
|
|
||||||
{(plan.features || []).map((feature, idx) => (
|
|
||||||
<li key={idx}>
|
|
||||||
<i className="bi bi-check-circle-fill text-success me-2"></i>
|
|
||||||
{feature.startsWith('landing.pricing.features.') ? t(feature) : feature}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div className="pricing-footer">
|
|
||||||
{plan.coming_soon ? (
|
|
||||||
<button className="btn btn-secondary w-100" disabled>
|
|
||||||
<i className="bi bi-bell me-2"></i>
|
|
||||||
{t('landing.pricing.comingSoon')}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<Link
|
|
||||||
to={`/register?plan=${plan.slug}`}
|
|
||||||
className={`btn w-100 ${plan.is_featured ? 'btn-primary' : 'btn-outline-primary'}`}
|
|
||||||
>
|
|
||||||
{plan.is_free ? t('landing.pricing.startFree') : t('landing.pricing.subscribe')}
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Gold Plan Teaser */}
|
|
||||||
<div className="row justify-content-center mt-5">
|
|
||||||
<div className="col-lg-8">
|
|
||||||
<div className="gold-teaser text-center p-4 rounded-4">
|
|
||||||
<div className="d-flex align-items-center justify-content-center gap-3 mb-3">
|
|
||||||
<i className="bi bi-stars text-warning fs-2"></i>
|
|
||||||
<h4 className="mb-0 text-warning">{t('landing.pricing.goldTeaser.title')}</h4>
|
|
||||||
<i className="bi bi-stars text-warning fs-2"></i>
|
|
||||||
</div>
|
|
||||||
<p className="mb-0 text-light">
|
|
||||||
{t('landing.pricing.goldTeaser.description')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* FAQ Section */}
|
|
||||||
<section id="faq" className="faq-section py-5">
|
|
||||||
<div className="container py-5">
|
|
||||||
<div className="text-center mb-5">
|
|
||||||
<h2 className="display-5 fw-bold">{t('landing.faq.title')}</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="row justify-content-center">
|
|
||||||
<div className="col-lg-8">
|
|
||||||
<div className="accordion" id="faqAccordion">
|
|
||||||
{faqs.map((faq, index) => (
|
|
||||||
<div key={index} className="accordion-item">
|
|
||||||
<h2 className="accordion-header">
|
|
||||||
<button
|
|
||||||
className={`accordion-button ${index !== 0 ? 'collapsed' : ''}`}
|
|
||||||
type="button"
|
|
||||||
data-bs-toggle="collapse"
|
|
||||||
data-bs-target={`#faq${index}`}
|
|
||||||
>
|
|
||||||
{faq.question}
|
|
||||||
</button>
|
|
||||||
</h2>
|
|
||||||
<div
|
|
||||||
id={`faq${index}`}
|
|
||||||
className={`accordion-collapse collapse ${index === 0 ? 'show' : ''}`}
|
|
||||||
data-bs-parent="#faqAccordion"
|
|
||||||
>
|
|
||||||
<div className="accordion-body">
|
|
||||||
{faq.answer}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* CTA Section */}
|
|
||||||
<section className="cta-section py-5">
|
|
||||||
<div className="container py-5 text-center">
|
|
||||||
<h2 className="display-5 fw-bold text-white mb-4">
|
|
||||||
{t('landing.cta.title')}
|
|
||||||
</h2>
|
|
||||||
<p className="lead text-white-50 mb-4">
|
|
||||||
{t('landing.cta.subtitle')}
|
|
||||||
</p>
|
|
||||||
<Link to="/register" className="btn btn-light btn-lg">
|
|
||||||
<i className="bi bi-person-plus me-2"></i>
|
|
||||||
{t('landing.cta.button')}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<footer className="landing-footer py-4">
|
|
||||||
<div className="container">
|
|
||||||
<div className="row align-items-center">
|
|
||||||
<div className="col-md-6">
|
|
||||||
<div className="d-flex align-items-center">
|
|
||||||
<img src={logo} alt="WebMoney" height="30" className="me-2" />
|
|
||||||
<span className="text-muted">© 2025 WebMoney. {t('landing.footer.rights')}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="col-md-6 text-md-end mt-3 mt-md-0">
|
|
||||||
<a href="#" className="text-muted me-3">{t('landing.footer.privacy')}</a>
|
|
||||||
<a href="#" className="text-muted me-3">{t('landing.footer.terms')}</a>
|
|
||||||
<a href="mailto:support@webmoney.app" className="text-muted">{t('landing.footer.contact')}</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -4,7 +4,6 @@ import { liabilityAccountService } from '../services/api';
|
|||||||
import { useToast } from '../components/Toast';
|
import { useToast } from '../components/Toast';
|
||||||
import { ConfirmModal } from '../components/Modal';
|
import { ConfirmModal } from '../components/Modal';
|
||||||
import { useFormatters } from '../hooks';
|
import { useFormatters } from '../hooks';
|
||||||
import LiabilityWizard from '../components/LiabilityWizard';
|
|
||||||
|
|
||||||
const LiabilityAccounts = () => {
|
const LiabilityAccounts = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -25,7 +24,6 @@ const LiabilityAccounts = () => {
|
|||||||
const [summary, setSummary] = useState(null);
|
const [summary, setSummary] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showImportModal, setShowImportModal] = useState(false);
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
const [showWizardModal, setShowWizardModal] = useState(false);
|
|
||||||
const [showDetailModal, setShowDetailModal] = useState(false);
|
const [showDetailModal, setShowDetailModal] = useState(false);
|
||||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||||
const [showReconcileModal, setShowReconcileModal] = useState(false);
|
const [showReconcileModal, setShowReconcileModal] = useState(false);
|
||||||
@ -72,31 +70,6 @@ const LiabilityAccounts = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Download template Excel
|
|
||||||
const handleDownloadTemplate = async () => {
|
|
||||||
try {
|
|
||||||
const blob = await liabilityAccountService.downloadTemplate();
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = 'plantilla_importacion_pasivo.xlsx';
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
document.body.removeChild(a);
|
|
||||||
toast.success('Plantilla descargada');
|
|
||||||
} catch (error) {
|
|
||||||
toast.error('Error al descargar plantilla');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Wizard success handler
|
|
||||||
const handleWizardSuccess = (newAccount) => {
|
|
||||||
loadAccounts();
|
|
||||||
setSelectedAccount(newAccount);
|
|
||||||
setShowDetailModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenImportModal = () => {
|
const handleOpenImportModal = () => {
|
||||||
setImportForm({
|
setImportForm({
|
||||||
file: null,
|
file: null,
|
||||||
@ -393,44 +366,13 @@ const LiabilityAccounts = () => {
|
|||||||
{t('liabilities.subtitle')}
|
{t('liabilities.subtitle')}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<div className={`d-flex gap-2 ${isMobile ? 'flex-column' : ''}`}>
|
<button
|
||||||
{/* Botão Criar com Wizard */}
|
className={`btn btn-primary ${isMobile ? 'w-100' : ''}`}
|
||||||
<button
|
onClick={handleOpenImportModal}
|
||||||
className={`btn btn-success ${isMobile ? 'w-100' : ''}`}
|
>
|
||||||
onClick={() => setShowWizardModal(true)}
|
<i className="bi bi-upload me-2"></i>
|
||||||
>
|
{isMobile ? t('common.import') : t('liabilities.importContract')}
|
||||||
<i className="bi bi-plus-circle me-2"></i>
|
</button>
|
||||||
{isMobile ? 'Crear' : 'Crear Pasivo'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Dropdown para importação */}
|
|
||||||
<div className="dropdown">
|
|
||||||
<button
|
|
||||||
className={`btn btn-primary dropdown-toggle ${isMobile ? 'w-100' : ''}`}
|
|
||||||
type="button"
|
|
||||||
data-bs-toggle="dropdown"
|
|
||||||
aria-expanded="false"
|
|
||||||
>
|
|
||||||
<i className="bi bi-upload me-2"></i>
|
|
||||||
{isMobile ? t('common.import') : t('liabilities.importContract')}
|
|
||||||
</button>
|
|
||||||
<ul className="dropdown-menu dropdown-menu-end">
|
|
||||||
<li>
|
|
||||||
<button className="dropdown-item" onClick={handleOpenImportModal}>
|
|
||||||
<i className="bi bi-file-earmark-excel me-2 text-success"></i>
|
|
||||||
Importar desde Excel
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li><hr className="dropdown-divider" /></li>
|
|
||||||
<li>
|
|
||||||
<button className="dropdown-item" onClick={handleDownloadTemplate}>
|
|
||||||
<i className="bi bi-download me-2 text-primary"></i>
|
|
||||||
Descargar Plantilla
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary Cards */}
|
{/* Summary Cards */}
|
||||||
@ -546,20 +488,10 @@ const LiabilityAccounts = () => {
|
|||||||
<i className="bi bi-inbox fs-1 text-muted mb-3 d-block"></i>
|
<i className="bi bi-inbox fs-1 text-muted mb-3 d-block"></i>
|
||||||
<h5 className="text-muted">{t('liabilities.noContracts')}</h5>
|
<h5 className="text-muted">{t('liabilities.noContracts')}</h5>
|
||||||
<p className="text-muted mb-3">{t('liabilities.importHint')}</p>
|
<p className="text-muted mb-3">{t('liabilities.importHint')}</p>
|
||||||
<div className="d-flex gap-2 justify-content-center flex-wrap">
|
<button className="btn btn-primary" onClick={handleOpenImportModal}>
|
||||||
<button className="btn btn-success" onClick={() => setShowWizardModal(true)}>
|
<i className="bi bi-upload me-2"></i>
|
||||||
<i className="bi bi-plus-circle me-2"></i>
|
{t('liabilities.importContract')}
|
||||||
Crear Pasivo
|
</button>
|
||||||
</button>
|
|
||||||
<button className="btn btn-primary" onClick={handleOpenImportModal}>
|
|
||||||
<i className="bi bi-upload me-2"></i>
|
|
||||||
{t('liabilities.importContract')}
|
|
||||||
</button>
|
|
||||||
<button className="btn btn-outline-primary" onClick={handleDownloadTemplate}>
|
|
||||||
<i className="bi bi-download me-2"></i>
|
|
||||||
Descargar Plantilla
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -1533,13 +1465,6 @@ const LiabilityAccounts = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Liability Wizard Modal */}
|
|
||||||
<LiabilityWizard
|
|
||||||
isOpen={showWizardModal}
|
|
||||||
onClose={() => setShowWizardModal(false)}
|
|
||||||
onSuccess={handleWizardSuccess}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,13 +1,10 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import Footer from '../components/Footer';
|
import Footer from '../components/Footer';
|
||||||
import api from '../services/api';
|
|
||||||
import logo from '../assets/logo-white.png';
|
import logo from '../assets/logo-white.png';
|
||||||
|
|
||||||
const Login = () => {
|
const Login = () => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { login } = useAuth();
|
const { login } = useAuth();
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
@ -16,9 +13,6 @@ const Login = () => {
|
|||||||
});
|
});
|
||||||
const [errors, setErrors] = useState({});
|
const [errors, setErrors] = useState({});
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [needsActivation, setNeedsActivation] = useState(false);
|
|
||||||
const [resendingEmail, setResendingEmail] = useState(false);
|
|
||||||
const [resendSuccess, setResendSuccess] = useState(false);
|
|
||||||
|
|
||||||
const handleChange = (e) => {
|
const handleChange = (e) => {
|
||||||
setFormData({
|
setFormData({
|
||||||
@ -29,19 +23,12 @@ const Login = () => {
|
|||||||
if (errors[e.target.name]) {
|
if (errors[e.target.name]) {
|
||||||
setErrors({ ...errors, [e.target.name]: null });
|
setErrors({ ...errors, [e.target.name]: null });
|
||||||
}
|
}
|
||||||
// Reset activation state when email changes
|
|
||||||
if (e.target.name === 'email') {
|
|
||||||
setNeedsActivation(false);
|
|
||||||
setResendSuccess(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setErrors({});
|
setErrors({});
|
||||||
setNeedsActivation(false);
|
|
||||||
setResendSuccess(false);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await login(formData);
|
const response = await login(formData);
|
||||||
@ -49,42 +36,18 @@ const Login = () => {
|
|||||||
navigate('/dashboard');
|
navigate('/dashboard');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorData = error.response?.data;
|
if (error.response?.data?.errors) {
|
||||||
|
setErrors(error.response.data.errors);
|
||||||
// Check if it's an activation error
|
} else if (error.response?.data?.message) {
|
||||||
if (errorData?.error === 'email_not_verified') {
|
setErrors({ general: error.response.data.message });
|
||||||
setNeedsActivation(true);
|
|
||||||
setErrors({ general: errorData.message });
|
|
||||||
} else if (errorData?.error === 'no_subscription') {
|
|
||||||
setErrors({ general: t('login.noSubscription', 'Você não possui uma assinatura ativa. Por favor, complete o pagamento.') });
|
|
||||||
} else if (errorData?.errors) {
|
|
||||||
setErrors(errorData.errors);
|
|
||||||
} else if (errorData?.message) {
|
|
||||||
setErrors({ general: errorData.message });
|
|
||||||
} else {
|
} else {
|
||||||
setErrors({ general: t('errors.connection', 'Erro de conexão. Tente novamente.') });
|
setErrors({ general: 'Error de conexión. Intenta nuevamente.' });
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleResendActivation = async () => {
|
|
||||||
setResendingEmail(true);
|
|
||||||
setResendSuccess(false);
|
|
||||||
try {
|
|
||||||
const response = await api.post('/resend-activation', { email: formData.email });
|
|
||||||
if (response.data.success) {
|
|
||||||
setResendSuccess(true);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const message = error.response?.data?.message || t('errors.resendFailed', 'Erro ao reenviar email');
|
|
||||||
setErrors({ general: message });
|
|
||||||
} finally {
|
|
||||||
setResendingEmail(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<div className="row justify-content-center align-items-center min-vh-100">
|
<div className="row justify-content-center align-items-center min-vh-100">
|
||||||
@ -92,49 +55,22 @@ const Login = () => {
|
|||||||
<div className="card shadow-lg border-0">
|
<div className="card shadow-lg border-0">
|
||||||
<div className="card-body p-5">
|
<div className="card-body p-5">
|
||||||
<div className="text-center mb-4">
|
<div className="text-center mb-4">
|
||||||
<Link to="/">
|
<img src={logo} alt="WebMoney" className="mb-3" style={{ height: '80px', width: 'auto' }} />
|
||||||
<img src={logo} alt="WebMoney" className="mb-3" style={{ height: '80px', width: 'auto' }} />
|
|
||||||
</Link>
|
|
||||||
<h2 className="fw-bold text-primary">WebMoney</h2>
|
<h2 className="fw-bold text-primary">WebMoney</h2>
|
||||||
<p className="text-muted">{t('landing.hero.subtitle', 'Gestión Financiera Inteligente')}</p>
|
<p className="text-muted">Gestión Financiera Inteligente</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{errors.general && (
|
{errors.general && (
|
||||||
<div className={`alert ${needsActivation ? 'alert-warning' : 'alert-danger'}`} role="alert">
|
<div className="alert alert-danger" role="alert">
|
||||||
<i className={`bi ${needsActivation ? 'bi-envelope-exclamation' : 'bi-exclamation-circle'} me-2`}></i>
|
<i className="bi bi-exclamation-circle me-2"></i>
|
||||||
{errors.general}
|
{errors.general}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{needsActivation && (
|
|
||||||
<div className="mb-3">
|
|
||||||
{resendSuccess ? (
|
|
||||||
<div className="alert alert-success">
|
|
||||||
<i className="bi bi-check-circle me-2"></i>
|
|
||||||
{t('activate.resendSuccess', 'Email de ativação reenviado! Verifique sua caixa de entrada.')}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-outline-warning w-100"
|
|
||||||
onClick={handleResendActivation}
|
|
||||||
disabled={resendingEmail || !formData.email}
|
|
||||||
>
|
|
||||||
{resendingEmail ? (
|
|
||||||
<span className="spinner-border spinner-border-sm me-2"></span>
|
|
||||||
) : (
|
|
||||||
<i className="bi bi-envelope me-2"></i>
|
|
||||||
)}
|
|
||||||
{t('activate.resend', 'Reenviar email de ativação')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<label htmlFor="email" className="form-label">
|
<label htmlFor="email" className="form-label">
|
||||||
{t('auth.email', 'Email')}
|
Email
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
@ -154,7 +90,7 @@ const Login = () => {
|
|||||||
|
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<label htmlFor="password" className="form-label">
|
<label htmlFor="password" className="form-label">
|
||||||
{t('auth.password', 'Contraseña')}
|
Contraseña
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
@ -180,22 +116,13 @@ const Login = () => {
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
<>
|
<>
|
||||||
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
|
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
|
||||||
{t('common.processing', 'Procesando...')}
|
Iniciando sesión...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
t('auth.login', 'Iniciar Sesión')
|
'Iniciar Sesión'
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="text-center mt-4">
|
|
||||||
<p className="mb-0">
|
|
||||||
{t('login.noAccount', '¿No tienes cuenta?')}{' '}
|
|
||||||
<Link to="/register" className="text-decoration-none fw-semibold">
|
|
||||||
{t('login.createAccount', 'Crea una aquí')}
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -1,160 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { Link, useSearchParams } from 'react-router-dom';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import api from '../services/api';
|
|
||||||
import logo from '../assets/logo-white.png';
|
|
||||||
|
|
||||||
const PaymentSuccess = () => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const [status, setStatus] = useState('loading'); // loading, success, error
|
|
||||||
const [message, setMessage] = useState('');
|
|
||||||
const [userEmail, setUserEmail] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const confirmPayment = async () => {
|
|
||||||
const subscriptionId = searchParams.get('subscription_id');
|
|
||||||
const email = searchParams.get('user_email') || sessionStorage.getItem('pendingActivationEmail');
|
|
||||||
|
|
||||||
if (email) {
|
|
||||||
setUserEmail(email);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!subscriptionId) {
|
|
||||||
setStatus('error');
|
|
||||||
setMessage(t('payment.noSubscriptionId', 'ID da assinatura não encontrado'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Confirm the subscription with PayPal
|
|
||||||
const response = await api.post('/subscription/confirm-public', {
|
|
||||||
subscription_id: subscriptionId,
|
|
||||||
user_email: email,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.data.success) {
|
|
||||||
setStatus('success');
|
|
||||||
setMessage(response.data.message || t('payment.success', 'Pagamento confirmado!'));
|
|
||||||
// Clean up session storage
|
|
||||||
sessionStorage.removeItem('pendingActivationEmail');
|
|
||||||
} else {
|
|
||||||
setStatus('error');
|
|
||||||
setMessage(response.data.message || t('payment.error', 'Erro ao confirmar pagamento'));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Payment confirmation error:', error);
|
|
||||||
setStatus('error');
|
|
||||||
setMessage(error.response?.data?.message || t('payment.error', 'Erro ao confirmar pagamento'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
confirmPayment();
|
|
||||||
}, [searchParams, t]);
|
|
||||||
|
|
||||||
const renderContent = () => {
|
|
||||||
switch (status) {
|
|
||||||
case 'loading':
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="mb-4">
|
|
||||||
<div className="spinner-border text-primary" style={{ width: '3rem', height: '3rem' }} role="status">
|
|
||||||
<span className="visually-hidden">Loading...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h3 className="fw-bold mb-3">
|
|
||||||
{t('payment.confirming', 'Confirmando pagamento...')}
|
|
||||||
</h3>
|
|
||||||
<p className="text-muted">
|
|
||||||
{t('payment.pleaseWait', 'Por favor, aguarde enquanto processamos seu pagamento.')}
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'success':
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="mb-4">
|
|
||||||
<div className="bg-success bg-opacity-10 rounded-circle d-inline-flex p-3">
|
|
||||||
<i className="bi bi-check-circle-fill text-success" style={{ fontSize: '3rem' }}></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h3 className="fw-bold text-success mb-3">
|
|
||||||
{t('payment.successTitle', 'Pagamento Confirmado!')}
|
|
||||||
</h3>
|
|
||||||
<p className="text-muted mb-4">
|
|
||||||
{t('payment.successMessage', 'Sua assinatura foi confirmada com sucesso.')}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="alert alert-info text-start">
|
|
||||||
<i className="bi bi-envelope-check me-2"></i>
|
|
||||||
<strong>{t('payment.checkYourEmail', 'Verifique seu email!')}</strong>
|
|
||||||
<p className="mb-0 mt-2">
|
|
||||||
{t('payment.activationSent', 'Enviamos um email de ativação para {{email}}. Clique no link para ativar sua conta e começar a usar o WEBMoney.', { email: userEmail || 'seu email' })}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4">
|
|
||||||
<Link to="/login" className="btn btn-primary">
|
|
||||||
<i className="bi bi-box-arrow-in-right me-2"></i>
|
|
||||||
{t('auth.goToLogin', 'Ir para Login')}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'error':
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="mb-4">
|
|
||||||
<div className="bg-danger bg-opacity-10 rounded-circle d-inline-flex p-3">
|
|
||||||
<i className="bi bi-exclamation-circle-fill text-danger" style={{ fontSize: '3rem' }}></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h3 className="fw-bold text-danger mb-3">
|
|
||||||
{t('payment.errorTitle', 'Erro no Pagamento')}
|
|
||||||
</h3>
|
|
||||||
<p className="text-muted mb-4">
|
|
||||||
{message}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="d-flex gap-2 justify-content-center">
|
|
||||||
<Link to="/register" className="btn btn-outline-primary">
|
|
||||||
<i className="bi bi-arrow-left me-2"></i>
|
|
||||||
{t('common.tryAgain', 'Tentar novamente')}
|
|
||||||
</Link>
|
|
||||||
<Link to="/" className="btn btn-outline-secondary">
|
|
||||||
<i className="bi bi-house me-2"></i>
|
|
||||||
{t('common.backToHome', 'Voltar ao início')}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="container">
|
|
||||||
<div className="row justify-content-center align-items-center min-vh-100">
|
|
||||||
<div className="col-md-6">
|
|
||||||
<div className="card shadow-lg border-0">
|
|
||||||
<div className="card-body p-5 text-center">
|
|
||||||
<div className="mb-4">
|
|
||||||
<Link to="/">
|
|
||||||
<img src={logo} alt="WebMoney" style={{ height: '60px', width: 'auto' }} />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{renderContent()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PaymentSuccess;
|
|
||||||
@ -1,15 +1,12 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Link, useSearchParams } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import Footer from '../components/Footer';
|
import Footer from '../components/Footer';
|
||||||
import api, { authService } from '../services/api';
|
|
||||||
import logo from '../assets/logo-white.png';
|
import logo from '../assets/logo-white.png';
|
||||||
|
|
||||||
const Register = () => {
|
const Register = () => {
|
||||||
const { t } = useTranslation();
|
const navigate = useNavigate();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const { register } = useAuth();
|
||||||
const [plans, setPlans] = useState([]);
|
|
||||||
const [selectedPlan, setSelectedPlan] = useState(null);
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
@ -18,65 +15,6 @@ const Register = () => {
|
|||||||
});
|
});
|
||||||
const [errors, setErrors] = useState({});
|
const [errors, setErrors] = useState({});
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [loadingPlans, setLoadingPlans] = useState(true);
|
|
||||||
const [registrationComplete, setRegistrationComplete] = useState(false);
|
|
||||||
const [registeredEmail, setRegisteredEmail] = useState('');
|
|
||||||
const [resendingEmail, setResendingEmail] = useState(false);
|
|
||||||
const [resendSuccess, setResendSuccess] = useState(false);
|
|
||||||
const [paymentCanceled, setPaymentCanceled] = useState(false);
|
|
||||||
|
|
||||||
// Check if payment was canceled
|
|
||||||
useEffect(() => {
|
|
||||||
const canceled = searchParams.get('payment_canceled');
|
|
||||||
const email = sessionStorage.getItem('pendingActivationEmail');
|
|
||||||
|
|
||||||
if (canceled === 'true' && email) {
|
|
||||||
setPaymentCanceled(true);
|
|
||||||
// Clean up the unactivated account
|
|
||||||
api.post('/cancel-registration', { email })
|
|
||||||
.then(() => {
|
|
||||||
sessionStorage.removeItem('pendingActivationEmail');
|
|
||||||
// Clean URL
|
|
||||||
setSearchParams({});
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
console.error('Error canceling registration:', err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [searchParams, setSearchParams]);
|
|
||||||
|
|
||||||
// Carregar planos
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchPlans = async () => {
|
|
||||||
try {
|
|
||||||
const response = await api.get('/plans');
|
|
||||||
// API returns { success: true, data: { plans: [...] } }
|
|
||||||
const allPlans = response.data.data?.plans || response.data.plans || response.data.data || [];
|
|
||||||
// Filtrar apenas planos ativos
|
|
||||||
const activePlans = Array.isArray(allPlans) ? allPlans.filter(p => p.is_active) : [];
|
|
||||||
setPlans(activePlans);
|
|
||||||
|
|
||||||
// Verificar se há plano na URL
|
|
||||||
const planSlug = searchParams.get('plan');
|
|
||||||
if (planSlug && activePlans.length > 0) {
|
|
||||||
const plan = activePlans.find(p => p.slug === planSlug);
|
|
||||||
if (plan) {
|
|
||||||
setSelectedPlan(plan);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Se só houver um plano, selecioná-lo automaticamente
|
|
||||||
if (activePlans.length === 1) {
|
|
||||||
setSelectedPlan(activePlans[0]);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading plans:', error);
|
|
||||||
} finally {
|
|
||||||
setLoadingPlans(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
fetchPlans();
|
|
||||||
}, [searchParams]);
|
|
||||||
|
|
||||||
const handleChange = (e) => {
|
const handleChange = (e) => {
|
||||||
setFormData({
|
setFormData({
|
||||||
@ -91,43 +29,13 @@ const Register = () => {
|
|||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!selectedPlan) {
|
|
||||||
setErrors({ general: t('register.selectPlan', 'Selecione um plano') });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setErrors({});
|
setErrors({});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Step 1: Register user (won't login - needs activation)
|
const response = await register(formData);
|
||||||
const response = await api.post('/register', {
|
if (response.success) {
|
||||||
...formData,
|
navigate('/dashboard');
|
||||||
plan_id: selectedPlan.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.data.success) {
|
|
||||||
// Step 2: Create PayPal subscription via public endpoint
|
|
||||||
try {
|
|
||||||
const subscriptionResponse = await api.post('/subscription/start', {
|
|
||||||
plan_id: selectedPlan.id,
|
|
||||||
user_email: formData.email,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (subscriptionResponse.data.data?.approve_url) {
|
|
||||||
// Save email for later reference when user returns
|
|
||||||
sessionStorage.setItem('pendingActivationEmail', formData.email);
|
|
||||||
// Redirect to PayPal
|
|
||||||
window.location.href = subscriptionResponse.data.data.approve_url;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (subError) {
|
|
||||||
console.error('Subscription error:', subError);
|
|
||||||
setErrors({
|
|
||||||
general: t('errors.subscriptionFailed', 'Erro ao criar assinatura. Tente novamente.')
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.response?.data?.errors) {
|
if (error.response?.data?.errors) {
|
||||||
@ -135,154 +43,25 @@ const Register = () => {
|
|||||||
} else if (error.response?.data?.message) {
|
} else if (error.response?.data?.message) {
|
||||||
setErrors({ general: error.response.data.message });
|
setErrors({ general: error.response.data.message });
|
||||||
} else {
|
} else {
|
||||||
setErrors({ general: t('errors.connection', 'Erro de conexão. Tente novamente.') });
|
setErrors({ general: 'Error de conexión. Intenta nuevamente.' });
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleResendEmail = async () => {
|
|
||||||
setResendingEmail(true);
|
|
||||||
setResendSuccess(false);
|
|
||||||
try {
|
|
||||||
await authService.resendActivation(registeredEmail);
|
|
||||||
setResendSuccess(true);
|
|
||||||
setTimeout(() => setResendSuccess(false), 5000);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error resending email:', error);
|
|
||||||
} finally {
|
|
||||||
setResendingEmail(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Show activation pending screen
|
|
||||||
if (registrationComplete) {
|
|
||||||
return (
|
|
||||||
<div className="container">
|
|
||||||
<div className="row justify-content-center align-items-center min-vh-100">
|
|
||||||
<div className="col-md-6">
|
|
||||||
<div className="card shadow-lg border-0">
|
|
||||||
<div className="card-body p-5 text-center">
|
|
||||||
<div className="mb-4">
|
|
||||||
<Link to="/">
|
|
||||||
<img src={logo} alt="WebMoney" style={{ height: '60px', width: 'auto' }} />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-4">
|
|
||||||
<div className="bg-success bg-opacity-10 rounded-circle d-inline-flex p-3 mb-3">
|
|
||||||
<i className="bi bi-envelope-check text-success" style={{ fontSize: '3rem' }}></i>
|
|
||||||
</div>
|
|
||||||
<h3 className="fw-bold text-success">
|
|
||||||
{t('activate.checkEmail', 'Verifique seu email')}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-muted mb-4">
|
|
||||||
{t('activate.checkEmailMessage', 'Enviamos um email de ativação para {{email}}. Clique no link para ativar sua conta.', { email: registeredEmail })}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="alert alert-info">
|
|
||||||
<i className="bi bi-info-circle me-2"></i>
|
|
||||||
{t('activate.didntReceive', 'Não recebeu o email?')}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="btn btn-outline-primary"
|
|
||||||
onClick={handleResendEmail}
|
|
||||||
disabled={resendingEmail}
|
|
||||||
>
|
|
||||||
{resendingEmail ? (
|
|
||||||
<span className="spinner-border spinner-border-sm me-2"></span>
|
|
||||||
) : (
|
|
||||||
<i className="bi bi-arrow-repeat me-2"></i>
|
|
||||||
)}
|
|
||||||
{t('activate.resend', 'Reenviar email')}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{resendSuccess && (
|
|
||||||
<div className="alert alert-success mt-3">
|
|
||||||
<i className="bi bi-check-circle me-2"></i>
|
|
||||||
{t('activate.resendSuccess', 'Email reenviado com sucesso!')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<hr className="my-4" />
|
|
||||||
|
|
||||||
<Link to="/login" className="text-decoration-none">
|
|
||||||
<i className="bi bi-arrow-left me-1"></i>
|
|
||||||
{t('auth.backToLogin', 'Voltar para login')}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<div className="row justify-content-center align-items-center min-vh-100">
|
<div className="row justify-content-center align-items-center min-vh-100">
|
||||||
<div className="col-lg-8">
|
<div className="col-md-6">
|
||||||
<div className="card shadow-lg border-0">
|
<div className="card shadow-lg border-0">
|
||||||
<div className="card-body p-5">
|
<div className="card-body p-5">
|
||||||
<div className="text-center mb-4">
|
<div className="text-center mb-4">
|
||||||
<Link to="/">
|
<img src={logo} alt="WebMoney" className="mb-3" style={{ height: '80px', width: 'auto' }} />
|
||||||
<img src={logo} alt="WebMoney" className="mb-3" style={{ height: '80px', width: 'auto' }} />
|
|
||||||
</Link>
|
|
||||||
<h2 className="fw-bold text-primary">WebMoney</h2>
|
<h2 className="fw-bold text-primary">WebMoney</h2>
|
||||||
<p className="text-muted">{t('auth.createAccount', 'Crea tu cuenta')}</p>
|
<p className="text-muted">Crie sua conta</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Plan Selection */}
|
|
||||||
{!loadingPlans && plans.length > 0 && (
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="form-label fw-semibold">
|
|
||||||
<i className="bi bi-box me-2"></i>
|
|
||||||
{t('register.selectPlan', 'Selecciona un plan')}
|
|
||||||
</label>
|
|
||||||
<div className="row g-3">
|
|
||||||
{plans.map((plan) => (
|
|
||||||
<div key={plan.id} className="col-md-4">
|
|
||||||
<div
|
|
||||||
className={`card h-100 cursor-pointer ${selectedPlan?.id === plan.id ? 'border-primary bg-primary bg-opacity-10' : ''}`}
|
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
onClick={() => setSelectedPlan(plan)}
|
|
||||||
>
|
|
||||||
<div className="card-body text-center p-3">
|
|
||||||
<h6 className="card-title mb-1">{plan.name}</h6>
|
|
||||||
<p className="h5 mb-1 text-primary">
|
|
||||||
{plan.price > 0 ? `€${plan.price}` : t('pricing.free', 'Gratis')}
|
|
||||||
{plan.price > 0 && <small className="text-muted fs-6">/{plan.billing_period === 'yearly' ? t('pricing.year', 'año') : t('pricing.month', 'mes')}</small>}
|
|
||||||
</p>
|
|
||||||
{plan.trial_days > 0 && (
|
|
||||||
<small className="text-success">
|
|
||||||
<i className="bi bi-gift me-1"></i>
|
|
||||||
{plan.trial_days} {t('common.days', 'días')} {t('pricing.trial', 'de prueba')}
|
|
||||||
</small>
|
|
||||||
)}
|
|
||||||
{selectedPlan?.id === plan.id && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<i className="bi bi-check-circle-fill text-primary"></i>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{paymentCanceled && (
|
|
||||||
<div className="alert alert-warning" role="alert">
|
|
||||||
<i className="bi bi-exclamation-triangle me-2"></i>
|
|
||||||
{t('register.paymentCanceled', 'O pagamento foi cancelado. Você pode tentar novamente.')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{errors.general && (
|
{errors.general && (
|
||||||
<div className="alert alert-danger" role="alert">
|
<div className="alert alert-danger" role="alert">
|
||||||
<i className="bi bi-exclamation-circle me-2"></i>
|
<i className="bi bi-exclamation-circle me-2"></i>
|
||||||
@ -291,122 +70,106 @@ const Register = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="row">
|
<div className="mb-3">
|
||||||
<div className="col-md-6 mb-3">
|
<label htmlFor="name" className="form-label">
|
||||||
<label htmlFor="name" className="form-label">
|
Nombre completo
|
||||||
{t('profile.name', 'Nombre completo')}
|
</label>
|
||||||
</label>
|
<input
|
||||||
<input
|
type="text"
|
||||||
type="text"
|
className={`form-control ${errors.name ? 'is-invalid' : ''}`}
|
||||||
className={`form-control ${errors.name ? 'is-invalid' : ''}`}
|
id="name"
|
||||||
id="name"
|
name="name"
|
||||||
name="name"
|
value={formData.name}
|
||||||
value={formData.name}
|
onChange={handleChange}
|
||||||
onChange={handleChange}
|
placeholder="Tu nombre"
|
||||||
placeholder={t('profile.namePlaceholder', 'Tu nombre')}
|
required
|
||||||
required
|
/>
|
||||||
/>
|
{errors.name && (
|
||||||
{errors.name && (
|
<div className="invalid-feedback">{errors.name}</div>
|
||||||
<div className="invalid-feedback">{errors.name}</div>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="col-md-6 mb-3">
|
|
||||||
<label htmlFor="email" className="form-label">
|
|
||||||
{t('auth.email', 'Email')}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
className={`form-control ${errors.email ? 'is-invalid' : ''}`}
|
|
||||||
id="email"
|
|
||||||
name="email"
|
|
||||||
autoComplete="email"
|
|
||||||
value={formData.email}
|
|
||||||
onChange={handleChange}
|
|
||||||
placeholder="tu@email.com"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
{errors.email && (
|
|
||||||
<div className="invalid-feedback">{errors.email}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="row">
|
<div className="mb-3">
|
||||||
<div className="col-md-6 mb-3">
|
<label htmlFor="email" className="form-label">
|
||||||
<label htmlFor="password" className="form-label">
|
Email
|
||||||
{t('auth.password', 'Contraseña')}
|
</label>
|
||||||
</label>
|
<input
|
||||||
<input
|
type="email"
|
||||||
type="password"
|
className={`form-control ${errors.email ? 'is-invalid' : ''}`}
|
||||||
className={`form-control ${errors.password ? 'is-invalid' : ''}`}
|
id="email"
|
||||||
id="password"
|
name="email"
|
||||||
name="password"
|
autoComplete="email"
|
||||||
autoComplete="new-password"
|
value={formData.email}
|
||||||
value={formData.password}
|
onChange={handleChange}
|
||||||
onChange={handleChange}
|
placeholder="tu@email.com"
|
||||||
placeholder={t('profile.passwordHint', 'Mínimo 8 caracteres')}
|
required
|
||||||
required
|
/>
|
||||||
/>
|
{errors.email && (
|
||||||
{errors.password && (
|
<div className="invalid-feedback">{errors.email}</div>
|
||||||
<div className="invalid-feedback">{errors.password}</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="col-md-6 mb-3">
|
<div className="mb-3">
|
||||||
<label htmlFor="password_confirmation" className="form-label">
|
<label htmlFor="password" className="form-label">
|
||||||
{t('profile.confirmPassword', 'Confirmar contraseña')}
|
Contraseña
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
className={`form-control ${errors.password_confirmation ? 'is-invalid' : ''}`}
|
className={`form-control ${errors.password ? 'is-invalid' : ''}`}
|
||||||
id="password_confirmation"
|
id="password"
|
||||||
name="password_confirmation"
|
name="password"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
value={formData.password_confirmation}
|
value={formData.password}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
placeholder={t('register.repeatPassword', 'Repite tu contraseña')}
|
placeholder="Mínimo 8 caracteres"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
{errors.password_confirmation && (
|
{errors.password && (
|
||||||
<div className="invalid-feedback">{errors.password_confirmation}</div>
|
<div className="invalid-feedback">{errors.password}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label htmlFor="password_confirmation" className="form-label">
|
||||||
|
Confirmar contraseña
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className={`form-control ${errors.password_confirmation ? 'is-invalid' : ''}`}
|
||||||
|
id="password_confirmation"
|
||||||
|
name="password_confirmation"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={formData.password_confirmation}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="Repite tu contraseña"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{errors.password_confirmation && (
|
||||||
|
<div className="invalid-feedback">{errors.password_confirmation}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="btn btn-primary w-100 py-2 mt-3"
|
className="btn btn-primary w-100 py-2"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<>
|
<>
|
||||||
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
|
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
|
||||||
{t('common.processing', 'Procesando...')}
|
Registrando...
|
||||||
</>
|
|
||||||
) : selectedPlan && selectedPlan.price > 0 ? (
|
|
||||||
<>
|
|
||||||
<i className="bi bi-credit-card me-2"></i>
|
|
||||||
{t('register.continueToPayment', 'Continuar al pago')}
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
t('register.createAccount', 'Crear Cuenta')
|
'Crear Cuenta'
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{selectedPlan && selectedPlan.price > 0 && (
|
|
||||||
<p className="text-center text-muted mt-2 small">
|
|
||||||
<i className="bi bi-shield-check me-1"></i>
|
|
||||||
{t('pricing.paypalSecure', 'Pago seguro con PayPal')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="text-center mt-4">
|
<div className="text-center mt-4">
|
||||||
<p className="mb-0">
|
<p className="mb-0">
|
||||||
{t('register.alreadyHaveAccount', '¿Ya tienes cuenta?')}{' '}
|
¿Ya tienes cuenta?{' '}
|
||||||
<Link to="/login" className="text-decoration-none fw-semibold">
|
<Link to="/login" className="text-decoration-none fw-semibold">
|
||||||
{t('register.loginHere', 'Inicia sesión aquí')}
|
Inicia sesión aquí
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,310 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import api from '../services/api';
|
|
||||||
import { useToast } from '../components/Toast';
|
|
||||||
|
|
||||||
export default function SiteSettings() {
|
|
||||||
const toast = useToast();
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [deploying, setDeploying] = useState(false);
|
|
||||||
const [status, setStatus] = useState({
|
|
||||||
mode: 'live',
|
|
||||||
maintenance_mode: false,
|
|
||||||
modes_available: {
|
|
||||||
live: 'Página institucional completa',
|
|
||||||
maintenance: 'Página de mantenimiento',
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchStatus();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchStatus = async () => {
|
|
||||||
try {
|
|
||||||
const response = await api.get('/admin/site-settings/cnxifly/status');
|
|
||||||
setStatus(response.data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching status:', error);
|
|
||||||
toast.error('Error al obtener el estado del sitio');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggleMode = async (newMode) => {
|
|
||||||
try {
|
|
||||||
setDeploying(true);
|
|
||||||
const response = await api.post('/admin/site-settings/cnxifly/toggle', { mode: newMode });
|
|
||||||
|
|
||||||
if (response.data.success) {
|
|
||||||
toast.success(response.data.message);
|
|
||||||
setStatus(prev => ({
|
|
||||||
...prev,
|
|
||||||
mode: newMode,
|
|
||||||
maintenance_mode: newMode !== 'live'
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error toggling mode:', error);
|
|
||||||
toast.error('Error al cambiar el modo');
|
|
||||||
} finally {
|
|
||||||
setDeploying(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeploy = async (mode) => {
|
|
||||||
if (!confirm(`¿Está seguro de desplegar la página en modo "${mode}"? Esto actualizará cnxifly.com`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setDeploying(true);
|
|
||||||
const response = await api.post('/admin/site-settings/cnxifly/deploy', { mode });
|
|
||||||
|
|
||||||
if (response.data.success) {
|
|
||||||
toast.success(`Configuración actualizada. Use el comando de deploy para aplicar los cambios.`);
|
|
||||||
|
|
||||||
// Log deploy instructions to console for copying
|
|
||||||
console.log('Deploy command:', response.data.deploy_instructions?.command);
|
|
||||||
|
|
||||||
setStatus(prev => ({
|
|
||||||
...prev,
|
|
||||||
mode: mode,
|
|
||||||
maintenance_mode: mode === 'maintenance'
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error deploying:', error);
|
|
||||||
toast.error('Error al desplegar la página');
|
|
||||||
} finally {
|
|
||||||
setDeploying(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getModeColor = (mode, isActive) => {
|
|
||||||
if (!isActive) return 'bg-gray-700 border-gray-600 hover:border-gray-500';
|
|
||||||
|
|
||||||
switch (mode) {
|
|
||||||
case 'live':
|
|
||||||
return 'bg-green-900/30 border-green-500 ring-2 ring-green-500/30';
|
|
||||||
case 'maintenance':
|
|
||||||
return 'bg-yellow-900/30 border-yellow-500 ring-2 ring-yellow-500/30';
|
|
||||||
case 'construction':
|
|
||||||
return 'bg-orange-900/30 border-orange-500 ring-2 ring-orange-500/30';
|
|
||||||
default:
|
|
||||||
return 'bg-gray-700 border-gray-600';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getModeTextColor = (mode) => {
|
|
||||||
switch (mode) {
|
|
||||||
case 'live':
|
|
||||||
return 'text-green-400';
|
|
||||||
case 'maintenance':
|
|
||||||
return 'text-yellow-400';
|
|
||||||
case 'construction':
|
|
||||||
return 'text-orange-400';
|
|
||||||
default:
|
|
||||||
return 'text-gray-400';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<i className="bi bi-arrow-repeat text-4xl text-blue-500 animate-spin"></i>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
|
|
||||||
<i className="bi bi-globe text-3xl text-blue-400"></i>
|
|
||||||
Configuración del Sitio cnxifly.com
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-400 mt-1">
|
|
||||||
Controle el estado de la página institucional de ConneXiFly
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={fetchStatus}
|
|
||||||
className="btn btn-secondary flex items-center gap-2"
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
<i className={`bi bi-arrow-repeat ${loading ? 'animate-spin' : ''}`}></i>
|
|
||||||
Actualizar
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Current Status */}
|
|
||||||
<div className="bg-gray-800 rounded-xl p-6 border border-gray-700">
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-white">Estado Actual</h2>
|
|
||||||
<p className="text-gray-400 text-sm">El modo seleccionado determina qué página se muestra</p>
|
|
||||||
</div>
|
|
||||||
<div className={`flex items-center gap-2 px-4 py-2 rounded-full ${getModeColor(status.mode, true)}`}>
|
|
||||||
<i className={`bi ${status.mode === 'live' ? 'bi-rocket-takeoff' : 'bi-tools'}`}></i>
|
|
||||||
<span className={`font-medium capitalize ${getModeTextColor(status.mode)}`}>
|
|
||||||
{status.mode}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mode Cards */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
{/* Live Mode */}
|
|
||||||
<div
|
|
||||||
className={`p-5 rounded-xl border-2 cursor-pointer transition-all ${getModeColor('live', status.mode === 'live')}`}
|
|
||||||
onClick={() => !deploying && handleToggleMode('live')}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className={`p-3 rounded-lg ${status.mode === 'live' ? 'bg-green-500/20' : 'bg-gray-600'}`}>
|
|
||||||
<i className={`bi bi-rocket-takeoff text-2xl ${status.mode === 'live' ? 'text-green-400' : 'text-gray-400'}`}></i>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-white">Página en Vivo</h3>
|
|
||||||
<p className="text-sm text-gray-400">
|
|
||||||
Página institucional completa con todos los productos
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{status.mode === 'live' && (
|
|
||||||
<i className="bi bi-check-circle-fill text-green-400 text-xl"></i>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 text-sm text-gray-500">
|
|
||||||
Muestra: WebMoney, EZPool, precios, registro y contacto
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Maintenance Mode */}
|
|
||||||
<div
|
|
||||||
className={`p-5 rounded-xl border-2 cursor-pointer transition-all ${getModeColor('maintenance', status.mode === 'maintenance')}`}
|
|
||||||
onClick={() => !deploying && handleToggleMode('maintenance')}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className={`p-3 rounded-lg ${status.mode === 'maintenance' ? 'bg-yellow-500/20' : 'bg-gray-600'}`}>
|
|
||||||
<i className={`bi bi-tools text-2xl ${status.mode === 'maintenance' ? 'text-yellow-400' : 'text-gray-400'}`}></i>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-white">En Mantenimiento</h3>
|
|
||||||
<p className="text-sm text-gray-400">
|
|
||||||
Página simple informando mantenimiento
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{status.mode === 'maintenance' && (
|
|
||||||
<i className="bi bi-check-circle-fill text-yellow-400 text-xl"></i>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 text-sm text-gray-500">
|
|
||||||
Muestra: Mensaje de mantenimiento con links a los productos
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Deploy Section */}
|
|
||||||
<div className="bg-gray-800 rounded-xl p-6 border border-gray-700">
|
|
||||||
<div className="flex items-center gap-3 mb-4">
|
|
||||||
<i className="bi bi-server text-2xl text-blue-400"></i>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-white">Despliegue</h2>
|
|
||||||
<p className="text-gray-400 text-sm">Aplique los cambios al servidor de producción</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-gray-900/50 rounded-lg p-4 mb-4">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<i className="bi bi-exclamation-triangle text-yellow-400 text-xl flex-shrink-0 mt-0.5"></i>
|
|
||||||
<div className="text-sm text-gray-300">
|
|
||||||
<p className="font-medium text-yellow-400 mb-1">Importante</p>
|
|
||||||
<p>
|
|
||||||
Los cambios se guardan en la configuración, pero para aplicarlos al servidor debe ejecutar
|
|
||||||
el comando de deploy. La página actual en cnxifly.com no cambiará hasta que se despliegue.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<button
|
|
||||||
onClick={() => handleDeploy('live')}
|
|
||||||
disabled={deploying}
|
|
||||||
className="flex-1 py-3 px-4 bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg flex items-center justify-center gap-2 transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{deploying ? (
|
|
||||||
<i className="bi bi-arrow-repeat animate-spin"></i>
|
|
||||||
) : (
|
|
||||||
<i className="bi bi-rocket-takeoff"></i>
|
|
||||||
)}
|
|
||||||
Desplegar Página Completa
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => handleDeploy('maintenance')}
|
|
||||||
disabled={deploying}
|
|
||||||
className="flex-1 py-3 px-4 bg-yellow-600 hover:bg-yellow-700 text-white font-medium rounded-lg flex items-center justify-center gap-2 transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{deploying ? (
|
|
||||||
<i className="bi bi-arrow-repeat animate-spin"></i>
|
|
||||||
) : (
|
|
||||||
<i className="bi bi-tools"></i>
|
|
||||||
)}
|
|
||||||
Desplegar Mantenimiento
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Preview Links */}
|
|
||||||
<div className="bg-gray-800 rounded-xl p-6 border border-gray-700">
|
|
||||||
<h2 className="text-lg font-semibold text-white mb-4">Enlaces de Vista Previa</h2>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<a
|
|
||||||
href="https://cnxifly.com"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center gap-3 p-4 bg-gray-700/50 rounded-lg hover:bg-gray-700 transition-colors"
|
|
||||||
>
|
|
||||||
<i className="bi bi-globe text-blue-400 text-xl"></i>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-white">cnxifly.com</p>
|
|
||||||
<p className="text-sm text-gray-400">Página actual en producción</p>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://webmoney.cnxifly.com"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center gap-3 p-4 bg-gray-700/50 rounded-lg hover:bg-gray-700 transition-colors"
|
|
||||||
>
|
|
||||||
<i className="bi bi-wallet2 text-green-400 text-xl"></i>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-white">webmoney.cnxifly.com</p>
|
|
||||||
<p className="text-sm text-gray-400">Aplicación WebMoney</p>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://ezpool.cnxifly.com"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center gap-3 p-4 bg-gray-700/50 rounded-lg hover:bg-gray-700 transition-colors"
|
|
||||||
>
|
|
||||||
<i className="bi bi-droplet-half text-cyan-400 text-xl"></i>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-white">ezpool.cnxifly.com</p>
|
|
||||||
<p className="text-sm text-gray-400">Aplicación EZPool (En breve)</p>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -928,15 +928,11 @@ export default function Transactions() {
|
|||||||
pending: 'warning',
|
pending: 'warning',
|
||||||
completed: 'success',
|
completed: 'success',
|
||||||
cancelled: 'secondary',
|
cancelled: 'secondary',
|
||||||
effective: 'success',
|
|
||||||
scheduled: 'primary',
|
|
||||||
};
|
};
|
||||||
const labels = {
|
const labels = {
|
||||||
pending: t('transactions.status.pending'),
|
pending: t('transactions.status.pending'),
|
||||||
completed: t('transactions.status.completed'),
|
completed: t('transactions.status.completed'),
|
||||||
cancelled: t('transactions.status.cancelled'),
|
cancelled: t('transactions.status.cancelled'),
|
||||||
effective: t('transactions.status.effective'),
|
|
||||||
scheduled: t('transactions.status.scheduled'),
|
|
||||||
};
|
};
|
||||||
return <span className={`badge bg-${colors[status]}`}>{labels[status]}</span>;
|
return <span className={`badge bg-${colors[status]}`}>{labels[status]}</span>;
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -41,7 +41,6 @@ api.interceptors.response.use(
|
|||||||
// Auth Services
|
// Auth Services
|
||||||
export const authService = {
|
export const authService = {
|
||||||
register: async (userData) => {
|
register: async (userData) => {
|
||||||
// Register user but DON'T save token - user needs to pay and activate via email first
|
|
||||||
const response = await api.post('/register', userData);
|
const response = await api.post('/register', userData);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
@ -55,22 +54,6 @@ export const authService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Activate account from email link
|
|
||||||
activateAccount: async (token) => {
|
|
||||||
const response = await api.post('/activate', { token });
|
|
||||||
if (response.data.success) {
|
|
||||||
localStorage.setItem('token', response.data.data.token);
|
|
||||||
localStorage.setItem('user', JSON.stringify(response.data.data.user));
|
|
||||||
}
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
// Resend activation email
|
|
||||||
resendActivation: async (email) => {
|
|
||||||
const response = await api.post('/resend-activation', { email });
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
logout: async () => {
|
logout: async () => {
|
||||||
try {
|
try {
|
||||||
await api.post('/logout');
|
await api.post('/logout');
|
||||||
@ -417,116 +400,20 @@ export const liabilityAccountService = {
|
|||||||
|
|
||||||
// Status disponíveis para contas
|
// Status disponíveis para contas
|
||||||
statuses: {
|
statuses: {
|
||||||
active: 'Activo',
|
active: 'Ativo',
|
||||||
paid_off: 'Liquidado',
|
paid_off: 'Quitado',
|
||||||
defaulted: 'En mora',
|
defaulted: 'Inadimplente',
|
||||||
renegotiated: 'Renegociado',
|
renegotiated: 'Renegociado',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Status disponíveis para parcelas
|
// Status disponíveis para parcelas
|
||||||
installmentStatuses: {
|
installmentStatuses: {
|
||||||
pending: 'Pendiente',
|
pending: 'Pendente',
|
||||||
paid: 'Pagado',
|
paid: 'Pago',
|
||||||
partial: 'Parcial',
|
partial: 'Parcial',
|
||||||
overdue: 'Vencido',
|
overdue: 'Atrasado',
|
||||||
cancelled: 'Cancelado',
|
cancelled: 'Cancelado',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Download template Excel
|
|
||||||
downloadTemplate: async () => {
|
|
||||||
const response = await api.get('/liability-accounts/template', {
|
|
||||||
responseType: 'blob',
|
|
||||||
});
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
// Obter tipos de contrato
|
|
||||||
getContractTypes: async () => {
|
|
||||||
const response = await api.get('/liability-accounts/contract-types');
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
// Criar conta passivo via wizard
|
|
||||||
createWithWizard: async (data) => {
|
|
||||||
const response = await api.post('/liability-accounts/wizard', data);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Asset Account Services (Contas Ativo)
|
|
||||||
// ============================================
|
|
||||||
export const assetAccountService = {
|
|
||||||
// Listar todos os ativos
|
|
||||||
getAll: async (params = {}) => {
|
|
||||||
const response = await api.get('/asset-accounts', { params });
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
// Obter um ativo específico
|
|
||||||
getById: async (id) => {
|
|
||||||
const response = await api.get(`/asset-accounts/${id}`);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
// Criar novo ativo manualmente
|
|
||||||
create: async (data) => {
|
|
||||||
const response = await api.post('/asset-accounts', data);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
// Atualizar ativo
|
|
||||||
update: async (id, data) => {
|
|
||||||
const response = await api.put(`/asset-accounts/${id}`, data);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
// Excluir ativo
|
|
||||||
delete: async (id) => {
|
|
||||||
const response = await api.delete(`/asset-accounts/${id}`);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
// Obter tipos de ativos e opções
|
|
||||||
getAssetTypes: async () => {
|
|
||||||
const response = await api.get('/asset-accounts/asset-types');
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
// Criar ativo via wizard
|
|
||||||
createWithWizard: async (data) => {
|
|
||||||
const response = await api.post('/asset-accounts/wizard', data);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
// Obter resumo geral
|
|
||||||
getSummary: async () => {
|
|
||||||
const response = await api.get('/asset-summary');
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
// Atualizar valor de mercado
|
|
||||||
updateValue: async (id, currentValue, note = null) => {
|
|
||||||
const response = await api.put(`/asset-accounts/${id}/value`, {
|
|
||||||
current_value: currentValue,
|
|
||||||
note: note,
|
|
||||||
});
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
// Registrar venda/baixa
|
|
||||||
dispose: async (id, data) => {
|
|
||||||
const response = await api.post(`/asset-accounts/${id}/dispose`, data);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
// Status disponíveis
|
|
||||||
statuses: {
|
|
||||||
active: 'Activo',
|
|
||||||
sold: 'Vendido',
|
|
||||||
depreciated: 'Depreciado',
|
|
||||||
written_off: 'Dado de baja',
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
1055
landing/index.html
1055
landing/index.html
File diff suppressed because it is too large
Load Diff
@ -1,190 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="es">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>ConneXiFly - En Mantenimiento</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--primary: #3b82f6;
|
|
||||||
--secondary: #10b981;
|
|
||||||
--dark: #0f172a;
|
|
||||||
--dark-lighter: #1e293b;
|
|
||||||
--text: #f1f5f9;
|
|
||||||
--text-muted: #94a3b8;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: "Inter", -apple-system, BlinkMacSystemFont, sans-serif;
|
|
||||||
background: radial-gradient(ellipse at 20% 20%, rgba(59, 130, 246, 0.15), transparent 50%),
|
|
||||||
radial-gradient(ellipse at 80% 80%, rgba(16, 185, 129, 0.1), transparent 50%),
|
|
||||||
var(--dark);
|
|
||||||
color: var(--text);
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
text-align: center;
|
|
||||||
padding: 2rem;
|
|
||||||
max-width: 600px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-container {
|
|
||||||
margin-bottom: 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
font-size: 3rem;
|
|
||||||
font-weight: 800;
|
|
||||||
background: linear-gradient(135deg, var(--primary), var(--secondary));
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
background-clip: text;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo i {
|
|
||||||
font-size: 2.5rem;
|
|
||||||
color: var(--primary);
|
|
||||||
-webkit-text-fill-color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.maintenance-icon {
|
|
||||||
font-size: 5rem;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
animation: pulse 2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% { transform: scale(1); opacity: 1; }
|
|
||||||
50% { transform: scale(1.05); opacity: 0.8; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.maintenance-icon i {
|
|
||||||
background: linear-gradient(135deg, var(--primary), var(--secondary));
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
background-clip: text;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 2.5rem;
|
|
||||||
font-weight: 800;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtitle {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.products-preview {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 1.5rem;
|
|
||||||
margin-top: 3rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-link {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 1rem 1.5rem;
|
|
||||||
background: var(--dark-lighter);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 1rem;
|
|
||||||
color: var(--text);
|
|
||||||
text-decoration: none;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-link:hover {
|
|
||||||
transform: translateY(-3px);
|
|
||||||
border-color: var(--primary);
|
|
||||||
box-shadow: 0 10px 30px rgba(59, 130, 246, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-link i {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-link.webmoney i {
|
|
||||||
color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-link.ezpool i {
|
|
||||||
color: var(--secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-link span {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact {
|
|
||||||
margin-top: 3rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact a {
|
|
||||||
color: var(--primary);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact a:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="logo-container">
|
|
||||||
<div class="logo">
|
|
||||||
<i class="bi bi-lightning-charge-fill"></i>
|
|
||||||
ConneXiFly
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="maintenance-icon">
|
|
||||||
<i class="bi bi-gear-wide-connected"></i>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1>Estamos en Mantenimiento</h1>
|
|
||||||
<p class="subtitle">
|
|
||||||
Estamos trabajando para traerte una mejor experiencia.
|
|
||||||
Volveremos muy pronto con novedades increíbles.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="products-preview">
|
|
||||||
<a href="https://webmoney.cnxifly.com" class="product-link webmoney">
|
|
||||||
<i class="bi bi-wallet2"></i>
|
|
||||||
<span>WebMoney</span>
|
|
||||||
</a>
|
|
||||||
<a href="https://ezpool.cnxifly.com" class="product-link ezpool">
|
|
||||||
<i class="bi bi-droplet-half"></i>
|
|
||||||
<span>EZPool</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="contact">
|
|
||||||
<p>¿Necesitas ayuda? <a href="mailto:admin@cnxifly.com">admin@cnxifly.com</a></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Loading…
Reference in New Issue
Block a user