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:
2026-01-31 22:24:00 -06:00
commit 4c3dc94ff2
107 changed files with 10701 additions and 0 deletions

86
frontend/README.md Normal file
View 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
View 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
View 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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

99
frontend/src/App.tsx Normal file
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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>,
)

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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;
}

View 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
View 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" }]
}

View 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
View 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,
},
},
},
})