feat: complete fases 3,4,5 - frontend, containers, e2e tests

FASE 3 - Frontend (22 paginas):
- Leads (listado, detalle, nuevo), Cotizaciones, Arqueo
- Cursos, Alumnos, Empresas (listado, detalle, ventas)
- Reportes (ventas, ventas-empresas, reimpresion)
- Autorizador, Contacto

FASE 4 - Contenedores:
- docker-compose.yml (backend-api + services-externos + frontend)
- Dockerfiles para backend, services-externos, frontend
- .env.example con secretos

FASE 5 - Pruebas E2E:
- Playwright config + 3 spec files (login, leads, dashboard)
- 7 escenarios: login, error, sidebar, crear lead,
  dashboard cards, timeout, reportes
This commit is contained in:
2026-07-08 11:07:27 -04:00
parent cf2349ef2f
commit 129991cfb7
24 changed files with 967 additions and 3 deletions
+67
View File
@@ -0,0 +1,67 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/hooks/useAuth';
import { api } from '@/services/api';
import LoadingSpinner from '@/components/LoadingSpinner';
export default function AlumnosPage() {
const { user, loading } = useAuth();
const router = useRouter();
const [alumnos, setAlumnos] = useState<any[]>([]);
const [busqueda, setBusqueda] = useState('');
const [loadingData, setLoadingData] = useState(true);
useEffect(() => {
if (!loading && !user) router.push('/login');
if (user) {
api.get('/alumno', { tipoBusqueda: 'NOMBRE', nombre: '' }).then(data => {
setAlumnos(Array.isArray(data) ? data : []);
setLoadingData(false);
}).catch(() => setLoadingData(false));
}
}, [user, loading, router]);
const handleSearch = async () => {
setLoadingData(true);
try {
const data = await api.get('/alumno', { tipoBusqueda: 'NOMBRE', nombre: busqueda });
setAlumnos(Array.isArray(data) ? data : []);
} catch { /* ignore */ }
setLoadingData(false);
};
if (loading) return <LoadingSpinner />;
return (
<div>
<h3 className="mb-3">Alumnos</h3>
<div className="input-group mb-3">
<input className="form-control" value={busqueda} onChange={e => setBusqueda(e.target.value)}
placeholder="Buscar alumno..." onKeyDown={e => e.key === 'Enter' && handleSearch()} />
<button className="btn btn-primary" onClick={handleSearch}>Buscar</button>
</div>
<div className="card shadow">
<div className="card-body">
<table className="table table-hover">
<thead>
<tr><th>RUT</th><th>Nombre</th><th>Mail</th><th>Teléfono</th></tr>
</thead>
<tbody>
{alumnos.map((a: any, i: number) => (
<tr key={i}>
<td>{a.AlumnoRut || a.rut || a.Rut}</td>
<td>{a.NombreAlumno || a.nombre || a.Nombre}</td>
<td>{a.Email || a.mail || a.Mail}</td>
<td>{a.Telefono || a.telefono || a.Fono}</td>
</tr>
))}
{alumnos.length === 0 && <tr><td colSpan={4} className="text-center">Sin resultados</td></tr>}
</tbody>
</table>
</div>
</div>
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/hooks/useAuth';
import { api } from '@/services/api';
import LoadingSpinner from '@/components/LoadingSpinner';
export default function ArqueoPage() {
const { user, loading } = useAuth();
const router = useRouter();
const [ingresos, setIngresos] = useState<any[]>([]);
const [loadingData, setLoadingData] = useState(true);
const fecha = new Date().toISOString().split('T')[0];
useEffect(() => {
if (!loading && !user) router.push('/login');
if (user) {
api.get('/arqueo/todos', { fecha }).then(data => {
setIngresos(Array.isArray(data) ? data : []);
setLoadingData(false);
}).catch(() => setLoadingData(false));
}
}, [user, loading, fecha, router]);
if (loading || loadingData) return <LoadingSpinner />;
return (
<div>
<h3 className="mb-3">Arqueo de Caja</h3>
<p className="text-muted">Fecha: {new Date().toLocaleDateString()}</p>
<div className="card shadow">
<div className="card-body">
<table className="table table-hover">
<thead>
<tr><th>Cajero</th><th>Crédito</th><th>Débito</th><th>Total</th></tr>
</thead>
<tbody>
{ingresos.map((r: any, i: number) => (
<tr key={i}>
<td>{r.Cajero || r.cajero}</td>
<td>${(r.Credito || r.credito || 0).toLocaleString()}</td>
<td>${(r.Debito || r.debito || 0).toLocaleString()}</td>
<td>${(r.Total || r.total || 0).toLocaleString()}</td>
</tr>
))}
{ingresos.length === 0 && <tr><td colSpan={4} className="text-center">Sin movimientos hoy</td></tr>}
</tbody>
</table>
</div>
</div>
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
'use client';
import { useState } from 'react';
import { api } from '@/services/api';
export default function AutorizadorPage() {
const [cotizacionId, setCotizacionId] = useState('');
const [mensaje, setMensaje] = useState('');
const handleAutorizar = async () => {
try {
await api.put(`/cotizacion/${cotizacionId}/desactivar`);
setMensaje('Cotización autorizada');
} catch {
setMensaje('Error al autorizar');
}
};
return (
<div>
<h3 className="mb-3">Autorizador de Descuentos</h3>
<div className="card shadow p-4">
<div className="mb-3">
<label className="form-label">ID Cotización</label>
<input className="form-control" value={cotizacionId} onChange={e => setCotizacionId(e.target.value)} />
</div>
<button className="btn btn-warning" onClick={handleAutorizar}>Autorizar Descuento</button>
{mensaje && <div className="alert alert-info mt-3">{mensaje}</div>}
</div>
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
'use client';
import { useState, FormEvent } from 'react';
import { api } from '@/services/api';
export default function ContactoPage() {
const [mail, setMail] = useState('');
const [mensaje, setMensaje] = useState('');
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
try {
await api.post('/email/send', { to: mail, subject: 'Contacto desde SAM', body: mensaje });
alert('Correo enviado');
setMail('');
setMensaje('');
} catch {
alert('Error al enviar');
}
};
return (
<div>
<h3 className="mb-3">Contacto</h3>
<div className="card shadow p-4">
<form onSubmit={handleSubmit}>
<div className="mb-3">
<label className="form-label">Correo</label>
<input type="email" className="form-control" value={mail} onChange={e => setMail(e.target.value)} required />
</div>
<div className="mb-3">
<label className="form-label">Mensaje</label>
<textarea className="form-control" rows={4} value={mensaje} onChange={e => setMensaje(e.target.value)} required />
</div>
<button type="submit" className="btn btn-primary">Enviar</button>
</form>
</div>
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/hooks/useAuth';
import { api } from '@/services/api';
import LoadingSpinner from '@/components/LoadingSpinner';
export default function CotizacionesPage() {
const { user, loading } = useAuth();
const router = useRouter();
const [cotizaciones, setCotizaciones] = useState<any[]>([]);
const [loadingData, setLoadingData] = useState(true);
useEffect(() => {
if (!loading && !user) router.push('/login');
if (user) {
api.get('/cotizacion/buscar', { tipoBusqueda: 'NOMBRE', nombre: '' }).then(data => {
setCotizaciones(Array.isArray(data) ? data : []);
setLoadingData(false);
}).catch(() => setLoadingData(false));
}
}, [user, loading, router]);
if (loading || loadingData) return <LoadingSpinner />;
return (
<div>
<h3 className="mb-3">Cotizaciones</h3>
<div className="card shadow">
<div className="card-body">
<table className="table table-hover">
<thead>
<tr><th>#</th><th>Cliente</th><th>Monto</th><th>Fecha</th></tr>
</thead>
<tbody>
{cotizaciones.map((c: any, i: number) => (
<tr key={i}>
<td>{c.Id || c.idCotizacionEmpresa || i}</td>
<td>{c.NombreLead || c.nombre || c.Cliente}</td>
<td>${(c.Monto || c.monto || 0).toLocaleString()}</td>
<td>{c.Fecha ? new Date(c.Fecha).toLocaleDateString() : ''}</td>
</tr>
))}
{cotizaciones.length === 0 && <tr><td colSpan={4} className="text-center">Sin cotizaciones</td></tr>}
</tbody>
</table>
</div>
</div>
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/hooks/useAuth';
import { api } from '@/services/api';
import LoadingSpinner from '@/components/LoadingSpinner';
export default function CursosPage() {
const { user, loading } = useAuth();
const router = useRouter();
const [cursos, setCursos] = useState<any[]>([]);
const [loadingData, setLoadingData] = useState(true);
useEffect(() => {
if (!loading && !user) router.push('/login');
if (user) {
api.get('/curso', { tipoBusqueda: 'NOMBRE', nombre: '' }).then(data => {
setCursos(Array.isArray(data) ? data : []);
setLoadingData(false);
}).catch(() => setLoadingData(false));
}
}, [user, loading, router]);
if (loading || loadingData) return <LoadingSpinner />;
return (
<div>
<h3 className="mb-3">Cursos</h3>
<div className="card shadow">
<div className="card-body">
<table className="table table-hover">
<thead>
<tr><th>ID</th><th>Nombre</th><th>Programa</th><th>Duración</th></tr>
</thead>
<tbody>
{cursos.map((c: any, i: number) => (
<tr key={i}>
<td>{c.idCursos || c.Id || i}</td>
<td>{c.Curso || c.Nombre || c.nombre}</td>
<td>{c.Programa || c.programa}</td>
<td>{c.Duracion || c.duracion || '-'}</td>
</tr>
))}
{cursos.length === 0 && <tr><td colSpan={4} className="text-center">Sin cursos</td></tr>}
</tbody>
</table>
</div>
</div>
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useAuth } from '@/hooks/useAuth';
import { api } from '@/services/api';
import LoadingSpinner from '@/components/LoadingSpinner';
export default function EmpresaDetallePage() {
const { id } = useParams();
const { user, loading } = useAuth();
const router = useRouter();
const [empresa, setEmpresa] = useState<any>(null);
const [loadingData, setLoadingData] = useState(true);
useEffect(() => {
if (!loading && !user) router.push('/login');
if (user && id) {
api.get('/empresa', { busqueda: 'IDEMP', rut: String(id), varB: '', varC: 0 }).then(data => {
setEmpresa(Array.isArray(data) ? data[0] : data);
setLoadingData(false);
}).catch(() => setLoadingData(false));
}
}, [user, loading, id, router]);
if (loading || loadingData) return <LoadingSpinner />;
if (!empresa) return <p>Empresa no encontrada</p>;
return (
<div>
<h3>Empresa {id}</h3>
<div className="card shadow">
<div className="card-body">
<table className="table">
<tbody>
<tr><td>RUT</td><td>{empresa.Rut || empresa.rut}</td></tr>
<tr><td>Razón Social</td><td>{empresa.RazonSocial || empresa.razonSocial}</td></tr>
<tr><td>Dirección</td><td>{empresa.Direccion || empresa.direccion}</td></tr>
<tr><td>Contacto</td><td>{empresa.Contacto || empresa.contacto}</td></tr>
<tr><td>Mail</td><td>{empresa.Mail || empresa.mail}</td></tr>
</tbody>
</table>
</div>
</div>
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/hooks/useAuth';
import { api } from '@/services/api';
import LoadingSpinner from '@/components/LoadingSpinner';
export default function EmpresasPage() {
const { user, loading } = useAuth();
const router = useRouter();
const [empresas, setEmpresas] = useState<any[]>([]);
const [loadingData, setLoadingData] = useState(true);
useEffect(() => {
if (!loading && !user) router.push('/login');
if (user) {
api.get('/empresa', { busqueda: 'LISTADO', rut: '', varB: '', varC: 0 }).then(data => {
setEmpresas(Array.isArray(data) ? data : []);
setLoadingData(false);
}).catch(() => setLoadingData(false));
}
}, [user, loading, router]);
if (loading || loadingData) return <LoadingSpinner />;
return (
<div>
<h3 className="mb-3">Empresas</h3>
<div className="card shadow">
<div className="card-body">
<table className="table table-hover">
<thead>
<tr><th>RUT</th><th>Razón Social</th><th>Contacto</th><th>Mail</th></tr>
</thead>
<tbody>
{empresas.map((e: any, i: number) => (
<tr key={i} style={{ cursor: 'pointer' }} onClick={() => router.push(`/empresas/${e.Rut || e.rut}`)}>
<td>{e.Rut || e.rut}</td>
<td>{e.RazonSocial || e.razonSocial || e.Nombre}</td>
<td>{e.Contacto || e.contacto}</td>
<td>{e.Mail || e.mail}</td>
</tr>
))}
{empresas.length === 0 && <tr><td colSpan={4} className="text-center">Sin empresas</td></tr>}
</tbody>
</table>
</div>
</div>
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/hooks/useAuth';
import { api } from '@/services/api';
import LoadingSpinner from '@/components/LoadingSpinner';
export default function VentasEmpresaPage() {
const { user, loading } = useAuth();
const router = useRouter();
const [ventas, setVentas] = useState<any[]>([]);
const [loadingData, setLoadingData] = useState(true);
useEffect(() => {
if (!loading && !user) router.push('/login');
if (user) {
api.get('/informe/ventas-empresa', { tipo: 'LISTADO', varA: '', varB: '', varC: 0 }).then(data => {
setVentas(Array.isArray(data) ? data : []);
setLoadingData(false);
}).catch(() => setLoadingData(false));
}
}, [user, loading, router]);
if (loading || loadingData) return <LoadingSpinner />;
return (
<div>
<h3 className="mb-3">Ventas Empresas</h3>
<div className="card shadow">
<div className="card-body">
<table className="table table-hover">
<thead>
<tr><th>Empresa</th><th>Curso</th><th>Valor</th><th>Fecha</th></tr>
</thead>
<tbody>
{ventas.map((v: any, i: number) => (
<tr key={i}>
<td>{v.Empresa || v.empresa}</td>
<td>{v.Curso || v.curso}</td>
<td>${(v.Valor || v.valor || 0).toLocaleString()}</td>
<td>{v.Fecha ? new Date(v.Fecha).toLocaleDateString() : ''}</td>
</tr>
))}
{ventas.length === 0 && <tr><td colSpan={4} className="text-center">Sin ventas</td></tr>}
</tbody>
</table>
</div>
</div>
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useAuth } from '@/hooks/useAuth';
import { api } from '@/services/api';
import LoadingSpinner from '@/components/LoadingSpinner';
export default function LeadDetallePage() {
const { id } = useParams();
const { user, loading } = useAuth();
const router = useRouter();
const [lead, setLead] = useState<any>(null);
const [actividades, setActividades] = useState<any[]>([]);
const [loadingData, setLoadingData] = useState(true);
useEffect(() => {
if (!loading && !user) router.push('/login');
if (user && id) {
Promise.all([
api.get(`/lead/${id}`),
api.get(`/lead/${id}/actividades`, { tipo: '' })
]).then(([leadData, actData]) => {
setLead(Array.isArray(leadData) ? leadData[0] : leadData);
setActividades(Array.isArray(actData) ? actData : []);
setLoadingData(false);
}).catch(() => setLoadingData(false));
}
}, [user, loading, id, router]);
if (loading || loadingData) return <LoadingSpinner />;
if (!lead) return <p>Lead no encontrado</p>;
return (
<div>
<h3>Lead #{id}</h3>
<div className="row">
<div className="col-md-6">
<div className="card shadow mb-3">
<div className="card-body">
<h5>Información</h5>
<table className="table">
<tbody>
<tr><td>Nombre</td><td>{lead.Nombre || lead.nombre}</td></tr>
<tr><td>Mail</td><td>{lead.Mail || lead.mail}</td></tr>
<tr><td>Teléfono</td><td>{lead.Telefono || lead.telefono}</td></tr>
<tr><td>Producto</td><td>{lead.Producto || lead.producto}</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div className="col-md-6">
<div className="card shadow mb-3">
<div className="card-body">
<h5>Actividades</h5>
{actividades.length === 0 ? <p className="text-muted">Sin actividades</p> : (
<ul className="list-group">
{actividades.map((a: any, i: number) => (
<li key={i} className="list-group-item">
<small>{a.Tipo || a.tipo}</small>
<p className="mb-0">{a.Descripcion || a.descripcion}</p>
</li>
))}
</ul>
)}
</div>
</div>
</div>
</div>
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
'use client';
import { useState, FormEvent } from 'react';
import { useRouter } from 'next/navigation';
import { api } from '@/services/api';
import toast from 'react-hot-toast';
export default function NuevoLeadPage() {
const router = useRouter();
const [nombre, setNombre] = useState('');
const [mail, setMail] = useState('');
const [fono, setFono] = useState('');
const [producto, setProducto] = useState('');
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
try {
await api.post('/lead', { nombre, mail, telefono: fono, producto, contacto: 1, ejecutivoId: 1 });
toast.success('Lead creado');
router.push('/leads');
} catch {
toast.error('Error al crear lead');
}
};
return (
<div className="card shadow p-4">
<h3 className="mb-4">Nuevo Lead</h3>
<form onSubmit={handleSubmit}>
<div className="mb-3">
<label className="form-label">Nombre</label>
<input className="form-control" value={nombre} onChange={e => setNombre(e.target.value)} required />
</div>
<div className="mb-3">
<label className="form-label">Mail</label>
<input type="email" className="form-control" value={mail} onChange={e => setMail(e.target.value)} />
</div>
<div className="mb-3">
<label className="form-label">Teléfono</label>
<input className="form-control" value={fono} onChange={e => setFono(e.target.value)} />
</div>
<div className="mb-3">
<label className="form-label">Producto</label>
<input className="form-control" value={producto} onChange={e => setProducto(e.target.value)} />
</div>
<button type="submit" className="btn btn-primary">Guardar</button>
<button type="button" className="btn btn-secondary ms-2" onClick={() => router.back()}>Cancelar</button>
</form>
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/hooks/useAuth';
import { api } from '@/services/api';
import LoadingSpinner from '@/components/LoadingSpinner';
export default function LeadsPage() {
const { user, loading } = useAuth();
const router = useRouter();
const [leads, setLeads] = useState<any[]>([]);
const [loadingData, setLoadingData] = useState(true);
useEffect(() => {
if (!loading && !user) router.push('/login');
if (user) {
api.get('/lead/nuevos', { ejecutivo: 1 }).then(data => {
setLeads(Array.isArray(data) ? data : []);
setLoadingData(false);
}).catch(() => setLoadingData(false));
}
}, [user, loading, router]);
if (loading || loadingData) return <LoadingSpinner />;
return (
<div>
<div className="d-flex justify-content-between align-items-center mb-3">
<h3>Leads</h3>
<button className="btn btn-primary" onClick={() => router.push('/leads/nuevo')}>Nuevo Lead</button>
</div>
<div className="card shadow">
<div className="card-body">
<table className="table table-hover">
<thead>
<tr>
<th>Nombre</th>
<th>Mail</th>
<th>Teléfono</th>
<th>Producto</th>
<th>Fecha</th>
<th></th>
</tr>
</thead>
<tbody>
{leads.map((lead: any, i: number) => (
<tr key={i} style={{ cursor: 'pointer' }} onClick={() => router.push(`/leads/${lead.Id || i}`)}>
<td>{lead.Nombre || lead.nombre}</td>
<td>{lead.Mail || lead.mail}</td>
<td>{lead.Telefono || lead.telefono}</td>
<td>{lead.Producto || lead.producto}</td>
<td>{lead.FechaCreacion ? new Date(lead.FechaCreacion).toLocaleDateString() : ''}</td>
<td><button className="btn btn-sm btn-outline-primary">Ver</button></td>
</tr>
))}
{leads.length === 0 && <tr><td colSpan={6} className="text-center">Sin leads</td></tr>}
</tbody>
</table>
</div>
</div>
</div>
);
}
@@ -0,0 +1,37 @@
'use client';
import { useState } from 'react';
import { api } from '@/services/api';
export default function ReimpresionPage() {
const [tipo, setTipo] = useState('contrato');
const [id, setId] = useState('');
const handleReimprimir = () => {
if (tipo === 'contrato') {
window.open(`${process.env.NEXT_PUBLIC_API_URL}/report/contrato/${id}`, '_blank');
} else if (tipo === 'cotizacion') {
window.open(`${process.env.NEXT_PUBLIC_API_URL}/report/cotizacion/${id}`, '_blank');
}
};
return (
<div>
<h3 className="mb-3">Reimpresión de Documentos</h3>
<div className="card shadow p-4">
<div className="mb-3">
<label className="form-label">Tipo</label>
<select className="form-select" value={tipo} onChange={e => setTipo(e.target.value)}>
<option value="contrato">Contrato</option>
<option value="cotizacion">Cotización</option>
</select>
</div>
<div className="mb-3">
<label className="form-label">N° Documento</label>
<input className="form-control" value={id} onChange={e => setId(e.target.value)} />
</div>
<button className="btn btn-primary" onClick={handleReimprimir}>Reimprimir</button>
</div>
</div>
);
}
@@ -0,0 +1,40 @@
'use client';
import { useState } from 'react';
import { api } from '@/services/api';
export default function ReporteVentasEmpPage() {
const [data, setData] = useState<any[] | null>(null);
const load = async () => {
const result = await api.get('/informe/ventas-empresa', { tipo: 'LISTADO', varA: '', varB: '', varC: 0 });
setData(Array.isArray(result) ? result : []);
};
return (
<div>
<h3 className="mb-3">Ventas Empresas</h3>
<button className="btn btn-primary mb-3" onClick={load}>Cargar</button>
{data && (
<div className="card shadow">
<div className="card-body">
<table className="table">
<thead><tr><th>Empresa</th><th>Curso</th><th>Monto</th><th>Fecha</th></tr></thead>
<tbody>
{data.map((r: any, i: number) => (
<tr key={i}>
<td>{r.Empresa || r.empresa}</td>
<td>{r.Curso || r.curso}</td>
<td>${(r.Monto || r.monto || 0).toLocaleString()}</td>
<td>{r.Fecha ? new Date(r.Fecha).toLocaleDateString() : ''}</td>
</tr>
))}
{data.length === 0 && <tr><td colSpan={4} className="text-center">Sin datos</td></tr>}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
'use client';
import { useState } from 'react';
import { api } from '@/services/api';
export default function ReporteVentasPage() {
const [mes, setMes] = useState(new Date().getMonth() + 1);
const [agno, setAgno] = useState(new Date().getFullYear());
const [data, setData] = useState<any[] | null>(null);
const load = async () => {
const result = await api.get('/informe/ventas-mensuales', { mes, agno, tipo: 'VENTA' });
setData(Array.isArray(result) ? result : []);
};
return (
<div>
<h3 className="mb-3">Reporte de Ventas</h3>
<div className="row mb-3">
<div className="col-auto">
<select className="form-select" value={mes} onChange={e => setMes(Number(e.target.value))}>
{Array.from({ length: 12 }, (_, i) => (
<option key={i + 1} value={i + 1}>{new Date(0, i).toLocaleString('es', { month: 'long' })}</option>
))}
</select>
</div>
<div className="col-auto">
<input type="number" className="form-control" value={agno} onChange={e => setAgno(Number(e.target.value))} />
</div>
<div className="col-auto">
<button className="btn btn-primary" onClick={load}>Generar</button>
</div>
</div>
{data && (
<div className="card shadow">
<div className="card-body">
<table className="table table-hover">
<thead>
<tr><th>Ejecutivo</th><th>Ventas</th><th>Monto</th></tr>
</thead>
<tbody>
{data.map((r: any, i: number) => (
<tr key={i}>
<td>{r.Ejecutivo || r.ejecutivo || r.Nombre}</td>
<td>{r.Cantidad || r.cantidad || r.Ventas}</td>
<td>${(r.Monto || r.monto || r.Total || 0).toLocaleString()}</td>
</tr>
))}
{data.length === 0 && <tr><td colSpan={3} className="text-center">Sin datos</td></tr>}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}