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:
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>
|
||||
);
|
||||
}
|
||||
22
frontend/src/pages/ReporteView.tsx
Normal file
22
frontend/src/pages/ReporteView.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
|
||||
export default function ReporteView() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
// Redirigir al dashboard del reporte
|
||||
if (id) {
|
||||
// En una implementación completa, cargaríamos el reporte
|
||||
// y redirigiríamos al dashboard correcto
|
||||
navigate(`/clientes`);
|
||||
}
|
||||
}, [id, navigate]);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
284
frontend/src/services/api.ts
Normal file
284
frontend/src/services/api.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||
import type {
|
||||
AuthResponse,
|
||||
User,
|
||||
Cliente,
|
||||
Balanza,
|
||||
Cuenta,
|
||||
Reporte,
|
||||
Giro,
|
||||
Umbral,
|
||||
ReglaMapeo,
|
||||
ReporteContable,
|
||||
CategoriaContable
|
||||
} from '../types';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
// Interceptor para agregar token
|
||||
api.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Interceptor para manejar errores
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error: AxiosError) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('token');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// ========== AUTH ==========
|
||||
export const authApi = {
|
||||
login: async (email: string, password: string): Promise<AuthResponse> => {
|
||||
const { data } = await api.post<AuthResponse>('/login', { email, password });
|
||||
localStorage.setItem('token', data.token);
|
||||
return data;
|
||||
},
|
||||
|
||||
logout: async (): Promise<void> => {
|
||||
await api.post('/logout');
|
||||
localStorage.removeItem('token');
|
||||
},
|
||||
|
||||
getUser: async (): Promise<User> => {
|
||||
const { data } = await api.get<User>('/user');
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
// ========== GIROS ==========
|
||||
export const girosApi = {
|
||||
list: async (): Promise<Giro[]> => {
|
||||
const { data } = await api.get<Giro[]>('/giros');
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
// ========== CLIENTES ==========
|
||||
export const clientesApi = {
|
||||
list: async (): Promise<Cliente[]> => {
|
||||
const { data } = await api.get<Cliente[]>('/clientes');
|
||||
return data;
|
||||
},
|
||||
|
||||
get: async (id: number): Promise<Cliente> => {
|
||||
const { data } = await api.get<Cliente>(`/clientes/${id}`);
|
||||
return data;
|
||||
},
|
||||
|
||||
create: async (formData: FormData): Promise<Cliente> => {
|
||||
const { data } = await api.post<Cliente>('/clientes', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
update: async (id: number, formData: FormData): Promise<Cliente> => {
|
||||
const { data } = await api.post<Cliente>(`/clientes/${id}`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
delete: async (id: number): Promise<void> => {
|
||||
await api.delete(`/clientes/${id}`);
|
||||
},
|
||||
};
|
||||
|
||||
// ========== BALANZAS ==========
|
||||
export const balanzasApi = {
|
||||
list: async (clienteId: number): Promise<Balanza[]> => {
|
||||
const { data } = await api.get<Balanza[]>(`/clientes/${clienteId}/balanzas`);
|
||||
return data;
|
||||
},
|
||||
|
||||
upload: async (clienteId: number, formData: FormData): Promise<Balanza> => {
|
||||
const { data } = await api.post<Balanza>(`/clientes/${clienteId}/balanzas`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
get: async (id: number): Promise<Balanza> => {
|
||||
const { data } = await api.get<Balanza>(`/balanzas/${id}`);
|
||||
return data;
|
||||
},
|
||||
|
||||
getCuentas: async (id: number): Promise<Cuenta[]> => {
|
||||
const { data } = await api.get<Cuenta[]>(`/balanzas/${id}/cuentas`);
|
||||
return data;
|
||||
},
|
||||
|
||||
updateExclusiones: async (id: number, exclusiones: number[]): Promise<void> => {
|
||||
await api.put(`/balanzas/${id}/exclusiones`, { exclusiones });
|
||||
},
|
||||
};
|
||||
|
||||
// ========== CUENTAS ==========
|
||||
export const cuentasApi = {
|
||||
updateClasificacion: async (
|
||||
id: number,
|
||||
data: {
|
||||
reporte_contable_id: number;
|
||||
categoria_contable_id: number;
|
||||
requiere_revision?: boolean;
|
||||
nota_revision?: string;
|
||||
}
|
||||
): Promise<Cuenta> => {
|
||||
const { data: cuenta } = await api.put<Cuenta>(`/cuentas/${id}/clasificacion`, data);
|
||||
return cuenta;
|
||||
},
|
||||
|
||||
toggleExclusion: async (id: number): Promise<Cuenta> => {
|
||||
const { data } = await api.post<Cuenta>(`/cuentas/${id}/toggle-exclusion`);
|
||||
return data;
|
||||
},
|
||||
|
||||
getAnomalias: async (): Promise<Cuenta[]> => {
|
||||
const { data } = await api.get<Cuenta[]>('/anomalias');
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
// ========== REPORTES ==========
|
||||
export const reportesApi = {
|
||||
list: async (clienteId: number): Promise<Reporte[]> => {
|
||||
const { data } = await api.get<Reporte[]>(`/clientes/${clienteId}/reportes`);
|
||||
return data;
|
||||
},
|
||||
|
||||
create: async (clienteId: number, nombre: string, balanzaIds: number[]): Promise<Reporte> => {
|
||||
const { data } = await api.post<Reporte>(`/clientes/${clienteId}/reportes`, {
|
||||
nombre,
|
||||
balanza_ids: balanzaIds,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
get: async (id: number): Promise<Reporte> => {
|
||||
const { data } = await api.get<Reporte>(`/reportes/${id}`);
|
||||
return data;
|
||||
},
|
||||
|
||||
downloadPdf: async (id: number): Promise<Blob> => {
|
||||
const { data } = await api.get(`/reportes/${id}/pdf`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
delete: async (id: number): Promise<void> => {
|
||||
await api.delete(`/reportes/${id}`);
|
||||
},
|
||||
};
|
||||
|
||||
// ========== ADMIN ==========
|
||||
export const adminApi = {
|
||||
// Usuarios
|
||||
usuarios: {
|
||||
list: async (): Promise<User[]> => {
|
||||
const { data } = await api.get<User[]>('/admin/usuarios');
|
||||
return data;
|
||||
},
|
||||
create: async (userData: Partial<User> & { password: string }): Promise<User> => {
|
||||
const { data } = await api.post<User>('/admin/usuarios', userData);
|
||||
return data;
|
||||
},
|
||||
update: async (id: number, userData: Partial<User>): Promise<User> => {
|
||||
const { data } = await api.put<User>(`/admin/usuarios/${id}`, userData);
|
||||
return data;
|
||||
},
|
||||
delete: async (id: number): Promise<void> => {
|
||||
await api.delete(`/admin/usuarios/${id}`);
|
||||
},
|
||||
},
|
||||
|
||||
// Giros
|
||||
giros: {
|
||||
list: async (): Promise<Giro[]> => {
|
||||
const { data } = await api.get<Giro[]>('/admin/giros');
|
||||
return data;
|
||||
},
|
||||
create: async (giro: Partial<Giro>): Promise<Giro> => {
|
||||
const { data } = await api.post<Giro>('/admin/giros', giro);
|
||||
return data;
|
||||
},
|
||||
update: async (id: number, giro: Partial<Giro>): Promise<Giro> => {
|
||||
const { data } = await api.put<Giro>(`/admin/giros/${id}`, giro);
|
||||
return data;
|
||||
},
|
||||
delete: async (id: number): Promise<void> => {
|
||||
await api.delete(`/admin/giros/${id}`);
|
||||
},
|
||||
},
|
||||
|
||||
// Umbrales
|
||||
umbrales: {
|
||||
list: async (giroId?: number): Promise<Umbral[]> => {
|
||||
const params = giroId ? { giro_id: giroId } : {};
|
||||
const { data } = await api.get<Umbral[]>('/admin/umbrales', { params });
|
||||
return data;
|
||||
},
|
||||
create: async (umbral: Partial<Umbral>): Promise<Umbral> => {
|
||||
const { data } = await api.post<Umbral>('/admin/umbrales', umbral);
|
||||
return data;
|
||||
},
|
||||
update: async (id: number, umbral: Partial<Umbral>): Promise<Umbral> => {
|
||||
const { data } = await api.put<Umbral>(`/admin/umbrales/${id}`, umbral);
|
||||
return data;
|
||||
},
|
||||
delete: async (id: number): Promise<void> => {
|
||||
await api.delete(`/admin/umbrales/${id}`);
|
||||
},
|
||||
},
|
||||
|
||||
// Reglas de mapeo
|
||||
reglasMapeeo: {
|
||||
list: async (sistemaOrigen?: string): Promise<ReglaMapeo[]> => {
|
||||
const params = sistemaOrigen ? { sistema_origen: sistemaOrigen } : {};
|
||||
const { data } = await api.get<ReglaMapeo[]>('/admin/reglas-mapeo', { params });
|
||||
return data;
|
||||
},
|
||||
create: async (regla: Partial<ReglaMapeo>): Promise<ReglaMapeo> => {
|
||||
const { data } = await api.post<ReglaMapeo>('/admin/reglas-mapeo', regla);
|
||||
return data;
|
||||
},
|
||||
update: async (id: number, regla: Partial<ReglaMapeo>): Promise<ReglaMapeo> => {
|
||||
const { data } = await api.put<ReglaMapeo>(`/admin/reglas-mapeo/${id}`, regla);
|
||||
return data;
|
||||
},
|
||||
delete: async (id: number): Promise<void> => {
|
||||
await api.delete(`/admin/reglas-mapeo/${id}`);
|
||||
},
|
||||
},
|
||||
|
||||
// Catálogos
|
||||
catalogos: {
|
||||
reportesContables: async (): Promise<ReporteContable[]> => {
|
||||
const { data } = await api.get<ReporteContable[]>('/admin/reportes-contables');
|
||||
return data;
|
||||
},
|
||||
categoriasContables: async (): Promise<CategoriaContable[]> => {
|
||||
const { data } = await api.get<CategoriaContable[]>('/admin/categorias-contables');
|
||||
return data;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default api;
|
||||
197
frontend/src/types/index.ts
Normal file
197
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
// Tipos de usuario y autenticación
|
||||
export type UserRole = 'admin' | 'analista' | 'cliente' | 'empleado';
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
nombre: string;
|
||||
email: string;
|
||||
role: UserRole;
|
||||
cliente_id: number | null;
|
||||
cliente?: Cliente;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
user: User;
|
||||
token: string;
|
||||
}
|
||||
|
||||
// Tipos de negocio
|
||||
export interface Giro {
|
||||
id: number;
|
||||
nombre: string;
|
||||
activo: boolean;
|
||||
}
|
||||
|
||||
export interface Cliente {
|
||||
id: number;
|
||||
nombre_empresa: string;
|
||||
logo: string | null;
|
||||
giro_id: number;
|
||||
giro?: Giro;
|
||||
moneda: string;
|
||||
configuracion: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface Balanza {
|
||||
id: number;
|
||||
cliente_id: number;
|
||||
periodo_inicio: string;
|
||||
periodo_fin: string;
|
||||
sistema_origen: string;
|
||||
archivo_original: string;
|
||||
status: 'pendiente' | 'procesando' | 'completado' | 'error';
|
||||
error_mensaje: string | null;
|
||||
}
|
||||
|
||||
export interface Cuenta {
|
||||
id: number;
|
||||
balanza_id: number;
|
||||
codigo: string;
|
||||
nombre: string;
|
||||
nivel: number;
|
||||
reporte_contable_id: number | null;
|
||||
categoria_contable_id: number | null;
|
||||
cuenta_padre_id: number | null;
|
||||
saldo_inicial_deudor: number;
|
||||
saldo_inicial_acreedor: number;
|
||||
cargos: number;
|
||||
abonos: number;
|
||||
saldo_final_deudor: number;
|
||||
saldo_final_acreedor: number;
|
||||
excluida: boolean;
|
||||
es_cuenta_padre: boolean;
|
||||
requiere_revision: boolean;
|
||||
nota_revision: string | null;
|
||||
categoria_contable?: CategoriaContable;
|
||||
reporte_contable?: ReporteContable;
|
||||
}
|
||||
|
||||
export interface ReporteContable {
|
||||
id: number;
|
||||
nombre: string;
|
||||
}
|
||||
|
||||
export interface CategoriaContable {
|
||||
id: number;
|
||||
reporte_contable_id: number;
|
||||
nombre: string;
|
||||
orden: number;
|
||||
}
|
||||
|
||||
export interface Reporte {
|
||||
id: number;
|
||||
cliente_id: number;
|
||||
nombre: string;
|
||||
periodo_tipo: 'mensual' | 'trimestral' | 'anual';
|
||||
periodo_inicio: string;
|
||||
periodo_fin: string;
|
||||
fecha_generacion: string | null;
|
||||
data_calculada: DataCalculada | null;
|
||||
pdf_path: string | null;
|
||||
status: 'pendiente' | 'procesando' | 'completado' | 'error';
|
||||
cliente?: Cliente;
|
||||
balanzas?: Balanza[];
|
||||
}
|
||||
|
||||
// Tipos de métricas y cálculos
|
||||
export type Tendencia = 'muy_positivo' | 'positivo' | 'neutral' | 'negativo' | 'muy_negativo';
|
||||
|
||||
export interface Metrica {
|
||||
nombre: string;
|
||||
valor: number;
|
||||
valor_porcentaje: number;
|
||||
tendencia: Tendencia;
|
||||
}
|
||||
|
||||
export interface BalanceGeneral {
|
||||
activos_circulantes: number;
|
||||
activos_no_circulantes: number;
|
||||
total_activos: number;
|
||||
pasivo_circulante: number;
|
||||
pasivo_no_circulante: number;
|
||||
total_pasivos: number;
|
||||
capital_social: number;
|
||||
utilidades_anteriores: number;
|
||||
perdidas_anteriores: number;
|
||||
total_capital: number;
|
||||
}
|
||||
|
||||
export interface EstadoResultados {
|
||||
ingresos: number;
|
||||
costo_venta: number;
|
||||
utilidad_bruta: number;
|
||||
gastos_operativos: number;
|
||||
utilidad_operativa: number;
|
||||
otros_gastos: number;
|
||||
gastos_financieros: number;
|
||||
utilidad_antes_impuestos: number;
|
||||
impuestos: number;
|
||||
utilidad_neta: number;
|
||||
}
|
||||
|
||||
export interface FlujoEfectivo {
|
||||
metodo: string;
|
||||
flujo_operacion: number;
|
||||
flujo_inversion: number;
|
||||
flujo_financiamiento: number;
|
||||
flujo_neto: number;
|
||||
detalle?: {
|
||||
utilidad_neta: number;
|
||||
depreciacion: number;
|
||||
cambio_capital_trabajo: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Comparativo {
|
||||
valor_actual: number;
|
||||
valor_anterior: number;
|
||||
variacion_absoluta: number;
|
||||
variacion_porcentual: number;
|
||||
promedio_3_periodos?: number;
|
||||
}
|
||||
|
||||
export interface PeriodoData {
|
||||
periodo: string;
|
||||
balance_general: BalanceGeneral;
|
||||
estado_resultados: EstadoResultados;
|
||||
}
|
||||
|
||||
export interface DataCalculada {
|
||||
periodos: PeriodoData[];
|
||||
metricas: Record<string, Metrica>;
|
||||
comparativos: Record<string, Comparativo>;
|
||||
flujo_efectivo: FlujoEfectivo;
|
||||
estados_financieros: {
|
||||
balance_general: BalanceGeneral;
|
||||
estado_resultados: EstadoResultados;
|
||||
flujo_efectivo: FlujoEfectivo;
|
||||
};
|
||||
}
|
||||
|
||||
// Tipos de administración
|
||||
export interface Umbral {
|
||||
id: number;
|
||||
metrica: string;
|
||||
muy_positivo: number | null;
|
||||
positivo: number | null;
|
||||
neutral: number | null;
|
||||
negativo: number | null;
|
||||
muy_negativo: number | null;
|
||||
giro_id: number | null;
|
||||
giro?: Giro;
|
||||
}
|
||||
|
||||
export interface ReglaMapeo {
|
||||
id: number;
|
||||
sistema_origen: string;
|
||||
cuenta_padre_codigo: string | null;
|
||||
rango_inicio: string | null;
|
||||
rango_fin: string | null;
|
||||
patron_regex: string | null;
|
||||
reporte_contable_id: number;
|
||||
categoria_contable_id: number;
|
||||
prioridad: number;
|
||||
activo: boolean;
|
||||
reporte_contable?: ReporteContable;
|
||||
categoria_contable?: CategoriaContable;
|
||||
}
|
||||
42
frontend/tailwind.config.js
Normal file
42
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,42 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#f0f9ff',
|
||||
100: '#e0f2fe',
|
||||
200: '#bae6fd',
|
||||
300: '#7dd3fc',
|
||||
400: '#38bdf8',
|
||||
500: '#0ea5e9',
|
||||
600: '#0284c7',
|
||||
700: '#0369a1',
|
||||
800: '#075985',
|
||||
900: '#0c4a6e',
|
||||
},
|
||||
horux: {
|
||||
dark: '#1a1a2e',
|
||||
primary: '#16213e',
|
||||
accent: '#0f3460',
|
||||
highlight: '#e94560',
|
||||
},
|
||||
status: {
|
||||
'muy-positivo': '#10b981',
|
||||
'positivo': '#34d399',
|
||||
'neutral': '#fbbf24',
|
||||
'negativo': '#f97316',
|
||||
'muy-negativo': '#ef4444',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
25
frontend/tsconfig.json
Normal file
25
frontend/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
11
frontend/tsconfig.node.json
Normal file
11
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
15
frontend/vite.config.ts
Normal file
15
frontend/vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user