Initial commit: Horux Strategy Platform
- Laravel 11 backend with API REST - React 18 + TypeScript + Vite frontend - Multi-parser architecture for accounting systems (CONTPAQi, Aspel, SAP) - 27+ financial metrics calculation - PDF report generation with Browsershot - Complete documentation (10 documents) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
48
.gitignore
vendored
Normal file
48
.gitignore
vendored
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# Dependencies
|
||||||
|
/backend/vendor/
|
||||||
|
/frontend/node_modules/
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
/frontend/dist/
|
||||||
|
/frontend/build/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Laravel
|
||||||
|
/backend/storage/*.key
|
||||||
|
/backend/storage/framework/cache/*
|
||||||
|
/backend/storage/framework/sessions/*
|
||||||
|
/backend/storage/framework/views/*
|
||||||
|
/backend/storage/logs/*
|
||||||
|
/backend/bootstrap/cache/*
|
||||||
|
!/backend/storage/framework/.gitignore
|
||||||
|
!/backend/storage/framework/cache/.gitignore
|
||||||
|
!/backend/storage/framework/sessions/.gitignore
|
||||||
|
!/backend/storage/framework/views/.gitignore
|
||||||
|
!/backend/storage/logs/.gitignore
|
||||||
|
!/backend/bootstrap/cache/.gitignore
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
/backend/coverage/
|
||||||
|
/frontend/coverage/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
*.bak
|
||||||
|
*.tmp
|
||||||
98
README.md
Normal file
98
README.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# Horux Strategy Platform
|
||||||
|
|
||||||
|
Plataforma de Reportes Financieros automatizados a partir de balanzas de comprobación.
|
||||||
|
|
||||||
|
## Descripción
|
||||||
|
|
||||||
|
Sistema web que permite a empresas subir sus datos contables de múltiples sistemas (CONTPAQi, Aspel, SAP, Odoo, etc.) y obtener:
|
||||||
|
- Dashboard interactivo con métricas financieras
|
||||||
|
- Exportación a PDF profesional (32 páginas)
|
||||||
|
- Clasificación automática de cuentas
|
||||||
|
- Comparativos entre periodos
|
||||||
|
- Semáforos de rendimiento
|
||||||
|
|
||||||
|
## Stack Tecnológico
|
||||||
|
|
||||||
|
| Componente | Tecnología |
|
||||||
|
|------------|------------|
|
||||||
|
| Backend | Laravel 11 (API REST) |
|
||||||
|
| Frontend | React 18 + TypeScript + Vite |
|
||||||
|
| Autenticación | Laravel Sanctum |
|
||||||
|
| Base de datos | MySQL / PostgreSQL |
|
||||||
|
| Gráficas | Recharts |
|
||||||
|
| PDF | Browsershot (Puppeteer) |
|
||||||
|
| Estilos | Tailwind CSS |
|
||||||
|
|
||||||
|
## Estructura del Proyecto
|
||||||
|
|
||||||
|
```
|
||||||
|
horux-strategy-platform/
|
||||||
|
├── backend/ # Laravel 11 API
|
||||||
|
├── frontend/ # React + TypeScript
|
||||||
|
└── docs/
|
||||||
|
└── plans/ # Documentación de diseño
|
||||||
|
```
|
||||||
|
|
||||||
|
## Instalación Rápida
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
composer install
|
||||||
|
cp .env.example .env
|
||||||
|
php artisan key:generate
|
||||||
|
# Configurar base de datos en .env
|
||||||
|
php artisan migrate --seed
|
||||||
|
php artisan serve
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Credenciales por defecto
|
||||||
|
|
||||||
|
- **Email**: admin@horux360.com
|
||||||
|
- **Password**: password
|
||||||
|
|
||||||
|
## Roles de Usuario
|
||||||
|
|
||||||
|
| Rol | Permisos |
|
||||||
|
|-----|----------|
|
||||||
|
| **Admin** | Acceso total, configuración global |
|
||||||
|
| **Analista** | Gestionar clientes, procesar balanzas |
|
||||||
|
| **Cliente** | Ver dashboard propio, descargar PDFs |
|
||||||
|
| **Empleado** | Permisos configurables por cliente |
|
||||||
|
|
||||||
|
## Sistemas Contables Soportados
|
||||||
|
|
||||||
|
- CONTPAQi (implementado)
|
||||||
|
- Aspel
|
||||||
|
- SAP
|
||||||
|
- Odoo
|
||||||
|
- Alegra
|
||||||
|
- Parser genérico (CSV/Excel)
|
||||||
|
|
||||||
|
## Métricas Calculadas
|
||||||
|
|
||||||
|
- **Márgenes**: Bruto, EBITDA, Operativo, Neto, NOPAT, OCF, FCF
|
||||||
|
- **Retorno**: ROIC, ROE, ROA, ROCE
|
||||||
|
- **Eficiencia**: Asset Turnover, Días Clientes/Proveedores/Inventario
|
||||||
|
- **Liquidez**: Current Ratio, Quick Ratio, Cash Ratio
|
||||||
|
- **Solvencia**: Net Debt/EBITDA, Interest Coverage, Debt Ratio
|
||||||
|
- **Gestión**: Revenue Growth, CapEx/Revenue, Tax Rate
|
||||||
|
|
||||||
|
## Documentación
|
||||||
|
|
||||||
|
Ver `docs/plans/` para:
|
||||||
|
- Diseño del sistema
|
||||||
|
- Plan de implementación
|
||||||
|
|
||||||
|
## Licencia
|
||||||
|
|
||||||
|
Propietario - Horux 360
|
||||||
31
backend/.env.example
Normal file
31
backend/.env.example
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
APP_NAME="Horux Strategy"
|
||||||
|
APP_ENV=local
|
||||||
|
APP_KEY=
|
||||||
|
APP_DEBUG=true
|
||||||
|
APP_URL=http://localhost:8000
|
||||||
|
FRONTEND_URL=http://localhost:5173
|
||||||
|
|
||||||
|
LOG_CHANNEL=stack
|
||||||
|
LOG_DEPRECATIONS_CHANNEL=null
|
||||||
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
|
DB_CONNECTION=mysql
|
||||||
|
DB_HOST=127.0.0.1
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_DATABASE=horux_strategy
|
||||||
|
DB_USERNAME=root
|
||||||
|
DB_PASSWORD=
|
||||||
|
|
||||||
|
BROADCAST_DRIVER=log
|
||||||
|
CACHE_DRIVER=file
|
||||||
|
FILESYSTEM_DISK=local
|
||||||
|
QUEUE_CONNECTION=sync
|
||||||
|
SESSION_DRIVER=file
|
||||||
|
SESSION_LIFETIME=120
|
||||||
|
|
||||||
|
SANCTUM_STATEFUL_DOMAINS=localhost:5173,localhost:3000,127.0.0.1:5173
|
||||||
|
|
||||||
|
# Browsershot (para generación de PDF)
|
||||||
|
# Instalar Chromium: npx puppeteer browsers install chrome
|
||||||
|
BROWSERSHOT_NODE_PATH=/usr/bin/node
|
||||||
|
BROWSERSHOT_NPM_PATH=/usr/bin/npm
|
||||||
107
backend/README.md
Normal file
107
backend/README.md
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
# Horux Strategy Platform - Backend
|
||||||
|
|
||||||
|
API REST con Laravel 11 para la plataforma de reportes financieros.
|
||||||
|
|
||||||
|
## Requisitos
|
||||||
|
|
||||||
|
- PHP 8.2+
|
||||||
|
- Composer
|
||||||
|
- MySQL 8.0+ o PostgreSQL 14+
|
||||||
|
- Node.js 18+ (para Browsershot/PDF)
|
||||||
|
|
||||||
|
## Instalación
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Instalar dependencias
|
||||||
|
composer install
|
||||||
|
|
||||||
|
# Copiar configuración
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# Generar key
|
||||||
|
php artisan key:generate
|
||||||
|
|
||||||
|
# Configurar base de datos en .env
|
||||||
|
# DB_DATABASE=horux_strategy
|
||||||
|
# DB_USERNAME=root
|
||||||
|
# DB_PASSWORD=
|
||||||
|
|
||||||
|
# Ejecutar migraciones y seeders
|
||||||
|
php artisan migrate --seed
|
||||||
|
|
||||||
|
# Crear link storage
|
||||||
|
php artisan storage:link
|
||||||
|
|
||||||
|
# Iniciar servidor
|
||||||
|
php artisan serve
|
||||||
|
```
|
||||||
|
|
||||||
|
## Credenciales por defecto
|
||||||
|
|
||||||
|
- **Admin**: admin@horux360.com / password
|
||||||
|
|
||||||
|
## Endpoints principales
|
||||||
|
|
||||||
|
### Autenticación
|
||||||
|
- `POST /api/login` - Iniciar sesión
|
||||||
|
- `POST /api/logout` - Cerrar sesión
|
||||||
|
- `GET /api/user` - Usuario actual
|
||||||
|
|
||||||
|
### Clientes
|
||||||
|
- `GET /api/clientes` - Listar clientes
|
||||||
|
- `POST /api/clientes` - Crear cliente
|
||||||
|
- `GET /api/clientes/{id}` - Ver cliente
|
||||||
|
- `PUT /api/clientes/{id}` - Actualizar cliente
|
||||||
|
- `DELETE /api/clientes/{id}` - Eliminar cliente
|
||||||
|
|
||||||
|
### Balanzas
|
||||||
|
- `GET /api/clientes/{id}/balanzas` - Listar balanzas
|
||||||
|
- `POST /api/clientes/{id}/balanzas` - Subir balanza
|
||||||
|
- `GET /api/balanzas/{id}/cuentas` - Ver cuentas
|
||||||
|
- `PUT /api/balanzas/{id}/exclusiones` - Actualizar exclusiones
|
||||||
|
|
||||||
|
### Reportes
|
||||||
|
- `GET /api/clientes/{id}/reportes` - Listar reportes
|
||||||
|
- `POST /api/clientes/{id}/reportes` - Generar reporte
|
||||||
|
- `GET /api/reportes/{id}` - Ver reporte
|
||||||
|
- `GET /api/reportes/{id}/pdf` - Descargar PDF
|
||||||
|
|
||||||
|
### Administración (solo admin)
|
||||||
|
- `GET/POST/PUT/DELETE /api/admin/usuarios`
|
||||||
|
- `GET/POST/PUT/DELETE /api/admin/giros`
|
||||||
|
- `GET/POST/PUT/DELETE /api/admin/umbrales`
|
||||||
|
- `GET/POST/PUT/DELETE /api/admin/reglas-mapeo`
|
||||||
|
|
||||||
|
## Estructura de carpetas
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── Http/Controllers/
|
||||||
|
│ ├── AuthController.php
|
||||||
|
│ ├── ClienteController.php
|
||||||
|
│ ├── BalanzaController.php
|
||||||
|
│ ├── CuentaController.php
|
||||||
|
│ ├── ReporteController.php
|
||||||
|
│ └── Admin/
|
||||||
|
├── Models/
|
||||||
|
├── Services/
|
||||||
|
│ ├── Parsers/
|
||||||
|
│ │ ├── ParserInterface.php
|
||||||
|
│ │ ├── ContpaqiParser.php
|
||||||
|
│ │ ├── GenericoParser.php
|
||||||
|
│ │ └── DetectorFormato.php
|
||||||
|
│ ├── ClasificadorCuentas.php
|
||||||
|
│ ├── CalculadorMetricas.php
|
||||||
|
│ └── GeneradorPdf.php
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Agregar nuevo parser
|
||||||
|
|
||||||
|
1. Crear clase en `app/Services/Parsers/` implementando `ParserInterface`
|
||||||
|
2. Registrar en `DetectorFormato::__construct()`
|
||||||
|
3. Agregar reglas de mapeo en seeder
|
||||||
|
|
||||||
|
## Licencia
|
||||||
|
|
||||||
|
Propietario - Horux 360
|
||||||
62
backend/app/Http/Controllers/Admin/GiroController.php
Normal file
62
backend/app/Http/Controllers/Admin/GiroController.php
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Giro;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class GiroController extends Controller
|
||||||
|
{
|
||||||
|
public function index(): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json(Giro::all());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function activos(): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json(Giro::where('activo', true)->get());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'nombre' => 'required|string|max:255|unique:giros,nombre',
|
||||||
|
'activo' => 'boolean',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$giro = Giro::create($validated);
|
||||||
|
|
||||||
|
return response()->json($giro, 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Giro $giro): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json($giro);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, Giro $giro): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'nombre' => 'string|max:255|unique:giros,nombre,' . $giro->id,
|
||||||
|
'activo' => 'boolean',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$giro->update($validated);
|
||||||
|
|
||||||
|
return response()->json($giro);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Giro $giro): JsonResponse
|
||||||
|
{
|
||||||
|
if ($giro->clientes()->exists()) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'No se puede eliminar un giro con clientes asociados'
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$giro->delete();
|
||||||
|
return response()->json(['message' => 'Giro eliminado']);
|
||||||
|
}
|
||||||
|
}
|
||||||
71
backend/app/Http/Controllers/Admin/ReglaMapeeoController.php
Normal file
71
backend/app/Http/Controllers/Admin/ReglaMapeeoController.php
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\ReglaMapeo;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class ReglaMapeeoController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$query = ReglaMapeo::with(['reporteContable', 'categoriaContable']);
|
||||||
|
|
||||||
|
if ($request->has('sistema_origen')) {
|
||||||
|
$query->where('sistema_origen', $request->sistema_origen);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json($query->orderBy('prioridad', 'desc')->get());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'sistema_origen' => 'required|string|max:50',
|
||||||
|
'cuenta_padre_codigo' => 'nullable|string|max:20',
|
||||||
|
'rango_inicio' => 'nullable|string|max:20',
|
||||||
|
'rango_fin' => 'nullable|string|max:20',
|
||||||
|
'patron_regex' => 'nullable|string|max:255',
|
||||||
|
'reporte_contable_id' => 'required|exists:reportes_contables,id',
|
||||||
|
'categoria_contable_id' => 'required|exists:categorias_contables,id',
|
||||||
|
'prioridad' => 'integer',
|
||||||
|
'activo' => 'boolean',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$regla = ReglaMapeo::create($validated);
|
||||||
|
|
||||||
|
return response()->json($regla->load(['reporteContable', 'categoriaContable']), 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(ReglaMapeo $reglaMapeo): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json($reglaMapeo->load(['reporteContable', 'categoriaContable']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, ReglaMapeo $reglaMapeo): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'sistema_origen' => 'string|max:50',
|
||||||
|
'cuenta_padre_codigo' => 'nullable|string|max:20',
|
||||||
|
'rango_inicio' => 'nullable|string|max:20',
|
||||||
|
'rango_fin' => 'nullable|string|max:20',
|
||||||
|
'patron_regex' => 'nullable|string|max:255',
|
||||||
|
'reporte_contable_id' => 'exists:reportes_contables,id',
|
||||||
|
'categoria_contable_id' => 'exists:categorias_contables,id',
|
||||||
|
'prioridad' => 'integer',
|
||||||
|
'activo' => 'boolean',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$reglaMapeo->update($validated);
|
||||||
|
|
||||||
|
return response()->json($reglaMapeo->load(['reporteContable', 'categoriaContable']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(ReglaMapeo $reglaMapeo): JsonResponse
|
||||||
|
{
|
||||||
|
$reglaMapeo->delete();
|
||||||
|
return response()->json(['message' => 'Regla de mapeo eliminada']);
|
||||||
|
}
|
||||||
|
}
|
||||||
87
backend/app/Http/Controllers/Admin/UmbralController.php
Normal file
87
backend/app/Http/Controllers/Admin/UmbralController.php
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Umbral;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class UmbralController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$query = Umbral::with('giro');
|
||||||
|
|
||||||
|
if ($request->has('giro_id')) {
|
||||||
|
$query->where('giro_id', $request->giro_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json($query->get());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'metrica' => 'required|string|max:100',
|
||||||
|
'muy_positivo' => 'nullable|numeric',
|
||||||
|
'positivo' => 'nullable|numeric',
|
||||||
|
'neutral' => 'nullable|numeric',
|
||||||
|
'negativo' => 'nullable|numeric',
|
||||||
|
'muy_negativo' => 'nullable|numeric',
|
||||||
|
'giro_id' => 'nullable|exists:giros,id',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$umbral = Umbral::create($validated);
|
||||||
|
|
||||||
|
return response()->json($umbral->load('giro'), 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Umbral $umbral): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json($umbral->load('giro'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, Umbral $umbral): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'metrica' => 'string|max:100',
|
||||||
|
'muy_positivo' => 'nullable|numeric',
|
||||||
|
'positivo' => 'nullable|numeric',
|
||||||
|
'neutral' => 'nullable|numeric',
|
||||||
|
'negativo' => 'nullable|numeric',
|
||||||
|
'muy_negativo' => 'nullable|numeric',
|
||||||
|
'giro_id' => 'nullable|exists:giros,id',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$umbral->update($validated);
|
||||||
|
|
||||||
|
return response()->json($umbral->load('giro'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Umbral $umbral): JsonResponse
|
||||||
|
{
|
||||||
|
$umbral->delete();
|
||||||
|
return response()->json(['message' => 'Umbral eliminado']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function porMetrica(string $metrica, ?int $giroId = null): JsonResponse
|
||||||
|
{
|
||||||
|
// Buscar primero umbral específico del giro, luego el genérico
|
||||||
|
$umbral = Umbral::where('metrica', $metrica)
|
||||||
|
->where('giro_id', $giroId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (!$umbral) {
|
||||||
|
$umbral = Umbral::where('metrica', $metrica)
|
||||||
|
->whereNull('giro_id')
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$umbral) {
|
||||||
|
return response()->json(['message' => 'Umbral no encontrado'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json($umbral);
|
||||||
|
}
|
||||||
|
}
|
||||||
71
backend/app/Http/Controllers/Admin/UsuarioController.php
Normal file
71
backend/app/Http/Controllers/Admin/UsuarioController.php
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
|
||||||
|
class UsuarioController extends Controller
|
||||||
|
{
|
||||||
|
public function index(): JsonResponse
|
||||||
|
{
|
||||||
|
$usuarios = User::with('cliente')->get();
|
||||||
|
return response()->json($usuarios);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'nombre' => 'required|string|max:255',
|
||||||
|
'email' => 'required|email|unique:users,email',
|
||||||
|
'password' => 'required|string|min:8',
|
||||||
|
'role' => 'required|in:admin,analista,cliente,empleado',
|
||||||
|
'cliente_id' => 'nullable|exists:clientes,id',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$usuario = User::create([
|
||||||
|
'nombre' => $validated['nombre'],
|
||||||
|
'email' => $validated['email'],
|
||||||
|
'password' => Hash::make($validated['password']),
|
||||||
|
'role' => $validated['role'],
|
||||||
|
'cliente_id' => $validated['cliente_id'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json($usuario->load('cliente'), 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(User $usuario): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json($usuario->load('cliente'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, User $usuario): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'nombre' => 'string|max:255',
|
||||||
|
'email' => 'email|unique:users,email,' . $usuario->id,
|
||||||
|
'password' => 'nullable|string|min:8',
|
||||||
|
'role' => 'in:admin,analista,cliente,empleado',
|
||||||
|
'cliente_id' => 'nullable|exists:clientes,id',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (isset($validated['password'])) {
|
||||||
|
$validated['password'] = Hash::make($validated['password']);
|
||||||
|
} else {
|
||||||
|
unset($validated['password']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$usuario->update($validated);
|
||||||
|
|
||||||
|
return response()->json($usuario->load('cliente'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(User $usuario): JsonResponse
|
||||||
|
{
|
||||||
|
$usuario->delete();
|
||||||
|
return response()->json(['message' => 'Usuario eliminado']);
|
||||||
|
}
|
||||||
|
}
|
||||||
74
backend/app/Http/Controllers/AuthController.php
Normal file
74
backend/app/Http/Controllers/AuthController.php
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class AuthController extends Controller
|
||||||
|
{
|
||||||
|
public function login(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'email' => 'required|email',
|
||||||
|
'password' => 'required',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = User::where('email', $request->email)->first();
|
||||||
|
|
||||||
|
if (!$user || !Hash::check($request->password, $user->password)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'email' => ['Las credenciales proporcionadas son incorrectas.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = $user->createToken('auth-token')->plainTextToken;
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'user' => $user->load('cliente'),
|
||||||
|
'token' => $token,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logout(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$request->user()->currentAccessToken()->delete();
|
||||||
|
|
||||||
|
return response()->json(['message' => 'Sesión cerrada exitosamente']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json($request->user()->load('cliente'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function register(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
// Solo admin puede registrar usuarios
|
||||||
|
if (!$request->user()->isAdmin()) {
|
||||||
|
return response()->json(['message' => 'No autorizado'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'nombre' => 'required|string|max:255',
|
||||||
|
'email' => 'required|email|unique:users,email',
|
||||||
|
'password' => 'required|string|min:8',
|
||||||
|
'role' => 'required|in:admin,analista,cliente,empleado',
|
||||||
|
'cliente_id' => 'nullable|exists:clientes,id',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = User::create([
|
||||||
|
'nombre' => $validated['nombre'],
|
||||||
|
'email' => $validated['email'],
|
||||||
|
'password' => Hash::make($validated['password']),
|
||||||
|
'role' => $validated['role'],
|
||||||
|
'cliente_id' => $validated['cliente_id'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json($user, 201);
|
||||||
|
}
|
||||||
|
}
|
||||||
143
backend/app/Http/Controllers/BalanzaController.php
Normal file
143
backend/app/Http/Controllers/BalanzaController.php
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Balanza;
|
||||||
|
use App\Models\Cliente;
|
||||||
|
use App\Services\Parsers\DetectorFormato;
|
||||||
|
use App\Services\ClasificadorCuentas;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class BalanzaController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private DetectorFormato $detector,
|
||||||
|
private ClasificadorCuentas $clasificador,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(Request $request, Cliente $cliente): JsonResponse
|
||||||
|
{
|
||||||
|
if (!$request->user()->canAccessCliente($cliente->id)) {
|
||||||
|
return response()->json(['message' => 'No autorizado'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$balanzas = $cliente->balanzas()
|
||||||
|
->orderByDesc('periodo_fin')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return response()->json($balanzas);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request, Cliente $cliente): JsonResponse
|
||||||
|
{
|
||||||
|
if (!$request->user()->canAccessCliente($cliente->id)) {
|
||||||
|
return response()->json(['message' => 'No autorizado'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->validate([
|
||||||
|
'archivo' => 'required|file|mimes:pdf,xlsx,xls,csv|max:10240',
|
||||||
|
'periodo_inicio' => 'required|date',
|
||||||
|
'periodo_fin' => 'required|date|after_or_equal:periodo_inicio',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$file = $request->file('archivo');
|
||||||
|
$path = $file->store('balanzas/' . $cliente->id, 'local');
|
||||||
|
|
||||||
|
$balanza = Balanza::create([
|
||||||
|
'cliente_id' => $cliente->id,
|
||||||
|
'periodo_inicio' => $request->periodo_inicio,
|
||||||
|
'periodo_fin' => $request->periodo_fin,
|
||||||
|
'archivo_original' => $path,
|
||||||
|
'sistema_origen' => 'pendiente',
|
||||||
|
'status' => 'pendiente',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Procesar archivo en background o inmediatamente
|
||||||
|
try {
|
||||||
|
$this->procesarBalanza($balanza, $path);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$balanza->update([
|
||||||
|
'status' => 'error',
|
||||||
|
'error_mensaje' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json($balanza, 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request, Balanza $balanza): JsonResponse
|
||||||
|
{
|
||||||
|
if (!$request->user()->canAccessCliente($balanza->cliente_id)) {
|
||||||
|
return response()->json(['message' => 'No autorizado'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json($balanza->load(['cuentas', 'cliente']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cuentas(Request $request, Balanza $balanza): JsonResponse
|
||||||
|
{
|
||||||
|
if (!$request->user()->canAccessCliente($balanza->cliente_id)) {
|
||||||
|
return response()->json(['message' => 'No autorizado'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$cuentas = $balanza->cuentas()
|
||||||
|
->with(['categoriaContable', 'reporteContable', 'cuentaPadre'])
|
||||||
|
->orderBy('codigo')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return response()->json($cuentas);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateExclusiones(Request $request, Balanza $balanza): JsonResponse
|
||||||
|
{
|
||||||
|
if (!$request->user()->canAccessCliente($balanza->cliente_id)) {
|
||||||
|
return response()->json(['message' => 'No autorizado'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->validate([
|
||||||
|
'exclusiones' => 'required|array',
|
||||||
|
'exclusiones.*' => 'integer|exists:cuentas,id',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Marcar todas como incluidas primero
|
||||||
|
$balanza->cuentas()->update(['excluida' => false]);
|
||||||
|
|
||||||
|
// Marcar las seleccionadas como excluidas
|
||||||
|
$balanza->cuentas()
|
||||||
|
->whereIn('id', $request->exclusiones)
|
||||||
|
->update(['excluida' => true]);
|
||||||
|
|
||||||
|
// Recalcular saldos de cuentas padre
|
||||||
|
$cuentasPadre = $balanza->cuentasPadre()->get();
|
||||||
|
foreach ($cuentasPadre as $cuentaPadre) {
|
||||||
|
$cuentaPadre->recalcularSaldoDesdeHijos();
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['message' => 'Exclusiones actualizadas']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function procesarBalanza(Balanza $balanza, string $path): void
|
||||||
|
{
|
||||||
|
$balanza->update(['status' => 'procesando']);
|
||||||
|
|
||||||
|
$fullPath = Storage::disk('local')->path($path);
|
||||||
|
|
||||||
|
// Detectar sistema origen
|
||||||
|
$resultado = $this->detector->detectar($fullPath);
|
||||||
|
$balanza->update(['sistema_origen' => $resultado['sistema']]);
|
||||||
|
|
||||||
|
// Parsear y guardar cuentas
|
||||||
|
$cuentas = $resultado['parser']->parsear($fullPath);
|
||||||
|
|
||||||
|
foreach ($cuentas as $cuentaData) {
|
||||||
|
$balanza->cuentas()->create($cuentaData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clasificar cuentas
|
||||||
|
$this->clasificador->clasificar($balanza);
|
||||||
|
|
||||||
|
$balanza->update(['status' => 'completado']);
|
||||||
|
}
|
||||||
|
}
|
||||||
99
backend/app/Http/Controllers/ClienteController.php
Normal file
99
backend/app/Http/Controllers/ClienteController.php
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Cliente;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class ClienteController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if ($user->isAdmin() || $user->isAnalista()) {
|
||||||
|
$clientes = Cliente::with('giro')->get();
|
||||||
|
} else {
|
||||||
|
$clientes = Cliente::where('id', $user->cliente_id)->with('giro')->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json($clientes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'nombre_empresa' => 'required|string|max:255',
|
||||||
|
'giro_id' => 'required|exists:giros,id',
|
||||||
|
'moneda' => 'string|max:3',
|
||||||
|
'logo' => 'nullable|image|max:2048',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$cliente = new Cliente([
|
||||||
|
'nombre_empresa' => $validated['nombre_empresa'],
|
||||||
|
'giro_id' => $validated['giro_id'],
|
||||||
|
'moneda' => $validated['moneda'] ?? 'MXN',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($request->hasFile('logo')) {
|
||||||
|
$path = $request->file('logo')->store('logos', 'public');
|
||||||
|
$cliente->logo = $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cliente->save();
|
||||||
|
|
||||||
|
return response()->json($cliente->load('giro'), 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request, Cliente $cliente): JsonResponse
|
||||||
|
{
|
||||||
|
if (!$request->user()->canAccessCliente($cliente->id)) {
|
||||||
|
return response()->json(['message' => 'No autorizado'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json($cliente->load(['giro', 'balanzas', 'reportes']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, Cliente $cliente): JsonResponse
|
||||||
|
{
|
||||||
|
if (!$request->user()->canAccessCliente($cliente->id)) {
|
||||||
|
return response()->json(['message' => 'No autorizado'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'nombre_empresa' => 'string|max:255',
|
||||||
|
'giro_id' => 'exists:giros,id',
|
||||||
|
'moneda' => 'string|max:3',
|
||||||
|
'logo' => 'nullable|image|max:2048',
|
||||||
|
'configuracion' => 'nullable|array',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($request->hasFile('logo')) {
|
||||||
|
if ($cliente->logo) {
|
||||||
|
Storage::disk('public')->delete($cliente->logo);
|
||||||
|
}
|
||||||
|
$validated['logo'] = $request->file('logo')->store('logos', 'public');
|
||||||
|
}
|
||||||
|
|
||||||
|
$cliente->update($validated);
|
||||||
|
|
||||||
|
return response()->json($cliente->load('giro'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request, Cliente $cliente): JsonResponse
|
||||||
|
{
|
||||||
|
if (!$request->user()->isAdmin()) {
|
||||||
|
return response()->json(['message' => 'No autorizado'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($cliente->logo) {
|
||||||
|
Storage::disk('public')->delete($cliente->logo);
|
||||||
|
}
|
||||||
|
|
||||||
|
$cliente->delete();
|
||||||
|
|
||||||
|
return response()->json(['message' => 'Cliente eliminado']);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
backend/app/Http/Controllers/Controller.php
Normal file
12
backend/app/Http/Controllers/Controller.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
|
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||||
|
use Illuminate\Routing\Controller as BaseController;
|
||||||
|
|
||||||
|
abstract class Controller extends BaseController
|
||||||
|
{
|
||||||
|
use AuthorizesRequests, ValidatesRequests;
|
||||||
|
}
|
||||||
65
backend/app/Http/Controllers/CuentaController.php
Normal file
65
backend/app/Http/Controllers/CuentaController.php
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Cuenta;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class CuentaController extends Controller
|
||||||
|
{
|
||||||
|
public function updateClasificacion(Request $request, Cuenta $cuenta): JsonResponse
|
||||||
|
{
|
||||||
|
if (!$request->user()->canAccessCliente($cuenta->balanza->cliente_id)) {
|
||||||
|
return response()->json(['message' => 'No autorizado'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'reporte_contable_id' => 'required|exists:reportes_contables,id',
|
||||||
|
'categoria_contable_id' => 'required|exists:categorias_contables,id',
|
||||||
|
'requiere_revision' => 'boolean',
|
||||||
|
'nota_revision' => 'nullable|string',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$cuenta->update([
|
||||||
|
'reporte_contable_id' => $validated['reporte_contable_id'],
|
||||||
|
'categoria_contable_id' => $validated['categoria_contable_id'],
|
||||||
|
'requiere_revision' => $validated['requiere_revision'] ?? false,
|
||||||
|
'nota_revision' => $validated['nota_revision'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json($cuenta->load(['categoriaContable', 'reporteContable']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggleExclusion(Request $request, Cuenta $cuenta): JsonResponse
|
||||||
|
{
|
||||||
|
if (!$request->user()->canAccessCliente($cuenta->balanza->cliente_id)) {
|
||||||
|
return response()->json(['message' => 'No autorizado'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$cuenta->update(['excluida' => !$cuenta->excluida]);
|
||||||
|
|
||||||
|
// Recalcular cuenta padre si existe
|
||||||
|
if ($cuenta->cuentaPadre) {
|
||||||
|
$cuenta->cuentaPadre->recalcularSaldoDesdeHijos();
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json($cuenta);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function anomalias(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
$query = Cuenta::where('requiere_revision', true)
|
||||||
|
->with(['balanza.cliente', 'categoriaContable', 'reporteContable']);
|
||||||
|
|
||||||
|
if (!$user->isAdmin() && !$user->isAnalista()) {
|
||||||
|
$query->whereHas('balanza', function ($q) use ($user) {
|
||||||
|
$q->where('cliente_id', $user->cliente_id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json($query->get());
|
||||||
|
}
|
||||||
|
}
|
||||||
162
backend/app/Http/Controllers/ReporteController.php
Normal file
162
backend/app/Http/Controllers/ReporteController.php
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Cliente;
|
||||||
|
use App\Models\Reporte;
|
||||||
|
use App\Services\CalculadorMetricas;
|
||||||
|
use App\Services\GeneradorPdf;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||||
|
|
||||||
|
class ReporteController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private CalculadorMetricas $calculador,
|
||||||
|
private GeneradorPdf $generadorPdf,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(Request $request, Cliente $cliente): JsonResponse
|
||||||
|
{
|
||||||
|
if (!$request->user()->canAccessCliente($cliente->id)) {
|
||||||
|
return response()->json(['message' => 'No autorizado'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$reportes = $cliente->reportes()
|
||||||
|
->orderByDesc('periodo_fin')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return response()->json($reportes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request, Cliente $cliente): JsonResponse
|
||||||
|
{
|
||||||
|
if (!$request->user()->canAccessCliente($cliente->id)) {
|
||||||
|
return response()->json(['message' => 'No autorizado'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'nombre' => 'required|string|max:255',
|
||||||
|
'balanza_ids' => 'required|array|min:1',
|
||||||
|
'balanza_ids.*' => 'exists:balanzas,id',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verificar que las balanzas pertenecen al cliente
|
||||||
|
$balanzasValidas = $cliente->balanzas()
|
||||||
|
->whereIn('id', $validated['balanza_ids'])
|
||||||
|
->where('status', 'completado')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($balanzasValidas->count() !== count($validated['balanza_ids'])) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Algunas balanzas no son válidas o no están procesadas'
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determinar tipo de periodo
|
||||||
|
$periodoTipo = $this->determinarTipoPeriodo($balanzasValidas);
|
||||||
|
|
||||||
|
$reporte = Reporte::create([
|
||||||
|
'cliente_id' => $cliente->id,
|
||||||
|
'nombre' => $validated['nombre'],
|
||||||
|
'periodo_tipo' => $periodoTipo,
|
||||||
|
'periodo_inicio' => $balanzasValidas->min('periodo_inicio'),
|
||||||
|
'periodo_fin' => $balanzasValidas->max('periodo_fin'),
|
||||||
|
'status' => 'procesando',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$reporte->balanzas()->attach($validated['balanza_ids']);
|
||||||
|
|
||||||
|
// Calcular métricas
|
||||||
|
try {
|
||||||
|
$dataCalculada = $this->calculador->calcular($reporte);
|
||||||
|
$reporte->update([
|
||||||
|
'data_calculada' => $dataCalculada,
|
||||||
|
'fecha_generacion' => now(),
|
||||||
|
'status' => 'completado',
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$reporte->update(['status' => 'error']);
|
||||||
|
return response()->json(['message' => $e->getMessage()], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json($reporte, 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request, Reporte $reporte): JsonResponse
|
||||||
|
{
|
||||||
|
if (!$request->user()->canAccessCliente($reporte->cliente_id)) {
|
||||||
|
return response()->json(['message' => 'No autorizado'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json($reporte->load(['cliente', 'balanzas']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pdf(Request $request, Reporte $reporte): BinaryFileResponse|JsonResponse
|
||||||
|
{
|
||||||
|
if (!$request->user()->canAccessCliente($reporte->cliente_id)) {
|
||||||
|
return response()->json(['message' => 'No autorizado'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar permisos de empleado si aplica
|
||||||
|
$user = $request->user();
|
||||||
|
if ($user->isEmpleado()) {
|
||||||
|
$permiso = $user->permisosEmpleado()
|
||||||
|
->where('cliente_id', $reporte->cliente_id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (!$permiso || !$permiso->tienePermiso('exportar_pdf')) {
|
||||||
|
return response()->json(['message' => 'No tiene permiso para exportar PDF'], 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$reporte->pdf_path || !file_exists(storage_path('app/' . $reporte->pdf_path))) {
|
||||||
|
// Generar PDF
|
||||||
|
$pdfPath = $this->generadorPdf->generar($reporte);
|
||||||
|
$reporte->update(['pdf_path' => $pdfPath]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->download(
|
||||||
|
storage_path('app/' . $reporte->pdf_path),
|
||||||
|
$reporte->nombre . '.pdf'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request, Reporte $reporte): JsonResponse
|
||||||
|
{
|
||||||
|
if (!$request->user()->isAdmin() && !$request->user()->isAnalista()) {
|
||||||
|
return response()->json(['message' => 'No autorizado'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$reporte->delete();
|
||||||
|
|
||||||
|
return response()->json(['message' => 'Reporte eliminado']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function determinarTipoPeriodo($balanzas): string
|
||||||
|
{
|
||||||
|
if ($balanzas->count() < 2) {
|
||||||
|
$balanza = $balanzas->first();
|
||||||
|
$dias = $balanza->periodo_inicio->diffInDays($balanza->periodo_fin);
|
||||||
|
|
||||||
|
if ($dias <= 35) return 'mensual';
|
||||||
|
if ($dias <= 100) return 'trimestral';
|
||||||
|
return 'anual';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcular diferencia promedio entre periodos
|
||||||
|
$diferencias = [];
|
||||||
|
$sorted = $balanzas->sortBy('periodo_inicio')->values();
|
||||||
|
|
||||||
|
for ($i = 1; $i < $sorted->count(); $i++) {
|
||||||
|
$diferencias[] = $sorted[$i - 1]->periodo_fin->diffInDays($sorted[$i]->periodo_inicio);
|
||||||
|
}
|
||||||
|
|
||||||
|
$promedio = array_sum($diferencias) / count($diferencias);
|
||||||
|
|
||||||
|
if ($promedio <= 35) return 'mensual';
|
||||||
|
if ($promedio <= 100) return 'trimestral';
|
||||||
|
return 'anual';
|
||||||
|
}
|
||||||
|
}
|
||||||
31
backend/app/Http/Middleware/RoleMiddleware.php
Normal file
31
backend/app/Http/Middleware/RoleMiddleware.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class RoleMiddleware
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*
|
||||||
|
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||||
|
* @param string ...$roles
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next, string ...$roles): Response
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if (!$user) {
|
||||||
|
return response()->json(['message' => 'No autenticado'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!in_array($user->role, $roles)) {
|
||||||
|
return response()->json(['message' => 'No autorizado'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
57
backend/app/Models/Balanza.php
Normal file
57
backend/app/Models/Balanza.php
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
|
||||||
|
class Balanza extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'cliente_id',
|
||||||
|
'periodo_inicio',
|
||||||
|
'periodo_fin',
|
||||||
|
'sistema_origen',
|
||||||
|
'archivo_original',
|
||||||
|
'status',
|
||||||
|
'error_mensaje',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'periodo_inicio' => 'date',
|
||||||
|
'periodo_fin' => 'date',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cliente(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Cliente::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cuentas(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Cuenta::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reportes(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(Reporte::class, 'reporte_balanza');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cuentasActivas(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Cuenta::class)->where('excluida', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cuentasPadre(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Cuenta::class)->where('es_cuenta_padre', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
36
backend/app/Models/CategoriaContable.php
Normal file
36
backend/app/Models/CategoriaContable.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class CategoriaContable extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $table = 'categorias_contables';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'reporte_contable_id',
|
||||||
|
'nombre',
|
||||||
|
'orden',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function reporteContable(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ReporteContable::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cuentas(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Cuenta::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reglasMapeoo(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(ReglaMapeoo::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
58
backend/app/Models/Cliente.php
Normal file
58
backend/app/Models/Cliente.php
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class Cliente extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'nombre_empresa',
|
||||||
|
'logo',
|
||||||
|
'giro_id',
|
||||||
|
'moneda',
|
||||||
|
'configuracion',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'configuracion' => 'array',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function giro(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Giro::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function usuarios(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function balanzas(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Balanza::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reportes(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Reporte::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mapeoCuentas(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(MapeoCuenta::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function permisosEmpleado(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(PermisoEmpleado::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
109
backend/app/Models/Cuenta.php
Normal file
109
backend/app/Models/Cuenta.php
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class Cuenta extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'balanza_id',
|
||||||
|
'codigo',
|
||||||
|
'nombre',
|
||||||
|
'nivel',
|
||||||
|
'reporte_contable_id',
|
||||||
|
'categoria_contable_id',
|
||||||
|
'cuenta_padre_id',
|
||||||
|
'saldo_inicial_deudor',
|
||||||
|
'saldo_inicial_acreedor',
|
||||||
|
'cargos',
|
||||||
|
'abonos',
|
||||||
|
'saldo_final_deudor',
|
||||||
|
'saldo_final_acreedor',
|
||||||
|
'excluida',
|
||||||
|
'es_cuenta_padre',
|
||||||
|
'requiere_revision',
|
||||||
|
'nota_revision',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'saldo_inicial_deudor' => 'decimal:2',
|
||||||
|
'saldo_inicial_acreedor' => 'decimal:2',
|
||||||
|
'cargos' => 'decimal:2',
|
||||||
|
'abonos' => 'decimal:2',
|
||||||
|
'saldo_final_deudor' => 'decimal:2',
|
||||||
|
'saldo_final_acreedor' => 'decimal:2',
|
||||||
|
'excluida' => 'boolean',
|
||||||
|
'es_cuenta_padre' => 'boolean',
|
||||||
|
'requiere_revision' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function balanza(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Balanza::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reporteContable(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ReporteContable::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function categoriaContable(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(CategoriaContable::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cuentaPadre(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Cuenta::class, 'cuenta_padre_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cuentasHijo(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Cuenta::class, 'cuenta_padre_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSaldoInicialNetoAttribute(): float
|
||||||
|
{
|
||||||
|
return $this->saldo_inicial_deudor - $this->saldo_inicial_acreedor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSaldoFinalNetoAttribute(): float
|
||||||
|
{
|
||||||
|
return $this->saldo_final_deudor - $this->saldo_final_acreedor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMovimientoNetoAttribute(): float
|
||||||
|
{
|
||||||
|
return $this->cargos - $this->abonos;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recalcula el saldo considerando solo cuentas hijo activas
|
||||||
|
*/
|
||||||
|
public function recalcularSaldoDesdeHijos(): void
|
||||||
|
{
|
||||||
|
if (!$this->es_cuenta_padre) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hijosActivos = $this->cuentasHijo()->where('excluida', false)->get();
|
||||||
|
|
||||||
|
$this->saldo_inicial_deudor = $hijosActivos->sum('saldo_inicial_deudor');
|
||||||
|
$this->saldo_inicial_acreedor = $hijosActivos->sum('saldo_inicial_acreedor');
|
||||||
|
$this->cargos = $hijosActivos->sum('cargos');
|
||||||
|
$this->abonos = $hijosActivos->sum('abonos');
|
||||||
|
$this->saldo_final_deudor = $hijosActivos->sum('saldo_final_deudor');
|
||||||
|
$this->saldo_final_acreedor = $hijosActivos->sum('saldo_final_acreedor');
|
||||||
|
|
||||||
|
$this->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
34
backend/app/Models/Giro.php
Normal file
34
backend/app/Models/Giro.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class Giro extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'nombre',
|
||||||
|
'activo',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'activo' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clientes(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Cliente::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function umbrales(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Umbral::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
backend/app/Models/MapeoCuenta.php
Normal file
31
backend/app/Models/MapeoCuenta.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class MapeoCuenta extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $table = 'mapeo_cuentas';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'cliente_id',
|
||||||
|
'codigo_patron',
|
||||||
|
'categoria_contable_id',
|
||||||
|
'notas',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function cliente(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Cliente::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function categoriaContable(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(CategoriaContable::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
42
backend/app/Models/PermisoEmpleado.php
Normal file
42
backend/app/Models/PermisoEmpleado.php
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class PermisoEmpleado extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $table = 'permisos_empleado';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'cliente_id',
|
||||||
|
'permisos',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'permisos' => 'array',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cliente(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Cliente::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function tienePermiso(string $permiso): bool
|
||||||
|
{
|
||||||
|
return in_array($permiso, $this->permisos ?? []);
|
||||||
|
}
|
||||||
|
}
|
||||||
73
backend/app/Models/ReglaMapeo.php
Normal file
73
backend/app/Models/ReglaMapeo.php
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class ReglaMapeo extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $table = 'reglas_mapeo';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'sistema_origen',
|
||||||
|
'cuenta_padre_codigo',
|
||||||
|
'rango_inicio',
|
||||||
|
'rango_fin',
|
||||||
|
'patron_regex',
|
||||||
|
'reporte_contable_id',
|
||||||
|
'categoria_contable_id',
|
||||||
|
'prioridad',
|
||||||
|
'activo',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'activo' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reporteContable(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ReporteContable::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function categoriaContable(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(CategoriaContable::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica si un código de cuenta coincide con esta regla
|
||||||
|
*/
|
||||||
|
public function coincideCon(string $codigo): bool
|
||||||
|
{
|
||||||
|
// Si tiene patrón regex, usarlo
|
||||||
|
if ($this->patron_regex) {
|
||||||
|
return (bool) preg_match($this->patron_regex, $codigo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si tiene rango, verificar
|
||||||
|
if ($this->rango_inicio && $this->rango_fin) {
|
||||||
|
$codigoNumerico = $this->codigoANumero($codigo);
|
||||||
|
$inicioNumerico = $this->codigoANumero($this->rango_inicio);
|
||||||
|
$finNumerico = $this->codigoANumero($this->rango_fin);
|
||||||
|
|
||||||
|
return $codigoNumerico >= $inicioNumerico && $codigoNumerico <= $finNumerico;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convierte código tipo "001-100-000" a número para comparación
|
||||||
|
*/
|
||||||
|
private function codigoANumero(string $codigo): int
|
||||||
|
{
|
||||||
|
return (int) str_replace(['-', '.', ' '], '', $codigo);
|
||||||
|
}
|
||||||
|
}
|
||||||
55
backend/app/Models/Reporte.php
Normal file
55
backend/app/Models/Reporte.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
|
||||||
|
class Reporte extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'cliente_id',
|
||||||
|
'nombre',
|
||||||
|
'periodo_tipo',
|
||||||
|
'periodo_inicio',
|
||||||
|
'periodo_fin',
|
||||||
|
'fecha_generacion',
|
||||||
|
'data_calculada',
|
||||||
|
'pdf_path',
|
||||||
|
'status',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'periodo_inicio' => 'date',
|
||||||
|
'periodo_fin' => 'date',
|
||||||
|
'fecha_generacion' => 'datetime',
|
||||||
|
'data_calculada' => 'array',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cliente(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Cliente::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function balanzas(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(Balanza::class, 'reporte_balanza');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMetrica(string $nombre): ?array
|
||||||
|
{
|
||||||
|
return $this->data_calculada['metricas'][$nombre] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEstadoFinanciero(string $tipo): ?array
|
||||||
|
{
|
||||||
|
return $this->data_calculada['estados_financieros'][$tipo] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
backend/app/Models/ReporteContable.php
Normal file
28
backend/app/Models/ReporteContable.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class ReporteContable extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $table = 'reportes_contables';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'nombre',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function categorias(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(CategoriaContable::class)->orderBy('orden');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cuentas(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Cuenta::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
57
backend/app/Models/Umbral.php
Normal file
57
backend/app/Models/Umbral.php
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class Umbral extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $table = 'umbrales';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'metrica',
|
||||||
|
'muy_positivo',
|
||||||
|
'positivo',
|
||||||
|
'neutral',
|
||||||
|
'negativo',
|
||||||
|
'muy_negativo',
|
||||||
|
'giro_id',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'muy_positivo' => 'decimal:4',
|
||||||
|
'positivo' => 'decimal:4',
|
||||||
|
'neutral' => 'decimal:4',
|
||||||
|
'negativo' => 'decimal:4',
|
||||||
|
'muy_negativo' => 'decimal:4',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function giro(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Giro::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function evaluarValor(float $valor): string
|
||||||
|
{
|
||||||
|
if ($this->muy_positivo !== null && $valor >= $this->muy_positivo) {
|
||||||
|
return 'muy_positivo';
|
||||||
|
}
|
||||||
|
if ($this->positivo !== null && $valor >= $this->positivo) {
|
||||||
|
return 'positivo';
|
||||||
|
}
|
||||||
|
if ($this->neutral !== null && $valor >= $this->neutral) {
|
||||||
|
return 'neutral';
|
||||||
|
}
|
||||||
|
if ($this->negativo !== null && $valor >= $this->negativo) {
|
||||||
|
return 'negativo';
|
||||||
|
}
|
||||||
|
return 'muy_negativo';
|
||||||
|
}
|
||||||
|
}
|
||||||
75
backend/app/Models/User.php
Normal file
75
backend/app/Models/User.php
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class User extends Authenticatable
|
||||||
|
{
|
||||||
|
use HasApiTokens, HasFactory, Notifiable;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'nombre',
|
||||||
|
'email',
|
||||||
|
'password',
|
||||||
|
'role',
|
||||||
|
'cliente_id',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $hidden = [
|
||||||
|
'password',
|
||||||
|
'remember_token',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email_verified_at' => 'datetime',
|
||||||
|
'password' => 'hashed',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cliente(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Cliente::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function permisosEmpleado(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(PermisoEmpleado::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isAdmin(): bool
|
||||||
|
{
|
||||||
|
return $this->role === 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isAnalista(): bool
|
||||||
|
{
|
||||||
|
return $this->role === 'analista';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isCliente(): bool
|
||||||
|
{
|
||||||
|
return $this->role === 'cliente';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isEmpleado(): bool
|
||||||
|
{
|
||||||
|
return $this->role === 'empleado';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canAccessCliente(int $clienteId): bool
|
||||||
|
{
|
||||||
|
if ($this->isAdmin() || $this->isAnalista()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->cliente_id === $clienteId;
|
||||||
|
}
|
||||||
|
}
|
||||||
419
backend/app/Services/CalculadorMetricas.php
Normal file
419
backend/app/Services/CalculadorMetricas.php
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Reporte;
|
||||||
|
use App\Models\Umbral;
|
||||||
|
use App\Models\CategoriaContable;
|
||||||
|
|
||||||
|
class CalculadorMetricas
|
||||||
|
{
|
||||||
|
private array $categorias;
|
||||||
|
private ?int $giroId;
|
||||||
|
|
||||||
|
public function calcular(Reporte $reporte): array
|
||||||
|
{
|
||||||
|
$this->giroId = $reporte->cliente->giro_id;
|
||||||
|
$this->cargarCategorias();
|
||||||
|
|
||||||
|
$balanzas = $reporte->balanzas()->with('cuentas')->orderBy('periodo_fin')->get();
|
||||||
|
|
||||||
|
// Calcular estados financieros para cada periodo
|
||||||
|
$periodos = [];
|
||||||
|
foreach ($balanzas as $balanza) {
|
||||||
|
$periodos[] = [
|
||||||
|
'periodo' => $balanza->periodo_fin->format('Y-m'),
|
||||||
|
'balance_general' => $this->calcularBalanceGeneral($balanza),
|
||||||
|
'estado_resultados' => $this->calcularEstadoResultados($balanza),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcular métricas del último periodo
|
||||||
|
$ultimoPeriodo = end($periodos);
|
||||||
|
$metricas = $this->calcularTodasLasMetricas($ultimoPeriodo);
|
||||||
|
|
||||||
|
// Calcular comparativos si hay múltiples periodos
|
||||||
|
$comparativos = [];
|
||||||
|
if (count($periodos) >= 2) {
|
||||||
|
$comparativos = $this->calcularComparativos($periodos, $metricas);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcular flujo de efectivo
|
||||||
|
$flujoEfectivo = $this->calcularFlujoEfectivo($periodos);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'periodos' => $periodos,
|
||||||
|
'metricas' => $metricas,
|
||||||
|
'comparativos' => $comparativos,
|
||||||
|
'flujo_efectivo' => $flujoEfectivo,
|
||||||
|
'estados_financieros' => [
|
||||||
|
'balance_general' => $ultimoPeriodo['balance_general'],
|
||||||
|
'estado_resultados' => $ultimoPeriodo['estado_resultados'],
|
||||||
|
'flujo_efectivo' => $flujoEfectivo,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cargarCategorias(): void
|
||||||
|
{
|
||||||
|
$this->categorias = CategoriaContable::all()->keyBy('nombre')->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function calcularBalanceGeneral($balanza): array
|
||||||
|
{
|
||||||
|
$cuentas = $balanza->cuentasActivas()
|
||||||
|
->whereHas('reporteContable', fn($q) => $q->where('nombre', 'Balance General'))
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$totales = [
|
||||||
|
'activos_circulantes' => 0,
|
||||||
|
'activos_no_circulantes' => 0,
|
||||||
|
'total_activos' => 0,
|
||||||
|
'pasivo_circulante' => 0,
|
||||||
|
'pasivo_no_circulante' => 0,
|
||||||
|
'total_pasivos' => 0,
|
||||||
|
'capital_social' => 0,
|
||||||
|
'utilidades_anteriores' => 0,
|
||||||
|
'perdidas_anteriores' => 0,
|
||||||
|
'total_capital' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($cuentas as $cuenta) {
|
||||||
|
if (!$cuenta->categoriaContable) continue;
|
||||||
|
|
||||||
|
$saldo = $cuenta->saldo_final_neto;
|
||||||
|
$categoria = $cuenta->categoriaContable->nombre;
|
||||||
|
|
||||||
|
switch ($categoria) {
|
||||||
|
case 'Activos Circulantes':
|
||||||
|
$totales['activos_circulantes'] += $saldo;
|
||||||
|
break;
|
||||||
|
case 'Activos No Circulantes':
|
||||||
|
$totales['activos_no_circulantes'] += $saldo;
|
||||||
|
break;
|
||||||
|
case 'Pasivo Circulante':
|
||||||
|
$totales['pasivo_circulante'] += abs($saldo);
|
||||||
|
break;
|
||||||
|
case 'Pasivo No Circulante':
|
||||||
|
$totales['pasivo_no_circulante'] += abs($saldo);
|
||||||
|
break;
|
||||||
|
case 'Capital Social':
|
||||||
|
$totales['capital_social'] += abs($saldo);
|
||||||
|
break;
|
||||||
|
case 'Utilidades Ejercicios Anteriores':
|
||||||
|
$totales['utilidades_anteriores'] += abs($saldo);
|
||||||
|
break;
|
||||||
|
case 'Pérdidas Ejercicios Anteriores':
|
||||||
|
$totales['perdidas_anteriores'] += abs($saldo);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$totales['total_activos'] = $totales['activos_circulantes'] + $totales['activos_no_circulantes'];
|
||||||
|
$totales['total_pasivos'] = $totales['pasivo_circulante'] + $totales['pasivo_no_circulante'];
|
||||||
|
$totales['total_capital'] = $totales['capital_social'] + $totales['utilidades_anteriores'] - $totales['perdidas_anteriores'];
|
||||||
|
|
||||||
|
return $totales;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function calcularEstadoResultados($balanza): array
|
||||||
|
{
|
||||||
|
$cuentas = $balanza->cuentasActivas()
|
||||||
|
->whereHas('reporteContable', fn($q) => $q->where('nombre', 'Estado de Resultados'))
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$totales = [
|
||||||
|
'ingresos' => 0,
|
||||||
|
'costo_venta' => 0,
|
||||||
|
'utilidad_bruta' => 0,
|
||||||
|
'gastos_operativos' => 0,
|
||||||
|
'utilidad_operativa' => 0,
|
||||||
|
'otros_gastos' => 0,
|
||||||
|
'gastos_financieros' => 0,
|
||||||
|
'utilidad_antes_impuestos' => 0,
|
||||||
|
'impuestos' => 0,
|
||||||
|
'utilidad_neta' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($cuentas as $cuenta) {
|
||||||
|
if (!$cuenta->categoriaContable) continue;
|
||||||
|
|
||||||
|
$saldo = abs($cuenta->saldo_final_neto);
|
||||||
|
$categoria = $cuenta->categoriaContable->nombre;
|
||||||
|
|
||||||
|
switch ($categoria) {
|
||||||
|
case 'Ingresos':
|
||||||
|
$totales['ingresos'] += $saldo;
|
||||||
|
break;
|
||||||
|
case 'Costo de Venta':
|
||||||
|
$totales['costo_venta'] += $saldo;
|
||||||
|
break;
|
||||||
|
case 'Gastos Operativos':
|
||||||
|
$totales['gastos_operativos'] += $saldo;
|
||||||
|
break;
|
||||||
|
case 'Otros Gastos':
|
||||||
|
$totales['otros_gastos'] += $saldo;
|
||||||
|
break;
|
||||||
|
case 'Gastos Financieros':
|
||||||
|
$totales['gastos_financieros'] += $saldo;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcular subtotales
|
||||||
|
$totales['utilidad_bruta'] = $totales['ingresos'] - $totales['costo_venta'];
|
||||||
|
$totales['utilidad_operativa'] = $totales['utilidad_bruta'] - $totales['gastos_operativos'];
|
||||||
|
$totales['utilidad_antes_impuestos'] = $totales['utilidad_operativa'] - $totales['otros_gastos'] - $totales['gastos_financieros'];
|
||||||
|
|
||||||
|
// Estimación de impuestos (30% para México)
|
||||||
|
$totales['impuestos'] = max(0, $totales['utilidad_antes_impuestos'] * 0.30);
|
||||||
|
$totales['utilidad_neta'] = $totales['utilidad_antes_impuestos'] - $totales['impuestos'];
|
||||||
|
|
||||||
|
return $totales;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function calcularTodasLasMetricas(array $periodo): array
|
||||||
|
{
|
||||||
|
$balance = $periodo['balance_general'];
|
||||||
|
$resultados = $periodo['estado_resultados'];
|
||||||
|
|
||||||
|
$metricas = [];
|
||||||
|
|
||||||
|
// === MÁRGENES ===
|
||||||
|
$ingresos = $resultados['ingresos'] ?: 1; // Evitar división por cero
|
||||||
|
|
||||||
|
$metricas['margen_bruto'] = $this->crearMetrica(
|
||||||
|
'Margen Bruto',
|
||||||
|
$resultados['utilidad_bruta'] / $ingresos,
|
||||||
|
'margen_bruto'
|
||||||
|
);
|
||||||
|
|
||||||
|
// EBITDA = Utilidad Operativa + Depreciación (estimada como 5% de activos fijos)
|
||||||
|
$depreciacion = $balance['activos_no_circulantes'] * 0.05;
|
||||||
|
$ebitda = $resultados['utilidad_operativa'] + $depreciacion;
|
||||||
|
|
||||||
|
$metricas['margen_ebitda'] = $this->crearMetrica(
|
||||||
|
'Margen EBITDA',
|
||||||
|
$ebitda / $ingresos,
|
||||||
|
'margen_ebitda'
|
||||||
|
);
|
||||||
|
|
||||||
|
$metricas['margen_operativo'] = $this->crearMetrica(
|
||||||
|
'Margen Operativo',
|
||||||
|
$resultados['utilidad_operativa'] / $ingresos,
|
||||||
|
'margen_operativo'
|
||||||
|
);
|
||||||
|
|
||||||
|
$metricas['margen_neto'] = $this->crearMetrica(
|
||||||
|
'Margen Neto',
|
||||||
|
$resultados['utilidad_neta'] / $ingresos,
|
||||||
|
'margen_neto'
|
||||||
|
);
|
||||||
|
|
||||||
|
// NOPAT = EBIT * (1 - Tasa impuestos)
|
||||||
|
$nopat = $resultados['utilidad_operativa'] * 0.70;
|
||||||
|
$metricas['margen_nopat'] = $this->crearMetrica(
|
||||||
|
'Margen NOPAT',
|
||||||
|
$nopat / $ingresos,
|
||||||
|
'margen_nopat'
|
||||||
|
);
|
||||||
|
|
||||||
|
// === RETORNO ===
|
||||||
|
$capitalInvertido = $balance['total_activos'] - $balance['pasivo_circulante'];
|
||||||
|
$capitalEmpleado = $balance['total_activos'] - $balance['pasivo_circulante'];
|
||||||
|
|
||||||
|
$metricas['roic'] = $this->crearMetrica(
|
||||||
|
'ROIC',
|
||||||
|
$capitalInvertido > 0 ? $nopat / $capitalInvertido : 0,
|
||||||
|
'roic'
|
||||||
|
);
|
||||||
|
|
||||||
|
$metricas['roe'] = $this->crearMetrica(
|
||||||
|
'ROE',
|
||||||
|
$balance['total_capital'] > 0 ? $resultados['utilidad_neta'] / $balance['total_capital'] : 0,
|
||||||
|
'roe'
|
||||||
|
);
|
||||||
|
|
||||||
|
$metricas['roa'] = $this->crearMetrica(
|
||||||
|
'ROA',
|
||||||
|
$balance['total_activos'] > 0 ? $resultados['utilidad_neta'] / $balance['total_activos'] : 0,
|
||||||
|
'roa'
|
||||||
|
);
|
||||||
|
|
||||||
|
$metricas['roce'] = $this->crearMetrica(
|
||||||
|
'ROCE',
|
||||||
|
$capitalEmpleado > 0 ? $resultados['utilidad_operativa'] / $capitalEmpleado : 0,
|
||||||
|
'roce'
|
||||||
|
);
|
||||||
|
|
||||||
|
// === LIQUIDEZ ===
|
||||||
|
$pasivoCirculante = $balance['pasivo_circulante'] ?: 1;
|
||||||
|
|
||||||
|
$metricas['current_ratio'] = $this->crearMetrica(
|
||||||
|
'Current Ratio',
|
||||||
|
$balance['activos_circulantes'] / $pasivoCirculante,
|
||||||
|
'current_ratio'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Quick ratio (estimando inventario como 30% de activos circulantes)
|
||||||
|
$inventarioEstimado = $balance['activos_circulantes'] * 0.30;
|
||||||
|
$metricas['quick_ratio'] = $this->crearMetrica(
|
||||||
|
'Quick Ratio',
|
||||||
|
($balance['activos_circulantes'] - $inventarioEstimado) / $pasivoCirculante,
|
||||||
|
'quick_ratio'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cash ratio (estimando efectivo como 15% de activos circulantes)
|
||||||
|
$efectivoEstimado = $balance['activos_circulantes'] * 0.15;
|
||||||
|
$metricas['cash_ratio'] = $this->crearMetrica(
|
||||||
|
'Cash Ratio',
|
||||||
|
$efectivoEstimado / $pasivoCirculante,
|
||||||
|
'cash_ratio'
|
||||||
|
);
|
||||||
|
|
||||||
|
// === SOLVENCIA ===
|
||||||
|
$deudaTotal = $balance['total_pasivos'];
|
||||||
|
$ebitdaAnual = $ebitda * 12; // Anualizar si es mensual
|
||||||
|
|
||||||
|
$metricas['net_debt_ebitda'] = $this->crearMetrica(
|
||||||
|
'Net Debt / EBITDA',
|
||||||
|
$ebitdaAnual > 0 ? ($deudaTotal - $efectivoEstimado) / $ebitdaAnual : 0,
|
||||||
|
'net_debt_ebitda'
|
||||||
|
);
|
||||||
|
|
||||||
|
$gastosFinancieros = $resultados['gastos_financieros'] ?: 1;
|
||||||
|
$metricas['interest_coverage'] = $this->crearMetrica(
|
||||||
|
'Interest Coverage',
|
||||||
|
$ebitda / $gastosFinancieros,
|
||||||
|
'interest_coverage'
|
||||||
|
);
|
||||||
|
|
||||||
|
$metricas['debt_ratio'] = $this->crearMetrica(
|
||||||
|
'Debt Ratio',
|
||||||
|
$balance['total_activos'] > 0 ? $deudaTotal / $balance['total_activos'] : 0,
|
||||||
|
'debt_ratio'
|
||||||
|
);
|
||||||
|
|
||||||
|
return $metricas;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function crearMetrica(string $nombre, float $valor, string $codigoUmbral): array
|
||||||
|
{
|
||||||
|
$umbral = $this->obtenerUmbral($codigoUmbral);
|
||||||
|
$tendencia = $umbral ? $umbral->evaluarValor($valor) : 'neutral';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'nombre' => $nombre,
|
||||||
|
'valor' => round($valor, 4),
|
||||||
|
'valor_porcentaje' => round($valor * 100, 2),
|
||||||
|
'tendencia' => $tendencia,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function obtenerUmbral(string $metrica): ?Umbral
|
||||||
|
{
|
||||||
|
// Buscar umbral específico del giro primero
|
||||||
|
$umbral = Umbral::where('metrica', $metrica)
|
||||||
|
->where('giro_id', $this->giroId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (!$umbral) {
|
||||||
|
// Buscar umbral genérico
|
||||||
|
$umbral = Umbral::where('metrica', $metrica)
|
||||||
|
->whereNull('giro_id')
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $umbral;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function calcularComparativos(array $periodos, array $metricasActuales): array
|
||||||
|
{
|
||||||
|
$comparativos = [];
|
||||||
|
|
||||||
|
if (count($periodos) < 2) {
|
||||||
|
return $comparativos;
|
||||||
|
}
|
||||||
|
|
||||||
|
$periodoActual = end($periodos);
|
||||||
|
$periodoAnterior = $periodos[count($periodos) - 2];
|
||||||
|
|
||||||
|
// Calcular métricas del periodo anterior
|
||||||
|
$metricasAnterior = $this->calcularTodasLasMetricas($periodoAnterior);
|
||||||
|
|
||||||
|
foreach ($metricasActuales as $key => $metrica) {
|
||||||
|
if (isset($metricasAnterior[$key])) {
|
||||||
|
$valorActual = $metrica['valor'];
|
||||||
|
$valorAnterior = $metricasAnterior[$key]['valor'];
|
||||||
|
$variacion = $valorAnterior != 0 ? ($valorActual - $valorAnterior) / abs($valorAnterior) : 0;
|
||||||
|
|
||||||
|
$comparativos[$key] = [
|
||||||
|
'valor_actual' => $valorActual,
|
||||||
|
'valor_anterior' => $valorAnterior,
|
||||||
|
'variacion_absoluta' => $valorActual - $valorAnterior,
|
||||||
|
'variacion_porcentual' => round($variacion * 100, 2),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Promedio de 3 periodos si hay suficientes datos
|
||||||
|
if (count($periodos) >= 3) {
|
||||||
|
$ultimos3 = array_slice($periodos, -3);
|
||||||
|
foreach ($metricasActuales as $key => $metrica) {
|
||||||
|
$suma = 0;
|
||||||
|
foreach ($ultimos3 as $p) {
|
||||||
|
$m = $this->calcularTodasLasMetricas($p);
|
||||||
|
$suma += $m[$key]['valor'] ?? 0;
|
||||||
|
}
|
||||||
|
$comparativos[$key]['promedio_3_periodos'] = round($suma / 3, 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $comparativos;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function calcularFlujoEfectivo(array $periodos): array
|
||||||
|
{
|
||||||
|
if (count($periodos) < 2) {
|
||||||
|
return ['metodo' => 'indirecto', 'sin_datos' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
$actual = end($periodos);
|
||||||
|
$anterior = $periodos[count($periodos) - 2];
|
||||||
|
|
||||||
|
$balanceActual = $actual['balance_general'];
|
||||||
|
$balanceAnterior = $anterior['balance_general'];
|
||||||
|
$resultados = $actual['estado_resultados'];
|
||||||
|
|
||||||
|
// Método indirecto
|
||||||
|
$utilidadNeta = $resultados['utilidad_neta'];
|
||||||
|
$depreciacion = $balanceActual['activos_no_circulantes'] * 0.05;
|
||||||
|
|
||||||
|
// Cambios en capital de trabajo
|
||||||
|
$cambioActivosCirc = $balanceActual['activos_circulantes'] - $balanceAnterior['activos_circulantes'];
|
||||||
|
$cambioPasivosCirc = $balanceActual['pasivo_circulante'] - $balanceAnterior['pasivo_circulante'];
|
||||||
|
|
||||||
|
$flujoOperacion = $utilidadNeta + $depreciacion - $cambioActivosCirc + $cambioPasivosCirc;
|
||||||
|
|
||||||
|
// Flujo de inversión (cambio en activos no circulantes)
|
||||||
|
$flujoInversion = -($balanceActual['activos_no_circulantes'] - $balanceAnterior['activos_no_circulantes']);
|
||||||
|
|
||||||
|
// Flujo de financiamiento
|
||||||
|
$cambioDeuda = $balanceActual['total_pasivos'] - $balanceAnterior['total_pasivos'];
|
||||||
|
$cambioCapital = $balanceActual['total_capital'] - $balanceAnterior['total_capital'];
|
||||||
|
$flujoFinanciamiento = $cambioDeuda + $cambioCapital - $utilidadNeta;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'metodo' => 'indirecto',
|
||||||
|
'flujo_operacion' => round($flujoOperacion, 2),
|
||||||
|
'flujo_inversion' => round($flujoInversion, 2),
|
||||||
|
'flujo_financiamiento' => round($flujoFinanciamiento, 2),
|
||||||
|
'flujo_neto' => round($flujoOperacion + $flujoInversion + $flujoFinanciamiento, 2),
|
||||||
|
'detalle' => [
|
||||||
|
'utilidad_neta' => $utilidadNeta,
|
||||||
|
'depreciacion' => $depreciacion,
|
||||||
|
'cambio_capital_trabajo' => -$cambioActivosCirc + $cambioPasivosCirc,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
119
backend/app/Services/ClasificadorCuentas.php
Normal file
119
backend/app/Services/ClasificadorCuentas.php
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Balanza;
|
||||||
|
use App\Models\Cuenta;
|
||||||
|
use App\Models\ReglaMapeo;
|
||||||
|
use App\Models\MapeoCuenta;
|
||||||
|
|
||||||
|
class ClasificadorCuentas
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Clasifica todas las cuentas de una balanza según las reglas de mapeo
|
||||||
|
*/
|
||||||
|
public function clasificar(Balanza $balanza): void
|
||||||
|
{
|
||||||
|
$cuentas = $balanza->cuentas()->get();
|
||||||
|
$sistemaOrigen = $balanza->sistema_origen;
|
||||||
|
$clienteId = $balanza->cliente_id;
|
||||||
|
|
||||||
|
// Obtener reglas ordenadas por prioridad
|
||||||
|
$reglas = ReglaMapeo::where('sistema_origen', $sistemaOrigen)
|
||||||
|
->where('activo', true)
|
||||||
|
->orderByDesc('prioridad')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// Obtener mapeos específicos del cliente
|
||||||
|
$mapeosCliente = MapeoCuenta::where('cliente_id', $clienteId)->get();
|
||||||
|
|
||||||
|
foreach ($cuentas as $cuenta) {
|
||||||
|
$this->clasificarCuenta($cuenta, $reglas, $mapeosCliente);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Establecer relaciones padre-hijo en base de datos
|
||||||
|
$this->establecerRelacionesPadreHijo($balanza);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function clasificarCuenta(Cuenta $cuenta, $reglas, $mapeosCliente): void
|
||||||
|
{
|
||||||
|
// Primero buscar en mapeos específicos del cliente
|
||||||
|
foreach ($mapeosCliente as $mapeo) {
|
||||||
|
if ($this->coincidePatron($cuenta->codigo, $mapeo->codigo_patron)) {
|
||||||
|
$cuenta->update([
|
||||||
|
'categoria_contable_id' => $mapeo->categoria_contable_id,
|
||||||
|
'reporte_contable_id' => $mapeo->categoriaContable->reporte_contable_id,
|
||||||
|
'requiere_revision' => false,
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar en reglas del sistema
|
||||||
|
foreach ($reglas as $regla) {
|
||||||
|
if ($regla->coincideCon($cuenta->codigo)) {
|
||||||
|
$cuenta->update([
|
||||||
|
'categoria_contable_id' => $regla->categoria_contable_id,
|
||||||
|
'reporte_contable_id' => $regla->reporte_contable_id,
|
||||||
|
'requiere_revision' => false,
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No se encontró regla - marcar para revisión
|
||||||
|
$this->marcarParaRevision($cuenta);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function coincidePatron(string $codigo, string $patron): bool
|
||||||
|
{
|
||||||
|
// El patrón puede ser exacto o con wildcards (*)
|
||||||
|
$regex = '/^' . str_replace(['*', '-'], ['.*', '\-'], $patron) . '$/';
|
||||||
|
return (bool) preg_match($regex, $codigo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function marcarParaRevision(Cuenta $cuenta): void
|
||||||
|
{
|
||||||
|
$nota = $this->generarNotaRevision($cuenta);
|
||||||
|
|
||||||
|
$cuenta->update([
|
||||||
|
'requiere_revision' => true,
|
||||||
|
'nota_revision' => $nota,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generarNotaRevision(Cuenta $cuenta): string
|
||||||
|
{
|
||||||
|
$codigo = $cuenta->codigo;
|
||||||
|
$notas = [];
|
||||||
|
|
||||||
|
// Detectar posibles anomalías basadas en el código
|
||||||
|
if (preg_match('/^45[0-9]-/', $codigo)) {
|
||||||
|
$notas[] = 'Código 45X normalmente es pasivo pero podría ser gasto. Verificar clasificación.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/^[89][0-9]{2}-/', $codigo)) {
|
||||||
|
$notas[] = 'Cuenta de orden o especial. Verificar si debe incluirse en estados financieros.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($notas)) {
|
||||||
|
$notas[] = 'No se encontró regla de mapeo para este código. Asignar clasificación manualmente.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(' ', $notas);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function establecerRelacionesPadreHijo(Balanza $balanza): void
|
||||||
|
{
|
||||||
|
$cuentas = $balanza->cuentas()->get()->keyBy('codigo');
|
||||||
|
|
||||||
|
foreach ($cuentas as $cuenta) {
|
||||||
|
if (isset($cuenta->cuenta_padre_codigo)) {
|
||||||
|
$padre = $cuentas->get($cuenta->cuenta_padre_codigo);
|
||||||
|
if ($padre) {
|
||||||
|
$cuenta->update(['cuenta_padre_id' => $padre->id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
60
backend/app/Services/GeneradorPdf.php
Normal file
60
backend/app/Services/GeneradorPdf.php
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Reporte;
|
||||||
|
use Spatie\Browsershot\Browsershot;
|
||||||
|
|
||||||
|
class GeneradorPdf
|
||||||
|
{
|
||||||
|
private string $frontendUrl;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->frontendUrl = config('app.frontend_url', 'http://localhost:5173');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genera el PDF del reporte
|
||||||
|
*
|
||||||
|
* @param Reporte $reporte
|
||||||
|
* @return string Path relativo del PDF generado
|
||||||
|
*/
|
||||||
|
public function generar(Reporte $reporte): string
|
||||||
|
{
|
||||||
|
$outputPath = 'reportes/' . $reporte->cliente_id . '/' . $reporte->id . '.pdf';
|
||||||
|
$fullPath = storage_path('app/' . $outputPath);
|
||||||
|
|
||||||
|
// Crear directorio si no existe
|
||||||
|
$dir = dirname($fullPath);
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
mkdir($dir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL del frontend para renderizar el reporte
|
||||||
|
$url = $this->frontendUrl . '/pdf-view/' . $reporte->id . '?token=' . $this->generarTokenTemporal($reporte);
|
||||||
|
|
||||||
|
Browsershot::url($url)
|
||||||
|
->waitUntilNetworkIdle()
|
||||||
|
->format('Letter')
|
||||||
|
->margins(0, 0, 0, 0)
|
||||||
|
->showBackground()
|
||||||
|
->save($fullPath);
|
||||||
|
|
||||||
|
return $outputPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genera un token temporal para acceso al PDF
|
||||||
|
*/
|
||||||
|
private function generarTokenTemporal(Reporte $reporte): string
|
||||||
|
{
|
||||||
|
// Token válido por 5 minutos
|
||||||
|
$data = [
|
||||||
|
'reporte_id' => $reporte->id,
|
||||||
|
'expires' => time() + 300,
|
||||||
|
];
|
||||||
|
|
||||||
|
return base64_encode(json_encode($data));
|
||||||
|
}
|
||||||
|
}
|
||||||
149
backend/app/Services/Parsers/ContpaqiParser.php
Normal file
149
backend/app/Services/Parsers/ContpaqiParser.php
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Parsers;
|
||||||
|
|
||||||
|
use Spatie\PdfToText\Pdf;
|
||||||
|
|
||||||
|
class ContpaqiParser implements ParserInterface
|
||||||
|
{
|
||||||
|
public function getSistema(): string
|
||||||
|
{
|
||||||
|
return 'contpaqi';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function puedeManej(string $filePath): bool
|
||||||
|
{
|
||||||
|
$extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
|
||||||
|
|
||||||
|
if ($extension !== 'pdf') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$text = Pdf::getText($filePath);
|
||||||
|
|
||||||
|
// Buscar patrones característicos de CONTPAQi
|
||||||
|
$patronesContpaqi = [
|
||||||
|
'/CONTPAQ/i',
|
||||||
|
'/Balanza de Comprobaci[óo]n/i',
|
||||||
|
'/\d{3}-\d{3}-\d{3}/', // Patrón de código de cuenta CONTPAQi
|
||||||
|
'/Saldo\s+Inicial.*Debe.*Haber.*Saldo\s+Final/is',
|
||||||
|
];
|
||||||
|
|
||||||
|
$coincidencias = 0;
|
||||||
|
foreach ($patronesContpaqi as $patron) {
|
||||||
|
if (preg_match($patron, $text)) {
|
||||||
|
$coincidencias++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $coincidencias >= 2;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function parsear(string $filePath): array
|
||||||
|
{
|
||||||
|
$text = Pdf::getText($filePath);
|
||||||
|
$lineas = explode("\n", $text);
|
||||||
|
$cuentas = [];
|
||||||
|
|
||||||
|
foreach ($lineas as $linea) {
|
||||||
|
$cuenta = $this->parsearLinea($linea);
|
||||||
|
if ($cuenta) {
|
||||||
|
$cuentas[] = $cuenta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Establecer relaciones padre-hijo
|
||||||
|
$this->establecerJerarquia($cuentas);
|
||||||
|
|
||||||
|
return $cuentas;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parsearLinea(string $linea): ?array
|
||||||
|
{
|
||||||
|
// Patrón para líneas de cuenta CONTPAQi
|
||||||
|
// Formato típico: "001-100-000 ACTIVO CIRCULANTE 1,234.56 0.00 500.00 200.00 1,534.56 0.00"
|
||||||
|
$patron = '/^(\d{3}-\d{3}-\d{3})\s+(.+?)\s+([\d,]+\.?\d*)\s+([\d,]+\.?\d*)\s+([\d,]+\.?\d*)\s+([\d,]+\.?\d*)\s+([\d,]+\.?\d*)\s+([\d,]+\.?\d*)$/';
|
||||||
|
|
||||||
|
if (!preg_match($patron, trim($linea), $matches)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$codigo = $matches[1];
|
||||||
|
$nombre = trim($matches[2]);
|
||||||
|
|
||||||
|
// Determinar nivel basado en el código
|
||||||
|
$nivel = $this->determinarNivel($codigo);
|
||||||
|
|
||||||
|
// Determinar si es cuenta padre (termina en -000-000 o -XXX-000)
|
||||||
|
$esCuentaPadre = preg_match('/-000-000$/', $codigo) || preg_match('/-\d{3}-000$/', $codigo);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'codigo' => $codigo,
|
||||||
|
'nombre' => $nombre,
|
||||||
|
'nivel' => $nivel,
|
||||||
|
'saldo_inicial_deudor' => $this->parsearNumero($matches[3]),
|
||||||
|
'saldo_inicial_acreedor' => $this->parsearNumero($matches[4]),
|
||||||
|
'cargos' => $this->parsearNumero($matches[5]),
|
||||||
|
'abonos' => $this->parsearNumero($matches[6]),
|
||||||
|
'saldo_final_deudor' => $this->parsearNumero($matches[7]),
|
||||||
|
'saldo_final_acreedor' => $this->parsearNumero($matches[8]),
|
||||||
|
'es_cuenta_padre' => $esCuentaPadre,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function determinarNivel(string $codigo): int
|
||||||
|
{
|
||||||
|
// En CONTPAQi el nivel se puede inferir del patrón de código
|
||||||
|
// XXX-000-000 = Nivel 1 (cuenta mayor)
|
||||||
|
// XXX-XXX-000 = Nivel 2 (subcuenta)
|
||||||
|
// XXX-XXX-XXX = Nivel 3 (detalle)
|
||||||
|
|
||||||
|
if (preg_match('/-000-000$/', $codigo)) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (preg_match('/-\d{3}-000$/', $codigo)) {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parsearNumero(string $valor): float
|
||||||
|
{
|
||||||
|
// Remover comas y convertir a float
|
||||||
|
return (float) str_replace(',', '', $valor);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function establecerJerarquia(array &$cuentas): void
|
||||||
|
{
|
||||||
|
// Crear índice por código
|
||||||
|
$indice = [];
|
||||||
|
foreach ($cuentas as $i => $cuenta) {
|
||||||
|
$indice[$cuenta['codigo']] = $i;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Establecer padres basado en el código
|
||||||
|
foreach ($cuentas as $i => &$cuenta) {
|
||||||
|
$codigo = $cuenta['codigo'];
|
||||||
|
$partes = explode('-', $codigo);
|
||||||
|
|
||||||
|
// Buscar cuenta padre
|
||||||
|
if ($partes[2] !== '000') {
|
||||||
|
// Buscar padre de nivel 2 (XXX-XXX-000)
|
||||||
|
$codigoPadre = $partes[0] . '-' . $partes[1] . '-000';
|
||||||
|
if (isset($indice[$codigoPadre])) {
|
||||||
|
$cuenta['cuenta_padre_codigo'] = $codigoPadre;
|
||||||
|
}
|
||||||
|
} elseif ($partes[1] !== '000') {
|
||||||
|
// Buscar padre de nivel 1 (XXX-000-000)
|
||||||
|
$codigoPadre = $partes[0] . '-000-000';
|
||||||
|
if (isset($indice[$codigoPadre])) {
|
||||||
|
$cuenta['cuenta_padre_codigo'] = $codigoPadre;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
47
backend/app/Services/Parsers/DetectorFormato.php
Normal file
47
backend/app/Services/Parsers/DetectorFormato.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Parsers;
|
||||||
|
|
||||||
|
class DetectorFormato
|
||||||
|
{
|
||||||
|
private array $parsers;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->parsers = [
|
||||||
|
new ContpaqiParser(),
|
||||||
|
// Agregar más parsers aquí: AspelParser, SapParser, etc.
|
||||||
|
new GenericoParser(), // Debe ser el último como fallback
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detecta el formato del archivo y retorna el parser apropiado
|
||||||
|
*
|
||||||
|
* @param string $filePath Ruta completa al archivo
|
||||||
|
* @return array{sistema: string, parser: ParserInterface}
|
||||||
|
* @throws \Exception Si no se puede detectar el formato
|
||||||
|
*/
|
||||||
|
public function detectar(string $filePath): array
|
||||||
|
{
|
||||||
|
foreach ($this->parsers as $parser) {
|
||||||
|
if ($parser->puedeManej($filePath)) {
|
||||||
|
return [
|
||||||
|
'sistema' => $parser->getSistema(),
|
||||||
|
'parser' => $parser,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \Exception('No se pudo detectar el formato del archivo');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registra un parser adicional
|
||||||
|
*/
|
||||||
|
public function registrarParser(ParserInterface $parser): void
|
||||||
|
{
|
||||||
|
// Insertar antes del GenericoParser
|
||||||
|
array_splice($this->parsers, -1, 0, [$parser]);
|
||||||
|
}
|
||||||
|
}
|
||||||
180
backend/app/Services/Parsers/GenericoParser.php
Normal file
180
backend/app/Services/Parsers/GenericoParser.php
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Parsers;
|
||||||
|
|
||||||
|
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||||
|
|
||||||
|
class GenericoParser implements ParserInterface
|
||||||
|
{
|
||||||
|
private array $mapeoColumnas = [
|
||||||
|
'codigo' => ['codigo', 'cuenta', 'code', 'account', 'cta', 'numero'],
|
||||||
|
'nombre' => ['nombre', 'descripcion', 'name', 'description', 'concepto'],
|
||||||
|
'saldo_inicial_deudor' => ['saldo_inicial_deudor', 'inicial_debe', 'opening_debit', 'si_deudor'],
|
||||||
|
'saldo_inicial_acreedor' => ['saldo_inicial_acreedor', 'inicial_haber', 'opening_credit', 'si_acreedor'],
|
||||||
|
'cargos' => ['cargos', 'debe', 'debit', 'debits', 'movs_deudor'],
|
||||||
|
'abonos' => ['abonos', 'haber', 'credit', 'credits', 'movs_acreedor'],
|
||||||
|
'saldo_final_deudor' => ['saldo_final_deudor', 'final_debe', 'closing_debit', 'sf_deudor'],
|
||||||
|
'saldo_final_acreedor' => ['saldo_final_acreedor', 'final_haber', 'closing_credit', 'sf_acreedor'],
|
||||||
|
];
|
||||||
|
|
||||||
|
public function getSistema(): string
|
||||||
|
{
|
||||||
|
return 'generico';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function puedeManej(string $filePath): bool
|
||||||
|
{
|
||||||
|
$extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
|
||||||
|
return in_array($extension, ['xlsx', 'xls', 'csv']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function parsear(string $filePath): array
|
||||||
|
{
|
||||||
|
$extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
|
||||||
|
|
||||||
|
if ($extension === 'csv') {
|
||||||
|
return $this->parsearCsv($filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->parsearExcel($filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parsearExcel(string $filePath): array
|
||||||
|
{
|
||||||
|
$spreadsheet = IOFactory::load($filePath);
|
||||||
|
$worksheet = $spreadsheet->getActiveSheet();
|
||||||
|
$rows = $worksheet->toArray();
|
||||||
|
|
||||||
|
if (empty($rows)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Primera fila son headers
|
||||||
|
$headers = array_map(fn($h) => $this->normalizarHeader($h), $rows[0]);
|
||||||
|
$mapeo = $this->mapearColumnas($headers);
|
||||||
|
|
||||||
|
$cuentas = [];
|
||||||
|
for ($i = 1; $i < count($rows); $i++) {
|
||||||
|
$cuenta = $this->parsearFila($rows[$i], $mapeo);
|
||||||
|
if ($cuenta) {
|
||||||
|
$cuentas[] = $cuenta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $cuentas;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parsearCsv(string $filePath): array
|
||||||
|
{
|
||||||
|
$handle = fopen($filePath, 'r');
|
||||||
|
if (!$handle) {
|
||||||
|
throw new \Exception('No se pudo abrir el archivo CSV');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detectar delimitador
|
||||||
|
$primeraLinea = fgets($handle);
|
||||||
|
rewind($handle);
|
||||||
|
$delimitador = $this->detectarDelimitador($primeraLinea);
|
||||||
|
|
||||||
|
// Primera fila son headers
|
||||||
|
$headers = fgetcsv($handle, 0, $delimitador);
|
||||||
|
$headers = array_map(fn($h) => $this->normalizarHeader($h), $headers);
|
||||||
|
$mapeo = $this->mapearColumnas($headers);
|
||||||
|
|
||||||
|
$cuentas = [];
|
||||||
|
while (($row = fgetcsv($handle, 0, $delimitador)) !== false) {
|
||||||
|
$cuenta = $this->parsearFila($row, $mapeo);
|
||||||
|
if ($cuenta) {
|
||||||
|
$cuentas[] = $cuenta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($handle);
|
||||||
|
return $cuentas;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizarHeader(?string $header): string
|
||||||
|
{
|
||||||
|
if ($header === null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return strtolower(trim(preg_replace('/[^a-zA-Z0-9]/', '_', $header)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function detectarDelimitador(string $linea): string
|
||||||
|
{
|
||||||
|
$delimitadores = [',', ';', "\t", '|'];
|
||||||
|
$conteos = [];
|
||||||
|
|
||||||
|
foreach ($delimitadores as $d) {
|
||||||
|
$conteos[$d] = substr_count($linea, $d);
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_keys($conteos, max($conteos))[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mapearColumnas(array $headers): array
|
||||||
|
{
|
||||||
|
$mapeo = [];
|
||||||
|
|
||||||
|
foreach ($this->mapeoColumnas as $campo => $aliases) {
|
||||||
|
foreach ($headers as $index => $header) {
|
||||||
|
if (in_array($header, $aliases)) {
|
||||||
|
$mapeo[$campo] = $index;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $mapeo;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parsearFila(array $row, array $mapeo): ?array
|
||||||
|
{
|
||||||
|
// Verificar que tenemos código y nombre
|
||||||
|
if (!isset($mapeo['codigo']) || !isset($mapeo['nombre'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$codigo = trim($row[$mapeo['codigo']] ?? '');
|
||||||
|
$nombre = trim($row[$mapeo['nombre']] ?? '');
|
||||||
|
|
||||||
|
if (empty($codigo) || empty($nombre)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determinar nivel basado en la estructura del código
|
||||||
|
$nivel = $this->determinarNivel($codigo);
|
||||||
|
$esCuentaPadre = $nivel <= 2;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'codigo' => $codigo,
|
||||||
|
'nombre' => $nombre,
|
||||||
|
'nivel' => $nivel,
|
||||||
|
'saldo_inicial_deudor' => $this->obtenerNumero($row, $mapeo, 'saldo_inicial_deudor'),
|
||||||
|
'saldo_inicial_acreedor' => $this->obtenerNumero($row, $mapeo, 'saldo_inicial_acreedor'),
|
||||||
|
'cargos' => $this->obtenerNumero($row, $mapeo, 'cargos'),
|
||||||
|
'abonos' => $this->obtenerNumero($row, $mapeo, 'abonos'),
|
||||||
|
'saldo_final_deudor' => $this->obtenerNumero($row, $mapeo, 'saldo_final_deudor'),
|
||||||
|
'saldo_final_acreedor' => $this->obtenerNumero($row, $mapeo, 'saldo_final_acreedor'),
|
||||||
|
'es_cuenta_padre' => $esCuentaPadre,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function determinarNivel(string $codigo): int
|
||||||
|
{
|
||||||
|
// Contar separadores para determinar nivel
|
||||||
|
$separadores = preg_match_all('/[-.\s]/', $codigo);
|
||||||
|
return min(3, $separadores + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function obtenerNumero(array $row, array $mapeo, string $campo): float
|
||||||
|
{
|
||||||
|
if (!isset($mapeo[$campo])) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$valor = $row[$mapeo[$campo]] ?? 0;
|
||||||
|
return (float) str_replace([',', '$', ' '], '', $valor);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
backend/app/Services/Parsers/ParserInterface.php
Normal file
40
backend/app/Services/Parsers/ParserInterface.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Parsers;
|
||||||
|
|
||||||
|
interface ParserInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Parsea un archivo de balanza y retorna array de cuentas normalizadas
|
||||||
|
*
|
||||||
|
* @param string $filePath Ruta completa al archivo
|
||||||
|
* @return array<int, array{
|
||||||
|
* codigo: string,
|
||||||
|
* nombre: string,
|
||||||
|
* nivel: int,
|
||||||
|
* saldo_inicial_deudor: float,
|
||||||
|
* saldo_inicial_acreedor: float,
|
||||||
|
* cargos: float,
|
||||||
|
* abonos: float,
|
||||||
|
* saldo_final_deudor: float,
|
||||||
|
* saldo_final_acreedor: float,
|
||||||
|
* es_cuenta_padre: bool
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
public function parsear(string $filePath): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica si este parser puede manejar el archivo
|
||||||
|
*
|
||||||
|
* @param string $filePath Ruta completa al archivo
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function puedeManej(string $filePath): bool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retorna el identificador del sistema
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getSistema(): string;
|
||||||
|
}
|
||||||
26
backend/bootstrap/app.php
Normal file
26
backend/bootstrap/app.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Application;
|
||||||
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
|
|
||||||
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
|
->withRouting(
|
||||||
|
api: __DIR__.'/../routes/api.php',
|
||||||
|
commands: __DIR__.'/../routes/console.php',
|
||||||
|
health: '/up',
|
||||||
|
)
|
||||||
|
->withMiddleware(function (Middleware $middleware) {
|
||||||
|
$middleware->api(prepend: [
|
||||||
|
\Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$middleware->alias([
|
||||||
|
'role' => \App\Http\Middleware\RoleMiddleware::class,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$middleware->statefulApi();
|
||||||
|
})
|
||||||
|
->withExceptions(function (Exceptions $exceptions) {
|
||||||
|
//
|
||||||
|
})->create();
|
||||||
70
backend/composer.json
Normal file
70
backend/composer.json
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
{
|
||||||
|
"name": "horux360/strategy-platform",
|
||||||
|
"type": "project",
|
||||||
|
"description": "Plataforma de Reportes Financieros Horux Strategy",
|
||||||
|
"keywords": ["laravel", "financial", "reports", "horux"],
|
||||||
|
"license": "proprietary",
|
||||||
|
"require": {
|
||||||
|
"php": "^8.2",
|
||||||
|
"laravel/framework": "^11.0",
|
||||||
|
"laravel/sanctum": "^4.0",
|
||||||
|
"laravel/tinker": "^2.9",
|
||||||
|
"spatie/pdf-to-text": "^1.52",
|
||||||
|
"phpoffice/phpspreadsheet": "^2.0",
|
||||||
|
"spatie/browsershot": "^4.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"fakerphp/faker": "^1.23",
|
||||||
|
"laravel/pint": "^1.13",
|
||||||
|
"laravel/sail": "^1.26",
|
||||||
|
"mockery/mockery": "^1.6",
|
||||||
|
"nunomaduro/collision": "^8.0",
|
||||||
|
"phpunit/phpunit": "^11.0",
|
||||||
|
"spatie/laravel-ignition": "^2.4"
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"App\\": "app/",
|
||||||
|
"Database\\Factories\\": "database/factories/",
|
||||||
|
"Database\\Seeders\\": "database/seeders/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload-dev": {
|
||||||
|
"psr-4": {
|
||||||
|
"Tests\\": "tests/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"post-autoload-dump": [
|
||||||
|
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||||
|
"@php artisan package:discover --ansi"
|
||||||
|
],
|
||||||
|
"post-update-cmd": [
|
||||||
|
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
|
||||||
|
],
|
||||||
|
"post-root-package-install": [
|
||||||
|
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
|
||||||
|
],
|
||||||
|
"post-create-project-cmd": [
|
||||||
|
"@php artisan key:generate --ansi",
|
||||||
|
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
|
||||||
|
"@php artisan migrate --graceful --ansi"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"dont-discover": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"optimize-autoloader": true,
|
||||||
|
"preferred-install": "dist",
|
||||||
|
"sort-packages": true,
|
||||||
|
"allow-plugins": {
|
||||||
|
"pestphp/pest-plugin": true,
|
||||||
|
"php-http/discovery": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"minimum-stability": "stable",
|
||||||
|
"prefer-stable": true
|
||||||
|
}
|
||||||
26
backend/config/app.php
Normal file
26
backend/config/app.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
'name' => env('APP_NAME', 'Horux Strategy'),
|
||||||
|
'env' => env('APP_ENV', 'production'),
|
||||||
|
'debug' => (bool) env('APP_DEBUG', false),
|
||||||
|
'url' => env('APP_URL', 'http://localhost'),
|
||||||
|
'frontend_url' => env('FRONTEND_URL', 'http://localhost:5173'),
|
||||||
|
'timezone' => 'America/Mexico_City',
|
||||||
|
'locale' => 'es',
|
||||||
|
'fallback_locale' => 'en',
|
||||||
|
'faker_locale' => 'es_MX',
|
||||||
|
'cipher' => 'AES-256-CBC',
|
||||||
|
'key' => env('APP_KEY'),
|
||||||
|
'previous_keys' => [
|
||||||
|
...array_filter(
|
||||||
|
explode(',', env('APP_PREVIOUS_KEYS', ''))
|
||||||
|
),
|
||||||
|
],
|
||||||
|
'maintenance' => [
|
||||||
|
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
|
||||||
|
'store' => env('APP_MAINTENANCE_STORE', 'database'),
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
32
backend/config/cors.php
Normal file
32
backend/config/cors.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Cross-Origin Resource Sharing (CORS) Configuration
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
'paths' => ['api/*', 'sanctum/csrf-cookie'],
|
||||||
|
|
||||||
|
'allowed_methods' => ['*'],
|
||||||
|
|
||||||
|
'allowed_origins' => [
|
||||||
|
'http://localhost:5173',
|
||||||
|
'http://localhost:3000',
|
||||||
|
'http://127.0.0.1:5173',
|
||||||
|
'http://127.0.0.1:3000',
|
||||||
|
],
|
||||||
|
|
||||||
|
'allowed_origins_patterns' => [],
|
||||||
|
|
||||||
|
'allowed_headers' => ['*'],
|
||||||
|
|
||||||
|
'exposed_headers' => [],
|
||||||
|
|
||||||
|
'max_age' => 0,
|
||||||
|
|
||||||
|
'supports_credentials' => true,
|
||||||
|
|
||||||
|
];
|
||||||
59
backend/config/database.php
Normal file
59
backend/config/database.php
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
'default' => env('DB_CONNECTION', 'mysql'),
|
||||||
|
|
||||||
|
'connections' => [
|
||||||
|
|
||||||
|
'sqlite' => [
|
||||||
|
'driver' => 'sqlite',
|
||||||
|
'url' => env('DB_URL'),
|
||||||
|
'database' => env('DB_DATABASE', database_path('database.sqlite')),
|
||||||
|
'prefix' => '',
|
||||||
|
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
|
||||||
|
],
|
||||||
|
|
||||||
|
'mysql' => [
|
||||||
|
'driver' => 'mysql',
|
||||||
|
'url' => env('DB_URL'),
|
||||||
|
'host' => env('DB_HOST', '127.0.0.1'),
|
||||||
|
'port' => env('DB_PORT', '3306'),
|
||||||
|
'database' => env('DB_DATABASE', 'monthly_platform'),
|
||||||
|
'username' => env('DB_USERNAME', 'root'),
|
||||||
|
'password' => env('DB_PASSWORD', ''),
|
||||||
|
'unix_socket' => env('DB_SOCKET', ''),
|
||||||
|
'charset' => 'utf8mb4',
|
||||||
|
'collation' => 'utf8mb4_unicode_ci',
|
||||||
|
'prefix' => '',
|
||||||
|
'prefix_indexes' => true,
|
||||||
|
'strict' => true,
|
||||||
|
'engine' => null,
|
||||||
|
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||||
|
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
|
||||||
|
]) : [],
|
||||||
|
],
|
||||||
|
|
||||||
|
'pgsql' => [
|
||||||
|
'driver' => 'pgsql',
|
||||||
|
'url' => env('DB_URL'),
|
||||||
|
'host' => env('DB_HOST', '127.0.0.1'),
|
||||||
|
'port' => env('DB_PORT', '5432'),
|
||||||
|
'database' => env('DB_DATABASE', 'monthly_platform'),
|
||||||
|
'username' => env('DB_USERNAME', 'root'),
|
||||||
|
'password' => env('DB_PASSWORD', ''),
|
||||||
|
'charset' => 'utf8',
|
||||||
|
'prefix' => '',
|
||||||
|
'prefix_indexes' => true,
|
||||||
|
'search_path' => 'public',
|
||||||
|
'sslmode' => 'prefer',
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
'migrations' => [
|
||||||
|
'table' => 'migrations',
|
||||||
|
'update_date_on_publish' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
53
backend/config/sanctum.php
Normal file
53
backend/config/sanctum.php
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Stateful Domains
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
|
||||||
|
'%s%s',
|
||||||
|
'localhost,localhost:3000,localhost:5173,127.0.0.1,127.0.0.1:8000,127.0.0.1:5173,::1',
|
||||||
|
env('APP_URL') ? ','.parse_url(env('APP_URL'), PHP_URL_HOST) : ''
|
||||||
|
))),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Sanctum Guards
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
'guard' => ['web'],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Expiration Minutes
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
'expiration' => null,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Token Prefix
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Sanctum Middleware
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
'middleware' => [
|
||||||
|
'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class,
|
||||||
|
'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class,
|
||||||
|
'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class,
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?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('giros', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('nombre');
|
||||||
|
$table->boolean('activo')->default(true);
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('giros');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?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('clientes', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('nombre_empresa');
|
||||||
|
$table->string('logo')->nullable();
|
||||||
|
$table->foreignId('giro_id')->constrained('giros');
|
||||||
|
$table->string('moneda')->default('MXN');
|
||||||
|
$table->json('configuracion')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('clientes');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?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('users', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('nombre');
|
||||||
|
$table->string('email')->unique();
|
||||||
|
$table->timestamp('email_verified_at')->nullable();
|
||||||
|
$table->string('password');
|
||||||
|
$table->enum('role', ['admin', 'analista', 'cliente', 'empleado']);
|
||||||
|
$table->foreignId('cliente_id')->nullable()->constrained('clientes')->nullOnDelete();
|
||||||
|
$table->rememberToken();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('password_reset_tokens', function (Blueprint $table) {
|
||||||
|
$table->string('email')->primary();
|
||||||
|
$table->string('token');
|
||||||
|
$table->timestamp('created_at')->nullable();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('sessions', function (Blueprint $table) {
|
||||||
|
$table->string('id')->primary();
|
||||||
|
$table->foreignId('user_id')->nullable()->index();
|
||||||
|
$table->string('ip_address', 45)->nullable();
|
||||||
|
$table->text('user_agent')->nullable();
|
||||||
|
$table->longText('payload');
|
||||||
|
$table->integer('last_activity')->index();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('sessions');
|
||||||
|
Schema::dropIfExists('password_reset_tokens');
|
||||||
|
Schema::dropIfExists('users');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?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('permisos_empleado', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
|
||||||
|
$table->foreignId('cliente_id')->constrained('clientes')->cascadeOnDelete();
|
||||||
|
$table->json('permisos'); // {ver_dashboard, ver_estados, exportar_pdf, etc.}
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['user_id', 'cliente_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('permisos_empleado');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?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('reportes_contables', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('nombre'); // "Balance General", "Estado de Resultados"
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('reportes_contables');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?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('categorias_contables', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('reporte_contable_id')->constrained('reportes_contables')->cascadeOnDelete();
|
||||||
|
$table->string('nombre');
|
||||||
|
$table->integer('orden');
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('categorias_contables');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?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('umbrales', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('metrica'); // nombre de la métrica
|
||||||
|
$table->decimal('muy_positivo', 10, 4)->nullable();
|
||||||
|
$table->decimal('positivo', 10, 4)->nullable();
|
||||||
|
$table->decimal('neutral', 10, 4)->nullable();
|
||||||
|
$table->decimal('negativo', 10, 4)->nullable();
|
||||||
|
$table->decimal('muy_negativo', 10, 4)->nullable();
|
||||||
|
$table->foreignId('giro_id')->nullable()->constrained('giros')->nullOnDelete();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['metrica', 'giro_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('umbrales');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('balanzas', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('cliente_id')->constrained('clientes')->cascadeOnDelete();
|
||||||
|
$table->date('periodo_inicio');
|
||||||
|
$table->date('periodo_fin');
|
||||||
|
$table->string('sistema_origen'); // contpaqi, aspel, sap, etc.
|
||||||
|
$table->string('archivo_original');
|
||||||
|
$table->enum('status', ['pendiente', 'procesando', 'completado', 'error'])->default('pendiente');
|
||||||
|
$table->text('error_mensaje')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('balanzas');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?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('cuentas', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('balanza_id')->constrained('balanzas')->cascadeOnDelete();
|
||||||
|
$table->string('codigo'); // ej: 001-100-000
|
||||||
|
$table->string('nombre');
|
||||||
|
$table->integer('nivel'); // nivel jerárquico
|
||||||
|
$table->foreignId('reporte_contable_id')->nullable()->constrained('reportes_contables');
|
||||||
|
$table->foreignId('categoria_contable_id')->nullable()->constrained('categorias_contables');
|
||||||
|
$table->foreignId('cuenta_padre_id')->nullable()->constrained('cuentas')->nullOnDelete();
|
||||||
|
$table->decimal('saldo_inicial_deudor', 18, 2)->default(0);
|
||||||
|
$table->decimal('saldo_inicial_acreedor', 18, 2)->default(0);
|
||||||
|
$table->decimal('cargos', 18, 2)->default(0);
|
||||||
|
$table->decimal('abonos', 18, 2)->default(0);
|
||||||
|
$table->decimal('saldo_final_deudor', 18, 2)->default(0);
|
||||||
|
$table->decimal('saldo_final_acreedor', 18, 2)->default(0);
|
||||||
|
$table->boolean('excluida')->default(false);
|
||||||
|
$table->boolean('es_cuenta_padre')->default(false);
|
||||||
|
$table->boolean('requiere_revision')->default(false);
|
||||||
|
$table->text('nota_revision')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['balanza_id', 'codigo']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('cuentas');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?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('reglas_mapeo', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('sistema_origen'); // contpaqi, aspel, etc.
|
||||||
|
$table->string('cuenta_padre_codigo')->nullable(); // ej: 001-100-000
|
||||||
|
$table->string('rango_inicio')->nullable(); // ej: 101-000-000
|
||||||
|
$table->string('rango_fin')->nullable(); // ej: 154-999-999
|
||||||
|
$table->string('patron_regex')->nullable(); // para casos especiales
|
||||||
|
$table->foreignId('reporte_contable_id')->constrained('reportes_contables');
|
||||||
|
$table->foreignId('categoria_contable_id')->constrained('categorias_contables');
|
||||||
|
$table->integer('prioridad')->default(0);
|
||||||
|
$table->boolean('activo')->default(true);
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('reglas_mapeo');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?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('mapeo_cuentas', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('cliente_id')->constrained('clientes')->cascadeOnDelete();
|
||||||
|
$table->string('codigo_patron'); // patrón de código para matching
|
||||||
|
$table->foreignId('categoria_contable_id')->constrained('categorias_contables');
|
||||||
|
$table->text('notas')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['cliente_id', 'codigo_patron']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('mapeo_cuentas');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?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('reportes', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('cliente_id')->constrained('clientes')->cascadeOnDelete();
|
||||||
|
$table->string('nombre');
|
||||||
|
$table->enum('periodo_tipo', ['mensual', 'trimestral', 'anual']);
|
||||||
|
$table->date('periodo_inicio');
|
||||||
|
$table->date('periodo_fin');
|
||||||
|
$table->timestamp('fecha_generacion')->nullable();
|
||||||
|
$table->json('data_calculada')->nullable(); // métricas, estados financieros, etc.
|
||||||
|
$table->string('pdf_path')->nullable();
|
||||||
|
$table->enum('status', ['pendiente', 'procesando', 'completado', 'error'])->default('pendiente');
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tabla pivote para relacionar reportes con balanzas
|
||||||
|
Schema::create('reporte_balanza', function (Blueprint $table) {
|
||||||
|
$table->foreignId('reporte_id')->constrained('reportes')->cascadeOnDelete();
|
||||||
|
$table->foreignId('balanza_id')->constrained('balanzas')->cascadeOnDelete();
|
||||||
|
$table->primary(['reporte_id', 'balanza_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('reporte_balanza');
|
||||||
|
Schema::dropIfExists('reportes');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?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('personal_access_tokens', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->morphs('tokenable');
|
||||||
|
$table->string('name');
|
||||||
|
$table->string('token', 64)->unique();
|
||||||
|
$table->text('abilities')->nullable();
|
||||||
|
$table->timestamp('last_used_at')->nullable();
|
||||||
|
$table->timestamp('expires_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('personal_access_tokens');
|
||||||
|
}
|
||||||
|
};
|
||||||
21
backend/database/seeders/AdminUserSeeder.php
Normal file
21
backend/database/seeders/AdminUserSeeder.php
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
|
||||||
|
class AdminUserSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
User::create([
|
||||||
|
'nombre' => 'Administrador',
|
||||||
|
'email' => 'admin@horux360.com',
|
||||||
|
'password' => Hash::make('password'),
|
||||||
|
'role' => 'admin',
|
||||||
|
'cliente_id' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
backend/database/seeders/DatabaseSeeder.php
Normal file
19
backend/database/seeders/DatabaseSeeder.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
class DatabaseSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$this->call([
|
||||||
|
GirosSeeder::class,
|
||||||
|
ReportesContablesSeeder::class,
|
||||||
|
ReglasMapeeoContpaqiSeeder::class,
|
||||||
|
UmbralesSeeder::class,
|
||||||
|
AdminUserSeeder::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
backend/database/seeders/GirosSeeder.php
Normal file
43
backend/database/seeders/GirosSeeder.php
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\Giro;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
class GirosSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$giros = [
|
||||||
|
'Agricultura y Ganadería',
|
||||||
|
'Alimentos y Bebidas',
|
||||||
|
'Automotriz',
|
||||||
|
'Comercio al por Mayor',
|
||||||
|
'Comercio al por Menor',
|
||||||
|
'Construcción',
|
||||||
|
'Educación',
|
||||||
|
'Energía',
|
||||||
|
'Farmacéutica',
|
||||||
|
'Financiero',
|
||||||
|
'Hotelería',
|
||||||
|
'Inmobiliario',
|
||||||
|
'Logística y Transporte',
|
||||||
|
'Manufactura',
|
||||||
|
'Medios y Entretenimiento',
|
||||||
|
'Minería',
|
||||||
|
'Restaurantes',
|
||||||
|
'Salud',
|
||||||
|
'Servicios Profesionales',
|
||||||
|
'Tecnología',
|
||||||
|
'Telecomunicaciones',
|
||||||
|
'Textil y Moda',
|
||||||
|
'Turismo',
|
||||||
|
'Otro',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($giros as $nombre) {
|
||||||
|
Giro::create(['nombre' => $nombre, 'activo' => true]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
142
backend/database/seeders/ReglasMapeeoContpaqiSeeder.php
Normal file
142
backend/database/seeders/ReglasMapeeoContpaqiSeeder.php
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\ReglaMapeo;
|
||||||
|
use App\Models\ReporteContable;
|
||||||
|
use App\Models\CategoriaContable;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
class ReglasMapeeoContpaqiSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$balanceGeneral = ReporteContable::where('nombre', 'Balance General')->first();
|
||||||
|
$estadoResultados = ReporteContable::where('nombre', 'Estado de Resultados')->first();
|
||||||
|
|
||||||
|
// Obtener categorías
|
||||||
|
$activosCirculantes = CategoriaContable::where('nombre', 'Activos Circulantes')->first();
|
||||||
|
$activosNoCirculantes = CategoriaContable::where('nombre', 'Activos No Circulantes')->first();
|
||||||
|
$pasivoCirculante = CategoriaContable::where('nombre', 'Pasivo Circulante')->first();
|
||||||
|
$pasivoNoCirculante = CategoriaContable::where('nombre', 'Pasivo No Circulante')->first();
|
||||||
|
$capitalSocial = CategoriaContable::where('nombre', 'Capital Social')->first();
|
||||||
|
$perdidasAnteriores = CategoriaContable::where('nombre', 'Pérdidas Ejercicios Anteriores')->first();
|
||||||
|
$utilidadesAnteriores = CategoriaContable::where('nombre', 'Utilidades Ejercicios Anteriores')->first();
|
||||||
|
$ingresos = CategoriaContable::where('nombre', 'Ingresos')->first();
|
||||||
|
$costoVenta = CategoriaContable::where('nombre', 'Costo de Venta')->first();
|
||||||
|
$gastosOperativos = CategoriaContable::where('nombre', 'Gastos Operativos')->first();
|
||||||
|
$otrosGastos = CategoriaContable::where('nombre', 'Otros Gastos')->first();
|
||||||
|
$gastosFinancieros = CategoriaContable::where('nombre', 'Gastos Financieros')->first();
|
||||||
|
|
||||||
|
$reglas = [
|
||||||
|
// Activos Circulantes: 001-100-000, hijos 101-000-000 a 154-999-999
|
||||||
|
[
|
||||||
|
'sistema_origen' => 'contpaqi',
|
||||||
|
'cuenta_padre_codigo' => '001-100-000',
|
||||||
|
'rango_inicio' => '101-000-000',
|
||||||
|
'rango_fin' => '154-999-999',
|
||||||
|
'reporte_contable_id' => $balanceGeneral->id,
|
||||||
|
'categoria_contable_id' => $activosCirculantes->id,
|
||||||
|
'prioridad' => 10,
|
||||||
|
],
|
||||||
|
// Activos No Circulantes: 001-200-000, hijos 155-000-000 a 199-999-999
|
||||||
|
[
|
||||||
|
'sistema_origen' => 'contpaqi',
|
||||||
|
'cuenta_padre_codigo' => '001-200-000',
|
||||||
|
'rango_inicio' => '155-000-000',
|
||||||
|
'rango_fin' => '199-999-999',
|
||||||
|
'reporte_contable_id' => $balanceGeneral->id,
|
||||||
|
'categoria_contable_id' => $activosNoCirculantes->id,
|
||||||
|
'prioridad' => 10,
|
||||||
|
],
|
||||||
|
// Pasivo Circulante: 002-100-000, hijos 200-000-000 a 209-999-999
|
||||||
|
[
|
||||||
|
'sistema_origen' => 'contpaqi',
|
||||||
|
'cuenta_padre_codigo' => '002-100-000',
|
||||||
|
'rango_inicio' => '200-000-000',
|
||||||
|
'rango_fin' => '209-999-999',
|
||||||
|
'reporte_contable_id' => $balanceGeneral->id,
|
||||||
|
'categoria_contable_id' => $pasivoCirculante->id,
|
||||||
|
'prioridad' => 10,
|
||||||
|
],
|
||||||
|
// Pasivo No Circulante: 002-200-000, hijos 210-000-000 a 220-999-999
|
||||||
|
[
|
||||||
|
'sistema_origen' => 'contpaqi',
|
||||||
|
'cuenta_padre_codigo' => '002-200-000',
|
||||||
|
'rango_inicio' => '210-000-000',
|
||||||
|
'rango_fin' => '220-999-999',
|
||||||
|
'reporte_contable_id' => $balanceGeneral->id,
|
||||||
|
'categoria_contable_id' => $pasivoNoCirculante->id,
|
||||||
|
'prioridad' => 10,
|
||||||
|
],
|
||||||
|
// Capital Social: 300-XXX-XXX
|
||||||
|
[
|
||||||
|
'sistema_origen' => 'contpaqi',
|
||||||
|
'patron_regex' => '/^30[0-9]-/',
|
||||||
|
'reporte_contable_id' => $balanceGeneral->id,
|
||||||
|
'categoria_contable_id' => $capitalSocial->id,
|
||||||
|
'prioridad' => 10,
|
||||||
|
],
|
||||||
|
// Pérdidas Ejercicios Anteriores: 310-XXX-XXX
|
||||||
|
[
|
||||||
|
'sistema_origen' => 'contpaqi',
|
||||||
|
'patron_regex' => '/^310-/',
|
||||||
|
'reporte_contable_id' => $balanceGeneral->id,
|
||||||
|
'categoria_contable_id' => $perdidasAnteriores->id,
|
||||||
|
'prioridad' => 15,
|
||||||
|
],
|
||||||
|
// Utilidades Ejercicios Anteriores: 311-XXX-XXX
|
||||||
|
[
|
||||||
|
'sistema_origen' => 'contpaqi',
|
||||||
|
'patron_regex' => '/^311-/',
|
||||||
|
'reporte_contable_id' => $balanceGeneral->id,
|
||||||
|
'categoria_contable_id' => $utilidadesAnteriores->id,
|
||||||
|
'prioridad' => 15,
|
||||||
|
],
|
||||||
|
// Ingresos: 40X-XXX-XXX
|
||||||
|
[
|
||||||
|
'sistema_origen' => 'contpaqi',
|
||||||
|
'patron_regex' => '/^40[0-9]-/',
|
||||||
|
'reporte_contable_id' => $estadoResultados->id,
|
||||||
|
'categoria_contable_id' => $ingresos->id,
|
||||||
|
'prioridad' => 10,
|
||||||
|
],
|
||||||
|
// Costo de Venta: podría ser 410-XXX o 50X en algunos casos
|
||||||
|
[
|
||||||
|
'sistema_origen' => 'contpaqi',
|
||||||
|
'patron_regex' => '/^41[0-9]-/',
|
||||||
|
'reporte_contable_id' => $estadoResultados->id,
|
||||||
|
'categoria_contable_id' => $costoVenta->id,
|
||||||
|
'prioridad' => 10,
|
||||||
|
],
|
||||||
|
// Gastos Operativos: 5XX-XXX-XXX
|
||||||
|
[
|
||||||
|
'sistema_origen' => 'contpaqi',
|
||||||
|
'patron_regex' => '/^5[0-9]{2}-/',
|
||||||
|
'reporte_contable_id' => $estadoResultados->id,
|
||||||
|
'categoria_contable_id' => $gastosOperativos->id,
|
||||||
|
'prioridad' => 10,
|
||||||
|
],
|
||||||
|
// Otros Gastos: 6XX-XXX-XXX
|
||||||
|
[
|
||||||
|
'sistema_origen' => 'contpaqi',
|
||||||
|
'patron_regex' => '/^6[0-9]{2}-/',
|
||||||
|
'reporte_contable_id' => $estadoResultados->id,
|
||||||
|
'categoria_contable_id' => $otrosGastos->id,
|
||||||
|
'prioridad' => 10,
|
||||||
|
],
|
||||||
|
// Gastos Financieros: 7XX-XXX-XXX
|
||||||
|
[
|
||||||
|
'sistema_origen' => 'contpaqi',
|
||||||
|
'patron_regex' => '/^7[0-9]{2}-/',
|
||||||
|
'reporte_contable_id' => $estadoResultados->id,
|
||||||
|
'categoria_contable_id' => $gastosFinancieros->id,
|
||||||
|
'prioridad' => 10,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($reglas as $regla) {
|
||||||
|
ReglaMapeo::create(array_merge($regla, ['activo' => true]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
backend/database/seeders/ReportesContablesSeeder.php
Normal file
53
backend/database/seeders/ReportesContablesSeeder.php
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\ReporteContable;
|
||||||
|
use App\Models\CategoriaContable;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
class ReportesContablesSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
// Balance General
|
||||||
|
$balanceGeneral = ReporteContable::create(['nombre' => 'Balance General']);
|
||||||
|
|
||||||
|
$categoriasBalance = [
|
||||||
|
['nombre' => 'Activos Circulantes', 'orden' => 1],
|
||||||
|
['nombre' => 'Activos No Circulantes', 'orden' => 2],
|
||||||
|
['nombre' => 'Pasivo Circulante', 'orden' => 3],
|
||||||
|
['nombre' => 'Pasivo No Circulante', 'orden' => 4],
|
||||||
|
['nombre' => 'Capital Social', 'orden' => 5],
|
||||||
|
['nombre' => 'Pérdidas Ejercicios Anteriores', 'orden' => 6],
|
||||||
|
['nombre' => 'Utilidades Ejercicios Anteriores', 'orden' => 7],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($categoriasBalance as $categoria) {
|
||||||
|
CategoriaContable::create([
|
||||||
|
'reporte_contable_id' => $balanceGeneral->id,
|
||||||
|
'nombre' => $categoria['nombre'],
|
||||||
|
'orden' => $categoria['orden'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estado de Resultados
|
||||||
|
$estadoResultados = ReporteContable::create(['nombre' => 'Estado de Resultados']);
|
||||||
|
|
||||||
|
$categoriasResultados = [
|
||||||
|
['nombre' => 'Ingresos', 'orden' => 1],
|
||||||
|
['nombre' => 'Costo de Venta', 'orden' => 2],
|
||||||
|
['nombre' => 'Gastos Operativos', 'orden' => 3],
|
||||||
|
['nombre' => 'Otros Gastos', 'orden' => 4],
|
||||||
|
['nombre' => 'Gastos Financieros', 'orden' => 5],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($categoriasResultados as $categoria) {
|
||||||
|
CategoriaContable::create([
|
||||||
|
'reporte_contable_id' => $estadoResultados->id,
|
||||||
|
'nombre' => $categoria['nombre'],
|
||||||
|
'orden' => $categoria['orden'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
55
backend/database/seeders/UmbralesSeeder.php
Normal file
55
backend/database/seeders/UmbralesSeeder.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\Umbral;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
class UmbralesSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
// Umbrales por defecto (sin giro específico)
|
||||||
|
$umbrales = [
|
||||||
|
// Márgenes (valores en decimales, ej: 0.20 = 20%)
|
||||||
|
['metrica' => 'margen_bruto', 'muy_positivo' => 0.50, 'positivo' => 0.35, 'neutral' => 0.20, 'negativo' => 0.10, 'muy_negativo' => 0],
|
||||||
|
['metrica' => 'margen_ebitda', 'muy_positivo' => 0.30, 'positivo' => 0.20, 'neutral' => 0.10, 'negativo' => 0.05, 'muy_negativo' => 0],
|
||||||
|
['metrica' => 'margen_operativo', 'muy_positivo' => 0.25, 'positivo' => 0.15, 'neutral' => 0.08, 'negativo' => 0.03, 'muy_negativo' => 0],
|
||||||
|
['metrica' => 'margen_neto', 'muy_positivo' => 0.20, 'positivo' => 0.10, 'neutral' => 0.05, 'negativo' => 0.02, 'muy_negativo' => 0],
|
||||||
|
['metrica' => 'margen_nopat', 'muy_positivo' => 0.18, 'positivo' => 0.10, 'neutral' => 0.05, 'negativo' => 0.02, 'muy_negativo' => 0],
|
||||||
|
['metrica' => 'margen_ocf', 'muy_positivo' => 0.25, 'positivo' => 0.15, 'neutral' => 0.08, 'negativo' => 0.03, 'muy_negativo' => 0],
|
||||||
|
['metrica' => 'margen_fcf', 'muy_positivo' => 0.20, 'positivo' => 0.10, 'neutral' => 0.05, 'negativo' => 0, 'muy_negativo' => -0.10],
|
||||||
|
|
||||||
|
// Retorno
|
||||||
|
['metrica' => 'roic', 'muy_positivo' => 0.20, 'positivo' => 0.12, 'neutral' => 0.08, 'negativo' => 0.04, 'muy_negativo' => 0],
|
||||||
|
['metrica' => 'roe', 'muy_positivo' => 0.25, 'positivo' => 0.15, 'neutral' => 0.10, 'negativo' => 0.05, 'muy_negativo' => 0],
|
||||||
|
['metrica' => 'roa', 'muy_positivo' => 0.15, 'positivo' => 0.08, 'neutral' => 0.05, 'negativo' => 0.02, 'muy_negativo' => 0],
|
||||||
|
['metrica' => 'roce', 'muy_positivo' => 0.20, 'positivo' => 0.12, 'neutral' => 0.08, 'negativo' => 0.04, 'muy_negativo' => 0],
|
||||||
|
|
||||||
|
// Eficiencia (días)
|
||||||
|
['metrica' => 'dias_clientes', 'muy_positivo' => 30, 'positivo' => 45, 'neutral' => 60, 'negativo' => 90, 'muy_negativo' => 120],
|
||||||
|
['metrica' => 'dias_proveedores', 'muy_positivo' => 60, 'positivo' => 45, 'neutral' => 30, 'negativo' => 20, 'muy_negativo' => 15],
|
||||||
|
['metrica' => 'dias_inventario', 'muy_positivo' => 30, 'positivo' => 45, 'neutral' => 60, 'negativo' => 90, 'muy_negativo' => 120],
|
||||||
|
['metrica' => 'ciclo_conversion', 'muy_positivo' => 30, 'positivo' => 45, 'neutral' => 60, 'negativo' => 90, 'muy_negativo' => 120],
|
||||||
|
|
||||||
|
// Liquidez (ratios)
|
||||||
|
['metrica' => 'current_ratio', 'muy_positivo' => 2.5, 'positivo' => 2.0, 'neutral' => 1.5, 'negativo' => 1.0, 'muy_negativo' => 0.8],
|
||||||
|
['metrica' => 'quick_ratio', 'muy_positivo' => 1.5, 'positivo' => 1.2, 'neutral' => 1.0, 'negativo' => 0.8, 'muy_negativo' => 0.5],
|
||||||
|
['metrica' => 'cash_ratio', 'muy_positivo' => 0.5, 'positivo' => 0.3, 'neutral' => 0.2, 'negativo' => 0.1, 'muy_negativo' => 0.05],
|
||||||
|
|
||||||
|
// Solvencia
|
||||||
|
['metrica' => 'net_debt_ebitda', 'muy_positivo' => 1.0, 'positivo' => 2.0, 'neutral' => 3.0, 'negativo' => 4.0, 'muy_negativo' => 5.0],
|
||||||
|
['metrica' => 'interest_coverage', 'muy_positivo' => 10.0, 'positivo' => 5.0, 'neutral' => 3.0, 'negativo' => 2.0, 'muy_negativo' => 1.0],
|
||||||
|
['metrica' => 'debt_ratio', 'muy_positivo' => 0.30, 'positivo' => 0.40, 'neutral' => 0.50, 'negativo' => 0.60, 'muy_negativo' => 0.70],
|
||||||
|
|
||||||
|
// Gestión
|
||||||
|
['metrica' => 'revenue_growth', 'muy_positivo' => 0.20, 'positivo' => 0.10, 'neutral' => 0.05, 'negativo' => 0, 'muy_negativo' => -0.05],
|
||||||
|
['metrica' => 'capex_revenue', 'muy_positivo' => 0.05, 'positivo' => 0.08, 'neutral' => 0.12, 'negativo' => 0.15, 'muy_negativo' => 0.20],
|
||||||
|
['metrica' => 'effective_tax_rate', 'muy_positivo' => 0.25, 'positivo' => 0.28, 'neutral' => 0.30, 'negativo' => 0.33, 'muy_negativo' => 0.35],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($umbrales as $umbral) {
|
||||||
|
Umbral::create(array_merge($umbral, ['giro_id' => null]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
73
backend/routes/api.php
Normal file
73
backend/routes/api.php
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use App\Http\Controllers\AuthController;
|
||||||
|
use App\Http\Controllers\ClienteController;
|
||||||
|
use App\Http\Controllers\BalanzaController;
|
||||||
|
use App\Http\Controllers\CuentaController;
|
||||||
|
use App\Http\Controllers\ReporteController;
|
||||||
|
use App\Http\Controllers\Admin\UsuarioController;
|
||||||
|
use App\Http\Controllers\Admin\UmbralController;
|
||||||
|
use App\Http\Controllers\Admin\ReglaMapeeoController;
|
||||||
|
use App\Http\Controllers\Admin\GiroController;
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| API Routes
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Rutas públicas
|
||||||
|
Route::post('/login', [AuthController::class, 'login']);
|
||||||
|
|
||||||
|
// Rutas protegidas
|
||||||
|
Route::middleware('auth:sanctum')->group(function () {
|
||||||
|
|
||||||
|
// Autenticación
|
||||||
|
Route::post('/logout', [AuthController::class, 'logout']);
|
||||||
|
Route::get('/user', [AuthController::class, 'user']);
|
||||||
|
|
||||||
|
// Giros (público para selects)
|
||||||
|
Route::get('/giros', [GiroController::class, 'activos']);
|
||||||
|
|
||||||
|
// Clientes
|
||||||
|
Route::apiResource('clientes', ClienteController::class);
|
||||||
|
|
||||||
|
// Balanzas (anidadas bajo clientes)
|
||||||
|
Route::get('/clientes/{cliente}/balanzas', [BalanzaController::class, 'index']);
|
||||||
|
Route::post('/clientes/{cliente}/balanzas', [BalanzaController::class, 'store']);
|
||||||
|
Route::get('/balanzas/{balanza}', [BalanzaController::class, 'show']);
|
||||||
|
Route::get('/balanzas/{balanza}/cuentas', [BalanzaController::class, 'cuentas']);
|
||||||
|
Route::put('/balanzas/{balanza}/exclusiones', [BalanzaController::class, 'updateExclusiones']);
|
||||||
|
|
||||||
|
// Cuentas
|
||||||
|
Route::put('/cuentas/{cuenta}/clasificacion', [CuentaController::class, 'updateClasificacion']);
|
||||||
|
Route::post('/cuentas/{cuenta}/toggle-exclusion', [CuentaController::class, 'toggleExclusion']);
|
||||||
|
Route::get('/anomalias', [CuentaController::class, 'anomalias']);
|
||||||
|
|
||||||
|
// Reportes
|
||||||
|
Route::get('/clientes/{cliente}/reportes', [ReporteController::class, 'index']);
|
||||||
|
Route::post('/clientes/{cliente}/reportes', [ReporteController::class, 'store']);
|
||||||
|
Route::get('/reportes/{reporte}', [ReporteController::class, 'show']);
|
||||||
|
Route::get('/reportes/{reporte}/pdf', [ReporteController::class, 'pdf']);
|
||||||
|
Route::delete('/reportes/{reporte}', [ReporteController::class, 'destroy']);
|
||||||
|
|
||||||
|
// Rutas de administración (solo admin)
|
||||||
|
Route::middleware('role:admin')->prefix('admin')->group(function () {
|
||||||
|
// Usuarios
|
||||||
|
Route::apiResource('usuarios', UsuarioController::class);
|
||||||
|
|
||||||
|
// Giros (CRUD completo)
|
||||||
|
Route::apiResource('giros', GiroController::class);
|
||||||
|
|
||||||
|
// Umbrales
|
||||||
|
Route::apiResource('umbrales', UmbralController::class);
|
||||||
|
Route::get('/umbrales/metrica/{metrica}/{giro_id?}', [UmbralController::class, 'porMetrica']);
|
||||||
|
|
||||||
|
// Reglas de mapeo
|
||||||
|
Route::apiResource('reglas-mapeo', ReglaMapeeoController::class);
|
||||||
|
|
||||||
|
// Registrar usuarios (solo admin)
|
||||||
|
Route::post('/register', [AuthController::class, 'register']);
|
||||||
|
});
|
||||||
|
});
|
||||||
86
docs/01-descripcion-general.md
Normal file
86
docs/01-descripcion-general.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# 1. Descripción General
|
||||||
|
|
||||||
|
## ¿Qué es Horux Strategy?
|
||||||
|
|
||||||
|
Horux Strategy es una plataforma web para generar reportes financieros automatizados a partir de balanzas de comprobación. Permite a empresas subir sus datos contables de múltiples sistemas (CONTPAQi, Aspel, SAP, Odoo, etc.) y obtener:
|
||||||
|
|
||||||
|
- Dashboard interactivo con métricas financieras
|
||||||
|
- Exportación a PDF profesional (32 páginas)
|
||||||
|
- Clasificación automática de cuentas contables
|
||||||
|
- Comparativos entre periodos
|
||||||
|
- Semáforos de rendimiento (indicadores de salud financiera)
|
||||||
|
|
||||||
|
## Características Principales
|
||||||
|
|
||||||
|
### Para Analistas
|
||||||
|
- Gestión de múltiples clientes
|
||||||
|
- Subida y procesamiento de balanzas de comprobación
|
||||||
|
- Revisión y corrección de anomalías contables
|
||||||
|
- Generación de reportes financieros
|
||||||
|
- Exportación a PDF
|
||||||
|
|
||||||
|
### Para Clientes
|
||||||
|
- Dashboard personalizado de su empresa
|
||||||
|
- Descarga de reportes en PDF
|
||||||
|
- Gestión de empleados y permisos
|
||||||
|
|
||||||
|
### Para Administradores
|
||||||
|
- Gestión completa de usuarios
|
||||||
|
- Configuración de umbrales por industria
|
||||||
|
- Reglas de mapeo contable por sistema
|
||||||
|
- Catálogo de giros de negocio
|
||||||
|
|
||||||
|
## Stack Tecnológico
|
||||||
|
|
||||||
|
| Componente | Tecnología |
|
||||||
|
|------------|------------|
|
||||||
|
| Backend | Laravel 11 (PHP 8.2+) |
|
||||||
|
| Frontend | React 18 + TypeScript |
|
||||||
|
| Build Tool | Vite |
|
||||||
|
| Base de Datos | MySQL / PostgreSQL |
|
||||||
|
| Autenticación | Laravel Sanctum (tokens) |
|
||||||
|
| Gráficas | Recharts |
|
||||||
|
| Estilos | Tailwind CSS |
|
||||||
|
| PDF | Browsershot (Puppeteer) |
|
||||||
|
| Parsing PDF | spatie/pdf-to-text |
|
||||||
|
| Excel | PhpSpreadsheet |
|
||||||
|
|
||||||
|
## Flujo de Trabajo
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Crear Cliente
|
||||||
|
└── Nombre, logo, giro, moneda
|
||||||
|
|
||||||
|
2. Subir Balanzas (2+ archivos)
|
||||||
|
└── PDF, Excel o CSV de diferentes periodos
|
||||||
|
|
||||||
|
3. Detección Automática
|
||||||
|
└── Sistema identifica origen (CONTPAQi, Aspel, etc.)
|
||||||
|
|
||||||
|
4. Clasificación Automática
|
||||||
|
└── Mapeo a categorías contables
|
||||||
|
|
||||||
|
5. Revisión de Anomalías
|
||||||
|
└── Corregir cuentas mal clasificadas
|
||||||
|
|
||||||
|
6. Limpieza de Cuentas
|
||||||
|
└── Excluir cuentas de estrategia fiscal
|
||||||
|
|
||||||
|
7. Generación de Reporte
|
||||||
|
└── Cálculo de 27+ métricas
|
||||||
|
|
||||||
|
8. Dashboard Interactivo
|
||||||
|
└── Visualización de resultados
|
||||||
|
|
||||||
|
9. Exportar PDF
|
||||||
|
└── Reporte profesional de 32 páginas
|
||||||
|
```
|
||||||
|
|
||||||
|
## Roles de Usuario
|
||||||
|
|
||||||
|
| Rol | Descripción | Permisos |
|
||||||
|
|-----|-------------|----------|
|
||||||
|
| **Admin** | Superusuario del sistema | Acceso total, configuración global, gestión de umbrales |
|
||||||
|
| **Analista** | Operador de la plataforma | Gestionar clientes asignados, procesar balanzas, generar reportes |
|
||||||
|
| **Cliente** | Usuario empresarial | Ver dashboard propio, descargar PDFs, gestionar empleados |
|
||||||
|
| **Empleado** | Colaborador del cliente | Permisos configurables por el cliente |
|
||||||
169
docs/02-arquitectura.md
Normal file
169
docs/02-arquitectura.md
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
# 2. Arquitectura del Sistema
|
||||||
|
|
||||||
|
## Diagrama de Componentes
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ FRONTEND (React) │
|
||||||
|
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||||
|
│ │ Login │ │Clientes │ │Dashboard│ │ Admin │ │ PdfView │ │
|
||||||
|
│ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │
|
||||||
|
│ └───────────┴───────────┴───────────┴───────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌─────────┴─────────┐ │
|
||||||
|
│ │ API Service │ │
|
||||||
|
│ │ (Axios) │ │
|
||||||
|
│ └─────────┬─────────┘ │
|
||||||
|
└──────────────────────────────┼──────────────────────────────────┘
|
||||||
|
│ HTTP/REST
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────┴──────────────────────────────────┐
|
||||||
|
│ BACKEND (Laravel) │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Controllers │ │
|
||||||
|
│ │ Auth │ Cliente │ Balanza │ Cuenta │ Reporte │ Admin │ │
|
||||||
|
│ └───────────────────────────┬─────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌───────────────────────────┴─────────────────────────────┐ │
|
||||||
|
│ │ Services │ │
|
||||||
|
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │
|
||||||
|
│ │ │ Parsers │ │ Clasificador │ │ Calculador │ │ │
|
||||||
|
│ │ │ - CONTPAQi │ │ Cuentas │ │ Métricas │ │ │
|
||||||
|
│ │ │ - Genérico │ │ │ │ │ │ │
|
||||||
|
│ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │
|
||||||
|
│ │ ┌──────────────┐ │ │
|
||||||
|
│ │ │ Generador │ │ │
|
||||||
|
│ │ │ PDF │ │ │
|
||||||
|
│ │ └──────────────┘ │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌───────────────────────────┴─────────────────────────────┐ │
|
||||||
|
│ │ Models │ │
|
||||||
|
│ │ User │ Cliente │ Balanza │ Cuenta │ Reporte │ etc. │ │
|
||||||
|
│ └───────────────────────────┬─────────────────────────────┘ │
|
||||||
|
└──────────────────────────────┼──────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────┐
|
||||||
|
│ Database │
|
||||||
|
│ MySQL/PostgreSQL│
|
||||||
|
└──────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Estructura de Directorios
|
||||||
|
|
||||||
|
```
|
||||||
|
horux-strategy-platform/
|
||||||
|
├── backend/ # Laravel 11 API
|
||||||
|
│ ├── app/
|
||||||
|
│ │ ├── Http/
|
||||||
|
│ │ │ ├── Controllers/
|
||||||
|
│ │ │ │ ├── AuthController.php
|
||||||
|
│ │ │ │ ├── ClienteController.php
|
||||||
|
│ │ │ │ ├── BalanzaController.php
|
||||||
|
│ │ │ │ ├── CuentaController.php
|
||||||
|
│ │ │ │ ├── ReporteController.php
|
||||||
|
│ │ │ │ └── Admin/
|
||||||
|
│ │ │ │ ├── UsuarioController.php
|
||||||
|
│ │ │ │ ├── GiroController.php
|
||||||
|
│ │ │ │ ├── UmbralController.php
|
||||||
|
│ │ │ │ └── ReglaMapeeoController.php
|
||||||
|
│ │ │ └── Middleware/
|
||||||
|
│ │ │ └── RoleMiddleware.php
|
||||||
|
│ │ ├── Models/
|
||||||
|
│ │ │ ├── User.php
|
||||||
|
│ │ │ ├── Cliente.php
|
||||||
|
│ │ │ ├── Giro.php
|
||||||
|
│ │ │ ├── Balanza.php
|
||||||
|
│ │ │ ├── Cuenta.php
|
||||||
|
│ │ │ ├── Reporte.php
|
||||||
|
│ │ │ ├── ReporteContable.php
|
||||||
|
│ │ │ ├── CategoriaContable.php
|
||||||
|
│ │ │ ├── Umbral.php
|
||||||
|
│ │ │ ├── ReglaMapeo.php
|
||||||
|
│ │ │ ├── MapeoCuenta.php
|
||||||
|
│ │ │ └── PermisoEmpleado.php
|
||||||
|
│ │ └── Services/
|
||||||
|
│ │ ├── Parsers/
|
||||||
|
│ │ │ ├── ParserInterface.php
|
||||||
|
│ │ │ ├── DetectorFormato.php
|
||||||
|
│ │ │ ├── ContpaqiParser.php
|
||||||
|
│ │ │ └── GenericoParser.php
|
||||||
|
│ │ ├── ClasificadorCuentas.php
|
||||||
|
│ │ ├── CalculadorMetricas.php
|
||||||
|
│ │ └── GeneradorPdf.php
|
||||||
|
│ ├── database/
|
||||||
|
│ │ ├── migrations/
|
||||||
|
│ │ └── seeders/
|
||||||
|
│ ├── routes/
|
||||||
|
│ │ └── api.php
|
||||||
|
│ └── config/
|
||||||
|
│
|
||||||
|
├── frontend/ # React + TypeScript
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── components/
|
||||||
|
│ │ │ ├── charts/
|
||||||
|
│ │ │ │ ├── BarChart.tsx
|
||||||
|
│ │ │ │ └── LineChart.tsx
|
||||||
|
│ │ │ ├── cards/
|
||||||
|
│ │ │ │ ├── KPICard.tsx
|
||||||
|
│ │ │ │ └── MetricTable.tsx
|
||||||
|
│ │ │ ├── forms/
|
||||||
|
│ │ │ │ ├── ClienteForm.tsx
|
||||||
|
│ │ │ │ ├── UploadBalanza.tsx
|
||||||
|
│ │ │ │ └── GenerarReporte.tsx
|
||||||
|
│ │ │ └── layout/
|
||||||
|
│ │ │ ├── Layout.tsx
|
||||||
|
│ │ │ ├── Sidebar.tsx
|
||||||
|
│ │ │ └── Header.tsx
|
||||||
|
│ │ ├── pages/
|
||||||
|
│ │ │ ├── Login.tsx
|
||||||
|
│ │ │ ├── Clientes/
|
||||||
|
│ │ │ ├── Dashboard/
|
||||||
|
│ │ │ ├── PdfView/
|
||||||
|
│ │ │ └── Admin/
|
||||||
|
│ │ ├── context/
|
||||||
|
│ │ │ └── AuthContext.tsx
|
||||||
|
│ │ ├── services/
|
||||||
|
│ │ │ └── api.ts
|
||||||
|
│ │ ├── types/
|
||||||
|
│ │ │ └── index.ts
|
||||||
|
│ │ └── hooks/
|
||||||
|
│ └── public/
|
||||||
|
│
|
||||||
|
└── docs/ # Documentación
|
||||||
|
└── plans/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Flujo de Datos
|
||||||
|
|
||||||
|
### Subida de Balanza
|
||||||
|
```
|
||||||
|
1. Usuario sube archivo (PDF/Excel/CSV)
|
||||||
|
2. DetectorFormato identifica sistema origen
|
||||||
|
3. Parser específico extrae datos
|
||||||
|
4. ClasificadorCuentas aplica reglas de mapeo
|
||||||
|
5. Cuentas se guardan en BD con clasificación
|
||||||
|
6. Cuentas con anomalías se marcan para revisión
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generación de Reporte
|
||||||
|
```
|
||||||
|
1. Usuario selecciona balanzas a incluir
|
||||||
|
2. CalculadorMetricas procesa datos
|
||||||
|
3. Se calculan estados financieros
|
||||||
|
4. Se calculan 27+ métricas
|
||||||
|
5. Se evalúan umbrales para semáforos
|
||||||
|
6. Se guardan resultados en JSON
|
||||||
|
7. Dashboard muestra resultados
|
||||||
|
```
|
||||||
|
|
||||||
|
### Exportación PDF
|
||||||
|
```
|
||||||
|
1. Usuario solicita PDF
|
||||||
|
2. GeneradorPdf prepara URL con token
|
||||||
|
3. Browsershot renderiza PdfView de React
|
||||||
|
4. Se genera PDF de 32 páginas
|
||||||
|
5. Se guarda en storage
|
||||||
|
6. Se descarga al usuario
|
||||||
|
```
|
||||||
205
docs/03-instalacion.md
Normal file
205
docs/03-instalacion.md
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
# 3. Instalación y Configuración
|
||||||
|
|
||||||
|
## Requisitos del Sistema
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- PHP 8.2 o superior
|
||||||
|
- Composer 2.x
|
||||||
|
- MySQL 8.0+ o PostgreSQL 14+
|
||||||
|
- Extensiones PHP: pdo_mysql, openssl, mbstring, fileinfo, gd
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- Node.js 18 o superior
|
||||||
|
- npm o yarn
|
||||||
|
|
||||||
|
### Para generación de PDF
|
||||||
|
- Node.js (para Puppeteer)
|
||||||
|
- Chromium (se instala automáticamente)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Instalación en Windows
|
||||||
|
|
||||||
|
### Opción 1: Usando Laragon (Recomendado)
|
||||||
|
|
||||||
|
1. **Descargar Laragon**
|
||||||
|
- Visita https://laragon.org/download/
|
||||||
|
- Descarga la versión "Full" (incluye PHP, MySQL, Composer)
|
||||||
|
- Instala y ejecuta Laragon
|
||||||
|
|
||||||
|
2. **Configurar el proyecto**
|
||||||
|
```bash
|
||||||
|
# Copia el proyecto a C:\laragon\www\horux-strategy-platform
|
||||||
|
|
||||||
|
# Abre la terminal de Laragon (clic derecho > Terminal)
|
||||||
|
cd horux-strategy-platform\backend
|
||||||
|
composer install
|
||||||
|
copy .env.example .env
|
||||||
|
php artisan key:generate
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Crear base de datos**
|
||||||
|
- En Laragon, clic en "Database" > "MySQL"
|
||||||
|
- Crea una base de datos llamada `horux_strategy`
|
||||||
|
|
||||||
|
4. **Ejecutar migraciones**
|
||||||
|
```bash
|
||||||
|
php artisan migrate --seed
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Iniciar backend**
|
||||||
|
```bash
|
||||||
|
php artisan serve
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Iniciar frontend** (otra terminal)
|
||||||
|
```bash
|
||||||
|
cd horux-strategy-platform\frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Opción 2: Instalación Manual
|
||||||
|
|
||||||
|
1. **Instalar PHP**
|
||||||
|
- Descarga de https://windows.php.net/download/
|
||||||
|
- Versión: VS16 x64 Thread Safe
|
||||||
|
- Extrae en `C:\php`
|
||||||
|
- Agrega `C:\php` al PATH del sistema
|
||||||
|
- Copia `php.ini-development` a `php.ini`
|
||||||
|
- Edita `php.ini` y habilita:
|
||||||
|
```ini
|
||||||
|
extension=pdo_mysql
|
||||||
|
extension=openssl
|
||||||
|
extension=mbstring
|
||||||
|
extension=fileinfo
|
||||||
|
extension=gd
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Instalar Composer**
|
||||||
|
- Descarga de https://getcomposer.org/download/
|
||||||
|
- Ejecuta el instalador
|
||||||
|
|
||||||
|
3. **Instalar MySQL**
|
||||||
|
- Descarga de https://dev.mysql.com/downloads/mysql/
|
||||||
|
- Durante instalación, anota usuario y contraseña
|
||||||
|
|
||||||
|
4. **Configurar proyecto** (igual que Opción 1, pasos 2-6)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Instalación en Linux/Mac
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clonar o copiar el proyecto
|
||||||
|
cd horux-strategy-platform
|
||||||
|
|
||||||
|
# Backend
|
||||||
|
cd backend
|
||||||
|
composer install
|
||||||
|
cp .env.example .env
|
||||||
|
php artisan key:generate
|
||||||
|
|
||||||
|
# Configurar .env con datos de MySQL
|
||||||
|
nano .env
|
||||||
|
|
||||||
|
# Migraciones
|
||||||
|
php artisan migrate --seed
|
||||||
|
php artisan serve
|
||||||
|
|
||||||
|
# Frontend (otra terminal)
|
||||||
|
cd ../frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuración del Archivo .env
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Aplicación
|
||||||
|
APP_NAME="Horux Strategy"
|
||||||
|
APP_ENV=local
|
||||||
|
APP_DEBUG=true
|
||||||
|
APP_URL=http://localhost:8000
|
||||||
|
FRONTEND_URL=http://localhost:5173
|
||||||
|
|
||||||
|
# Base de Datos
|
||||||
|
DB_CONNECTION=mysql
|
||||||
|
DB_HOST=127.0.0.1
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_DATABASE=horux_strategy
|
||||||
|
DB_USERNAME=root
|
||||||
|
DB_PASSWORD=tu_password
|
||||||
|
|
||||||
|
# Sanctum (autenticación)
|
||||||
|
SANCTUM_STATEFUL_DOMAINS=localhost:5173,127.0.0.1:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verificación de Instalación
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
```bash
|
||||||
|
# Verificar que el servidor responde
|
||||||
|
curl http://localhost:8000/api/giros
|
||||||
|
# Debe retornar lista de giros en JSON
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
```bash
|
||||||
|
# Abrir en navegador
|
||||||
|
http://localhost:5173
|
||||||
|
# Debe mostrar página de login
|
||||||
|
```
|
||||||
|
|
||||||
|
### Credenciales de Prueba
|
||||||
|
- **Email**: admin@horux360.com
|
||||||
|
- **Password**: password
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Solución de Problemas
|
||||||
|
|
||||||
|
### Error: "php" no es reconocido
|
||||||
|
- Verifica que PHP está en el PATH del sistema
|
||||||
|
- Reinicia la terminal
|
||||||
|
|
||||||
|
### Error: SQLSTATE Connection refused
|
||||||
|
- Verifica que MySQL está corriendo
|
||||||
|
- Verifica credenciales en .env
|
||||||
|
|
||||||
|
### Error: CORS
|
||||||
|
- Verifica FRONTEND_URL en .env
|
||||||
|
- Verifica config/cors.php
|
||||||
|
|
||||||
|
### Error: 419 Page Expired
|
||||||
|
- Ejecuta `php artisan config:clear`
|
||||||
|
- Verifica SANCTUM_STATEFUL_DOMAINS
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Despliegue en Producción
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
```bash
|
||||||
|
composer install --optimize-autoloader --no-dev
|
||||||
|
php artisan config:cache
|
||||||
|
php artisan route:cache
|
||||||
|
php artisan view:cache
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
# Servir carpeta dist/ con nginx/apache
|
||||||
|
```
|
||||||
|
|
||||||
|
### Variables de Producción
|
||||||
|
```env
|
||||||
|
APP_ENV=production
|
||||||
|
APP_DEBUG=false
|
||||||
|
FRONTEND_URL=https://tu-dominio.com
|
||||||
|
```
|
||||||
322
docs/04-api-reference.md
Normal file
322
docs/04-api-reference.md
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
# 4. API Reference
|
||||||
|
|
||||||
|
## Base URL
|
||||||
|
```
|
||||||
|
http://localhost:8000/api
|
||||||
|
```
|
||||||
|
|
||||||
|
## Autenticación
|
||||||
|
|
||||||
|
Todos los endpoints (excepto login) requieren token Bearer:
|
||||||
|
```
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoints de Autenticación
|
||||||
|
|
||||||
|
### POST /login
|
||||||
|
Iniciar sesión
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"email": "admin@horux360.com",
|
||||||
|
"password": "password"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response 200:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user": {
|
||||||
|
"id": 1,
|
||||||
|
"nombre": "Administrador",
|
||||||
|
"email": "admin@horux360.com",
|
||||||
|
"role": "admin",
|
||||||
|
"cliente_id": null
|
||||||
|
},
|
||||||
|
"token": "1|abc123..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /logout
|
||||||
|
Cerrar sesión (requiere autenticación)
|
||||||
|
|
||||||
|
**Response 200:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Sesión cerrada exitosamente"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /user
|
||||||
|
Obtener usuario actual
|
||||||
|
|
||||||
|
**Response 200:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"nombre": "Administrador",
|
||||||
|
"email": "admin@horux360.com",
|
||||||
|
"role": "admin",
|
||||||
|
"cliente_id": null,
|
||||||
|
"cliente": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoints de Giros
|
||||||
|
|
||||||
|
### GET /giros
|
||||||
|
Listar giros activos
|
||||||
|
|
||||||
|
**Response 200:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "id": 1, "nombre": "Hotelería", "activo": true },
|
||||||
|
{ "id": 2, "nombre": "Comercio", "activo": true }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoints de Clientes
|
||||||
|
|
||||||
|
### GET /clientes
|
||||||
|
Listar clientes (filtrado por rol)
|
||||||
|
|
||||||
|
**Response 200:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"nombre_empresa": "Hotel Boutique HSA",
|
||||||
|
"logo": "logos/abc123.png",
|
||||||
|
"giro_id": 1,
|
||||||
|
"moneda": "MXN",
|
||||||
|
"giro": { "id": 1, "nombre": "Hotelería" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /clientes
|
||||||
|
Crear cliente (multipart/form-data)
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```
|
||||||
|
nombre_empresa: Hotel Ejemplo
|
||||||
|
giro_id: 1
|
||||||
|
moneda: MXN
|
||||||
|
logo: [archivo imagen]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response 201:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"nombre_empresa": "Hotel Ejemplo",
|
||||||
|
"giro_id": 1,
|
||||||
|
"moneda": "MXN",
|
||||||
|
"logo": "logos/xyz789.png"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /clientes/{id}
|
||||||
|
Obtener cliente con balanzas y reportes
|
||||||
|
|
||||||
|
### PUT /clientes/{id}
|
||||||
|
Actualizar cliente
|
||||||
|
|
||||||
|
### DELETE /clientes/{id}
|
||||||
|
Eliminar cliente (solo admin)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoints de Balanzas
|
||||||
|
|
||||||
|
### GET /clientes/{id}/balanzas
|
||||||
|
Listar balanzas de un cliente
|
||||||
|
|
||||||
|
**Response 200:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"cliente_id": 1,
|
||||||
|
"periodo_inicio": "2024-01-01",
|
||||||
|
"periodo_fin": "2024-12-31",
|
||||||
|
"sistema_origen": "contpaqi",
|
||||||
|
"status": "completado"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /clientes/{id}/balanzas
|
||||||
|
Subir balanza (multipart/form-data)
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```
|
||||||
|
archivo: [archivo PDF/Excel/CSV]
|
||||||
|
periodo_inicio: 2024-01-01
|
||||||
|
periodo_fin: 2024-12-31
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response 201:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"sistema_origen": "contpaqi",
|
||||||
|
"status": "procesando"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /balanzas/{id}/cuentas
|
||||||
|
Listar cuentas de una balanza
|
||||||
|
|
||||||
|
**Response 200:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"codigo": "001-100-000",
|
||||||
|
"nombre": "ACTIVO CIRCULANTE",
|
||||||
|
"nivel": 1,
|
||||||
|
"saldo_final_deudor": 1500000.00,
|
||||||
|
"saldo_final_acreedor": 0,
|
||||||
|
"excluida": false,
|
||||||
|
"es_cuenta_padre": true,
|
||||||
|
"requiere_revision": false,
|
||||||
|
"categoria_contable": {
|
||||||
|
"id": 1,
|
||||||
|
"nombre": "Activos Circulantes"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### PUT /balanzas/{id}/exclusiones
|
||||||
|
Actualizar cuentas excluidas
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"exclusiones": [5, 12, 23]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoints de Cuentas
|
||||||
|
|
||||||
|
### PUT /cuentas/{id}/clasificacion
|
||||||
|
Corregir clasificación de cuenta
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"reporte_contable_id": 1,
|
||||||
|
"categoria_contable_id": 3,
|
||||||
|
"requiere_revision": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /cuentas/{id}/toggle-exclusion
|
||||||
|
Alternar exclusión de cuenta
|
||||||
|
|
||||||
|
### GET /anomalias
|
||||||
|
Listar cuentas que requieren revisión
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoints de Reportes
|
||||||
|
|
||||||
|
### GET /clientes/{id}/reportes
|
||||||
|
Listar reportes de un cliente
|
||||||
|
|
||||||
|
### POST /clientes/{id}/reportes
|
||||||
|
Generar nuevo reporte
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"nombre": "Reporte Anual 2024",
|
||||||
|
"balanza_ids": [1, 2, 3]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response 201:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"nombre": "Reporte Anual 2024",
|
||||||
|
"periodo_tipo": "anual",
|
||||||
|
"status": "completado",
|
||||||
|
"data_calculada": { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /reportes/{id}
|
||||||
|
Obtener reporte con datos calculados
|
||||||
|
|
||||||
|
### GET /reportes/{id}/pdf
|
||||||
|
Descargar PDF del reporte
|
||||||
|
|
||||||
|
**Response:** Archivo PDF
|
||||||
|
|
||||||
|
### DELETE /reportes/{id}
|
||||||
|
Eliminar reporte
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoints de Administración
|
||||||
|
|
||||||
|
Requieren rol `admin`.
|
||||||
|
|
||||||
|
### Usuarios
|
||||||
|
- `GET /admin/usuarios` - Listar usuarios
|
||||||
|
- `POST /admin/usuarios` - Crear usuario
|
||||||
|
- `PUT /admin/usuarios/{id}` - Actualizar usuario
|
||||||
|
- `DELETE /admin/usuarios/{id}` - Eliminar usuario
|
||||||
|
|
||||||
|
### Giros
|
||||||
|
- `GET /admin/giros` - Listar todos los giros
|
||||||
|
- `POST /admin/giros` - Crear giro
|
||||||
|
- `PUT /admin/giros/{id}` - Actualizar giro
|
||||||
|
- `DELETE /admin/giros/{id}` - Eliminar giro
|
||||||
|
|
||||||
|
### Umbrales
|
||||||
|
- `GET /admin/umbrales` - Listar umbrales
|
||||||
|
- `POST /admin/umbrales` - Crear umbral
|
||||||
|
- `PUT /admin/umbrales/{id}` - Actualizar umbral
|
||||||
|
- `DELETE /admin/umbrales/{id}` - Eliminar umbral
|
||||||
|
|
||||||
|
### Reglas de Mapeo
|
||||||
|
- `GET /admin/reglas-mapeo` - Listar reglas
|
||||||
|
- `POST /admin/reglas-mapeo` - Crear regla
|
||||||
|
- `PUT /admin/reglas-mapeo/{id}` - Actualizar regla
|
||||||
|
- `DELETE /admin/reglas-mapeo/{id}` - Eliminar regla
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Códigos de Error
|
||||||
|
|
||||||
|
| Código | Descripción |
|
||||||
|
|--------|-------------|
|
||||||
|
| 401 | No autenticado |
|
||||||
|
| 403 | No autorizado |
|
||||||
|
| 404 | Recurso no encontrado |
|
||||||
|
| 422 | Error de validación |
|
||||||
|
| 500 | Error del servidor |
|
||||||
|
|
||||||
|
**Ejemplo error 422:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "The email field is required.",
|
||||||
|
"errors": {
|
||||||
|
"email": ["The email field is required."]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
271
docs/05-frontend.md
Normal file
271
docs/05-frontend.md
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
# 5. Guía del Frontend
|
||||||
|
|
||||||
|
## Stack Tecnológico
|
||||||
|
|
||||||
|
- **React 18** - Biblioteca de UI
|
||||||
|
- **TypeScript** - Tipado estático
|
||||||
|
- **Vite** - Build tool y dev server
|
||||||
|
- **React Router** - Navegación
|
||||||
|
- **Axios** - Cliente HTTP
|
||||||
|
- **Recharts** - Gráficas
|
||||||
|
- **Tailwind CSS** - Estilos
|
||||||
|
- **react-hot-toast** - Notificaciones
|
||||||
|
- **react-dropzone** - Upload de archivos
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Estructura de Carpetas
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/
|
||||||
|
├── components/
|
||||||
|
│ ├── charts/ # Componentes de gráficas
|
||||||
|
│ │ ├── BarChart.tsx
|
||||||
|
│ │ └── LineChart.tsx
|
||||||
|
│ ├── cards/ # Tarjetas y tablas
|
||||||
|
│ │ ├── KPICard.tsx
|
||||||
|
│ │ └── MetricTable.tsx
|
||||||
|
│ ├── forms/ # Formularios
|
||||||
|
│ │ ├── ClienteForm.tsx
|
||||||
|
│ │ ├── UploadBalanza.tsx
|
||||||
|
│ │ └── GenerarReporte.tsx
|
||||||
|
│ └── layout/ # Estructura de página
|
||||||
|
│ ├── Layout.tsx
|
||||||
|
│ ├── Sidebar.tsx
|
||||||
|
│ └── Header.tsx
|
||||||
|
├── pages/
|
||||||
|
│ ├── Login.tsx
|
||||||
|
│ ├── Clientes/
|
||||||
|
│ │ ├── ClientesList.tsx
|
||||||
|
│ │ └── ClienteDetail.tsx
|
||||||
|
│ ├── Dashboard/
|
||||||
|
│ │ └── index.tsx
|
||||||
|
│ ├── PdfView/
|
||||||
|
│ │ └── index.tsx
|
||||||
|
│ └── Admin/
|
||||||
|
│ ├── Usuarios.tsx
|
||||||
|
│ ├── Giros.tsx
|
||||||
|
│ ├── Umbrales.tsx
|
||||||
|
│ └── ReglasMapeeo.tsx
|
||||||
|
├── context/
|
||||||
|
│ └── AuthContext.tsx
|
||||||
|
├── services/
|
||||||
|
│ └── api.ts
|
||||||
|
├── types/
|
||||||
|
│ └── index.ts
|
||||||
|
└── hooks/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contexto de Autenticación
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Uso del contexto
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const { user, login, logout, isAdmin, isAnalista } = useAuth();
|
||||||
|
|
||||||
|
if (!user) return <Navigate to="/login" />;
|
||||||
|
|
||||||
|
return <div>Hola {user.nombre}</div>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Propiedades disponibles:
|
||||||
|
- `user` - Usuario actual o null
|
||||||
|
- `loading` - Estado de carga inicial
|
||||||
|
- `login(email, password)` - Iniciar sesión
|
||||||
|
- `logout()` - Cerrar sesión
|
||||||
|
- `isAdmin` - Es administrador
|
||||||
|
- `isAnalista` - Es analista
|
||||||
|
- `isCliente` - Es cliente
|
||||||
|
- `isEmpleado` - Es empleado
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Servicio API
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { clientesApi, balanzasApi, reportesApi } from '../services/api';
|
||||||
|
|
||||||
|
// Listar clientes
|
||||||
|
const clientes = await clientesApi.list();
|
||||||
|
|
||||||
|
// Crear cliente
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('nombre_empresa', 'Mi Empresa');
|
||||||
|
formData.append('giro_id', '1');
|
||||||
|
const cliente = await clientesApi.create(formData);
|
||||||
|
|
||||||
|
// Subir balanza
|
||||||
|
const balanza = await balanzasApi.upload(clienteId, formData);
|
||||||
|
|
||||||
|
// Generar reporte
|
||||||
|
const reporte = await reportesApi.create(clienteId, 'Reporte 2024', [1, 2]);
|
||||||
|
|
||||||
|
// Descargar PDF
|
||||||
|
const blob = await reportesApi.downloadPdf(reporteId);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tipos TypeScript
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Principales tipos disponibles
|
||||||
|
interface User {
|
||||||
|
id: number;
|
||||||
|
nombre: string;
|
||||||
|
email: string;
|
||||||
|
role: 'admin' | 'analista' | 'cliente' | 'empleado';
|
||||||
|
cliente_id: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Cliente {
|
||||||
|
id: number;
|
||||||
|
nombre_empresa: string;
|
||||||
|
logo: string | null;
|
||||||
|
giro_id: number;
|
||||||
|
moneda: string;
|
||||||
|
giro?: Giro;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Balanza {
|
||||||
|
id: number;
|
||||||
|
cliente_id: number;
|
||||||
|
periodo_inicio: string;
|
||||||
|
periodo_fin: string;
|
||||||
|
sistema_origen: string;
|
||||||
|
status: 'pendiente' | 'procesando' | 'completado' | 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Reporte {
|
||||||
|
id: number;
|
||||||
|
nombre: string;
|
||||||
|
periodo_tipo: 'mensual' | 'trimestral' | 'anual';
|
||||||
|
data_calculada: DataCalculada | null;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tendencia = 'muy_positivo' | 'positivo' | 'neutral' | 'negativo' | 'muy_negativo';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Componentes Reutilizables
|
||||||
|
|
||||||
|
### KPICard
|
||||||
|
```tsx
|
||||||
|
<KPICard
|
||||||
|
title="Ingresos"
|
||||||
|
value="$1,500,000"
|
||||||
|
subtitle="12% vs mes anterior"
|
||||||
|
tendencia="positivo"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### MetricTable
|
||||||
|
```tsx
|
||||||
|
<MetricTable
|
||||||
|
title="Márgenes"
|
||||||
|
metricas={[
|
||||||
|
{ nombre: 'Margen Bruto', valor: 0.45, tendencia: 'positivo' },
|
||||||
|
{ nombre: 'Margen Neto', valor: 0.12, tendencia: 'neutral' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### BarChart
|
||||||
|
```tsx
|
||||||
|
<BarChartComponent
|
||||||
|
data={[
|
||||||
|
{ name: 'Ingresos', valor: 1500000 },
|
||||||
|
{ name: 'Gastos', valor: -800000 },
|
||||||
|
]}
|
||||||
|
horizontal={false}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### LineChart
|
||||||
|
```tsx
|
||||||
|
<LineChartComponent
|
||||||
|
data={periodos.map(p => ({
|
||||||
|
periodo: p.periodo,
|
||||||
|
'Margen Bruto': p.margen_bruto * 100,
|
||||||
|
'Margen Neto': p.margen_neto * 100,
|
||||||
|
}))}
|
||||||
|
lines={['Margen Bruto', 'Margen Neto']}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Estilos con Tailwind
|
||||||
|
|
||||||
|
### Clases personalizadas (index.css)
|
||||||
|
```css
|
||||||
|
.btn { @apply px-4 py-2 rounded-lg font-medium transition-colors; }
|
||||||
|
.btn-primary { @apply bg-primary-600 text-white hover:bg-primary-700; }
|
||||||
|
.btn-secondary { @apply bg-gray-200 text-gray-800 hover:bg-gray-300; }
|
||||||
|
.input { @apply w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500; }
|
||||||
|
.card { @apply bg-white rounded-xl shadow-sm border border-gray-100 p-6; }
|
||||||
|
.label { @apply block text-sm font-medium text-gray-700 mb-1; }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Colores de marca (tailwind.config.js)
|
||||||
|
```js
|
||||||
|
colors: {
|
||||||
|
horux: {
|
||||||
|
dark: '#1a1a2e',
|
||||||
|
primary: '#16213e',
|
||||||
|
accent: '#0f3460',
|
||||||
|
highlight: '#e94560',
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
'muy-positivo': '#10b981',
|
||||||
|
'positivo': '#34d399',
|
||||||
|
'neutral': '#fbbf24',
|
||||||
|
'negativo': '#f97316',
|
||||||
|
'muy-negativo': '#ef4444',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rutas
|
||||||
|
|
||||||
|
| Ruta | Componente | Rol requerido |
|
||||||
|
|------|------------|---------------|
|
||||||
|
| `/login` | Login | Público |
|
||||||
|
| `/clientes` | ClientesList | Autenticado |
|
||||||
|
| `/clientes/:id` | ClienteDetail | Autenticado |
|
||||||
|
| `/dashboard/:clienteId/:reporteId` | Dashboard | Autenticado |
|
||||||
|
| `/pdf-view/:id` | PdfView | Token especial |
|
||||||
|
| `/admin/usuarios` | AdminUsuarios | Admin |
|
||||||
|
| `/admin/giros` | AdminGiros | Admin |
|
||||||
|
| `/admin/umbrales` | AdminUmbrales | Admin |
|
||||||
|
| `/admin/reglas-mapeo` | AdminReglasMapeeo | Admin |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Desarrollo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Instalar dependencias
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Servidor de desarrollo
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Build para producción
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Preview del build
|
||||||
|
npm run preview
|
||||||
|
|
||||||
|
# Linting
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
241
docs/06-base-de-datos.md
Normal file
241
docs/06-base-de-datos.md
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
# 6. Base de Datos
|
||||||
|
|
||||||
|
## Diagrama Entidad-Relación
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||||
|
│ giros │ │ clientes │ │ users │
|
||||||
|
├─────────────┤ ├─────────────┤ ├─────────────┤
|
||||||
|
│ id │◄──────┤ giro_id │ │ id │
|
||||||
|
│ nombre │ │ id │◄──────┤ cliente_id │
|
||||||
|
│ activo │ │ nombre_emp │ │ nombre │
|
||||||
|
└─────────────┘ │ logo │ │ email │
|
||||||
|
│ moneda │ │ password │
|
||||||
|
│ config │ │ role │
|
||||||
|
└──────┬──────┘ └─────────────┘
|
||||||
|
│
|
||||||
|
┌──────────────┼──────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌─────────────┐┌─────────────┐┌─────────────┐
|
||||||
|
│ balanzas ││ reportes ││mapeo_cuentas│
|
||||||
|
├─────────────┤├─────────────┤├─────────────┤
|
||||||
|
│ id ││ id ││ id │
|
||||||
|
│ cliente_id ││ cliente_id ││ cliente_id │
|
||||||
|
│ periodo_ini ││ nombre ││ codigo_pat │
|
||||||
|
│ periodo_fin ││ periodo_tipo││ categoria_id│
|
||||||
|
│ sistema_orig││ data_calc │└─────────────┘
|
||||||
|
│ status ││ pdf_path │
|
||||||
|
└──────┬──────┘└─────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────┐
|
||||||
|
│ cuentas │
|
||||||
|
├─────────────┤
|
||||||
|
│ id │
|
||||||
|
│ balanza_id │
|
||||||
|
│ codigo │
|
||||||
|
│ nombre │
|
||||||
|
│ nivel │
|
||||||
|
│ saldos... │
|
||||||
|
│ excluida │
|
||||||
|
│ categoria_id│
|
||||||
|
└─────────────┘
|
||||||
|
|
||||||
|
┌─────────────────┐ ┌─────────────────┐
|
||||||
|
│reportes_contable│ │categorias_contab│
|
||||||
|
├─────────────────┤ ├─────────────────┤
|
||||||
|
│ id │◄────┤ reporte_cont_id │
|
||||||
|
│ nombre │ │ id │
|
||||||
|
└─────────────────┘ │ nombre │
|
||||||
|
│ orden │
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
┌────────┴────────┐
|
||||||
|
▼ ▼
|
||||||
|
┌─────────────┐ ┌─────────────┐
|
||||||
|
│reglas_mapeo │ │ umbrales │
|
||||||
|
├─────────────┤ ├─────────────┤
|
||||||
|
│ sistema_orig│ │ metrica │
|
||||||
|
│ rango_ini │ │ muy_positivo│
|
||||||
|
│ rango_fin │ │ positivo │
|
||||||
|
│ categoria_id│ │ neutral │
|
||||||
|
│ prioridad │ │ negativo │
|
||||||
|
└─────────────┘ │ muy_negativo│
|
||||||
|
│ giro_id │
|
||||||
|
└─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tablas
|
||||||
|
|
||||||
|
### users
|
||||||
|
Usuarios del sistema.
|
||||||
|
|
||||||
|
| Campo | Tipo | Descripción |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| id | bigint | PK |
|
||||||
|
| nombre | varchar(255) | Nombre completo |
|
||||||
|
| email | varchar(255) | Email único |
|
||||||
|
| password | varchar(255) | Hash de contraseña |
|
||||||
|
| role | enum | admin, analista, cliente, empleado |
|
||||||
|
| cliente_id | bigint | FK a clientes (nullable) |
|
||||||
|
|
||||||
|
### clientes
|
||||||
|
Empresas registradas.
|
||||||
|
|
||||||
|
| Campo | Tipo | Descripción |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| id | bigint | PK |
|
||||||
|
| nombre_empresa | varchar(255) | Nombre de la empresa |
|
||||||
|
| logo | varchar(255) | Path al logo |
|
||||||
|
| giro_id | bigint | FK a giros |
|
||||||
|
| moneda | varchar(3) | MXN, USD, EUR |
|
||||||
|
| configuracion | json | Configuración adicional |
|
||||||
|
|
||||||
|
### giros
|
||||||
|
Catálogo de giros de negocio.
|
||||||
|
|
||||||
|
| Campo | Tipo | Descripción |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| id | bigint | PK |
|
||||||
|
| nombre | varchar(255) | Nombre del giro |
|
||||||
|
| activo | boolean | Estado |
|
||||||
|
|
||||||
|
### balanzas
|
||||||
|
Archivos de balanza subidos.
|
||||||
|
|
||||||
|
| Campo | Tipo | Descripción |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| id | bigint | PK |
|
||||||
|
| cliente_id | bigint | FK a clientes |
|
||||||
|
| periodo_inicio | date | Inicio del periodo |
|
||||||
|
| periodo_fin | date | Fin del periodo |
|
||||||
|
| sistema_origen | varchar(50) | contpaqi, aspel, etc. |
|
||||||
|
| archivo_original | varchar(255) | Path al archivo |
|
||||||
|
| status | enum | pendiente, procesando, completado, error |
|
||||||
|
| error_mensaje | text | Mensaje de error si aplica |
|
||||||
|
|
||||||
|
### cuentas
|
||||||
|
Cuentas extraídas de balanzas.
|
||||||
|
|
||||||
|
| Campo | Tipo | Descripción |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| id | bigint | PK |
|
||||||
|
| balanza_id | bigint | FK a balanzas |
|
||||||
|
| codigo | varchar(20) | Código de cuenta (ej: 001-100-000) |
|
||||||
|
| nombre | varchar(255) | Nombre de la cuenta |
|
||||||
|
| nivel | int | Nivel jerárquico (1, 2, 3) |
|
||||||
|
| reporte_contable_id | bigint | FK a reportes_contables |
|
||||||
|
| categoria_contable_id | bigint | FK a categorias_contables |
|
||||||
|
| cuenta_padre_id | bigint | FK a cuentas (self-reference) |
|
||||||
|
| saldo_inicial_deudor | decimal(18,2) | |
|
||||||
|
| saldo_inicial_acreedor | decimal(18,2) | |
|
||||||
|
| cargos | decimal(18,2) | |
|
||||||
|
| abonos | decimal(18,2) | |
|
||||||
|
| saldo_final_deudor | decimal(18,2) | |
|
||||||
|
| saldo_final_acreedor | decimal(18,2) | |
|
||||||
|
| excluida | boolean | Excluida del cálculo |
|
||||||
|
| es_cuenta_padre | boolean | Es cuenta padre |
|
||||||
|
| requiere_revision | boolean | Necesita revisión manual |
|
||||||
|
| nota_revision | text | Nota sobre la anomalía |
|
||||||
|
|
||||||
|
### reportes_contables
|
||||||
|
Tipos de reporte (Balance, Estado de Resultados).
|
||||||
|
|
||||||
|
| Campo | Tipo | Descripción |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| id | bigint | PK |
|
||||||
|
| nombre | varchar(255) | "Balance General", "Estado de Resultados" |
|
||||||
|
|
||||||
|
### categorias_contables
|
||||||
|
Categorías dentro de cada reporte.
|
||||||
|
|
||||||
|
| Campo | Tipo | Descripción |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| id | bigint | PK |
|
||||||
|
| reporte_contable_id | bigint | FK a reportes_contables |
|
||||||
|
| nombre | varchar(255) | Nombre de la categoría |
|
||||||
|
| orden | int | Orden de aparición |
|
||||||
|
|
||||||
|
**Categorías de Balance General:**
|
||||||
|
1. Activos Circulantes
|
||||||
|
2. Activos No Circulantes
|
||||||
|
3. Pasivo Circulante
|
||||||
|
4. Pasivo No Circulante
|
||||||
|
5. Capital Social
|
||||||
|
6. Pérdidas Ejercicios Anteriores
|
||||||
|
7. Utilidades Ejercicios Anteriores
|
||||||
|
|
||||||
|
**Categorías de Estado de Resultados:**
|
||||||
|
1. Ingresos
|
||||||
|
2. Costo de Venta
|
||||||
|
3. Gastos Operativos
|
||||||
|
4. Otros Gastos
|
||||||
|
5. Gastos Financieros
|
||||||
|
|
||||||
|
### reglas_mapeo
|
||||||
|
Reglas para clasificar cuentas automáticamente.
|
||||||
|
|
||||||
|
| Campo | Tipo | Descripción |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| id | bigint | PK |
|
||||||
|
| sistema_origen | varchar(50) | contpaqi, aspel, etc. |
|
||||||
|
| cuenta_padre_codigo | varchar(20) | Código de cuenta padre |
|
||||||
|
| rango_inicio | varchar(20) | Inicio del rango de códigos |
|
||||||
|
| rango_fin | varchar(20) | Fin del rango |
|
||||||
|
| patron_regex | varchar(255) | Patrón regex alternativo |
|
||||||
|
| reporte_contable_id | bigint | FK |
|
||||||
|
| categoria_contable_id | bigint | FK |
|
||||||
|
| prioridad | int | Mayor = se evalúa primero |
|
||||||
|
| activo | boolean | Regla activa |
|
||||||
|
|
||||||
|
### umbrales
|
||||||
|
Umbrales para semáforos de métricas.
|
||||||
|
|
||||||
|
| Campo | Tipo | Descripción |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| id | bigint | PK |
|
||||||
|
| metrica | varchar(100) | Nombre de la métrica |
|
||||||
|
| muy_positivo | decimal(10,4) | Umbral muy positivo |
|
||||||
|
| positivo | decimal(10,4) | Umbral positivo |
|
||||||
|
| neutral | decimal(10,4) | Umbral neutral |
|
||||||
|
| negativo | decimal(10,4) | Umbral negativo |
|
||||||
|
| muy_negativo | decimal(10,4) | Umbral muy negativo |
|
||||||
|
| giro_id | bigint | FK a giros (nullable = general) |
|
||||||
|
|
||||||
|
### reportes
|
||||||
|
Reportes generados.
|
||||||
|
|
||||||
|
| Campo | Tipo | Descripción |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| id | bigint | PK |
|
||||||
|
| cliente_id | bigint | FK a clientes |
|
||||||
|
| nombre | varchar(255) | Nombre del reporte |
|
||||||
|
| periodo_tipo | enum | mensual, trimestral, anual |
|
||||||
|
| periodo_inicio | date | |
|
||||||
|
| periodo_fin | date | |
|
||||||
|
| fecha_generacion | timestamp | |
|
||||||
|
| data_calculada | json | Métricas y estados financieros |
|
||||||
|
| pdf_path | varchar(255) | Path al PDF generado |
|
||||||
|
| status | enum | pendiente, procesando, completado, error |
|
||||||
|
|
||||||
|
### reporte_balanza
|
||||||
|
Tabla pivote reporte-balanza.
|
||||||
|
|
||||||
|
| Campo | Tipo | Descripción |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| reporte_id | bigint | FK a reportes |
|
||||||
|
| balanza_id | bigint | FK a balanzas |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Seeders
|
||||||
|
|
||||||
|
Los seeders iniciales crean:
|
||||||
|
|
||||||
|
1. **GirosSeeder** - 24 giros de negocio
|
||||||
|
2. **ReportesContablesSeeder** - 2 reportes + 12 categorías
|
||||||
|
3. **ReglasMapeeoContpaqiSeeder** - 12 reglas para CONTPAQi
|
||||||
|
4. **UmbralesSeeder** - 24 umbrales por defecto
|
||||||
|
5. **AdminUserSeeder** - Usuario admin inicial
|
||||||
253
docs/07-parsers.md
Normal file
253
docs/07-parsers.md
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
# 7. Parsers de Balanzas
|
||||||
|
|
||||||
|
## Arquitectura
|
||||||
|
|
||||||
|
```
|
||||||
|
Upload archivo
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────┐
|
||||||
|
│ DetectorFormato │ ← Detecta el sistema origen
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────┐
|
||||||
|
│ Parser Específico│
|
||||||
|
│ - ContpaqiParser│
|
||||||
|
│ - GenericoParser│
|
||||||
|
│ - (futuro: Aspel)│
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────┐
|
||||||
|
│ Formato Standard│ ← Estructura normalizada
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────┐
|
||||||
|
│ ClasificadorCtas│ ← Aplica reglas de mapeo
|
||||||
|
└─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Interface ParserInterface
|
||||||
|
|
||||||
|
Todos los parsers deben implementar:
|
||||||
|
|
||||||
|
```php
|
||||||
|
interface ParserInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Parsea archivo y retorna array de cuentas normalizadas
|
||||||
|
*/
|
||||||
|
public function parsear(string $filePath): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica si puede manejar el archivo
|
||||||
|
*/
|
||||||
|
public function puedeManej(string $filePath): bool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retorna identificador del sistema
|
||||||
|
*/
|
||||||
|
public function getSistema(): string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Estructura de cuenta normalizada:
|
||||||
|
|
||||||
|
```php
|
||||||
|
[
|
||||||
|
'codigo' => '001-100-000',
|
||||||
|
'nombre' => 'ACTIVO CIRCULANTE',
|
||||||
|
'nivel' => 1,
|
||||||
|
'saldo_inicial_deudor' => 1500000.00,
|
||||||
|
'saldo_inicial_acreedor' => 0.00,
|
||||||
|
'cargos' => 500000.00,
|
||||||
|
'abonos' => 200000.00,
|
||||||
|
'saldo_final_deudor' => 1800000.00,
|
||||||
|
'saldo_final_acreedor' => 0.00,
|
||||||
|
'es_cuenta_padre' => true,
|
||||||
|
'cuenta_padre_codigo' => null, // opcional
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DetectorFormato
|
||||||
|
|
||||||
|
Detecta automáticamente el sistema origen del archivo.
|
||||||
|
|
||||||
|
```php
|
||||||
|
class DetectorFormato
|
||||||
|
{
|
||||||
|
private array $parsers = [
|
||||||
|
new ContpaqiParser(),
|
||||||
|
// new AspelParser(),
|
||||||
|
// new SapParser(),
|
||||||
|
new GenericoParser(), // Fallback
|
||||||
|
];
|
||||||
|
|
||||||
|
public function detectar(string $filePath): array
|
||||||
|
{
|
||||||
|
foreach ($this->parsers as $parser) {
|
||||||
|
if ($parser->puedeManej($filePath)) {
|
||||||
|
return [
|
||||||
|
'sistema' => $parser->getSistema(),
|
||||||
|
'parser' => $parser,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Exception('Formato no reconocido');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ContpaqiParser
|
||||||
|
|
||||||
|
Parser para balanzas de CONTPAQi.
|
||||||
|
|
||||||
|
### Detección
|
||||||
|
Busca patrones característicos en el PDF:
|
||||||
|
- Texto "CONTPAQ" o "CONTPAQi"
|
||||||
|
- Patrón de códigos: `\d{3}-\d{3}-\d{3}`
|
||||||
|
- Encabezados típicos: "Saldo Inicial", "Debe", "Haber"
|
||||||
|
|
||||||
|
### Formato de código CONTPAQi
|
||||||
|
```
|
||||||
|
XXX-XXX-XXX
|
||||||
|
│ │ │
|
||||||
|
│ │ └── Detalle (000 = padre)
|
||||||
|
│ └────── Subcuenta (000 = padre)
|
||||||
|
└────────── Mayor
|
||||||
|
```
|
||||||
|
|
||||||
|
Ejemplos:
|
||||||
|
- `001-100-000` → Nivel 1 (Mayor: Activo Circulante)
|
||||||
|
- `101-000-000` → Nivel 2 (Subcuenta: Caja)
|
||||||
|
- `101-001-000` → Nivel 3 (Detalle)
|
||||||
|
|
||||||
|
### Reglas de mapeo CONTPAQi
|
||||||
|
|
||||||
|
| Cuenta Padre | Rango Hijos | Categoría |
|
||||||
|
|--------------|-------------|-----------|
|
||||||
|
| 001-100-000 | 101-000-000 a 154-999-999 | Activos Circulantes |
|
||||||
|
| 001-200-000 | 155-000-000 a 199-999-999 | Activos No Circulantes |
|
||||||
|
| 002-100-000 | 200-000-000 a 209-999-999 | Pasivo Circulante |
|
||||||
|
| 002-200-000 | 210-000-000 a 220-999-999 | Pasivo No Circulante |
|
||||||
|
| 30X-XXX-XXX | - | Capital Social |
|
||||||
|
| 310-XXX-XXX | - | Pérdidas Anteriores |
|
||||||
|
| 311-XXX-XXX | - | Utilidades Anteriores |
|
||||||
|
| 40X-XXX-XXX | - | Ingresos |
|
||||||
|
| 5XX-XXX-XXX | - | Gastos Operativos |
|
||||||
|
| 6XX-XXX-XXX | - | Otros Gastos |
|
||||||
|
| 7XX-XXX-XXX | - | Gastos Financieros |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GenericoParser
|
||||||
|
|
||||||
|
Parser para archivos Excel/CSV genéricos.
|
||||||
|
|
||||||
|
### Formatos soportados
|
||||||
|
- `.xlsx` - Excel 2007+
|
||||||
|
- `.xls` - Excel 97-2003
|
||||||
|
- `.csv` - Valores separados por coma/punto y coma
|
||||||
|
|
||||||
|
### Detección de columnas
|
||||||
|
Busca headers similares a:
|
||||||
|
|
||||||
|
| Campo | Aliases aceptados |
|
||||||
|
|-------|-------------------|
|
||||||
|
| codigo | codigo, cuenta, code, account, cta, numero |
|
||||||
|
| nombre | nombre, descripcion, name, description, concepto |
|
||||||
|
| saldo_inicial_deudor | saldo_inicial_deudor, inicial_debe, opening_debit |
|
||||||
|
| cargos | cargos, debe, debit, debits, movs_deudor |
|
||||||
|
| abonos | abonos, haber, credit, credits, movs_acreedor |
|
||||||
|
| saldo_final_deudor | saldo_final_deudor, final_debe, closing_debit |
|
||||||
|
|
||||||
|
### Detección de delimitador CSV
|
||||||
|
Detecta automáticamente: `,`, `;`, `\t`, `|`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Agregar Nuevo Parser
|
||||||
|
|
||||||
|
1. **Crear clase** en `app/Services/Parsers/`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Parsers;
|
||||||
|
|
||||||
|
class AspelParser implements ParserInterface
|
||||||
|
{
|
||||||
|
public function getSistema(): string
|
||||||
|
{
|
||||||
|
return 'aspel';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function puedeManej(string $filePath): bool
|
||||||
|
{
|
||||||
|
// Lógica de detección para Aspel
|
||||||
|
$text = file_get_contents($filePath);
|
||||||
|
return str_contains(strtoupper($text), 'ASPEL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function parsear(string $filePath): array
|
||||||
|
{
|
||||||
|
// Lógica de parsing
|
||||||
|
$cuentas = [];
|
||||||
|
// ... extraer datos ...
|
||||||
|
return $cuentas;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Registrar en DetectorFormato**:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// En DetectorFormato::__construct()
|
||||||
|
$this->parsers = [
|
||||||
|
new ContpaqiParser(),
|
||||||
|
new AspelParser(), // ← Agregar antes de GenericoParser
|
||||||
|
new GenericoParser(),
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Agregar reglas de mapeo** en seeder:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Crear nuevo seeder: ReglasMapeeoAspelSeeder.php
|
||||||
|
ReglaMapeo::create([
|
||||||
|
'sistema_origen' => 'aspel',
|
||||||
|
'patron_regex' => '/^1\d{3}/',
|
||||||
|
'reporte_contable_id' => $balance->id,
|
||||||
|
'categoria_contable_id' => $activosCirc->id,
|
||||||
|
'prioridad' => 10,
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ClasificadorCuentas
|
||||||
|
|
||||||
|
Aplica reglas de mapeo a las cuentas importadas.
|
||||||
|
|
||||||
|
### Proceso:
|
||||||
|
1. Buscar mapeo específico del cliente
|
||||||
|
2. Si no existe, buscar regla del sistema
|
||||||
|
3. Si no existe regla, marcar para revisión
|
||||||
|
|
||||||
|
### Detección de anomalías:
|
||||||
|
- Cuentas 45X (normalmente pasivo) usadas como gasto
|
||||||
|
- Cuentas 8XX-9XX (cuentas de orden)
|
||||||
|
- Códigos sin regla de mapeo
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Ejemplo de nota de revisión automática
|
||||||
|
"Código 45X normalmente es pasivo pero podría ser gasto. Verificar clasificación."
|
||||||
|
```
|
||||||
232
docs/08-metricas.md
Normal file
232
docs/08-metricas.md
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
# 8. Métricas Financieras
|
||||||
|
|
||||||
|
## Resumen
|
||||||
|
|
||||||
|
El sistema calcula **27+ métricas financieras** organizadas en 6 categorías:
|
||||||
|
|
||||||
|
| Categoría | # Métricas |
|
||||||
|
|-----------|------------|
|
||||||
|
| Márgenes | 7 |
|
||||||
|
| Retorno | 4 |
|
||||||
|
| Eficiencia | 6 |
|
||||||
|
| Liquidez | 3 |
|
||||||
|
| Solvencia | 3 |
|
||||||
|
| Gestión | 4 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Estados Financieros Base
|
||||||
|
|
||||||
|
### Balance General
|
||||||
|
|
||||||
|
```
|
||||||
|
ACTIVOS
|
||||||
|
├── Activos Circulantes
|
||||||
|
│ ├── Efectivo
|
||||||
|
│ ├── Cuentas por Cobrar
|
||||||
|
│ └── Inventarios
|
||||||
|
├── Activos No Circulantes
|
||||||
|
│ ├── Propiedad, Planta y Equipo
|
||||||
|
│ └── Activos Intangibles
|
||||||
|
└── TOTAL ACTIVOS
|
||||||
|
|
||||||
|
PASIVOS
|
||||||
|
├── Pasivo Circulante
|
||||||
|
│ ├── Cuentas por Pagar
|
||||||
|
│ └── Deuda Corto Plazo
|
||||||
|
├── Pasivo No Circulante
|
||||||
|
│ └── Deuda Largo Plazo
|
||||||
|
└── TOTAL PASIVOS
|
||||||
|
|
||||||
|
CAPITAL
|
||||||
|
├── Capital Social
|
||||||
|
├── Utilidades Retenidas
|
||||||
|
└── TOTAL CAPITAL
|
||||||
|
|
||||||
|
TOTAL PASIVOS + CAPITAL = TOTAL ACTIVOS
|
||||||
|
```
|
||||||
|
|
||||||
|
### Estado de Resultados
|
||||||
|
|
||||||
|
```
|
||||||
|
Ingresos
|
||||||
|
(-) Costo de Venta
|
||||||
|
= Utilidad Bruta
|
||||||
|
(-) Gastos Operativos
|
||||||
|
= Utilidad Operativa (EBIT)
|
||||||
|
(-) Otros Gastos
|
||||||
|
(-) Gastos Financieros
|
||||||
|
= Utilidad Antes de Impuestos (EBT)
|
||||||
|
(-) Impuestos
|
||||||
|
= UTILIDAD NETA
|
||||||
|
```
|
||||||
|
|
||||||
|
### Flujo de Efectivo (Método Indirecto)
|
||||||
|
|
||||||
|
```
|
||||||
|
FLUJO DE OPERACIÓN
|
||||||
|
Utilidad Neta
|
||||||
|
+ Depreciación
|
||||||
|
- Cambios en Capital de Trabajo
|
||||||
|
|
||||||
|
FLUJO DE INVERSIÓN
|
||||||
|
- Adquisición de Activos Fijos
|
||||||
|
+ Venta de Activos
|
||||||
|
|
||||||
|
FLUJO DE FINANCIAMIENTO
|
||||||
|
+ Nueva Deuda
|
||||||
|
- Pago de Deuda
|
||||||
|
+ Aportaciones de Capital
|
||||||
|
- Dividendos
|
||||||
|
|
||||||
|
= FLUJO NETO
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Métricas por Categoría
|
||||||
|
|
||||||
|
### 1. Márgenes
|
||||||
|
|
||||||
|
| Métrica | Fórmula | Interpretación |
|
||||||
|
|---------|---------|----------------|
|
||||||
|
| **Margen Bruto** | Utilidad Bruta / Ingresos | Rentabilidad después de costos directos |
|
||||||
|
| **Margen EBITDA** | EBITDA / Ingresos | Rentabilidad operativa antes de D&A |
|
||||||
|
| **Margen Operativo** | EBIT / Ingresos | Rentabilidad operativa |
|
||||||
|
| **Margen Neto** | Utilidad Neta / Ingresos | Rentabilidad final |
|
||||||
|
| **Margen NOPAT** | NOPAT / Ingresos | Utilidad operativa después de impuestos |
|
||||||
|
| **Margen OCF** | Flujo Operación / Ingresos | Generación de efectivo operativo |
|
||||||
|
| **Margen FCF** | Flujo Libre / Ingresos | Efectivo disponible |
|
||||||
|
|
||||||
|
Donde:
|
||||||
|
- EBITDA = EBIT + Depreciación + Amortización
|
||||||
|
- NOPAT = EBIT × (1 - Tasa Impuestos)
|
||||||
|
- FCF = Flujo Operación - CapEx
|
||||||
|
|
||||||
|
### 2. Retorno
|
||||||
|
|
||||||
|
| Métrica | Fórmula | Interpretación |
|
||||||
|
|---------|---------|----------------|
|
||||||
|
| **ROIC** | NOPAT / Capital Invertido | Retorno sobre capital invertido |
|
||||||
|
| **ROE** | Utilidad Neta / Capital | Retorno para accionistas |
|
||||||
|
| **ROA** | Utilidad Neta / Activo Total | Eficiencia de activos |
|
||||||
|
| **ROCE** | EBIT / Capital Empleado | Retorno sobre capital empleado |
|
||||||
|
|
||||||
|
Donde:
|
||||||
|
- Capital Invertido = Activo Total - Pasivo Circulante
|
||||||
|
- Capital Empleado = Activo Total - Pasivo Circulante
|
||||||
|
|
||||||
|
### 3. Eficiencia
|
||||||
|
|
||||||
|
| Métrica | Fórmula | Interpretación |
|
||||||
|
|---------|---------|----------------|
|
||||||
|
| **Asset Turnover** | Ingresos / Activo Total | Rotación de activos |
|
||||||
|
| **Inventory Turnover** | Costo Venta / Inventario | Rotación de inventario |
|
||||||
|
| **Días Clientes** | (Clientes / Ingresos) × 365 | Días para cobrar |
|
||||||
|
| **Días Proveedores** | (Proveedores / Costo) × 365 | Días para pagar |
|
||||||
|
| **Días Inventario** | (Inventario / Costo) × 365 | Días de inventario |
|
||||||
|
| **Ciclo Conversión** | Días Clientes + Días Inv. - Días Prov. | Ciclo de efectivo |
|
||||||
|
|
||||||
|
### 4. Liquidez
|
||||||
|
|
||||||
|
| Métrica | Fórmula | Interpretación |
|
||||||
|
|---------|---------|----------------|
|
||||||
|
| **Current Ratio** | Activo Circ. / Pasivo Circ. | Capacidad de pago corto plazo |
|
||||||
|
| **Quick Ratio** | (Activo Circ. - Inv.) / Pasivo Circ. | Liquidez sin inventario |
|
||||||
|
| **Cash Ratio** | Efectivo / Pasivo Circ. | Liquidez inmediata |
|
||||||
|
|
||||||
|
### 5. Solvencia
|
||||||
|
|
||||||
|
| Métrica | Fórmula | Interpretación |
|
||||||
|
|---------|---------|----------------|
|
||||||
|
| **Net Debt/EBITDA** | (Deuda - Efectivo) / EBITDA | Años para pagar deuda |
|
||||||
|
| **Interest Coverage** | EBITDA / Gastos Financieros | Cobertura de intereses |
|
||||||
|
| **Debt Ratio** | Deuda Total / Activo Total | Apalancamiento |
|
||||||
|
|
||||||
|
### 6. Gestión
|
||||||
|
|
||||||
|
| Métrica | Fórmula | Interpretación |
|
||||||
|
|---------|---------|----------------|
|
||||||
|
| **Revenue Growth** | (Ingreso Actual - Anterior) / Anterior | Crecimiento de ventas |
|
||||||
|
| **Key Score** | Crec. Ingreso 12m + Margen EBITDA 12m | Score compuesto |
|
||||||
|
| **CapEx/Revenue** | CAPEX / Ingresos | Inversión en activos |
|
||||||
|
| **Effective Tax Rate** | Impuestos / EBT | Tasa impositiva efectiva |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sistema de Semáforos
|
||||||
|
|
||||||
|
Cada métrica se evalúa contra umbrales para determinar su "tendencia":
|
||||||
|
|
||||||
|
| Tendencia | Color | Significado |
|
||||||
|
|-----------|-------|-------------|
|
||||||
|
| muy_positivo | Verde oscuro | Excelente desempeño |
|
||||||
|
| positivo | Verde claro | Buen desempeño |
|
||||||
|
| neutral | Amarillo | Desempeño aceptable |
|
||||||
|
| negativo | Naranja | Área de atención |
|
||||||
|
| muy_negativo | Rojo | Área crítica |
|
||||||
|
|
||||||
|
### Ejemplo de umbrales (Margen Neto):
|
||||||
|
|
||||||
|
| Tendencia | Umbral |
|
||||||
|
|-----------|--------|
|
||||||
|
| muy_positivo | ≥ 20% |
|
||||||
|
| positivo | ≥ 10% |
|
||||||
|
| neutral | ≥ 5% |
|
||||||
|
| negativo | ≥ 2% |
|
||||||
|
| muy_negativo | < 2% |
|
||||||
|
|
||||||
|
### Umbrales por Giro
|
||||||
|
|
||||||
|
Los umbrales pueden personalizarse por industria. Por ejemplo, hotelería puede tener diferentes expectativas de margen que manufactura.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Comparativos
|
||||||
|
|
||||||
|
Para cada métrica se calculan:
|
||||||
|
|
||||||
|
| Comparativo | Descripción |
|
||||||
|
|-------------|-------------|
|
||||||
|
| Valor Actual | Valor del periodo actual |
|
||||||
|
| Periodo Anterior | Valor del periodo inmediato anterior |
|
||||||
|
| Variación Absoluta | Diferencia numérica |
|
||||||
|
| Variación % | Cambio porcentual |
|
||||||
|
| Promedio 3 Periodos | Media de últimos 3 periodos |
|
||||||
|
| Mismo Periodo Año Anterior | Para comparación interanual |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementación
|
||||||
|
|
||||||
|
```php
|
||||||
|
// CalculadorMetricas.php
|
||||||
|
|
||||||
|
public function calcular(Reporte $reporte): array
|
||||||
|
{
|
||||||
|
// 1. Calcular estados financieros por periodo
|
||||||
|
foreach ($balanzas as $balanza) {
|
||||||
|
$periodos[] = [
|
||||||
|
'periodo' => $balanza->periodo_fin,
|
||||||
|
'balance_general' => $this->calcularBalanceGeneral($balanza),
|
||||||
|
'estado_resultados' => $this->calcularEstadoResultados($balanza),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Calcular métricas del último periodo
|
||||||
|
$metricas = $this->calcularTodasLasMetricas($ultimoPeriodo);
|
||||||
|
|
||||||
|
// 3. Calcular comparativos
|
||||||
|
$comparativos = $this->calcularComparativos($periodos, $metricas);
|
||||||
|
|
||||||
|
// 4. Calcular flujo de efectivo
|
||||||
|
$flujoEfectivo = $this->calcularFlujoEfectivo($periodos);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'periodos' => $periodos,
|
||||||
|
'metricas' => $metricas,
|
||||||
|
'comparativos' => $comparativos,
|
||||||
|
'flujo_efectivo' => $flujoEfectivo,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
479
docs/09-pdf.md
Normal file
479
docs/09-pdf.md
Normal file
@@ -0,0 +1,479 @@
|
|||||||
|
# 9. Generación de PDF
|
||||||
|
|
||||||
|
## Descripción
|
||||||
|
|
||||||
|
El sistema genera reportes PDF profesionales de **32 páginas** usando Browsershot (wrapper de Puppeteer para Laravel).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Arquitectura
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐
|
||||||
|
│ ReporteController│
|
||||||
|
│ /pdf │
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────┐
|
||||||
|
│ GeneradorPdf │
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────┐
|
||||||
|
│ Browsershot │ ← Puppeteer headless Chrome
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────┐
|
||||||
|
│ React PdfView │ ← Componente optimizado para impresión
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────┐
|
||||||
|
│ PDF 32 págs │
|
||||||
|
└─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Estructura del PDF (32 páginas)
|
||||||
|
|
||||||
|
| # | Sección | Contenido |
|
||||||
|
|---|---------|-----------|
|
||||||
|
| 1 | Portada | Logo, nombre empresa, periodo |
|
||||||
|
| 2 | Índice | Tabla de contenidos |
|
||||||
|
| 3-4 | Resumen Ejecutivo | KPIs principales, semáforos |
|
||||||
|
| 5-8 | Balance General | Activos, Pasivos, Capital |
|
||||||
|
| 9-12 | Estado de Resultados | Ingresos, Costos, Gastos |
|
||||||
|
| 13-14 | Flujo de Efectivo | Operación, Inversión, Financiamiento |
|
||||||
|
| 15-18 | Análisis de Márgenes | 7 métricas con gráficas |
|
||||||
|
| 19-20 | Análisis de Retorno | ROIC, ROE, ROA, ROCE |
|
||||||
|
| 21-22 | Análisis de Eficiencia | Rotaciones, ciclo conversión |
|
||||||
|
| 23-24 | Análisis de Liquidez | Ratios de liquidez |
|
||||||
|
| 25-26 | Análisis de Solvencia | Deuda, cobertura |
|
||||||
|
| 27-28 | Análisis de Gestión | Crecimiento, inversión |
|
||||||
|
| 29-30 | Comparativos | Tendencias históricas |
|
||||||
|
| 31 | Notas | Observaciones y anomalías |
|
||||||
|
| 32 | Glosario | Definiciones de métricas |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementación Backend
|
||||||
|
|
||||||
|
### GeneradorPdf.php
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Reporte;
|
||||||
|
use Spatie\Browsershot\Browsershot;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class GeneradorPdf
|
||||||
|
{
|
||||||
|
public function generar(Reporte $reporte): string
|
||||||
|
{
|
||||||
|
// URL del frontend con datos del reporte
|
||||||
|
$url = config('app.frontend_url') . '/pdf/' . $reporte->id;
|
||||||
|
|
||||||
|
// Nombre del archivo
|
||||||
|
$filename = sprintf(
|
||||||
|
'reportes/%s/%s-%s.pdf',
|
||||||
|
$reporte->cliente_id,
|
||||||
|
$reporte->periodo_fin->format('Y-m'),
|
||||||
|
now()->timestamp
|
||||||
|
);
|
||||||
|
|
||||||
|
$fullPath = storage_path('app/public/' . $filename);
|
||||||
|
|
||||||
|
// Asegurar directorio existe
|
||||||
|
$dir = dirname($fullPath);
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
mkdir($dir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generar PDF con Browsershot
|
||||||
|
Browsershot::url($url)
|
||||||
|
->setNodeBinary(config('browsershot.node_binary'))
|
||||||
|
->setNpmBinary(config('browsershot.npm_binary'))
|
||||||
|
->setChromePath(config('browsershot.chrome_path'))
|
||||||
|
->format('Letter')
|
||||||
|
->margins(10, 10, 10, 10)
|
||||||
|
->showBackground()
|
||||||
|
->waitUntilNetworkIdle()
|
||||||
|
->timeout(120)
|
||||||
|
->save($fullPath);
|
||||||
|
|
||||||
|
// Actualizar reporte
|
||||||
|
$reporte->update([
|
||||||
|
'pdf_path' => $filename,
|
||||||
|
'status' => 'completado',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $filename;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuración Browsershot
|
||||||
|
|
||||||
|
```php
|
||||||
|
// config/browsershot.php
|
||||||
|
return [
|
||||||
|
'node_binary' => env('NODE_BINARY', '/usr/bin/node'),
|
||||||
|
'npm_binary' => env('NPM_BINARY', '/usr/bin/npm'),
|
||||||
|
'chrome_path' => env('CHROME_PATH', null), // null = usa Chromium de Puppeteer
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementación Frontend
|
||||||
|
|
||||||
|
### PdfView Component
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// src/pages/PdfView/index.tsx
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { api } from '@/services/api';
|
||||||
|
import { Reporte } from '@/types';
|
||||||
|
|
||||||
|
// Importar secciones
|
||||||
|
import Portada from './sections/Portada';
|
||||||
|
import Indice from './sections/Indice';
|
||||||
|
import ResumenEjecutivo from './sections/ResumenEjecutivo';
|
||||||
|
import BalanceGeneral from './sections/BalanceGeneral';
|
||||||
|
import EstadoResultados from './sections/EstadoResultados';
|
||||||
|
import FlujoEfectivo from './sections/FlujoEfectivo';
|
||||||
|
import AnalisisMargenes from './sections/AnalisisMargenes';
|
||||||
|
import AnalisisRetorno from './sections/AnalisisRetorno';
|
||||||
|
import AnalisisEficiencia from './sections/AnalisisEficiencia';
|
||||||
|
import AnalisisLiquidez from './sections/AnalisisLiquidez';
|
||||||
|
import AnalisisSolvencia from './sections/AnalisisSolvencia';
|
||||||
|
import AnalisisGestion from './sections/AnalisisGestion';
|
||||||
|
import Comparativos from './sections/Comparativos';
|
||||||
|
import Notas from './sections/Notas';
|
||||||
|
import Glosario from './sections/Glosario';
|
||||||
|
|
||||||
|
export default function PdfView() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const [reporte, setReporte] = useState<Reporte | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchReporte = async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.reportes.get(Number(id));
|
||||||
|
setReporte(data);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchReporte();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (loading || !reporte) {
|
||||||
|
return <div>Cargando...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pdf-container">
|
||||||
|
<Portada reporte={reporte} />
|
||||||
|
<Indice />
|
||||||
|
<ResumenEjecutivo reporte={reporte} />
|
||||||
|
<BalanceGeneral reporte={reporte} />
|
||||||
|
<EstadoResultados reporte={reporte} />
|
||||||
|
<FlujoEfectivo reporte={reporte} />
|
||||||
|
<AnalisisMargenes reporte={reporte} />
|
||||||
|
<AnalisisRetorno reporte={reporte} />
|
||||||
|
<AnalisisEficiencia reporte={reporte} />
|
||||||
|
<AnalisisLiquidez reporte={reporte} />
|
||||||
|
<AnalisisSolvencia reporte={reporte} />
|
||||||
|
<AnalisisGestion reporte={reporte} />
|
||||||
|
<Comparativos reporte={reporte} />
|
||||||
|
<Notas reporte={reporte} />
|
||||||
|
<Glosario />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Estilos de Impresión
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* src/styles/pdf.css */
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
/* Ocultar elementos no imprimibles */
|
||||||
|
.no-print {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Saltos de página */
|
||||||
|
.page-break {
|
||||||
|
page-break-after: always;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-break-before {
|
||||||
|
page-break-before: always;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avoid-break {
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Contenedor PDF */
|
||||||
|
.pdf-container {
|
||||||
|
width: 8.5in;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: white;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Página individual */
|
||||||
|
.pdf-page {
|
||||||
|
width: 8.5in;
|
||||||
|
min-height: 11in;
|
||||||
|
padding: 0.5in;
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header de página */
|
||||||
|
.pdf-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding-bottom: 0.25in;
|
||||||
|
border-bottom: 2px solid #1A1F36;
|
||||||
|
margin-bottom: 0.25in;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer de página */
|
||||||
|
.pdf-footer {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0.25in;
|
||||||
|
left: 0.5in;
|
||||||
|
right: 0.5in;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabla de datos */
|
||||||
|
.pdf-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-table th,
|
||||||
|
.pdf-table td {
|
||||||
|
padding: 6px 8px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-table th {
|
||||||
|
background: #f3f4f6;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Semáforos */
|
||||||
|
.indicator {
|
||||||
|
display: inline-block;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-muy_positivo { background: #059669; }
|
||||||
|
.indicator-positivo { background: #10b981; }
|
||||||
|
.indicator-neutral { background: #f59e0b; }
|
||||||
|
.indicator-negativo { background: #f97316; }
|
||||||
|
.indicator-muy_negativo { background: #ef4444; }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Componentes de Sección
|
||||||
|
|
||||||
|
### Portada
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// src/pages/PdfView/sections/Portada.tsx
|
||||||
|
|
||||||
|
interface PortadaProps {
|
||||||
|
reporte: Reporte;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Portada({ reporte }: PortadaProps) {
|
||||||
|
return (
|
||||||
|
<div className="pdf-page flex flex-col items-center justify-center">
|
||||||
|
{/* Logo */}
|
||||||
|
<img
|
||||||
|
src={reporte.cliente.logo || '/logo-horux.png'}
|
||||||
|
alt="Logo"
|
||||||
|
className="w-48 mb-8"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Nombre empresa */}
|
||||||
|
<h1 className="text-4xl font-bold text-horux-dark mb-4">
|
||||||
|
{reporte.cliente.nombre_empresa}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Tipo de reporte */}
|
||||||
|
<h2 className="text-2xl text-gray-600 mb-8">
|
||||||
|
Reporte Financiero
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Periodo */}
|
||||||
|
<p className="text-xl text-gray-500">
|
||||||
|
{formatPeriodo(reporte.periodo_tipo, reporte.periodo_inicio, reporte.periodo_fin)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Fecha generación */}
|
||||||
|
<p className="text-sm text-gray-400 mt-4">
|
||||||
|
Generado: {formatDate(reporte.fecha_generacion)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Branding */}
|
||||||
|
<div className="absolute bottom-8">
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Powered by Horux 360
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Resumen Ejecutivo
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// src/pages/PdfView/sections/ResumenEjecutivo.tsx
|
||||||
|
|
||||||
|
export default function ResumenEjecutivo({ reporte }: { reporte: Reporte }) {
|
||||||
|
const { metricas, comparativos } = reporte.data_calculada;
|
||||||
|
|
||||||
|
const kpis = [
|
||||||
|
{ nombre: 'Ingresos', valor: metricas.ingresos, formato: 'currency' },
|
||||||
|
{ nombre: 'Margen EBITDA', valor: metricas.margen_ebitda, formato: 'percent' },
|
||||||
|
{ nombre: 'Margen Neto', valor: metricas.margen_neto, formato: 'percent' },
|
||||||
|
{ nombre: 'ROIC', valor: metricas.roic, formato: 'percent' },
|
||||||
|
{ nombre: 'Current Ratio', valor: metricas.current_ratio, formato: 'number' },
|
||||||
|
{ nombre: 'Net Debt/EBITDA', valor: metricas.net_debt_ebitda, formato: 'number' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="pdf-page page-break">
|
||||||
|
<PdfHeader titulo="Resumen Ejecutivo" pagina={3} />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-4 mb-8">
|
||||||
|
{kpis.map((kpi) => (
|
||||||
|
<KpiCard key={kpi.nombre} {...kpi} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-lg font-bold mb-4">Semáforos de Rendimiento</h3>
|
||||||
|
<SemaforoTable metricas={metricas} />
|
||||||
|
|
||||||
|
<PdfFooter pagina={3} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pdf-page page-break">
|
||||||
|
<PdfHeader titulo="Resumen Ejecutivo" pagina={4} />
|
||||||
|
|
||||||
|
<h3 className="text-lg font-bold mb-4">Tendencias Principales</h3>
|
||||||
|
<TrendCharts comparativos={comparativos} />
|
||||||
|
|
||||||
|
<PdfFooter pagina={4} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requisitos del Servidor
|
||||||
|
|
||||||
|
### Instalación de Dependencias
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Node.js 18+
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
|
||||||
|
sudo apt-get install -y nodejs
|
||||||
|
|
||||||
|
# Puppeteer dependencies (Ubuntu/Debian)
|
||||||
|
sudo apt-get install -y \
|
||||||
|
libnss3 \
|
||||||
|
libatk1.0-0 \
|
||||||
|
libatk-bridge2.0-0 \
|
||||||
|
libcups2 \
|
||||||
|
libdrm2 \
|
||||||
|
libxkbcommon0 \
|
||||||
|
libxcomposite1 \
|
||||||
|
libxdamage1 \
|
||||||
|
libxfixes3 \
|
||||||
|
libxrandr2 \
|
||||||
|
libgbm1 \
|
||||||
|
libasound2
|
||||||
|
|
||||||
|
# Instalar Puppeteer
|
||||||
|
cd backend
|
||||||
|
npm install puppeteer
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuración en Producción
|
||||||
|
|
||||||
|
```env
|
||||||
|
# .env
|
||||||
|
NODE_BINARY=/usr/bin/node
|
||||||
|
NPM_BINARY=/usr/bin/npm
|
||||||
|
CHROME_PATH=/usr/bin/google-chrome-stable
|
||||||
|
FRONTEND_URL=https://app.horux360.com
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Error: "Unable to launch browser"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verificar Chrome instalado
|
||||||
|
which google-chrome-stable
|
||||||
|
|
||||||
|
# Instalar Chrome si no existe
|
||||||
|
wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
|
||||||
|
sudo dpkg -i google-chrome-stable_current_amd64.deb
|
||||||
|
sudo apt-get -f install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error: "Timeout exceeded"
|
||||||
|
|
||||||
|
Aumentar timeout en `GeneradorPdf.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
Browsershot::url($url)
|
||||||
|
->timeout(300) // 5 minutos
|
||||||
|
->waitUntilNetworkIdle(false) // No esperar red
|
||||||
|
->save($fullPath);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error: "Missing fonts"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Instalar fuentes
|
||||||
|
sudo apt-get install -y fonts-liberation fonts-noto-color-emoji
|
||||||
|
```
|
||||||
|
|
||||||
392
docs/10-administracion.md
Normal file
392
docs/10-administracion.md
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
# 10. Panel de Administración
|
||||||
|
|
||||||
|
## Descripción
|
||||||
|
|
||||||
|
El panel de administración permite a usuarios con rol **admin** gestionar la configuración global del sistema.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceso
|
||||||
|
|
||||||
|
- **URL**: `/admin`
|
||||||
|
- **Rol requerido**: `admin`
|
||||||
|
- **Middleware**: `auth:sanctum`, `role:admin`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Secciones
|
||||||
|
|
||||||
|
### 1. Gestión de Usuarios
|
||||||
|
|
||||||
|
**Ruta**: `/admin/usuarios`
|
||||||
|
|
||||||
|
Permite crear, editar y eliminar usuarios del sistema.
|
||||||
|
|
||||||
|
| Campo | Descripción |
|
||||||
|
|-------|-------------|
|
||||||
|
| nombre | Nombre completo |
|
||||||
|
| email | Email único |
|
||||||
|
| password | Contraseña (mínimo 8 caracteres) |
|
||||||
|
| role | admin, analista, cliente, empleado |
|
||||||
|
| cliente_id | Cliente asignado (requerido para cliente/empleado) |
|
||||||
|
| activo | Estado del usuario |
|
||||||
|
|
||||||
|
#### API Endpoints
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/admin/usuarios - Listar usuarios
|
||||||
|
POST /api/admin/usuarios - Crear usuario
|
||||||
|
GET /api/admin/usuarios/{id} - Ver usuario
|
||||||
|
PUT /api/admin/usuarios/{id} - Actualizar usuario
|
||||||
|
DELETE /api/admin/usuarios/{id} - Eliminar usuario
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Ejemplo de creación
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST /api/admin/usuarios \
|
||||||
|
-H "Authorization: Bearer {token}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"nombre": "Juan Pérez",
|
||||||
|
"email": "juan@empresa.com",
|
||||||
|
"password": "password123",
|
||||||
|
"role": "analista"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Gestión de Giros
|
||||||
|
|
||||||
|
**Ruta**: `/admin/giros`
|
||||||
|
|
||||||
|
Catálogo de giros de negocio (industrias).
|
||||||
|
|
||||||
|
| Campo | Descripción |
|
||||||
|
|-------|-------------|
|
||||||
|
| nombre | Nombre del giro |
|
||||||
|
| activo | Estado |
|
||||||
|
|
||||||
|
#### Giros predeterminados
|
||||||
|
|
||||||
|
1. Agricultura
|
||||||
|
2. Alimentación y Bebidas
|
||||||
|
3. Automotriz
|
||||||
|
4. Comercio Mayorista
|
||||||
|
5. Comercio Minorista
|
||||||
|
6. Construcción
|
||||||
|
7. Consultoría
|
||||||
|
8. Educación
|
||||||
|
9. Energía
|
||||||
|
10. Farmacéutico
|
||||||
|
11. Financiero
|
||||||
|
12. Hotelería
|
||||||
|
13. Inmobiliario
|
||||||
|
14. Logística
|
||||||
|
15. Manufactura
|
||||||
|
16. Minería
|
||||||
|
17. Publicidad
|
||||||
|
18. Salud
|
||||||
|
19. Seguros
|
||||||
|
20. Software
|
||||||
|
21. Telecomunicaciones
|
||||||
|
22. Textil
|
||||||
|
23. Transporte
|
||||||
|
24. Turismo
|
||||||
|
|
||||||
|
#### API Endpoints
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/admin/giros - Listar giros
|
||||||
|
POST /api/admin/giros - Crear giro
|
||||||
|
PUT /api/admin/giros/{id} - Actualizar giro
|
||||||
|
DELETE /api/admin/giros/{id} - Eliminar giro
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Gestión de Umbrales
|
||||||
|
|
||||||
|
**Ruta**: `/admin/umbrales`
|
||||||
|
|
||||||
|
Configuración de umbrales para el sistema de semáforos.
|
||||||
|
|
||||||
|
| Campo | Descripción |
|
||||||
|
|-------|-------------|
|
||||||
|
| metrica | Nombre de la métrica |
|
||||||
|
| muy_positivo | Umbral verde oscuro |
|
||||||
|
| positivo | Umbral verde claro |
|
||||||
|
| neutral | Umbral amarillo |
|
||||||
|
| negativo | Umbral naranja |
|
||||||
|
| muy_negativo | Umbral rojo |
|
||||||
|
| giro_id | Giro específico (null = general) |
|
||||||
|
|
||||||
|
#### Umbrales por defecto
|
||||||
|
|
||||||
|
| Métrica | Muy Positivo | Positivo | Neutral | Negativo |
|
||||||
|
|---------|--------------|----------|---------|----------|
|
||||||
|
| margen_bruto | ≥40% | ≥30% | ≥20% | ≥10% |
|
||||||
|
| margen_ebitda | ≥25% | ≥15% | ≥10% | ≥5% |
|
||||||
|
| margen_operativo | ≥20% | ≥12% | ≥8% | ≥4% |
|
||||||
|
| margen_neto | ≥15% | ≥10% | ≥5% | ≥2% |
|
||||||
|
| roic | ≥20% | ≥15% | ≥10% | ≥5% |
|
||||||
|
| roe | ≥20% | ≥15% | ≥10% | ≥5% |
|
||||||
|
| roa | ≥12% | ≥8% | ≥5% | ≥2% |
|
||||||
|
| current_ratio | ≥2.0 | ≥1.5 | ≥1.2 | ≥1.0 |
|
||||||
|
| quick_ratio | ≥1.5 | ≥1.0 | ≥0.8 | ≥0.5 |
|
||||||
|
| net_debt_ebitda | ≤1.0 | ≤2.0 | ≤3.0 | ≤4.0 |
|
||||||
|
| interest_coverage | ≥8.0 | ≥5.0 | ≥3.0 | ≥1.5 |
|
||||||
|
|
||||||
|
#### API Endpoints
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/admin/umbrales - Listar umbrales
|
||||||
|
POST /api/admin/umbrales - Crear umbral
|
||||||
|
PUT /api/admin/umbrales/{id} - Actualizar umbral
|
||||||
|
DELETE /api/admin/umbrales/{id} - Eliminar umbral
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Ejemplo: Umbral específico por giro
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Crear umbral de margen bruto específico para Hotelería
|
||||||
|
curl -X POST /api/admin/umbrales \
|
||||||
|
-H "Authorization: Bearer {token}" \
|
||||||
|
-d '{
|
||||||
|
"metrica": "margen_bruto",
|
||||||
|
"muy_positivo": 0.50,
|
||||||
|
"positivo": 0.40,
|
||||||
|
"neutral": 0.30,
|
||||||
|
"negativo": 0.20,
|
||||||
|
"giro_id": 12
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Reglas de Mapeo
|
||||||
|
|
||||||
|
**Ruta**: `/admin/reglas-mapeo`
|
||||||
|
|
||||||
|
Reglas para clasificar cuentas automáticamente según el sistema contable.
|
||||||
|
|
||||||
|
| Campo | Descripción |
|
||||||
|
|-------|-------------|
|
||||||
|
| sistema_origen | contpaqi, aspel, sap, etc. |
|
||||||
|
| cuenta_padre_codigo | Código de cuenta padre |
|
||||||
|
| rango_inicio | Inicio del rango de códigos |
|
||||||
|
| rango_fin | Fin del rango |
|
||||||
|
| patron_regex | Patrón regex alternativo |
|
||||||
|
| reporte_contable_id | Balance General o Estado de Resultados |
|
||||||
|
| categoria_contable_id | Categoría destino |
|
||||||
|
| prioridad | Mayor número = mayor prioridad |
|
||||||
|
| activo | Estado de la regla |
|
||||||
|
|
||||||
|
#### Reglas CONTPAQi predeterminadas
|
||||||
|
|
||||||
|
| Cuenta Padre | Rango | Categoría |
|
||||||
|
|--------------|-------|-----------|
|
||||||
|
| 001-100-000 | 101-000-000 a 154-999-999 | Activos Circulantes |
|
||||||
|
| 001-200-000 | 155-000-000 a 199-999-999 | Activos No Circulantes |
|
||||||
|
| 002-100-000 | 200-000-000 a 209-999-999 | Pasivo Circulante |
|
||||||
|
| 002-200-000 | 210-000-000 a 220-999-999 | Pasivo No Circulante |
|
||||||
|
| - | 30X-XXX-XXX | Capital Social |
|
||||||
|
| - | 310-XXX-XXX | Pérdidas Anteriores |
|
||||||
|
| - | 311-XXX-XXX | Utilidades Anteriores |
|
||||||
|
| - | 40X-XXX-XXX | Ingresos |
|
||||||
|
| - | 5XX-XXX-XXX | Gastos Operativos |
|
||||||
|
| - | 6XX-XXX-XXX | Otros Gastos |
|
||||||
|
| - | 7XX-XXX-XXX | Gastos Financieros |
|
||||||
|
|
||||||
|
#### API Endpoints
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/admin/reglas-mapeo - Listar reglas
|
||||||
|
POST /api/admin/reglas-mapeo - Crear regla
|
||||||
|
PUT /api/admin/reglas-mapeo/{id} - Actualizar regla
|
||||||
|
DELETE /api/admin/reglas-mapeo/{id} - Eliminar regla
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Ejemplo: Agregar regla para Aspel
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST /api/admin/reglas-mapeo \
|
||||||
|
-H "Authorization: Bearer {token}" \
|
||||||
|
-d '{
|
||||||
|
"sistema_origen": "aspel",
|
||||||
|
"patron_regex": "^1[0-4]\\d{2}",
|
||||||
|
"reporte_contable_id": 1,
|
||||||
|
"categoria_contable_id": 1,
|
||||||
|
"prioridad": 10,
|
||||||
|
"activo": true
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Interfaz de Usuario
|
||||||
|
|
||||||
|
### Layout Admin
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// src/pages/Admin/Layout.tsx
|
||||||
|
|
||||||
|
export default function AdminLayout({ children }) {
|
||||||
|
const menuItems = [
|
||||||
|
{ path: '/admin/usuarios', label: 'Usuarios', icon: UsersIcon },
|
||||||
|
{ path: '/admin/giros', label: 'Giros', icon: BuildingIcon },
|
||||||
|
{ path: '/admin/umbrales', label: 'Umbrales', icon: ChartIcon },
|
||||||
|
{ path: '/admin/reglas-mapeo', label: 'Reglas Mapeo', icon: MapIcon },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex">
|
||||||
|
<aside className="w-64 bg-horux-dark min-h-screen p-4">
|
||||||
|
<h2 className="text-white text-xl font-bold mb-6">Administración</h2>
|
||||||
|
<nav>
|
||||||
|
{menuItems.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center px-4 py-2 rounded ${
|
||||||
|
isActive ? 'bg-horux-primary text-white' : 'text-gray-300 hover:bg-gray-700'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<item.icon className="w-5 h-5 mr-3" />
|
||||||
|
{item.label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
<main className="flex-1 p-8">{children}</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tabla CRUD genérica
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// src/components/admin/CrudTable.tsx
|
||||||
|
|
||||||
|
interface CrudTableProps<T> {
|
||||||
|
data: T[];
|
||||||
|
columns: Column<T>[];
|
||||||
|
onEdit: (item: T) => void;
|
||||||
|
onDelete: (item: T) => void;
|
||||||
|
onCreate: () => void;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CrudTable<T extends { id: number }>({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onCreate,
|
||||||
|
title,
|
||||||
|
}: CrudTableProps<T>) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h1 className="text-2xl font-bold">{title}</h1>
|
||||||
|
<button
|
||||||
|
onClick={onCreate}
|
||||||
|
className="bg-horux-primary text-white px-4 py-2 rounded hover:bg-horux-secondary"
|
||||||
|
>
|
||||||
|
+ Nuevo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table className="w-full border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-100">
|
||||||
|
{columns.map((col) => (
|
||||||
|
<th key={col.key} className="p-3 text-left">{col.label}</th>
|
||||||
|
))}
|
||||||
|
<th className="p-3 text-right">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.map((item) => (
|
||||||
|
<tr key={item.id} className="border-b hover:bg-gray-50">
|
||||||
|
{columns.map((col) => (
|
||||||
|
<td key={col.key} className="p-3">
|
||||||
|
{col.render ? col.render(item) : item[col.key]}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td className="p-3 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => onEdit(item)}
|
||||||
|
className="text-blue-600 hover:text-blue-800 mr-3"
|
||||||
|
>
|
||||||
|
Editar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(item)}
|
||||||
|
className="text-red-600 hover:text-red-800"
|
||||||
|
>
|
||||||
|
Eliminar
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auditoría
|
||||||
|
|
||||||
|
Todas las acciones administrativas se registran en el log:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Ejemplo en AdminController
|
||||||
|
Log::channel('admin')->info('Usuario creado', [
|
||||||
|
'admin_id' => auth()->id(),
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'email' => $user->email,
|
||||||
|
'ip' => request()->ip(),
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuración de logging
|
||||||
|
|
||||||
|
```php
|
||||||
|
// config/logging.php
|
||||||
|
'channels' => [
|
||||||
|
'admin' => [
|
||||||
|
'driver' => 'daily',
|
||||||
|
'path' => storage_path('logs/admin.log'),
|
||||||
|
'level' => 'info',
|
||||||
|
'days' => 90,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Permisos por Rol
|
||||||
|
|
||||||
|
| Acción | Admin | Analista | Cliente | Empleado |
|
||||||
|
|--------|-------|----------|---------|----------|
|
||||||
|
| Gestionar usuarios | ✓ | ✗ | ✗ | ✗ |
|
||||||
|
| Gestionar giros | ✓ | ✗ | ✗ | ✗ |
|
||||||
|
| Gestionar umbrales | ✓ | ✗ | ✗ | ✗ |
|
||||||
|
| Gestionar reglas mapeo | ✓ | ✗ | ✗ | ✗ |
|
||||||
|
| Ver todos los clientes | ✓ | ✓ | ✗ | ✗ |
|
||||||
|
| Crear clientes | ✓ | ✓ | ✗ | ✗ |
|
||||||
|
| Subir balanzas | ✓ | ✓ | ✗ | ✗ |
|
||||||
|
| Generar reportes | ✓ | ✓ | ✗ | ✗ |
|
||||||
|
| Ver dashboard propio | ✓ | ✓ | ✓ | * |
|
||||||
|
| Descargar PDF | ✓ | ✓ | ✓ | * |
|
||||||
|
|
||||||
|
\* Según permisos configurados
|
||||||
|
|
||||||
35
docs/README.md
Normal file
35
docs/README.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Documentación - Horux Strategy Platform
|
||||||
|
|
||||||
|
## Índice
|
||||||
|
|
||||||
|
1. [Descripción General](./01-descripcion-general.md)
|
||||||
|
2. [Arquitectura del Sistema](./02-arquitectura.md)
|
||||||
|
3. [Instalación y Configuración](./03-instalacion.md)
|
||||||
|
4. [API Reference](./04-api-reference.md)
|
||||||
|
5. [Guía del Frontend](./05-frontend.md)
|
||||||
|
6. [Base de Datos](./06-base-de-datos.md)
|
||||||
|
7. [Parsers de Balanzas](./07-parsers.md)
|
||||||
|
8. [Métricas Financieras](./08-metricas.md)
|
||||||
|
9. [Generación de PDF](./09-pdf.md)
|
||||||
|
10. [Administración](./10-administracion.md)
|
||||||
|
|
||||||
|
## Inicio Rápido
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
cd backend
|
||||||
|
composer install
|
||||||
|
cp .env.example .env
|
||||||
|
php artisan key:generate
|
||||||
|
php artisan migrate --seed
|
||||||
|
php artisan serve
|
||||||
|
|
||||||
|
# Frontend (otra terminal)
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Accede a http://localhost:5173 con:
|
||||||
|
- Email: admin@horux360.com
|
||||||
|
- Password: password
|
||||||
384
docs/plans/2025-01-31-horux-strategy-design.md
Normal file
384
docs/plans/2025-01-31-horux-strategy-design.md
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
# Horux Strategy Platform - Diseño de Sistema
|
||||||
|
|
||||||
|
**Fecha:** 2025-01-31
|
||||||
|
**Proyecto:** Plataforma de Reportes Financieros
|
||||||
|
**Cliente:** Horux 360
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Resumen Ejecutivo
|
||||||
|
|
||||||
|
Plataforma web para generar reportes financieros automatizados a partir de balanzas de comprobación. Permite a empresas subir sus datos contables de múltiples sistemas (CONTPAQi, Aspel, SAP, etc.) y obtener dashboards interactivos con métricas financieras y exportación a PDF profesional.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Stack Tecnológico
|
||||||
|
|
||||||
|
| Componente | Tecnología |
|
||||||
|
|------------|------------|
|
||||||
|
| Backend | Laravel 11 (API REST) |
|
||||||
|
| Autenticación | Laravel Sanctum |
|
||||||
|
| Frontend | React 18 + TypeScript |
|
||||||
|
| Base de datos | MySQL / PostgreSQL |
|
||||||
|
| Gráficas | Recharts |
|
||||||
|
| Generación PDF | Puppeteer / Browsershot |
|
||||||
|
| Parsing PDFs | spatie/pdf-to-text + regex |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Roles de Usuario
|
||||||
|
|
||||||
|
| Rol | Permisos |
|
||||||
|
|-----|----------|
|
||||||
|
| **Administrador** | Acceso total, configuración global, gestión de umbrales |
|
||||||
|
| **Analista** | Gestionar clientes asignados, procesar balanzas, generar reportes |
|
||||||
|
| **Cliente** | Ver dashboard propio, descargar PDFs, gestionar empleados |
|
||||||
|
| **Empleado** | Permisos configurables por el cliente |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Sistemas Contables Soportados
|
||||||
|
|
||||||
|
- CONTPAQi (inicial, con ejemplos)
|
||||||
|
- Aspel
|
||||||
|
- Alegra
|
||||||
|
- SAP
|
||||||
|
- Odoo
|
||||||
|
- Parser genérico (CSV/Excel configurable)
|
||||||
|
|
||||||
|
### 4.1 Arquitectura de Parsers
|
||||||
|
|
||||||
|
```
|
||||||
|
Upload archivo → Detector de formato → Parser específico → Formato normalizado
|
||||||
|
```
|
||||||
|
|
||||||
|
Cada parser normaliza los datos a una estructura estándar interna, permitiendo agregar nuevos sistemas sin modificar el resto del código.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Modelo de Datos
|
||||||
|
|
||||||
|
### 5.1 Usuarios y Acceso
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- users
|
||||||
|
id, nombre, email, password, role, cliente_id (nullable)
|
||||||
|
|
||||||
|
-- clientes
|
||||||
|
id, nombre_empresa, logo, giro_id, moneda, configuracion (JSON)
|
||||||
|
|
||||||
|
-- permisos_empleado
|
||||||
|
user_id, cliente_id, permisos (JSON)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Catálogos
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- giros
|
||||||
|
id, nombre, activo
|
||||||
|
|
||||||
|
-- umbrales
|
||||||
|
id, metrica, muy_positivo, positivo, neutral, negativo, muy_negativo, giro_id
|
||||||
|
|
||||||
|
-- reportes_contables
|
||||||
|
id, nombre ("Balance General", "Estado de Resultados")
|
||||||
|
|
||||||
|
-- categorias_contables
|
||||||
|
id, reporte_contable_id, nombre, orden
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Categorías Contables
|
||||||
|
|
||||||
|
**Balance General:**
|
||||||
|
| ID | Categoría |
|
||||||
|
|----|-----------|
|
||||||
|
| 1 | Activos Circulantes |
|
||||||
|
| 2 | Activos No Circulantes |
|
||||||
|
| 3 | Pasivo Circulante |
|
||||||
|
| 4 | Pasivo No Circulante |
|
||||||
|
| 5 | Capital Social |
|
||||||
|
| 6 | Pérdidas Ejercicios Anteriores |
|
||||||
|
| 7 | Utilidades Ejercicios Anteriores |
|
||||||
|
|
||||||
|
**Estado de Resultados:**
|
||||||
|
| ID | Categoría |
|
||||||
|
|----|-----------|
|
||||||
|
| 8 | Ingresos |
|
||||||
|
| 9 | Costo de Venta |
|
||||||
|
| 10 | Gastos Operativos |
|
||||||
|
| 11 | Otros Gastos |
|
||||||
|
| 12 | Gastos Financieros |
|
||||||
|
|
||||||
|
### 5.4 Balanzas y Cuentas
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- balanzas
|
||||||
|
id, cliente_id, periodo_inicio, periodo_fin, sistema_origen, archivo_original, status
|
||||||
|
|
||||||
|
-- cuentas
|
||||||
|
id, balanza_id, codigo, nombre, nivel,
|
||||||
|
reporte_contable_id, categoria_contable_id, cuenta_padre_id,
|
||||||
|
saldo_inicial_deudor, saldo_inicial_acreedor,
|
||||||
|
cargos, abonos,
|
||||||
|
saldo_final_deudor, saldo_final_acreedor,
|
||||||
|
excluida (boolean)
|
||||||
|
|
||||||
|
-- reglas_mapeo
|
||||||
|
id, sistema_origen, cuenta_padre_codigo,
|
||||||
|
rango_hijo_inicio, rango_hijo_fin,
|
||||||
|
reporte_contable_id, categoria_contable_id, prioridad
|
||||||
|
|
||||||
|
-- mapeo_cuentas (excepciones por cliente)
|
||||||
|
cliente_id, codigo_patron, categoria_destino, notas
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.5 Reportes
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- reportes
|
||||||
|
id, cliente_id, nombre, periodo_tipo, fecha_generacion, data_calculada (JSON), pdf_path
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Reglas de Mapeo Contable (CONTPAQi)
|
||||||
|
|
||||||
|
| Cuenta Padre | Rango Hijos | Reporte | Categoría |
|
||||||
|
|--------------|-------------|---------|-----------|
|
||||||
|
| 001-100-000 | 101-000-000 a 154-999-999 | Balance | Activos Circulantes |
|
||||||
|
| 001-200-000 | 155-000-000 a 199-999-999 | Balance | Activos No Circulantes |
|
||||||
|
| 002-100-000 | 200-000-000 a 209-999-999 | Balance | Pasivo Circulante |
|
||||||
|
| 002-200-000 | 210-000-000 a 220-999-999 | Balance | Pasivo No Circulante |
|
||||||
|
| 300-000-000 | - | Balance | Capital Social |
|
||||||
|
| 310-000-000 | - | Balance | Pérdidas Ejercicios Anteriores |
|
||||||
|
| 311-000-000 | - | Balance | Utilidades Ejercicios Anteriores |
|
||||||
|
| 40X-000-000 | 401 a 409 | Edo. Resultados | Ingresos |
|
||||||
|
| 5XX-000-000 | 500 a 599 | Edo. Resultados | Gastos Operativos |
|
||||||
|
| 6XX-000-000 | 600 a 699 | Edo. Resultados | Otros Gastos |
|
||||||
|
| 70X-000-000 | 700 a 799 | Edo. Resultados | Gastos Financieros |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Flujo de Usuario
|
||||||
|
|
||||||
|
1. **Crear/seleccionar cliente** - Nombre, logo, giro, moneda
|
||||||
|
2. **Subir balanzas** - 2+ archivos PDF/Excel/CSV
|
||||||
|
3. **Detección automática** - Sistema identifica origen (CONTPAQi, etc.)
|
||||||
|
4. **Clasificación automática** - Mapeo a categorías contables
|
||||||
|
5. **Revisión de anomalías** - Corregir cuentas mal clasificadas
|
||||||
|
6. **Limpieza de cuentas** - Desmarcar cuentas a excluir
|
||||||
|
7. **Procesamiento** - Cálculo de métricas
|
||||||
|
8. **Dashboard** - Visualización interactiva
|
||||||
|
9. **Exportar PDF** - Descarga de reporte
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Métricas Financieras
|
||||||
|
|
||||||
|
### 8.1 Estados Financieros Calculados
|
||||||
|
|
||||||
|
- Balance General
|
||||||
|
- Estado de Resultados
|
||||||
|
- Flujo de Efectivo (método indirecto)
|
||||||
|
|
||||||
|
### 8.2 Métricas por Categoría
|
||||||
|
|
||||||
|
**Márgenes:**
|
||||||
|
- Margen Bruto = Utilidad Bruta / Ingresos
|
||||||
|
- Margen EBITDA = EBITDA / Ingresos
|
||||||
|
- Margen Operativo = EBIT / Ingresos
|
||||||
|
- Margen Neto = Utilidad Neta / Ingresos
|
||||||
|
- Margen NOPAT = NOPAT / Ingresos
|
||||||
|
- Margen OCF = Flujo Operación / Ingresos
|
||||||
|
- Margen FCF = Flujo Libre / Ingresos
|
||||||
|
|
||||||
|
**Retorno:**
|
||||||
|
- ROIC = NOPAT / Capital Invertido
|
||||||
|
- ROE = Utilidad Neta / Capital
|
||||||
|
- ROA = Utilidad Neta / Activo Total
|
||||||
|
- ROCE = EBIT / Capital Empleado
|
||||||
|
|
||||||
|
**Eficiencia:**
|
||||||
|
- Asset Turnover = Ingresos / Activo Total
|
||||||
|
- Inventory Turnover = Costo Venta / Inventario
|
||||||
|
- Días Clientes = (Clientes / Ingresos) × 365
|
||||||
|
- Días Proveedores = (Proveedores / Costo) × 365
|
||||||
|
- Días Inventario = (Inventario / Costo) × 365
|
||||||
|
- Ciclo Conversión = Días Clientes + Días Inv. - Días Prov.
|
||||||
|
|
||||||
|
**Liquidez:**
|
||||||
|
- Current Ratio = Activo Circ. / Pasivo Circ.
|
||||||
|
- Quick Ratio = (Activo Circ. - Inventario) / Pasivo Circ.
|
||||||
|
- Cash Ratio = Efectivo / Pasivo Circ.
|
||||||
|
|
||||||
|
**Solvencia:**
|
||||||
|
- Net Debt / EBITDA = (Deuda Total - Efectivo) / EBITDA
|
||||||
|
- Interest Coverage = EBITDA / Gastos Financieros
|
||||||
|
- Debt Ratio = Deuda Total / Activo Total
|
||||||
|
|
||||||
|
**Gestión:**
|
||||||
|
- Revenue Growth = (Ingreso Actual - Anterior) / Anterior
|
||||||
|
- Key Score = Crecimiento Ingreso 12m + Margen EBITDA 12m
|
||||||
|
- CapEx / Revenue = CAPEX / Ingresos
|
||||||
|
- Effective Tax Rate = Impuestos / EBT
|
||||||
|
|
||||||
|
### 8.3 Comparativos
|
||||||
|
|
||||||
|
Para cada métrica:
|
||||||
|
- Valor actual
|
||||||
|
- Promedio 3 periodos
|
||||||
|
- Periodo anterior
|
||||||
|
- Mismo periodo año anterior
|
||||||
|
- Variación absoluta y porcentual
|
||||||
|
- Tendencia (Muy positiva → Muy negativa)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Dashboard
|
||||||
|
|
||||||
|
### 9.1 Secciones
|
||||||
|
|
||||||
|
1. Mensajes Destacados (KPIs + insights)
|
||||||
|
2. Resumen Mensual
|
||||||
|
3. Resumen 12m
|
||||||
|
4. Márgenes
|
||||||
|
5. Márgenes Deep Dive
|
||||||
|
6. Resultados
|
||||||
|
7. Balance
|
||||||
|
8. Capital de Trabajo
|
||||||
|
9. Flujo de Efectivo
|
||||||
|
10. Métricas
|
||||||
|
11. Indicadores
|
||||||
|
12. Negocios (por unidad)
|
||||||
|
13. Estados Financieros
|
||||||
|
|
||||||
|
### 9.2 Componentes React
|
||||||
|
|
||||||
|
```
|
||||||
|
components/
|
||||||
|
├── charts/
|
||||||
|
│ ├── BarChart.tsx
|
||||||
|
│ ├── LineChart.tsx
|
||||||
|
│ ├── StackedBarChart.tsx
|
||||||
|
│ ├── WaterfallChart.tsx
|
||||||
|
│ └── ComboChart.tsx
|
||||||
|
├── cards/
|
||||||
|
│ ├── KPICard.tsx
|
||||||
|
│ ├── InsightCard.tsx
|
||||||
|
│ └── MetricTable.tsx
|
||||||
|
├── tables/
|
||||||
|
│ ├── FinancialStatement.tsx
|
||||||
|
│ └── MetricsGrid.tsx
|
||||||
|
└── layout/
|
||||||
|
├── DashboardNav.tsx
|
||||||
|
├── PeriodSelector.tsx
|
||||||
|
└── ExportButton.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Panel Administrativo
|
||||||
|
|
||||||
|
### 10.1 Módulos
|
||||||
|
|
||||||
|
1. Dashboard admin (estadísticas)
|
||||||
|
2. Gestión de usuarios
|
||||||
|
3. Gestión de clientes
|
||||||
|
4. Catálogo de giros
|
||||||
|
5. Configuración de umbrales
|
||||||
|
6. Reglas de mapeo contable
|
||||||
|
7. Parsers de sistemas
|
||||||
|
8. Configuración general
|
||||||
|
|
||||||
|
### 10.2 Gestión de Empleados (Cliente)
|
||||||
|
|
||||||
|
El cliente puede:
|
||||||
|
- Agregar empleados
|
||||||
|
- Configurar permisos granulares por empleado
|
||||||
|
- Permisos disponibles: ver dashboard, ver estados financieros, exportar PDF, etc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Generación de PDF
|
||||||
|
|
||||||
|
### 11.1 Enfoque
|
||||||
|
|
||||||
|
Usar Puppeteer/Browsershot para renderizar el dashboard de React a PDF, manteniendo el mismo diseño pixel-perfect.
|
||||||
|
|
||||||
|
### 11.2 Estructura (32 páginas)
|
||||||
|
|
||||||
|
1. Portada
|
||||||
|
2. Mensajes Destacados
|
||||||
|
3. Separador "Resumen"
|
||||||
|
4. Resumen Mensual
|
||||||
|
5. Resumen 12m
|
||||||
|
6. Márgenes
|
||||||
|
7. Márgenes Deep Dive
|
||||||
|
8. Separador "Resultados"
|
||||||
|
9. Resultados
|
||||||
|
10. Separador "Balance"
|
||||||
|
11. Balance
|
||||||
|
12. Capital de Trabajo
|
||||||
|
13. Separador "Flujo"
|
||||||
|
14. Flujo de Efectivo
|
||||||
|
15. Separador "Métricas"
|
||||||
|
16-17. Métricas
|
||||||
|
18. Separador "Indicadores"
|
||||||
|
19. Indicadores
|
||||||
|
20. Separador "Negocios"
|
||||||
|
21-22. Negocios por unidad
|
||||||
|
23. Separador "Estados Financieros"
|
||||||
|
24-25. Tabla de Métricas
|
||||||
|
26-27. Estado de Resultados
|
||||||
|
28-29. Subcategorías Costo/Gasto
|
||||||
|
30. Balance General
|
||||||
|
31. Flujo de Efectivo
|
||||||
|
32. Contraportada
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Estructura del Proyecto
|
||||||
|
|
||||||
|
```
|
||||||
|
monthly-platform/
|
||||||
|
├── backend/ # Laravel
|
||||||
|
│ ├── app/
|
||||||
|
│ │ ├── Http/Controllers/
|
||||||
|
│ │ ├── Models/
|
||||||
|
│ │ ├── Services/
|
||||||
|
│ │ │ ├── Parsers/
|
||||||
|
│ │ │ │ ├── ParserInterface.php
|
||||||
|
│ │ │ │ ├── ContpaqiParser.php
|
||||||
|
│ │ │ │ ├── AspelParser.php
|
||||||
|
│ │ │ │ └── GenericoParser.php
|
||||||
|
│ │ │ ├── DetectorFormato.php
|
||||||
|
│ │ │ ├── ClasificadorCuentas.php
|
||||||
|
│ │ │ ├── CalculadorMetricas.php
|
||||||
|
│ │ │ └── GeneradorPdf.php
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── database/migrations/
|
||||||
|
│ └── routes/api.php
|
||||||
|
│
|
||||||
|
└── frontend/ # React
|
||||||
|
├── src/
|
||||||
|
│ ├── components/
|
||||||
|
│ ├── pages/
|
||||||
|
│ │ ├── Dashboard/
|
||||||
|
│ │ ├── Admin/
|
||||||
|
│ │ ├── Cliente/
|
||||||
|
│ │ └── PdfView/
|
||||||
|
│ ├── hooks/
|
||||||
|
│ ├── services/
|
||||||
|
│ └── utils/
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Consideraciones Futuras
|
||||||
|
|
||||||
|
- Integración directa con APIs de sistemas contables
|
||||||
|
- Generación de insights con IA
|
||||||
|
- Comparativas con benchmarks de industria (datos reales)
|
||||||
|
- App móvil para consulta de reportes
|
||||||
|
- Alertas automáticas por email cuando métricas salen de rango
|
||||||
355
docs/plans/2025-01-31-implementation-plan.md
Normal file
355
docs/plans/2025-01-31-implementation-plan.md
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
# Horux Strategy Platform - Plan de Implementación
|
||||||
|
|
||||||
|
**Fecha:** 2025-01-31
|
||||||
|
**Documento de diseño:** 2025-01-31-horux-strategy-design.md
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fases de Implementación
|
||||||
|
|
||||||
|
### FASE 1: Fundamentos (Backend)
|
||||||
|
|
||||||
|
#### 1.1 Configuración del proyecto Laravel
|
||||||
|
- [ ] Crear proyecto Laravel 11
|
||||||
|
- [ ] Configurar base de datos (MySQL/PostgreSQL)
|
||||||
|
- [ ] Instalar y configurar Laravel Sanctum
|
||||||
|
- [ ] Configurar CORS para React
|
||||||
|
|
||||||
|
#### 1.2 Migraciones de base de datos
|
||||||
|
- [ ] Tabla `users` (con campo role)
|
||||||
|
- [ ] Tabla `clientes`
|
||||||
|
- [ ] Tabla `giros`
|
||||||
|
- [ ] Tabla `permisos_empleado`
|
||||||
|
- [ ] Tabla `reportes_contables`
|
||||||
|
- [ ] Tabla `categorias_contables`
|
||||||
|
- [ ] Tabla `umbrales`
|
||||||
|
- [ ] Tabla `balanzas`
|
||||||
|
- [ ] Tabla `cuentas`
|
||||||
|
- [ ] Tabla `reglas_mapeo`
|
||||||
|
- [ ] Tabla `mapeo_cuentas`
|
||||||
|
- [ ] Tabla `reportes`
|
||||||
|
- [ ] Seeders para catálogos iniciales (giros, categorías, reglas CONTPAQi)
|
||||||
|
|
||||||
|
#### 1.3 Modelos y relaciones
|
||||||
|
- [ ] User (con roles)
|
||||||
|
- [ ] Cliente
|
||||||
|
- [ ] Giro
|
||||||
|
- [ ] PermisoEmpleado
|
||||||
|
- [ ] Umbral
|
||||||
|
- [ ] Balanza
|
||||||
|
- [ ] Cuenta
|
||||||
|
- [ ] ReglaMappeo
|
||||||
|
- [ ] Reporte
|
||||||
|
|
||||||
|
#### 1.4 Sistema de autenticación
|
||||||
|
- [ ] Login / Logout
|
||||||
|
- [ ] Registro (solo admin puede crear usuarios)
|
||||||
|
- [ ] Middleware por rol
|
||||||
|
- [ ] Policies para autorización
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### FASE 2: Parser de Balanzas
|
||||||
|
|
||||||
|
#### 2.1 Arquitectura de parsers
|
||||||
|
- [ ] Crear `ParserInterface.php`
|
||||||
|
- [ ] Crear `DetectorFormato.php`
|
||||||
|
- [ ] Implementar `ContpaqiParser.php`
|
||||||
|
- [ ] Crear `ParserGenerico.php` (CSV/Excel)
|
||||||
|
|
||||||
|
#### 2.2 Procesamiento de archivos
|
||||||
|
- [ ] Endpoint para upload de archivos
|
||||||
|
- [ ] Extracción de texto de PDF
|
||||||
|
- [ ] Parsing de estructura CONTPAQi
|
||||||
|
- [ ] Normalización a formato interno
|
||||||
|
- [ ] Almacenamiento en tabla `cuentas`
|
||||||
|
|
||||||
|
#### 2.3 Clasificación automática
|
||||||
|
- [ ] Crear `ClasificadorCuentas.php`
|
||||||
|
- [ ] Aplicar reglas de mapeo
|
||||||
|
- [ ] Detectar anomalías
|
||||||
|
- [ ] Marcar cuentas que requieren revisión
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### FASE 3: Motor de Cálculo
|
||||||
|
|
||||||
|
#### 3.1 Calculador de estados financieros
|
||||||
|
- [ ] Crear `CalculadorMetricas.php`
|
||||||
|
- [ ] Calcular Balance General
|
||||||
|
- [ ] Calcular Estado de Resultados
|
||||||
|
- [ ] Calcular Flujo de Efectivo (método indirecto)
|
||||||
|
|
||||||
|
#### 3.2 Cálculo de métricas
|
||||||
|
- [ ] Métricas de márgenes (7 métricas)
|
||||||
|
- [ ] Métricas de retorno (4 métricas)
|
||||||
|
- [ ] Métricas de eficiencia (6 métricas)
|
||||||
|
- [ ] Métricas de liquidez (3 métricas)
|
||||||
|
- [ ] Métricas de solvencia (3 métricas)
|
||||||
|
- [ ] Métricas de gestión (4 métricas)
|
||||||
|
|
||||||
|
#### 3.3 Comparativos
|
||||||
|
- [ ] Calcular promedio 3 periodos
|
||||||
|
- [ ] Comparar con periodo anterior
|
||||||
|
- [ ] Comparar con año anterior
|
||||||
|
- [ ] Calcular variaciones
|
||||||
|
- [ ] Determinar tendencia según umbrales
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### FASE 4: API REST
|
||||||
|
|
||||||
|
#### 4.1 Endpoints de autenticación
|
||||||
|
- [ ] POST /api/login
|
||||||
|
- [ ] POST /api/logout
|
||||||
|
- [ ] GET /api/user
|
||||||
|
|
||||||
|
#### 4.2 Endpoints de clientes
|
||||||
|
- [ ] GET /api/clientes
|
||||||
|
- [ ] POST /api/clientes
|
||||||
|
- [ ] GET /api/clientes/{id}
|
||||||
|
- [ ] PUT /api/clientes/{id}
|
||||||
|
- [ ] DELETE /api/clientes/{id}
|
||||||
|
|
||||||
|
#### 4.3 Endpoints de balanzas
|
||||||
|
- [ ] POST /api/clientes/{id}/balanzas (upload)
|
||||||
|
- [ ] GET /api/clientes/{id}/balanzas
|
||||||
|
- [ ] GET /api/balanzas/{id}/cuentas
|
||||||
|
- [ ] PUT /api/balanzas/{id}/cuentas (actualizar exclusiones)
|
||||||
|
- [ ] PUT /api/cuentas/{id}/clasificacion (corregir anomalía)
|
||||||
|
|
||||||
|
#### 4.4 Endpoints de reportes
|
||||||
|
- [ ] POST /api/clientes/{id}/reportes (generar)
|
||||||
|
- [ ] GET /api/clientes/{id}/reportes
|
||||||
|
- [ ] GET /api/reportes/{id}
|
||||||
|
- [ ] GET /api/reportes/{id}/pdf
|
||||||
|
|
||||||
|
#### 4.5 Endpoints administrativos
|
||||||
|
- [ ] CRUD /api/admin/usuarios
|
||||||
|
- [ ] CRUD /api/admin/giros
|
||||||
|
- [ ] CRUD /api/admin/umbrales
|
||||||
|
- [ ] CRUD /api/admin/reglas-mapeo
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### FASE 5: Frontend - Fundamentos
|
||||||
|
|
||||||
|
#### 5.1 Configuración del proyecto React
|
||||||
|
- [ ] Crear proyecto con Vite + TypeScript
|
||||||
|
- [ ] Configurar React Router
|
||||||
|
- [ ] Configurar Axios para API
|
||||||
|
- [ ] Configurar Recharts
|
||||||
|
- [ ] Configurar Tailwind CSS
|
||||||
|
|
||||||
|
#### 5.2 Autenticación
|
||||||
|
- [ ] Página de Login
|
||||||
|
- [ ] Context de autenticación
|
||||||
|
- [ ] Rutas protegidas por rol
|
||||||
|
- [ ] Interceptor para tokens
|
||||||
|
|
||||||
|
#### 5.3 Layout base
|
||||||
|
- [ ] Sidebar de navegación
|
||||||
|
- [ ] Header con usuario
|
||||||
|
- [ ] Layout para admin
|
||||||
|
- [ ] Layout para cliente
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### FASE 6: Frontend - Flujo Principal
|
||||||
|
|
||||||
|
#### 6.1 Gestión de clientes
|
||||||
|
- [ ] Lista de clientes
|
||||||
|
- [ ] Formulario crear/editar cliente
|
||||||
|
- [ ] Subida de logo
|
||||||
|
|
||||||
|
#### 6.2 Subida de balanzas
|
||||||
|
- [ ] Componente de upload (drag & drop)
|
||||||
|
- [ ] Preview de archivos
|
||||||
|
- [ ] Indicador de progreso
|
||||||
|
- [ ] Detección de sistema origen
|
||||||
|
|
||||||
|
#### 6.3 Revisión y limpieza
|
||||||
|
- [ ] Vista de anomalías con corrección
|
||||||
|
- [ ] Lista de cuentas con checkboxes
|
||||||
|
- [ ] Filtros y búsqueda
|
||||||
|
- [ ] Guardar exclusiones
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### FASE 7: Frontend - Dashboard
|
||||||
|
|
||||||
|
#### 7.1 Componentes de gráficas
|
||||||
|
- [ ] BarChart
|
||||||
|
- [ ] LineChart
|
||||||
|
- [ ] StackedBarChart
|
||||||
|
- [ ] WaterfallChart
|
||||||
|
- [ ] ComboChart
|
||||||
|
|
||||||
|
#### 7.2 Componentes de cards
|
||||||
|
- [ ] KPICard (con semáforo)
|
||||||
|
- [ ] InsightCard
|
||||||
|
- [ ] MetricTable
|
||||||
|
|
||||||
|
#### 7.3 Secciones del dashboard
|
||||||
|
- [ ] Mensajes Destacados
|
||||||
|
- [ ] Resumen Mensual
|
||||||
|
- [ ] Resumen 12m
|
||||||
|
- [ ] Márgenes
|
||||||
|
- [ ] Márgenes Deep Dive
|
||||||
|
- [ ] Resultados
|
||||||
|
- [ ] Balance
|
||||||
|
- [ ] Capital de Trabajo
|
||||||
|
- [ ] Flujo de Efectivo
|
||||||
|
- [ ] Métricas
|
||||||
|
- [ ] Indicadores
|
||||||
|
- [ ] Negocios
|
||||||
|
- [ ] Estados Financieros
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### FASE 8: Generación de PDF
|
||||||
|
|
||||||
|
#### 8.1 Backend
|
||||||
|
- [ ] Instalar Browsershot
|
||||||
|
- [ ] Crear `GeneradorPdf.php`
|
||||||
|
- [ ] Endpoint GET /api/reportes/{id}/pdf
|
||||||
|
|
||||||
|
#### 8.2 Frontend
|
||||||
|
- [ ] Vista `PdfView` con todas las páginas
|
||||||
|
- [ ] Estilos específicos para impresión
|
||||||
|
- [ ] Separadores de sección
|
||||||
|
- [ ] Portada y contraportada
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### FASE 9: Panel Administrativo
|
||||||
|
|
||||||
|
#### 9.1 Dashboard admin
|
||||||
|
- [ ] Estadísticas generales
|
||||||
|
- [ ] Alertas pendientes
|
||||||
|
|
||||||
|
#### 9.2 Gestión de usuarios
|
||||||
|
- [ ] CRUD de usuarios
|
||||||
|
- [ ] Asignación de roles
|
||||||
|
|
||||||
|
#### 9.3 Configuración
|
||||||
|
- [ ] Editor de umbrales
|
||||||
|
- [ ] Editor de reglas de mapeo
|
||||||
|
- [ ] Editor de giros
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### FASE 10: Funcionalidades de Cliente
|
||||||
|
|
||||||
|
#### 10.1 Gestión de empleados
|
||||||
|
- [ ] Lista de empleados
|
||||||
|
- [ ] Crear empleado
|
||||||
|
- [ ] Configurar permisos
|
||||||
|
|
||||||
|
#### 10.2 Configuración
|
||||||
|
- [ ] Exclusiones guardadas
|
||||||
|
- [ ] Preferencias de reporte
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### FASE 11: Testing y Refinamiento
|
||||||
|
|
||||||
|
#### 11.1 Testing
|
||||||
|
- [ ] Tests unitarios del motor de cálculo
|
||||||
|
- [ ] Tests de parsers
|
||||||
|
- [ ] Tests de API
|
||||||
|
- [ ] Tests E2E del flujo principal
|
||||||
|
|
||||||
|
#### 11.2 Refinamiento
|
||||||
|
- [ ] Optimización de rendimiento
|
||||||
|
- [ ] Manejo de errores
|
||||||
|
- [ ] Validaciones
|
||||||
|
- [ ] UX/UI polish
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prioridad de Implementación
|
||||||
|
|
||||||
|
1. **MVP (Fases 1-4, 5-7):** Sistema funcional con CONTPAQi, dashboard básico
|
||||||
|
2. **PDF (Fase 8):** Exportación de reportes
|
||||||
|
3. **Admin (Fase 9):** Panel de configuración
|
||||||
|
4. **Multi-usuario (Fase 10):** Funcionalidades de cliente
|
||||||
|
5. **Calidad (Fase 11):** Testing y refinamiento
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Archivos Clave a Crear
|
||||||
|
|
||||||
|
### Backend (Laravel)
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── Http/Controllers/
|
||||||
|
│ ├── AuthController.php
|
||||||
|
│ ├── ClienteController.php
|
||||||
|
│ ├── BalanzaController.php
|
||||||
|
│ ├── CuentaController.php
|
||||||
|
│ ├── ReporteController.php
|
||||||
|
│ └── Admin/
|
||||||
|
│ ├── UsuarioController.php
|
||||||
|
│ ├── UmbralController.php
|
||||||
|
│ └── ReglaMappeoController.php
|
||||||
|
├── Models/
|
||||||
|
│ ├── User.php
|
||||||
|
│ ├── Cliente.php
|
||||||
|
│ ├── Balanza.php
|
||||||
|
│ ├── Cuenta.php
|
||||||
|
│ ├── Reporte.php
|
||||||
|
│ └── ...
|
||||||
|
├── Services/
|
||||||
|
│ ├── Parsers/
|
||||||
|
│ │ ├── ParserInterface.php
|
||||||
|
│ │ ├── ContpaqiParser.php
|
||||||
|
│ │ └── DetectorFormato.php
|
||||||
|
│ ├── ClasificadorCuentas.php
|
||||||
|
│ ├── CalculadorMetricas.php
|
||||||
|
│ └── GeneradorPdf.php
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend (React)
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── components/
|
||||||
|
│ ├── charts/
|
||||||
|
│ │ ├── BarChart.tsx
|
||||||
|
│ │ ├── LineChart.tsx
|
||||||
|
│ │ ├── StackedBarChart.tsx
|
||||||
|
│ │ ├── WaterfallChart.tsx
|
||||||
|
│ │ └── ComboChart.tsx
|
||||||
|
│ ├── cards/
|
||||||
|
│ │ ├── KPICard.tsx
|
||||||
|
│ │ └── InsightCard.tsx
|
||||||
|
│ ├── forms/
|
||||||
|
│ │ ├── ClienteForm.tsx
|
||||||
|
│ │ └── UploadBalanza.tsx
|
||||||
|
│ └── layout/
|
||||||
|
│ ├── Sidebar.tsx
|
||||||
|
│ └── Header.tsx
|
||||||
|
├── pages/
|
||||||
|
│ ├── Login.tsx
|
||||||
|
│ ├── Dashboard/
|
||||||
|
│ │ ├── index.tsx
|
||||||
|
│ │ ├── Resumen.tsx
|
||||||
|
│ │ ├── Margenes.tsx
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── Admin/
|
||||||
|
│ │ ├── Usuarios.tsx
|
||||||
|
│ │ └── Umbrales.tsx
|
||||||
|
│ └── PdfView/
|
||||||
|
│ └── index.tsx
|
||||||
|
├── hooks/
|
||||||
|
│ ├── useAuth.ts
|
||||||
|
│ ├── useCliente.ts
|
||||||
|
│ └── useReporte.ts
|
||||||
|
├── services/
|
||||||
|
│ └── api.ts
|
||||||
|
└── types/
|
||||||
|
└── index.ts
|
||||||
|
```
|
||||||
86
frontend/README.md
Normal file
86
frontend/README.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# Horux Strategy Platform - Frontend
|
||||||
|
|
||||||
|
Frontend con React 18 + TypeScript + Vite para la plataforma de reportes financieros.
|
||||||
|
|
||||||
|
## Requisitos
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- npm o yarn
|
||||||
|
|
||||||
|
## Instalación
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Instalar dependencias
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Iniciar servidor de desarrollo
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Build para producción
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Estructura
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── components/
|
||||||
|
│ ├── charts/ # Componentes de gráficas (Recharts)
|
||||||
|
│ │ ├── BarChart.tsx
|
||||||
|
│ │ └── LineChart.tsx
|
||||||
|
│ ├── cards/ # Tarjetas KPI y tablas
|
||||||
|
│ │ ├── KPICard.tsx
|
||||||
|
│ │ └── MetricTable.tsx
|
||||||
|
│ ├── forms/ # Formularios
|
||||||
|
│ │ ├── ClienteForm.tsx
|
||||||
|
│ │ ├── UploadBalanza.tsx
|
||||||
|
│ │ └── GenerarReporte.tsx
|
||||||
|
│ └── layout/ # Layout principal
|
||||||
|
│ ├── Layout.tsx
|
||||||
|
│ ├── Sidebar.tsx
|
||||||
|
│ └── Header.tsx
|
||||||
|
├── pages/
|
||||||
|
│ ├── Login.tsx
|
||||||
|
│ ├── Clientes/ # Gestión de clientes
|
||||||
|
│ ├── Dashboard/ # Dashboard de reportes
|
||||||
|
│ ├── PdfView/ # Vista para generación de PDF
|
||||||
|
│ └── Admin/ # Panel administrativo
|
||||||
|
├── context/
|
||||||
|
│ └── AuthContext.tsx # Contexto de autenticación
|
||||||
|
├── services/
|
||||||
|
│ └── api.ts # Cliente API
|
||||||
|
├── types/
|
||||||
|
│ └── index.ts # Tipos TypeScript
|
||||||
|
└── hooks/ # Hooks personalizados
|
||||||
|
```
|
||||||
|
|
||||||
|
## Características
|
||||||
|
|
||||||
|
- **Autenticación** con tokens Bearer
|
||||||
|
- **Dashboard interactivo** con gráficas Recharts
|
||||||
|
- **Subida de archivos** con drag & drop
|
||||||
|
- **Panel administrativo** para configuración
|
||||||
|
- **Generación de PDF** mediante Browsershot
|
||||||
|
- **Diseño responsivo** con Tailwind CSS
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # Desarrollo
|
||||||
|
npm run build # Build producción
|
||||||
|
npm run preview # Preview del build
|
||||||
|
npm run lint # Linting
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuración
|
||||||
|
|
||||||
|
El proxy de desarrollo está configurado en `vite.config.ts` para redireccionar las llamadas `/api/*` al backend Laravel en `http://localhost:8000`.
|
||||||
|
|
||||||
|
## Credenciales de prueba
|
||||||
|
|
||||||
|
- **Email**: admin@horux360.com
|
||||||
|
- **Password**: password
|
||||||
|
|
||||||
|
## Licencia
|
||||||
|
|
||||||
|
Propietario - Horux 360
|
||||||
16
frontend/index.html
Normal file
16
frontend/index.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Horux Strategy</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
38
frontend/package.json
Normal file
38
frontend/package.json
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "horux-strategy-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.22.0",
|
||||||
|
"axios": "^1.6.7",
|
||||||
|
"recharts": "^2.12.0",
|
||||||
|
"react-dropzone": "^14.2.3",
|
||||||
|
"clsx": "^2.1.0",
|
||||||
|
"date-fns": "^3.3.1",
|
||||||
|
"react-hot-toast": "^2.4.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"autoprefixer": "^10.4.18",
|
||||||
|
"eslint": "^9.17.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.0.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.16",
|
||||||
|
"globals": "^15.14.0",
|
||||||
|
"postcss": "^8.4.35",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"typescript": "~5.6.2",
|
||||||
|
"typescript-eslint": "^8.18.2",
|
||||||
|
"vite": "^6.0.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
99
frontend/src/App.tsx
Normal file
99
frontend/src/App.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { Toaster } from 'react-hot-toast';
|
||||||
|
import { AuthProvider, useAuth } from './context/AuthContext';
|
||||||
|
import Login from './pages/Login';
|
||||||
|
import Layout from './components/layout/Layout';
|
||||||
|
import ClientesList from './pages/Clientes/ClientesList';
|
||||||
|
import ClienteDetail from './pages/Clientes/ClienteDetail';
|
||||||
|
import Dashboard from './pages/Dashboard';
|
||||||
|
import ReporteView from './pages/ReporteView';
|
||||||
|
import PdfView from './pages/PdfView';
|
||||||
|
import AdminUsuarios from './pages/Admin/Usuarios';
|
||||||
|
import AdminGiros from './pages/Admin/Giros';
|
||||||
|
import AdminUmbrales from './pages/Admin/Umbrales';
|
||||||
|
import AdminReglasMapeeo from './pages/Admin/ReglasMapeeo';
|
||||||
|
|
||||||
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdminRoute({ children }: { children: React.ReactNode }) {
|
||||||
|
const { isAdmin } = useAuth();
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppRoutes() {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={user ? <Navigate to="/" replace /> : <Login />} />
|
||||||
|
|
||||||
|
{/* Vista PDF (sin layout) */}
|
||||||
|
<Route path="/pdf-view/:id" element={<PdfView />} />
|
||||||
|
|
||||||
|
{/* Rutas protegidas con layout */}
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Layout />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route index element={<Navigate to="/clientes" replace />} />
|
||||||
|
<Route path="clientes" element={<ClientesList />} />
|
||||||
|
<Route path="clientes/:id" element={<ClienteDetail />} />
|
||||||
|
<Route path="dashboard/:clienteId/:reporteId" element={<Dashboard />} />
|
||||||
|
<Route path="reportes/:id" element={<ReporteView />} />
|
||||||
|
|
||||||
|
{/* Rutas de administración */}
|
||||||
|
<Route path="admin" element={<AdminRoute><Navigate to="/admin/usuarios" replace /></AdminRoute>} />
|
||||||
|
<Route path="admin/usuarios" element={<AdminRoute><AdminUsuarios /></AdminRoute>} />
|
||||||
|
<Route path="admin/giros" element={<AdminRoute><AdminGiros /></AdminRoute>} />
|
||||||
|
<Route path="admin/umbrales" element={<AdminRoute><AdminUmbrales /></AdminRoute>} />
|
||||||
|
<Route path="admin/reglas-mapeo" element={<AdminRoute><AdminReglasMapeeo /></AdminRoute>} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<AppRoutes />
|
||||||
|
<Toaster position="top-right" />
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
frontend/src/components/cards/KPICard.tsx
Normal file
54
frontend/src/components/cards/KPICard.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { Tendencia, Comparativo } from '../../types';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
value: string;
|
||||||
|
subtitle?: string;
|
||||||
|
trend?: Comparativo;
|
||||||
|
tendencia?: Tendencia;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function KPICard({ title, value, subtitle, trend, tendencia }: Props) {
|
||||||
|
const getTendenciaColor = (t?: Tendencia) => {
|
||||||
|
switch (t) {
|
||||||
|
case 'muy_positivo': return 'bg-emerald-500';
|
||||||
|
case 'positivo': return 'bg-emerald-300';
|
||||||
|
case 'neutral': return 'bg-amber-400';
|
||||||
|
case 'negativo': return 'bg-orange-500';
|
||||||
|
case 'muy_negativo': return 'bg-red-500';
|
||||||
|
default: return 'bg-gray-300';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTrendIcon = (variacion?: number) => {
|
||||||
|
if (!variacion) return null;
|
||||||
|
if (variacion > 0) return '↑';
|
||||||
|
if (variacion < 0) return '↓';
|
||||||
|
return '→';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">{title}</p>
|
||||||
|
<p className="text-2xl font-bold mt-1">{value}</p>
|
||||||
|
{subtitle && <p className="text-sm text-gray-500 mt-1">{subtitle}</p>}
|
||||||
|
{trend && (
|
||||||
|
<p className={clsx(
|
||||||
|
'text-sm mt-2',
|
||||||
|
trend.variacion_porcentual > 0 ? 'text-green-600' : 'text-red-600'
|
||||||
|
)}>
|
||||||
|
{getTrendIcon(trend.variacion_porcentual)} {Math.abs(trend.variacion_porcentual).toFixed(1)}%
|
||||||
|
<span className="text-gray-400 ml-1">vs anterior</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{tendencia && (
|
||||||
|
<div className={clsx('w-3 h-3 rounded-full', getTendenciaColor(tendencia))} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
frontend/src/components/cards/MetricTable.tsx
Normal file
88
frontend/src/components/cards/MetricTable.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { Metrica, Comparativo, Tendencia } from '../../types';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
interface MetricaConComparativo extends Metrica {
|
||||||
|
comparativo?: Comparativo;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
metricas: MetricaConComparativo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MetricTable({ title, metricas }: Props) {
|
||||||
|
const getTendenciaClass = (t?: Tendencia) => {
|
||||||
|
switch (t) {
|
||||||
|
case 'muy_positivo': return 'tendencia-muy-positivo';
|
||||||
|
case 'positivo': return 'tendencia-positivo';
|
||||||
|
case 'neutral': return 'tendencia-neutral';
|
||||||
|
case 'negativo': return 'tendencia-negativo';
|
||||||
|
case 'muy_negativo': return 'tendencia-muy-negativo';
|
||||||
|
default: return 'bg-gray-100';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatValue = (valor: number, nombre: string) => {
|
||||||
|
// Determinar si es porcentaje o ratio
|
||||||
|
if (nombre.toLowerCase().includes('ratio') || nombre.toLowerCase().includes('coverage')) {
|
||||||
|
return valor.toFixed(2);
|
||||||
|
}
|
||||||
|
return `${(valor * 100).toFixed(1)}%`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="font-semibold mb-4">{title}</h3>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-gray-500">
|
||||||
|
<th className="text-left py-2 font-medium">Métrica</th>
|
||||||
|
<th className="text-right py-2 font-medium">Valor</th>
|
||||||
|
<th className="text-right py-2 font-medium">vs Anterior</th>
|
||||||
|
<th className="text-right py-2 font-medium">Prom. 3P</th>
|
||||||
|
<th className="text-center py-2 font-medium">Estado</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{metricas.filter(m => m).map((metrica, index) => (
|
||||||
|
<tr key={index} className="border-b last:border-0">
|
||||||
|
<td className="py-3">{metrica.nombre}</td>
|
||||||
|
<td className="text-right font-medium">
|
||||||
|
{formatValue(metrica.valor, metrica.nombre)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right">
|
||||||
|
{metrica.comparativo ? (
|
||||||
|
<span className={clsx(
|
||||||
|
metrica.comparativo.variacion_porcentual > 0 ? 'text-green-600' : 'text-red-600'
|
||||||
|
)}>
|
||||||
|
{metrica.comparativo.variacion_porcentual > 0 ? '+' : ''}
|
||||||
|
{metrica.comparativo.variacion_porcentual.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right">
|
||||||
|
{metrica.comparativo?.promedio_3_periodos != null ? (
|
||||||
|
formatValue(metrica.comparativo.promedio_3_periodos, metrica.nombre)
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="text-center">
|
||||||
|
<span className={clsx(
|
||||||
|
'px-2 py-1 rounded text-xs font-medium',
|
||||||
|
getTendenciaClass(metrica.tendencia)
|
||||||
|
)}>
|
||||||
|
{metrica.tendencia?.replace('_', ' ') || 'N/A'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
frontend/src/components/charts/BarChart.tsx
Normal file
61
frontend/src/components/charts/BarChart.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts';
|
||||||
|
|
||||||
|
interface DataItem {
|
||||||
|
name: string;
|
||||||
|
valor: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: DataItem[];
|
||||||
|
horizontal?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BarChartComponent({ data, horizontal = false }: Props) {
|
||||||
|
const getColor = (value: number) => {
|
||||||
|
return value >= 0 ? '#10b981' : '#ef4444';
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatValue = (value: number) => {
|
||||||
|
if (Math.abs(value) >= 1000000) {
|
||||||
|
return `${(value / 1000000).toFixed(1)}M`;
|
||||||
|
}
|
||||||
|
if (Math.abs(value) >= 1000) {
|
||||||
|
return `${(value / 1000).toFixed(1)}K`;
|
||||||
|
}
|
||||||
|
return value.toFixed(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (horizontal) {
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={200}>
|
||||||
|
<BarChart data={data} layout="vertical">
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis type="number" tickFormatter={formatValue} />
|
||||||
|
<YAxis dataKey="name" type="category" width={120} />
|
||||||
|
<Tooltip formatter={(value: number) => formatValue(value)} />
|
||||||
|
<Bar dataKey="valor" radius={[0, 4, 4, 0]}>
|
||||||
|
{data.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={getColor(entry.valor)} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<BarChart data={data}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="name" />
|
||||||
|
<YAxis tickFormatter={formatValue} />
|
||||||
|
<Tooltip formatter={(value: number) => formatValue(value)} />
|
||||||
|
<Bar dataKey="valor" radius={[4, 4, 0, 0]}>
|
||||||
|
{data.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={getColor(entry.valor)} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
frontend/src/components/charts/LineChart.tsx
Normal file
33
frontend/src/components/charts/LineChart.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: Record<string, string | number>[];
|
||||||
|
lines: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLORS = ['#0ea5e9', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'];
|
||||||
|
|
||||||
|
export default function LineChartComponent({ data, lines }: Props) {
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<LineChart data={data}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="periodo" />
|
||||||
|
<YAxis tickFormatter={(value) => `${value.toFixed(0)}%`} />
|
||||||
|
<Tooltip formatter={(value: number) => `${value.toFixed(1)}%`} />
|
||||||
|
<Legend />
|
||||||
|
{lines.map((line, index) => (
|
||||||
|
<Line
|
||||||
|
key={line}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={line}
|
||||||
|
stroke={COLORS[index % COLORS.length]}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ r: 4 }}
|
||||||
|
activeDot={{ r: 6 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
frontend/src/components/forms/ClienteForm.tsx
Normal file
110
frontend/src/components/forms/ClienteForm.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { clientesApi, girosApi } from '../../services/api';
|
||||||
|
import { Giro } from '../../types';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onSuccess: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ClienteForm({ onSuccess, onCancel }: Props) {
|
||||||
|
const [giros, setGiros] = useState<Giro[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
nombre_empresa: '',
|
||||||
|
giro_id: '',
|
||||||
|
moneda: 'MXN',
|
||||||
|
});
|
||||||
|
const [logo, setLogo] = useState<File | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
girosApi.list().then(setGiros).catch(() => toast.error('Error al cargar giros'));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = new FormData();
|
||||||
|
data.append('nombre_empresa', formData.nombre_empresa);
|
||||||
|
data.append('giro_id', formData.giro_id);
|
||||||
|
data.append('moneda', formData.moneda);
|
||||||
|
if (logo) {
|
||||||
|
data.append('logo', logo);
|
||||||
|
}
|
||||||
|
|
||||||
|
await clientesApi.create(data);
|
||||||
|
onSuccess();
|
||||||
|
} catch {
|
||||||
|
toast.error('Error al crear cliente');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="label">Nombre de la empresa</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.nombre_empresa}
|
||||||
|
onChange={(e) => setFormData({ ...formData, nombre_empresa: e.target.value })}
|
||||||
|
className="input"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="label">Giro</label>
|
||||||
|
<select
|
||||||
|
value={formData.giro_id}
|
||||||
|
onChange={(e) => setFormData({ ...formData, giro_id: e.target.value })}
|
||||||
|
className="input"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Seleccionar giro...</option>
|
||||||
|
{giros.map((giro) => (
|
||||||
|
<option key={giro.id} value={giro.id}>
|
||||||
|
{giro.nombre}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="label">Moneda</label>
|
||||||
|
<select
|
||||||
|
value={formData.moneda}
|
||||||
|
onChange={(e) => setFormData({ ...formData, moneda: e.target.value })}
|
||||||
|
className="input"
|
||||||
|
>
|
||||||
|
<option value="MXN">MXN - Peso Mexicano</option>
|
||||||
|
<option value="USD">USD - Dólar Estadounidense</option>
|
||||||
|
<option value="EUR">EUR - Euro</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="label">Logo (opcional)</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={(e) => setLogo(e.target.files?.[0] || null)}
|
||||||
|
className="input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<button type="button" onClick={onCancel} className="btn btn-secondary flex-1">
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button type="submit" disabled={loading} className="btn btn-primary flex-1">
|
||||||
|
{loading ? 'Guardando...' : 'Crear Cliente'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
frontend/src/components/forms/GenerarReporte.tsx
Normal file
99
frontend/src/components/forms/GenerarReporte.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { reportesApi } from '../../services/api';
|
||||||
|
import { Balanza } from '../../types';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
clienteId: number;
|
||||||
|
balanzas: Balanza[];
|
||||||
|
onSuccess: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GenerarReporte({ clienteId, balanzas, onSuccess, onCancel }: Props) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [nombre, setNombre] = useState('');
|
||||||
|
const [selectedBalanzas, setSelectedBalanzas] = useState<number[]>([]);
|
||||||
|
|
||||||
|
const handleToggleBalanza = (id: number) => {
|
||||||
|
setSelectedBalanzas((prev) =>
|
||||||
|
prev.includes(id) ? prev.filter((b) => b !== id) : [...prev, id]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (selectedBalanzas.length === 0) {
|
||||||
|
toast.error('Selecciona al menos una balanza');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await reportesApi.create(clienteId, nombre, selectedBalanzas);
|
||||||
|
onSuccess();
|
||||||
|
} catch {
|
||||||
|
toast.error('Error al generar reporte');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="label">Nombre del reporte</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={nombre}
|
||||||
|
onChange={(e) => setNombre(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
placeholder="Ej: Reporte Anual 2024"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="label">Seleccionar balanzas</label>
|
||||||
|
<div className="space-y-2 max-h-48 overflow-y-auto border rounded-lg p-3">
|
||||||
|
{balanzas.map((balanza) => (
|
||||||
|
<label
|
||||||
|
key={balanza.id}
|
||||||
|
className="flex items-center gap-3 p-2 hover:bg-gray-50 rounded cursor-pointer"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedBalanzas.includes(balanza.id)}
|
||||||
|
onChange={() => handleToggleBalanza(balanza.id)}
|
||||||
|
className="w-4 h-4 text-primary-600"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">
|
||||||
|
{new Date(balanza.periodo_fin).toLocaleDateString('es-MX', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500 capitalize">{balanza.sistema_origen}</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
{selectedBalanzas.length} balanza(s) seleccionada(s)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<button type="button" onClick={onCancel} className="btn btn-secondary flex-1">
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button type="submit" disabled={loading} className="btn btn-primary flex-1">
|
||||||
|
{loading ? 'Generando...' : 'Generar Reporte'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
130
frontend/src/components/forms/UploadBalanza.tsx
Normal file
130
frontend/src/components/forms/UploadBalanza.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useDropzone } from 'react-dropzone';
|
||||||
|
import { balanzasApi } from '../../services/api';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
clienteId: number;
|
||||||
|
onSuccess: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UploadBalanza({ clienteId, onSuccess, onCancel }: Props) {
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
periodo_inicio: '',
|
||||||
|
periodo_fin: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||||
|
if (acceptedFiles.length > 0) {
|
||||||
|
setFile(acceptedFiles[0]);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||||
|
onDrop,
|
||||||
|
accept: {
|
||||||
|
'application/pdf': ['.pdf'],
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
|
||||||
|
'application/vnd.ms-excel': ['.xls'],
|
||||||
|
'text/csv': ['.csv'],
|
||||||
|
},
|
||||||
|
maxFiles: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!file) {
|
||||||
|
toast.error('Selecciona un archivo');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = new FormData();
|
||||||
|
data.append('archivo', file);
|
||||||
|
data.append('periodo_inicio', formData.periodo_inicio);
|
||||||
|
data.append('periodo_fin', formData.periodo_fin);
|
||||||
|
|
||||||
|
await balanzasApi.upload(clienteId, data);
|
||||||
|
onSuccess();
|
||||||
|
} catch {
|
||||||
|
toast.error('Error al subir balanza');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{/* Dropzone */}
|
||||||
|
<div
|
||||||
|
{...getRootProps()}
|
||||||
|
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${
|
||||||
|
isDragActive
|
||||||
|
? 'border-primary-500 bg-primary-50'
|
||||||
|
: file
|
||||||
|
? 'border-green-500 bg-green-50'
|
||||||
|
: 'border-gray-300 hover:border-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input {...getInputProps()} />
|
||||||
|
{file ? (
|
||||||
|
<div>
|
||||||
|
<p className="text-green-600 font-medium">{file.name}</p>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
{(file.size / 1024 / 1024).toFixed(2)} MB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : isDragActive ? (
|
||||||
|
<p className="text-primary-600">Suelta el archivo aquí...</p>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
Arrastra un archivo aquí o haz clic para seleccionar
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">
|
||||||
|
PDF, Excel o CSV (máx. 10MB)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fechas */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="label">Periodo inicio</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={formData.periodo_inicio}
|
||||||
|
onChange={(e) => setFormData({ ...formData, periodo_inicio: e.target.value })}
|
||||||
|
className="input"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Periodo fin</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={formData.periodo_fin}
|
||||||
|
onChange={(e) => setFormData({ ...formData, periodo_fin: e.target.value })}
|
||||||
|
className="input"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<button type="button" onClick={onCancel} className="btn btn-secondary flex-1">
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button type="submit" disabled={loading || !file} className="btn btn-primary flex-1">
|
||||||
|
{loading ? 'Subiendo...' : 'Subir Balanza'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
frontend/src/components/layout/Header.tsx
Normal file
32
frontend/src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
export default function Header() {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await logout();
|
||||||
|
navigate('/login');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
{/* Breadcrumb or page title could go here */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
{user?.email}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="text-sm text-gray-600 hover:text-gray-900 transition-colors"
|
||||||
|
>
|
||||||
|
Cerrar sesión
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
frontend/src/components/layout/Layout.tsx
Normal file
17
frontend/src/components/layout/Layout.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Outlet } from 'react-router-dom';
|
||||||
|
import Sidebar from './Sidebar';
|
||||||
|
import Header from './Header';
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex">
|
||||||
|
<Sidebar />
|
||||||
|
<div className="flex-1 flex flex-col ml-64">
|
||||||
|
<Header />
|
||||||
|
<main className="flex-1 p-6 overflow-auto">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
frontend/src/components/layout/Sidebar.tsx
Normal file
95
frontend/src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { NavLink } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ name: 'Clientes', path: '/clientes', icon: '🏢', roles: ['admin', 'analista'] },
|
||||||
|
{ name: 'Mi Empresa', path: '/clientes', icon: '📊', roles: ['cliente', 'empleado'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const adminItems = [
|
||||||
|
{ name: 'Usuarios', path: '/admin/usuarios', icon: '👥' },
|
||||||
|
{ name: 'Giros', path: '/admin/giros', icon: '🏷️' },
|
||||||
|
{ name: 'Umbrales', path: '/admin/umbrales', icon: '📏' },
|
||||||
|
{ name: 'Reglas Mapeo', path: '/admin/reglas-mapeo', icon: '🔗' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Sidebar() {
|
||||||
|
const { user, isAdmin } = useAuth();
|
||||||
|
|
||||||
|
const filteredNavItems = navItems.filter(
|
||||||
|
(item) => item.roles.includes(user?.role || '')
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="fixed left-0 top-0 h-screen w-64 bg-horux-dark text-white flex flex-col z-50">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="p-6 border-b border-gray-700">
|
||||||
|
<h1 className="text-2xl font-bold text-horux-highlight">Horux Strategy</h1>
|
||||||
|
<p className="text-sm text-gray-400">Reportes Financieros</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav className="flex-1 p-4 space-y-2 overflow-y-auto">
|
||||||
|
<div className="text-xs uppercase text-gray-500 font-semibold mb-2 px-3">
|
||||||
|
Principal
|
||||||
|
</div>
|
||||||
|
{filteredNavItems.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
clsx(
|
||||||
|
'flex items-center gap-3 px-3 py-2 rounded-lg transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-horux-accent text-white'
|
||||||
|
: 'text-gray-300 hover:bg-horux-primary hover:text-white'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span>{item.icon}</span>
|
||||||
|
<span>{item.name}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
<div className="text-xs uppercase text-gray-500 font-semibold mt-6 mb-2 px-3">
|
||||||
|
Administración
|
||||||
|
</div>
|
||||||
|
{adminItems.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
clsx(
|
||||||
|
'flex items-center gap-3 px-3 py-2 rounded-lg transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-horux-accent text-white'
|
||||||
|
: 'text-gray-300 hover:bg-horux-primary hover:text-white'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span>{item.icon}</span>
|
||||||
|
<span>{item.name}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* User info */}
|
||||||
|
<div className="p-4 border-t border-gray-700">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-horux-accent flex items-center justify-center text-lg">
|
||||||
|
{user?.nombre?.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">{user?.nombre}</p>
|
||||||
|
<p className="text-xs text-gray-400 capitalize">{user?.role}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
frontend/src/context/AuthContext.tsx
Normal file
68
frontend/src/context/AuthContext.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||||
|
import { User } from '../types';
|
||||||
|
import { authApi } from '../services/api';
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: User | null;
|
||||||
|
loading: boolean;
|
||||||
|
login: (email: string, password: string) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
isAdmin: boolean;
|
||||||
|
isAnalista: boolean;
|
||||||
|
isCliente: boolean;
|
||||||
|
isEmpleado: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (token) {
|
||||||
|
authApi.getUser()
|
||||||
|
.then(setUser)
|
||||||
|
.catch(() => localStorage.removeItem('token'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
} else {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = async (email: string, password: string) => {
|
||||||
|
const { user } = await authApi.login(email, password);
|
||||||
|
setUser(user);
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
await authApi.logout();
|
||||||
|
setUser(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const value: AuthContextType = {
|
||||||
|
user,
|
||||||
|
loading,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
isAdmin: user?.role === 'admin',
|
||||||
|
isAnalista: user?.role === 'analista',
|
||||||
|
isCliente: user?.role === 'cliente',
|
||||||
|
isEmpleado: user?.role === 'empleado',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
83
frontend/src/index.css
Normal file
83
frontend/src/index.css
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #213547;
|
||||||
|
background-color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.btn {
|
||||||
|
@apply px-4 py-2 rounded-lg font-medium transition-colors duration-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
@apply bg-primary-600 text-white hover:bg-primary-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
@apply bg-gray-200 text-gray-800 hover:bg-gray-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
@apply bg-red-600 text-white hover:bg-red-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
@apply w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 outline-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
@apply bg-white rounded-xl shadow-sm border border-gray-100 p-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
@apply block text-sm font-medium text-gray-700 mb-1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Colores para tendencias/semáforos */
|
||||||
|
.tendencia-muy-positivo {
|
||||||
|
@apply bg-emerald-500 text-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tendencia-positivo {
|
||||||
|
@apply bg-emerald-300 text-emerald-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tendencia-neutral {
|
||||||
|
@apply bg-amber-400 text-amber-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tendencia-negativo {
|
||||||
|
@apply bg-orange-500 text-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tendencia-muy-negativo {
|
||||||
|
@apply bg-red-500 text-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Estilos para impresión/PDF */
|
||||||
|
@media print {
|
||||||
|
.no-print {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-break {
|
||||||
|
page-break-after: always;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
print-color-adjust: exact;
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
175
frontend/src/pages/Admin/Giros.tsx
Normal file
175
frontend/src/pages/Admin/Giros.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { adminApi } from '../../services/api';
|
||||||
|
import { Giro } from '../../types';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
export default function AdminGiros() {
|
||||||
|
const [giros, setGiros] = useState<Giro[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [formData, setFormData] = useState({ nombre: '', activo: true });
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadGiros();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadGiros = async () => {
|
||||||
|
try {
|
||||||
|
const data = await adminApi.giros.list();
|
||||||
|
setGiros(data);
|
||||||
|
} catch {
|
||||||
|
toast.error('Error al cargar giros');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
if (editingId) {
|
||||||
|
await adminApi.giros.update(editingId, formData);
|
||||||
|
toast.success('Giro actualizado');
|
||||||
|
} else {
|
||||||
|
await adminApi.giros.create(formData);
|
||||||
|
toast.success('Giro creado');
|
||||||
|
}
|
||||||
|
setShowForm(false);
|
||||||
|
setEditingId(null);
|
||||||
|
setFormData({ nombre: '', activo: true });
|
||||||
|
loadGiros();
|
||||||
|
} catch {
|
||||||
|
toast.error('Error al guardar giro');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (giro: Giro) => {
|
||||||
|
setFormData({ nombre: giro.nombre, activo: giro.activo });
|
||||||
|
setEditingId(giro.id);
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
if (!confirm('¿Estás seguro de eliminar este giro?')) return;
|
||||||
|
try {
|
||||||
|
await adminApi.giros.delete(id);
|
||||||
|
toast.success('Giro eliminado');
|
||||||
|
loadGiros();
|
||||||
|
} catch {
|
||||||
|
toast.error('No se puede eliminar (tiene clientes asociados)');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Giros de Negocio</h1>
|
||||||
|
<button onClick={() => setShowForm(true)} className="btn btn-primary">
|
||||||
|
+ Nuevo Giro
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card overflow-hidden">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-500">Nombre</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-500">Estado</th>
|
||||||
|
<th className="px-4 py-3 text-right text-sm font-medium text-gray-500">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{giros.map((giro) => (
|
||||||
|
<tr key={giro.id}>
|
||||||
|
<td className="px-4 py-3">{giro.nombre}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 text-xs rounded ${
|
||||||
|
giro.activo
|
||||||
|
? 'bg-green-100 text-green-700'
|
||||||
|
: 'bg-gray-100 text-gray-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{giro.activo ? 'Activo' : 'Inactivo'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => handleEdit(giro)}
|
||||||
|
className="text-primary-600 hover:text-primary-700 mr-3"
|
||||||
|
>
|
||||||
|
Editar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(giro.id)}
|
||||||
|
className="text-red-600 hover:text-red-700"
|
||||||
|
>
|
||||||
|
Eliminar
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-xl max-w-md w-full p-6">
|
||||||
|
<h2 className="text-xl font-bold mb-4">
|
||||||
|
{editingId ? 'Editar Giro' : 'Nuevo Giro'}
|
||||||
|
</h2>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="label">Nombre</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.nombre}
|
||||||
|
onChange={(e) => setFormData({ ...formData, nombre: e.target.value })}
|
||||||
|
className="input"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.activo}
|
||||||
|
onChange={(e) => setFormData({ ...formData, activo: e.target.checked })}
|
||||||
|
className="w-4 h-4"
|
||||||
|
/>
|
||||||
|
<span>Activo</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowForm(false);
|
||||||
|
setEditingId(null);
|
||||||
|
setFormData({ nombre: '', activo: true });
|
||||||
|
}}
|
||||||
|
className="btn btn-secondary flex-1"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn btn-primary flex-1">
|
||||||
|
Guardar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
frontend/src/pages/Admin/ReglasMapeeo.tsx
Normal file
122
frontend/src/pages/Admin/ReglasMapeeo.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { adminApi } from '../../services/api';
|
||||||
|
import { ReglaMapeo } from '../../types';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
export default function AdminReglasMapeeo() {
|
||||||
|
const [reglas, setReglas] = useState<ReglaMapeo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [filterSistema, setFilterSistema] = useState<string>('');
|
||||||
|
|
||||||
|
const sistemas = ['contpaqi', 'aspel', 'sap', 'odoo', 'alegra', 'generico'];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadReglas();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadReglas = async () => {
|
||||||
|
try {
|
||||||
|
const data = await adminApi.reglasMapeeo.list();
|
||||||
|
setReglas(data);
|
||||||
|
} catch {
|
||||||
|
toast.error('Error al cargar reglas');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredReglas = filterSistema
|
||||||
|
? reglas.filter((r) => r.sistema_origen === filterSistema)
|
||||||
|
: reglas;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Reglas de Mapeo Contable</h1>
|
||||||
|
<button className="btn btn-primary">+ Nueva Regla</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filtro */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<select
|
||||||
|
value={filterSistema}
|
||||||
|
onChange={(e) => setFilterSistema(e.target.value)}
|
||||||
|
className="input max-w-xs"
|
||||||
|
>
|
||||||
|
<option value="">Todos los sistemas</option>
|
||||||
|
{sistemas.map((sistema) => (
|
||||||
|
<option key={sistema} value={sistema}>
|
||||||
|
{sistema.charAt(0).toUpperCase() + sistema.slice(1)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-500">Sistema</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-500">Cuenta Padre</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-500">Rango / Patrón</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-500">Reporte</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-500">Categoría</th>
|
||||||
|
<th className="px-4 py-3 text-center font-medium text-gray-500">Prioridad</th>
|
||||||
|
<th className="px-4 py-3 text-center font-medium text-gray-500">Activo</th>
|
||||||
|
<th className="px-4 py-3 text-right font-medium text-gray-500">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{filteredReglas.map((regla) => (
|
||||||
|
<tr key={regla.id}>
|
||||||
|
<td className="px-4 py-3 capitalize">{regla.sistema_origen}</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs">
|
||||||
|
{regla.cuenta_padre_codigo || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs">
|
||||||
|
{regla.patron_regex ||
|
||||||
|
(regla.rango_inicio && regla.rango_fin
|
||||||
|
? `${regla.rango_inicio} - ${regla.rango_fin}`
|
||||||
|
: '-')}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">{regla.reporte_contable?.nombre}</td>
|
||||||
|
<td className="px-4 py-3">{regla.categoria_contable?.nombre}</td>
|
||||||
|
<td className="px-4 py-3 text-center">{regla.prioridad}</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 text-xs rounded ${
|
||||||
|
regla.activo
|
||||||
|
? 'bg-green-100 text-green-700'
|
||||||
|
: 'bg-gray-100 text-gray-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{regla.activo ? 'Sí' : 'No'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<button className="text-primary-600 hover:text-primary-700 mr-3">
|
||||||
|
Editar
|
||||||
|
</button>
|
||||||
|
<button className="text-red-600 hover:text-red-700">Eliminar</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 text-sm text-gray-500">
|
||||||
|
<p>Las reglas determinan cómo se clasifican las cuentas de cada sistema contable.</p>
|
||||||
|
<p>Las reglas con mayor prioridad se evalúan primero.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
frontend/src/pages/Admin/Umbrales.tsx
Normal file
113
frontend/src/pages/Admin/Umbrales.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { adminApi } from '../../services/api';
|
||||||
|
import { Umbral, Giro } from '../../types';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
export default function AdminUmbrales() {
|
||||||
|
const [umbrales, setUmbrales] = useState<Umbral[]>([]);
|
||||||
|
const [giros, setGiros] = useState<Giro[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [filterGiro, setFilterGiro] = useState<string>('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([loadUmbrales(), loadGiros()]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadUmbrales = async () => {
|
||||||
|
try {
|
||||||
|
const data = await adminApi.umbrales.list();
|
||||||
|
setUmbrales(data);
|
||||||
|
} catch {
|
||||||
|
toast.error('Error al cargar umbrales');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadGiros = async () => {
|
||||||
|
try {
|
||||||
|
const data = await adminApi.giros.list();
|
||||||
|
setGiros(data);
|
||||||
|
} catch {
|
||||||
|
toast.error('Error al cargar giros');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredUmbrales = filterGiro
|
||||||
|
? umbrales.filter((u) => u.giro_id?.toString() === filterGiro || (!u.giro_id && filterGiro === 'general'))
|
||||||
|
: umbrales;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Umbrales de Métricas</h1>
|
||||||
|
<button className="btn btn-primary">+ Nuevo Umbral</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filtro */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<select
|
||||||
|
value={filterGiro}
|
||||||
|
onChange={(e) => setFilterGiro(e.target.value)}
|
||||||
|
className="input max-w-xs"
|
||||||
|
>
|
||||||
|
<option value="">Todos los umbrales</option>
|
||||||
|
<option value="general">Umbrales generales</option>
|
||||||
|
{giros.map((giro) => (
|
||||||
|
<option key={giro.id} value={giro.id}>
|
||||||
|
{giro.nombre}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-500">Métrica</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-500">Giro</th>
|
||||||
|
<th className="px-4 py-3 text-center font-medium text-emerald-600">Muy +</th>
|
||||||
|
<th className="px-4 py-3 text-center font-medium text-emerald-400">+</th>
|
||||||
|
<th className="px-4 py-3 text-center font-medium text-amber-500">Neutral</th>
|
||||||
|
<th className="px-4 py-3 text-center font-medium text-orange-500">-</th>
|
||||||
|
<th className="px-4 py-3 text-center font-medium text-red-500">Muy -</th>
|
||||||
|
<th className="px-4 py-3 text-right font-medium text-gray-500">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{filteredUmbrales.map((umbral) => (
|
||||||
|
<tr key={umbral.id}>
|
||||||
|
<td className="px-4 py-3 font-medium">{umbral.metrica}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-500">
|
||||||
|
{umbral.giro?.nombre || 'General'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center">{umbral.muy_positivo ?? '-'}</td>
|
||||||
|
<td className="px-4 py-3 text-center">{umbral.positivo ?? '-'}</td>
|
||||||
|
<td className="px-4 py-3 text-center">{umbral.neutral ?? '-'}</td>
|
||||||
|
<td className="px-4 py-3 text-center">{umbral.negativo ?? '-'}</td>
|
||||||
|
<td className="px-4 py-3 text-center">{umbral.muy_negativo ?? '-'}</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<button className="text-primary-600 hover:text-primary-700">Editar</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 text-sm text-gray-500">
|
||||||
|
<p>Los umbrales determinan el color del semáforo para cada métrica.</p>
|
||||||
|
<p>Los umbrales por giro tienen prioridad sobre los generales.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
125
frontend/src/pages/Admin/Usuarios.tsx
Normal file
125
frontend/src/pages/Admin/Usuarios.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { adminApi } from '../../services/api';
|
||||||
|
import { User } from '../../types';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
export default function AdminUsuarios() {
|
||||||
|
const [usuarios, setUsuarios] = useState<User[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadUsuarios();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadUsuarios = async () => {
|
||||||
|
try {
|
||||||
|
const data = await adminApi.usuarios.list();
|
||||||
|
setUsuarios(data);
|
||||||
|
} catch {
|
||||||
|
toast.error('Error al cargar usuarios');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
if (!confirm('¿Estás seguro de eliminar este usuario?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await adminApi.usuarios.delete(id);
|
||||||
|
toast.success('Usuario eliminado');
|
||||||
|
loadUsuarios();
|
||||||
|
} catch {
|
||||||
|
toast.error('Error al eliminar usuario');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Usuarios</h1>
|
||||||
|
<button onClick={() => setShowForm(true)} className="btn btn-primary">
|
||||||
|
+ Nuevo Usuario
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card overflow-hidden">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-500">Nombre</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-500">Email</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-500">Rol</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-500">Cliente</th>
|
||||||
|
<th className="px-4 py-3 text-right text-sm font-medium text-gray-500">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{usuarios.map((usuario) => (
|
||||||
|
<tr key={usuario.id}>
|
||||||
|
<td className="px-4 py-3">{usuario.nombre}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-500">{usuario.email}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="px-2 py-1 text-xs rounded bg-primary-100 text-primary-700 capitalize">
|
||||||
|
{usuario.role}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-500">
|
||||||
|
{usuario.cliente?.nombre_empresa || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingUser(usuario)}
|
||||||
|
className="text-primary-600 hover:text-primary-700 mr-3"
|
||||||
|
>
|
||||||
|
Editar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(usuario.id)}
|
||||||
|
className="text-red-600 hover:text-red-700"
|
||||||
|
>
|
||||||
|
Eliminar
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal placeholder - implementar formulario completo */}
|
||||||
|
{(showForm || editingUser) && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-xl max-w-lg w-full p-6">
|
||||||
|
<h2 className="text-xl font-bold mb-4">
|
||||||
|
{editingUser ? 'Editar Usuario' : 'Nuevo Usuario'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-500 mb-4">Formulario de usuario aquí...</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowForm(false);
|
||||||
|
setEditingUser(null);
|
||||||
|
}}
|
||||||
|
className="btn btn-secondary flex-1"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-primary flex-1">Guardar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
275
frontend/src/pages/Clientes/ClienteDetail.tsx
Normal file
275
frontend/src/pages/Clientes/ClienteDetail.tsx
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { clientesApi, balanzasApi, reportesApi } from '../../services/api';
|
||||||
|
import { Cliente, Balanza, Reporte } from '../../types';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import UploadBalanza from '../../components/forms/UploadBalanza';
|
||||||
|
import GenerarReporte from '../../components/forms/GenerarReporte';
|
||||||
|
|
||||||
|
export default function ClienteDetail() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [cliente, setCliente] = useState<Cliente | null>(null);
|
||||||
|
const [balanzas, setBalanzas] = useState<Balanza[]>([]);
|
||||||
|
const [reportes, setReportes] = useState<Reporte[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showUpload, setShowUpload] = useState(false);
|
||||||
|
const [showGenerarReporte, setShowGenerarReporte] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (id) {
|
||||||
|
loadData(parseInt(id));
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const loadData = async (clienteId: number) => {
|
||||||
|
try {
|
||||||
|
const [clienteData, balanzasData, reportesData] = await Promise.all([
|
||||||
|
clientesApi.get(clienteId),
|
||||||
|
balanzasApi.list(clienteId),
|
||||||
|
reportesApi.list(clienteId),
|
||||||
|
]);
|
||||||
|
setCliente(clienteData);
|
||||||
|
setBalanzas(balanzasData);
|
||||||
|
setReportes(reportesData);
|
||||||
|
} catch {
|
||||||
|
toast.error('Error al cargar datos del cliente');
|
||||||
|
navigate('/clientes');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBalanzaUploaded = () => {
|
||||||
|
setShowUpload(false);
|
||||||
|
if (id) loadData(parseInt(id));
|
||||||
|
toast.success('Balanza subida exitosamente');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReporteGenerado = () => {
|
||||||
|
setShowGenerarReporte(false);
|
||||||
|
if (id) loadData(parseInt(id));
|
||||||
|
toast.success('Reporte generado exitosamente');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadPdf = async (reporteId: number, nombre: string) => {
|
||||||
|
try {
|
||||||
|
const blob = await reportesApi.downloadPdf(reporteId);
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${nombre}.pdf`;
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
} catch {
|
||||||
|
toast.error('Error al descargar PDF');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cliente) return null;
|
||||||
|
|
||||||
|
const balanzasCompletadas = balanzas.filter((b) => b.status === 'completado');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-4 mb-8">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/clientes')}
|
||||||
|
className="text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
← Volver
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{cliente.logo ? (
|
||||||
|
<img
|
||||||
|
src={`/storage/${cliente.logo}`}
|
||||||
|
alt={cliente.nombre_empresa}
|
||||||
|
className="w-16 h-16 rounded-lg object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-16 h-16 rounded-lg bg-primary-100 flex items-center justify-center text-2xl">
|
||||||
|
🏢
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">{cliente.nombre_empresa}</h1>
|
||||||
|
<p className="text-gray-500">{cliente.giro?.nombre} • {cliente.moneda}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Balanzas */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold">Balanzas de Comprobación</h2>
|
||||||
|
<button onClick={() => setShowUpload(true)} className="btn btn-primary text-sm">
|
||||||
|
+ Subir Balanza
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{balanzas.length === 0 ? (
|
||||||
|
<p className="text-gray-500 text-center py-8">
|
||||||
|
No hay balanzas cargadas
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{balanzas.map((balanza) => (
|
||||||
|
<div
|
||||||
|
key={balanza.id}
|
||||||
|
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">
|
||||||
|
{new Date(balanza.periodo_fin).toLocaleDateString('es-MX', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500 capitalize">
|
||||||
|
{balanza.sistema_origen}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 rounded text-xs font-medium ${
|
||||||
|
balanza.status === 'completado'
|
||||||
|
? 'bg-green-100 text-green-700'
|
||||||
|
: balanza.status === 'error'
|
||||||
|
? 'bg-red-100 text-red-700'
|
||||||
|
: 'bg-yellow-100 text-yellow-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{balanza.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reportes */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold">Reportes</h2>
|
||||||
|
{balanzasCompletadas.length >= 1 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowGenerarReporte(true)}
|
||||||
|
className="btn btn-primary text-sm"
|
||||||
|
>
|
||||||
|
+ Generar Reporte
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{reportes.length === 0 ? (
|
||||||
|
<p className="text-gray-500 text-center py-8">
|
||||||
|
No hay reportes generados
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{reportes.map((reporte) => (
|
||||||
|
<div
|
||||||
|
key={reporte.id}
|
||||||
|
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{reporte.nombre}</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{new Date(reporte.periodo_fin).toLocaleDateString('es-MX')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{reporte.status === 'completado' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/dashboard/${cliente.id}/${reporte.id}`)}
|
||||||
|
className="text-primary-600 hover:text-primary-700 text-sm"
|
||||||
|
>
|
||||||
|
Ver Dashboard
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDownloadPdf(reporte.id, reporte.nombre)}
|
||||||
|
className="text-gray-600 hover:text-gray-700 text-sm"
|
||||||
|
>
|
||||||
|
PDF
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 rounded text-xs font-medium ${
|
||||||
|
reporte.status === 'completado'
|
||||||
|
? 'bg-green-100 text-green-700'
|
||||||
|
: reporte.status === 'error'
|
||||||
|
? 'bg-red-100 text-red-700'
|
||||||
|
: 'bg-yellow-100 text-yellow-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{reporte.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modales */}
|
||||||
|
{showUpload && cliente && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-xl max-w-lg w-full">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-xl font-bold">Subir Balanza</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowUpload(false)}
|
||||||
|
className="text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<UploadBalanza
|
||||||
|
clienteId={cliente.id}
|
||||||
|
onSuccess={handleBalanzaUploaded}
|
||||||
|
onCancel={() => setShowUpload(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showGenerarReporte && cliente && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-xl max-w-lg w-full">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-xl font-bold">Generar Reporte</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowGenerarReporte(false)}
|
||||||
|
className="text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<GenerarReporte
|
||||||
|
clienteId={cliente.id}
|
||||||
|
balanzas={balanzasCompletadas}
|
||||||
|
onSuccess={handleReporteGenerado}
|
||||||
|
onCancel={() => setShowGenerarReporte(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
130
frontend/src/pages/Clientes/ClientesList.tsx
Normal file
130
frontend/src/pages/Clientes/ClientesList.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { clientesApi } from '../../services/api';
|
||||||
|
import { Cliente } from '../../types';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import ClienteForm from '../../components/forms/ClienteForm';
|
||||||
|
|
||||||
|
export default function ClientesList() {
|
||||||
|
const [clientes, setClientes] = useState<Cliente[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const { isAdmin, isAnalista } = useAuth();
|
||||||
|
|
||||||
|
const canCreate = isAdmin || isAnalista;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadClientes();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadClientes = async () => {
|
||||||
|
try {
|
||||||
|
const data = await clientesApi.list();
|
||||||
|
setClientes(data);
|
||||||
|
} catch {
|
||||||
|
toast.error('Error al cargar clientes');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClienteCreated = () => {
|
||||||
|
setShowForm(false);
|
||||||
|
loadClientes();
|
||||||
|
toast.success('Cliente creado exitosamente');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Clientes</h1>
|
||||||
|
{canCreate && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowForm(true)}
|
||||||
|
className="btn btn-primary"
|
||||||
|
>
|
||||||
|
+ Nuevo Cliente
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{clientes.length === 0 ? (
|
||||||
|
<div className="card text-center py-12">
|
||||||
|
<p className="text-gray-500">No hay clientes registrados</p>
|
||||||
|
{canCreate && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowForm(true)}
|
||||||
|
className="btn btn-primary mt-4"
|
||||||
|
>
|
||||||
|
Crear primer cliente
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{clientes.map((cliente) => (
|
||||||
|
<Link
|
||||||
|
key={cliente.id}
|
||||||
|
to={`/clientes/${cliente.id}`}
|
||||||
|
className="card hover:shadow-md transition-shadow"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{cliente.logo ? (
|
||||||
|
<img
|
||||||
|
src={`/storage/${cliente.logo}`}
|
||||||
|
alt={cliente.nombre_empresa}
|
||||||
|
className="w-16 h-16 rounded-lg object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-16 h-16 rounded-lg bg-primary-100 flex items-center justify-center text-2xl">
|
||||||
|
🏢
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900">
|
||||||
|
{cliente.nombre_empresa}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{cliente.giro?.nombre || 'Sin giro'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
|
Moneda: {cliente.moneda}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modal de formulario */}
|
||||||
|
{showForm && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-xl font-bold">Nuevo Cliente</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowForm(false)}
|
||||||
|
className="text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ClienteForm onSuccess={handleClienteCreated} onCancel={() => setShowForm(false)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
368
frontend/src/pages/Dashboard/index.tsx
Normal file
368
frontend/src/pages/Dashboard/index.tsx
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { reportesApi, clientesApi } from '../../services/api';
|
||||||
|
import { Reporte, Cliente } from '../../types';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import KPICard from '../../components/cards/KPICard';
|
||||||
|
import MetricTable from '../../components/cards/MetricTable';
|
||||||
|
import BarChartComponent from '../../components/charts/BarChart';
|
||||||
|
import LineChartComponent from '../../components/charts/LineChart';
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const { clienteId, reporteId } = useParams<{ clienteId: string; reporteId: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [reporte, setReporte] = useState<Reporte | null>(null);
|
||||||
|
const [cliente, setCliente] = useState<Cliente | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [activeSection, setActiveSection] = useState('resumen');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (clienteId && reporteId) {
|
||||||
|
loadData(parseInt(clienteId), parseInt(reporteId));
|
||||||
|
}
|
||||||
|
}, [clienteId, reporteId]);
|
||||||
|
|
||||||
|
const loadData = async (cId: number, rId: number) => {
|
||||||
|
try {
|
||||||
|
const [reporteData, clienteData] = await Promise.all([
|
||||||
|
reportesApi.get(rId),
|
||||||
|
clientesApi.get(cId),
|
||||||
|
]);
|
||||||
|
setReporte(reporteData);
|
||||||
|
setCliente(clienteData);
|
||||||
|
} catch {
|
||||||
|
toast.error('Error al cargar datos');
|
||||||
|
navigate(`/clientes/${clienteId}`);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!reporte || !cliente || !reporte.data_calculada) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { metricas, estados_financieros, comparativos, flujo_efectivo, periodos } = reporte.data_calculada;
|
||||||
|
const { balance_general, estado_resultados } = estados_financieros;
|
||||||
|
|
||||||
|
const sections = [
|
||||||
|
{ id: 'resumen', name: 'Resumen' },
|
||||||
|
{ id: 'margenes', name: 'Márgenes' },
|
||||||
|
{ id: 'resultados', name: 'Resultados' },
|
||||||
|
{ id: 'balance', name: 'Balance' },
|
||||||
|
{ id: 'flujo', name: 'Flujo de Efectivo' },
|
||||||
|
{ id: 'metricas', name: 'Métricas' },
|
||||||
|
{ id: 'estados', name: 'Estados Financieros' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const formatCurrency = (value: number) => {
|
||||||
|
return new Intl.NumberFormat('es-MX', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: cliente.moneda,
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatPercent = (value: number) => {
|
||||||
|
return `${(value * 100).toFixed(1)}%`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/clientes/${clienteId}`)}
|
||||||
|
className="text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
← Volver
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">{reporte.nombre}</h1>
|
||||||
|
<p className="text-gray-500">
|
||||||
|
{cliente.nombre_empresa} • {new Date(reporte.periodo_fin).toLocaleDateString('es-MX')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
const blob = await reportesApi.downloadPdf(reporte.id);
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${reporte.nombre}.pdf`;
|
||||||
|
a.click();
|
||||||
|
}}
|
||||||
|
className="btn btn-primary"
|
||||||
|
>
|
||||||
|
Descargar PDF
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<div className="flex gap-2 mb-6 overflow-x-auto pb-2">
|
||||||
|
{sections.map((section) => (
|
||||||
|
<button
|
||||||
|
key={section.id}
|
||||||
|
onClick={() => setActiveSection(section.id)}
|
||||||
|
className={`px-4 py-2 rounded-lg whitespace-nowrap transition-colors ${
|
||||||
|
activeSection === section.id
|
||||||
|
? 'bg-primary-600 text-white'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{section.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{activeSection === 'resumen' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* KPIs principales */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<KPICard
|
||||||
|
title="Ingresos"
|
||||||
|
value={formatCurrency(estado_resultados.ingresos)}
|
||||||
|
trend={comparativos.revenue_growth}
|
||||||
|
tendencia={metricas.revenue_growth?.tendencia}
|
||||||
|
/>
|
||||||
|
<KPICard
|
||||||
|
title="Utilidad Neta"
|
||||||
|
value={formatCurrency(estado_resultados.utilidad_neta)}
|
||||||
|
subtitle={formatPercent(metricas.margen_neto?.valor || 0)}
|
||||||
|
tendencia={metricas.margen_neto?.tendencia}
|
||||||
|
/>
|
||||||
|
<KPICard
|
||||||
|
title="EBITDA"
|
||||||
|
value={formatPercent(metricas.margen_ebitda?.valor || 0)}
|
||||||
|
subtitle="Margen EBITDA"
|
||||||
|
tendencia={metricas.margen_ebitda?.tendencia}
|
||||||
|
/>
|
||||||
|
<KPICard
|
||||||
|
title="ROE"
|
||||||
|
value={formatPercent(metricas.roe?.valor || 0)}
|
||||||
|
subtitle="Retorno sobre capital"
|
||||||
|
tendencia={metricas.roe?.tendencia}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Resumen estados */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="font-semibold mb-4">Balance General</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600">Total Activos</span>
|
||||||
|
<span className="font-medium">{formatCurrency(balance_general.total_activos)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600">Total Pasivos</span>
|
||||||
|
<span className="font-medium">{formatCurrency(balance_general.total_pasivos)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between border-t pt-2">
|
||||||
|
<span className="font-medium">Capital</span>
|
||||||
|
<span className="font-bold text-primary-600">
|
||||||
|
{formatCurrency(balance_general.total_capital)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="font-semibold mb-4">Estado de Resultados</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600">Ingresos</span>
|
||||||
|
<span className="font-medium">{formatCurrency(estado_resultados.ingresos)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600">Utilidad Bruta</span>
|
||||||
|
<span className="font-medium">{formatCurrency(estado_resultados.utilidad_bruta)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600">Utilidad Operativa</span>
|
||||||
|
<span className="font-medium">{formatCurrency(estado_resultados.utilidad_operativa)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between border-t pt-2">
|
||||||
|
<span className="font-medium">Utilidad Neta</span>
|
||||||
|
<span className="font-bold text-green-600">
|
||||||
|
{formatCurrency(estado_resultados.utilidad_neta)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeSection === 'margenes' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{['margen_bruto', 'margen_ebitda', 'margen_operativo', 'margen_neto'].map((key) => (
|
||||||
|
<KPICard
|
||||||
|
key={key}
|
||||||
|
title={metricas[key]?.nombre || key}
|
||||||
|
value={formatPercent(metricas[key]?.valor || 0)}
|
||||||
|
tendencia={metricas[key]?.tendencia}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{periodos.length > 1 && (
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="font-semibold mb-4">Evolución de Márgenes</h3>
|
||||||
|
<LineChartComponent
|
||||||
|
data={periodos.map((p) => ({
|
||||||
|
periodo: p.periodo,
|
||||||
|
'Margen Bruto': (p.estado_resultados.utilidad_bruta / p.estado_resultados.ingresos) * 100,
|
||||||
|
'Margen Operativo': (p.estado_resultados.utilidad_operativa / p.estado_resultados.ingresos) * 100,
|
||||||
|
'Margen Neto': (p.estado_resultados.utilidad_neta / p.estado_resultados.ingresos) * 100,
|
||||||
|
}))}
|
||||||
|
lines={['Margen Bruto', 'Margen Operativo', 'Margen Neto']}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeSection === 'resultados' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="font-semibold mb-4">Estado de Resultados</h3>
|
||||||
|
<BarChartComponent
|
||||||
|
data={[
|
||||||
|
{ name: 'Ingresos', valor: estado_resultados.ingresos },
|
||||||
|
{ name: 'Costo Venta', valor: -estado_resultados.costo_venta },
|
||||||
|
{ name: 'Gastos Op.', valor: -estado_resultados.gastos_operativos },
|
||||||
|
{ name: 'Otros', valor: -(estado_resultados.otros_gastos + estado_resultados.gastos_financieros) },
|
||||||
|
{ name: 'Utilidad Neta', valor: estado_resultados.utilidad_neta },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeSection === 'balance' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="font-semibold mb-4">Activos</h3>
|
||||||
|
<BarChartComponent
|
||||||
|
data={[
|
||||||
|
{ name: 'Circulantes', valor: balance_general.activos_circulantes },
|
||||||
|
{ name: 'No Circulantes', valor: balance_general.activos_no_circulantes },
|
||||||
|
]}
|
||||||
|
horizontal
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="font-semibold mb-4">Pasivos y Capital</h3>
|
||||||
|
<BarChartComponent
|
||||||
|
data={[
|
||||||
|
{ name: 'Pasivo Circulante', valor: balance_general.pasivo_circulante },
|
||||||
|
{ name: 'Pasivo No Circ.', valor: balance_general.pasivo_no_circulante },
|
||||||
|
{ name: 'Capital', valor: balance_general.total_capital },
|
||||||
|
]}
|
||||||
|
horizontal
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeSection === 'flujo' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<KPICard title="Flujo Operación" value={formatCurrency(flujo_efectivo.flujo_operacion)} />
|
||||||
|
<KPICard title="Flujo Inversión" value={formatCurrency(flujo_efectivo.flujo_inversion)} />
|
||||||
|
<KPICard title="Flujo Financiamiento" value={formatCurrency(flujo_efectivo.flujo_financiamiento)} />
|
||||||
|
<KPICard title="Flujo Neto" value={formatCurrency(flujo_efectivo.flujo_neto)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeSection === 'metricas' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<MetricTable
|
||||||
|
title="Liquidez"
|
||||||
|
metricas={[
|
||||||
|
{ ...metricas.current_ratio, comparativo: comparativos.current_ratio },
|
||||||
|
{ ...metricas.quick_ratio, comparativo: comparativos.quick_ratio },
|
||||||
|
{ ...metricas.cash_ratio, comparativo: comparativos.cash_ratio },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<MetricTable
|
||||||
|
title="Retorno"
|
||||||
|
metricas={[
|
||||||
|
{ ...metricas.roic, comparativo: comparativos.roic },
|
||||||
|
{ ...metricas.roe, comparativo: comparativos.roe },
|
||||||
|
{ ...metricas.roa, comparativo: comparativos.roa },
|
||||||
|
{ ...metricas.roce, comparativo: comparativos.roce },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<MetricTable
|
||||||
|
title="Solvencia"
|
||||||
|
metricas={[
|
||||||
|
{ ...metricas.net_debt_ebitda, comparativo: comparativos.net_debt_ebitda },
|
||||||
|
{ ...metricas.interest_coverage, comparativo: comparativos.interest_coverage },
|
||||||
|
{ ...metricas.debt_ratio, comparativo: comparativos.debt_ratio },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeSection === 'estados' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="card overflow-x-auto">
|
||||||
|
<h3 className="font-semibold mb-4">Estados Financieros Detallados</h3>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b">
|
||||||
|
<th className="text-left py-2">Concepto</th>
|
||||||
|
<th className="text-right py-2">Valor</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr className="bg-gray-50 font-semibold">
|
||||||
|
<td colSpan={2} className="py-2">Balance General</td>
|
||||||
|
</tr>
|
||||||
|
<tr><td className="py-1">Activos Circulantes</td><td className="text-right">{formatCurrency(balance_general.activos_circulantes)}</td></tr>
|
||||||
|
<tr><td className="py-1">Activos No Circulantes</td><td className="text-right">{formatCurrency(balance_general.activos_no_circulantes)}</td></tr>
|
||||||
|
<tr className="border-t"><td className="py-1 font-medium">Total Activos</td><td className="text-right font-medium">{formatCurrency(balance_general.total_activos)}</td></tr>
|
||||||
|
<tr><td className="py-1">Pasivo Circulante</td><td className="text-right">{formatCurrency(balance_general.pasivo_circulante)}</td></tr>
|
||||||
|
<tr><td className="py-1">Pasivo No Circulante</td><td className="text-right">{formatCurrency(balance_general.pasivo_no_circulante)}</td></tr>
|
||||||
|
<tr className="border-t"><td className="py-1 font-medium">Total Pasivos</td><td className="text-right font-medium">{formatCurrency(balance_general.total_pasivos)}</td></tr>
|
||||||
|
<tr className="border-t"><td className="py-1 font-medium">Capital</td><td className="text-right font-medium">{formatCurrency(balance_general.total_capital)}</td></tr>
|
||||||
|
|
||||||
|
<tr className="bg-gray-50 font-semibold">
|
||||||
|
<td colSpan={2} className="py-2 pt-4">Estado de Resultados</td>
|
||||||
|
</tr>
|
||||||
|
<tr><td className="py-1">Ingresos</td><td className="text-right">{formatCurrency(estado_resultados.ingresos)}</td></tr>
|
||||||
|
<tr><td className="py-1">Costo de Venta</td><td className="text-right">({formatCurrency(estado_resultados.costo_venta)})</td></tr>
|
||||||
|
<tr className="border-t"><td className="py-1 font-medium">Utilidad Bruta</td><td className="text-right font-medium">{formatCurrency(estado_resultados.utilidad_bruta)}</td></tr>
|
||||||
|
<tr><td className="py-1">Gastos Operativos</td><td className="text-right">({formatCurrency(estado_resultados.gastos_operativos)})</td></tr>
|
||||||
|
<tr className="border-t"><td className="py-1 font-medium">Utilidad Operativa</td><td className="text-right font-medium">{formatCurrency(estado_resultados.utilidad_operativa)}</td></tr>
|
||||||
|
<tr><td className="py-1">Otros Gastos</td><td className="text-right">({formatCurrency(estado_resultados.otros_gastos)})</td></tr>
|
||||||
|
<tr><td className="py-1">Gastos Financieros</td><td className="text-right">({formatCurrency(estado_resultados.gastos_financieros)})</td></tr>
|
||||||
|
<tr className="border-t"><td className="py-1 font-medium">Utilidad Antes de Impuestos</td><td className="text-right font-medium">{formatCurrency(estado_resultados.utilidad_antes_impuestos)}</td></tr>
|
||||||
|
<tr><td className="py-1">Impuestos</td><td className="text-right">({formatCurrency(estado_resultados.impuestos)})</td></tr>
|
||||||
|
<tr className="border-t bg-green-50"><td className="py-2 font-bold">Utilidad Neta</td><td className="text-right font-bold text-green-600">{formatCurrency(estado_resultados.utilidad_neta)}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
frontend/src/pages/Login.tsx
Normal file
82
frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { login } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login(email, password);
|
||||||
|
toast.success('Bienvenido');
|
||||||
|
navigate('/');
|
||||||
|
} catch {
|
||||||
|
toast.error('Credenciales incorrectas');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-horux-dark to-horux-primary flex items-center justify-center p-4">
|
||||||
|
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md p-8">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-horux-highlight">Horux Strategy</h1>
|
||||||
|
<p className="text-gray-600 mt-2">Plataforma de Reportes Financieros</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="label">
|
||||||
|
Correo electrónico
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
placeholder="correo@empresa.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="label">
|
||||||
|
Contraseña
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full btn btn-primary py-3 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'Iniciando sesión...' : 'Iniciar sesión'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="text-center text-sm text-gray-500 mt-6">
|
||||||
|
¿Necesitas ayuda? Contacta al administrador
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
278
frontend/src/pages/PdfView/index.tsx
Normal file
278
frontend/src/pages/PdfView/index.tsx
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useSearchParams } from 'react-router-dom';
|
||||||
|
import { reportesApi } from '../../services/api';
|
||||||
|
import { Reporte } from '../../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vista especial para renderizado de PDF
|
||||||
|
* Esta página es capturada por Browsershot para generar el PDF
|
||||||
|
*/
|
||||||
|
export default function PdfView() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const [reporte, setReporte] = useState<Reporte | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = searchParams.get('token');
|
||||||
|
|
||||||
|
if (!id || !token) {
|
||||||
|
setError('Acceso no autorizado');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar token y cargar reporte
|
||||||
|
loadReporte(parseInt(id));
|
||||||
|
}, [id, searchParams]);
|
||||||
|
|
||||||
|
const loadReporte = async (reporteId: number) => {
|
||||||
|
try {
|
||||||
|
const data = await reportesApi.get(reporteId);
|
||||||
|
setReporte(data);
|
||||||
|
} catch {
|
||||||
|
setError('Error al cargar reporte');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
|
||||||
|
<p className="mt-4 text-gray-500">Cargando reporte...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !reporte || !reporte.data_calculada) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<p className="text-red-500">{error || 'Reporte no disponible'}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { metricas, estados_financieros } = reporte.data_calculada;
|
||||||
|
const { balance_general, estado_resultados } = estados_financieros;
|
||||||
|
|
||||||
|
const formatCurrency = (value: number) => {
|
||||||
|
return new Intl.NumberFormat('es-MX', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'MXN',
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
}).format(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatPercent = (value: number) => `${(value * 100).toFixed(1)}%`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white min-h-screen">
|
||||||
|
{/* Portada */}
|
||||||
|
<div className="h-screen flex flex-col items-center justify-center bg-gradient-to-br from-horux-dark to-horux-primary text-white page-break">
|
||||||
|
<h1 className="text-5xl font-bold text-horux-highlight mb-4">Horux Strategy</h1>
|
||||||
|
<h2 className="text-3xl font-light mb-8">{reporte.nombre}</h2>
|
||||||
|
<p className="text-xl text-gray-300">
|
||||||
|
{new Date(reporte.periodo_fin).toLocaleDateString('es-MX', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Resumen */}
|
||||||
|
<div className="p-12 page-break">
|
||||||
|
<h2 className="text-3xl font-bold text-horux-dark mb-8">Mensajes Destacados</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-6 mb-12">
|
||||||
|
<div className="bg-gray-50 p-6 rounded-lg">
|
||||||
|
<p className="text-gray-500 text-sm">Ingresos</p>
|
||||||
|
<p className="text-3xl font-bold">{formatCurrency(estado_resultados.ingresos)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-50 p-6 rounded-lg">
|
||||||
|
<p className="text-gray-500 text-sm">Utilidad Neta</p>
|
||||||
|
<p className="text-3xl font-bold text-green-600">
|
||||||
|
{formatCurrency(estado_resultados.utilidad_neta)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-50 p-6 rounded-lg">
|
||||||
|
<p className="text-gray-500 text-sm">Margen EBITDA</p>
|
||||||
|
<p className="text-3xl font-bold">{formatPercent(metricas.margen_ebitda?.valor || 0)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-50 p-6 rounded-lg">
|
||||||
|
<p className="text-gray-500 text-sm">ROE</p>
|
||||||
|
<p className="text-3xl font-bold">{formatPercent(metricas.roe?.valor || 0)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Balance General */}
|
||||||
|
<div className="p-12 page-break">
|
||||||
|
<h2 className="text-3xl font-bold text-horux-dark mb-8">Balance General</h2>
|
||||||
|
|
||||||
|
<table className="w-full text-lg">
|
||||||
|
<tbody>
|
||||||
|
<tr className="border-b">
|
||||||
|
<td className="py-3">Activos Circulantes</td>
|
||||||
|
<td className="text-right">{formatCurrency(balance_general.activos_circulantes)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b">
|
||||||
|
<td className="py-3">Activos No Circulantes</td>
|
||||||
|
<td className="text-right">{formatCurrency(balance_general.activos_no_circulantes)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b bg-gray-50 font-bold">
|
||||||
|
<td className="py-3">Total Activos</td>
|
||||||
|
<td className="text-right">{formatCurrency(balance_general.total_activos)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b">
|
||||||
|
<td className="py-3">Pasivo Circulante</td>
|
||||||
|
<td className="text-right">{formatCurrency(balance_general.pasivo_circulante)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b">
|
||||||
|
<td className="py-3">Pasivo No Circulante</td>
|
||||||
|
<td className="text-right">{formatCurrency(balance_general.pasivo_no_circulante)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b bg-gray-50 font-bold">
|
||||||
|
<td className="py-3">Total Pasivos</td>
|
||||||
|
<td className="text-right">{formatCurrency(balance_general.total_pasivos)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="bg-primary-50 font-bold text-primary-700">
|
||||||
|
<td className="py-3">Capital</td>
|
||||||
|
<td className="text-right">{formatCurrency(balance_general.total_capital)}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Estado de Resultados */}
|
||||||
|
<div className="p-12 page-break">
|
||||||
|
<h2 className="text-3xl font-bold text-horux-dark mb-8">Estado de Resultados</h2>
|
||||||
|
|
||||||
|
<table className="w-full text-lg">
|
||||||
|
<tbody>
|
||||||
|
<tr className="border-b">
|
||||||
|
<td className="py-3">Ingresos</td>
|
||||||
|
<td className="text-right">{formatCurrency(estado_resultados.ingresos)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b">
|
||||||
|
<td className="py-3">Costo de Venta</td>
|
||||||
|
<td className="text-right">({formatCurrency(estado_resultados.costo_venta)})</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b bg-gray-50 font-bold">
|
||||||
|
<td className="py-3">Utilidad Bruta</td>
|
||||||
|
<td className="text-right">{formatCurrency(estado_resultados.utilidad_bruta)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b">
|
||||||
|
<td className="py-3">Gastos Operativos</td>
|
||||||
|
<td className="text-right">({formatCurrency(estado_resultados.gastos_operativos)})</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b bg-gray-50 font-bold">
|
||||||
|
<td className="py-3">Utilidad Operativa</td>
|
||||||
|
<td className="text-right">{formatCurrency(estado_resultados.utilidad_operativa)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b">
|
||||||
|
<td className="py-3">Otros Gastos</td>
|
||||||
|
<td className="text-right">({formatCurrency(estado_resultados.otros_gastos)})</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b">
|
||||||
|
<td className="py-3">Gastos Financieros</td>
|
||||||
|
<td className="text-right">({formatCurrency(estado_resultados.gastos_financieros)})</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b font-bold">
|
||||||
|
<td className="py-3">Utilidad Antes de Impuestos</td>
|
||||||
|
<td className="text-right">{formatCurrency(estado_resultados.utilidad_antes_impuestos)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b">
|
||||||
|
<td className="py-3">Impuestos</td>
|
||||||
|
<td className="text-right">({formatCurrency(estado_resultados.impuestos)})</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="bg-green-50 font-bold text-green-700">
|
||||||
|
<td className="py-4 text-xl">Utilidad Neta</td>
|
||||||
|
<td className="text-right text-xl">{formatCurrency(estado_resultados.utilidad_neta)}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Métricas */}
|
||||||
|
<div className="p-12 page-break">
|
||||||
|
<h2 className="text-3xl font-bold text-horux-dark mb-8">Métricas Financieras</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-8">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-semibold mb-4">Márgenes</h3>
|
||||||
|
<table className="w-full">
|
||||||
|
<tbody>
|
||||||
|
{['margen_bruto', 'margen_ebitda', 'margen_operativo', 'margen_neto'].map((key) => (
|
||||||
|
<tr key={key} className="border-b">
|
||||||
|
<td className="py-2">{metricas[key]?.nombre || key}</td>
|
||||||
|
<td className="text-right font-medium">{formatPercent(metricas[key]?.valor || 0)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-semibold mb-4">Retorno</h3>
|
||||||
|
<table className="w-full">
|
||||||
|
<tbody>
|
||||||
|
{['roic', 'roe', 'roa', 'roce'].map((key) => (
|
||||||
|
<tr key={key} className="border-b">
|
||||||
|
<td className="py-2">{metricas[key]?.nombre || key}</td>
|
||||||
|
<td className="text-right font-medium">{formatPercent(metricas[key]?.valor || 0)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-semibold mb-4">Liquidez</h3>
|
||||||
|
<table className="w-full">
|
||||||
|
<tbody>
|
||||||
|
{['current_ratio', 'quick_ratio', 'cash_ratio'].map((key) => (
|
||||||
|
<tr key={key} className="border-b">
|
||||||
|
<td className="py-2">{metricas[key]?.nombre || key}</td>
|
||||||
|
<td className="text-right font-medium">{(metricas[key]?.valor || 0).toFixed(2)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-semibold mb-4">Solvencia</h3>
|
||||||
|
<table className="w-full">
|
||||||
|
<tbody>
|
||||||
|
{['net_debt_ebitda', 'interest_coverage', 'debt_ratio'].map((key) => (
|
||||||
|
<tr key={key} className="border-b">
|
||||||
|
<td className="py-2">{metricas[key]?.nombre || key}</td>
|
||||||
|
<td className="text-right font-medium">
|
||||||
|
{key === 'debt_ratio'
|
||||||
|
? formatPercent(metricas[key]?.valor || 0)
|
||||||
|
: (metricas[key]?.valor || 0).toFixed(2)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contraportada */}
|
||||||
|
<div className="h-screen flex flex-col items-center justify-center bg-gradient-to-br from-horux-dark to-horux-primary text-white">
|
||||||
|
<h1 className="text-4xl font-bold text-horux-highlight mb-4">Horux Strategy</h1>
|
||||||
|
<p className="text-xl text-gray-300">Reportes Financieros Inteligentes</p>
|
||||||
|
<p className="text-sm text-gray-400 mt-8">
|
||||||
|
Generado el {new Date().toLocaleDateString('es-MX')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user