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
+15
View File
@@ -0,0 +1,15 @@
# JWT
JWT_SECRET=generar-clave-segura-aqui
JWT_EXPIRATION=30
# LibreDTE
LIBREDTE_USER_HASH=ZDLimhVCDEXoHR6yDTJpb80ta7KG4DqI
LIBREDTE_AMBIENTE=0
# Transbank
TRANSBANK_API_KEY=tu-api-key
TRANSBANK_COMMERCE_CODE=tu-codigo-comercio
TRANSBANK_ENVIRONMENT=integration
# SMTP
SMTP_PASSWORD=smith2251!
+7 -3
View File
@@ -88,11 +88,12 @@
- [x] **3.5 JS migration** (validarRut.ts, utils.ts, useInactividad)
- [x] **3.6 api.ts** (centralized HTTP client with cookies)
- [x] **Dockerfile Frontend** (multi-stage build)
- [ ] **3.5-3.8 Páginas restantes** (~20 páginas)
- [x] **3.5-3.8 Páginas** (22 páginas funcionales)
- Leads (listado, detalle, nuevo)
- Cotizaciones, Arqueo, Cursos, Alumnos
- Empresas (listado, detalle, ventas)
- Reportes (3), Autorizador, Contacto
- Reportes (ventas, ventas-empresas, reimpresion)
- Autorizador, Contacto
### FASE 4: CONTENEDORES (~1 semana)
@@ -123,7 +124,10 @@
| 2026-07-08 | F1.7 | Reportes restantes: Anexo, ContratoBlack, Presupuesto, CAEMP/CAEMPSNC, CC_EMPCSD, PropuestaComercial | Fase 2 |
| 2026-07-08 | F2 | ServicesExternos: DTE, Transbank, Email + API | Fase 3 |
| 2026-07-08 | AUDIT | 27 bugs corregidos en F1+F2 | Fase 3 |
| 2026-07-08 | F3 | Setup Next.js + Layout + Sidebar + Login + Auth + Dashboard + componentes + CSS | Resto páginas frontend |
| 2026-07-08 | F3 | Setup Next.js + Layout + Login + Auth + Dashboard + componentes + CSS | Resto páginas |
| 2026-07-08 | F3 | 22 páginas frontend completadas + Dockerfiles + docker-compose + .env | Fase 4 |
| 2026-07-08 | F4 | docker-compose.yml (3 servicios) + Dockerfiles + .env.example | Fase 5 |
| 2026-07-08 | F5 | Playwright E2E (7 escenarios) + playwright.config.ts | FIN |
| | | | |
| | | | |
+11
View File
@@ -0,0 +1,11 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet restore Ventas.slnx
RUN dotnet publish src/Ventas.API/Ventas.API.csproj -c Release -o /app
FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app .
EXPOSE 8080
ENTRYPOINT ["dotnet", "Ventas.API.dll"]
+51
View File
@@ -0,0 +1,51 @@
version: '3.8'
services:
backend-api:
build:
context: ./backend
dockerfile: src/Ventas.API/Dockerfile
environment:
- ConnectionStrings__Default=Host=192.168.0.254;Port=5432;Database=ichn;Username=postgres;Password=apoca11
- Jwt__Secret=${JWT_SECRET}
- Jwt__Expiration=30
ports:
- "5000:8080"
networks:
- ventas-network
extra_hosts:
- "host.docker.internal:192.168.0.254"
services-externos:
build:
context: ./services-externos
dockerfile: src/ServicesExternos.API/Dockerfile
environment:
- LibreDTE__UserHash=${LIBREDTE_USER_HASH}
- LibreDTE__Ambiente=${LIBREDTE_AMBIENTE:-1}
- Transbank__ApiKey=${TRANSBANK_API_KEY}
- Transbank__CommerceCode=${TRANSBANK_COMMERCE_CODE}
- Transbank__Environment=${TRANSBANK_ENVIRONMENT:-integration}
- Email__Host=smtp.gmail.com
- Email__Port=587
- Email__User=noresponder@norteamericano.cl
- Email__Password=${SMTP_PASSWORD}
ports:
- "5001:8080"
networks:
- ventas-network
frontend:
build: ./frontend
environment:
- NEXT_PUBLIC_API_URL=http://backend-api:8080/api
- NEXT_PUBLIC_SERVICES_URL=http://services-externos:8080/api
ports:
- "3000:3000"
depends_on:
- backend-api
networks:
- ventas-network
networks:
ventas-network:
driver: bridge
+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>
);
}
@@ -0,0 +1,11 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet restore ServicesExternos.slnx
RUN dotnet publish src/ServicesExternos.API/ServicesExternos.API.csproj -c Release -o /app
FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app .
EXPOSE 8080
ENTRYPOINT ["dotnet", "ServicesExternos.API.dll"]
+29
View File
@@ -0,0 +1,29 @@
import { test, expect } from '@playwright/test';
test('dashboard carga cards de resumen', async ({ page }) => {
await page.goto('/login');
await page.fill('[name="rut"]', '12345678-5');
await page.fill('[name="clave"]', 'password');
await page.click('button:has-text("Ingresar")');
await expect(page.locator('.card')).toHaveCount(4);
});
test('timeout de inactividad redirige a login', async ({ page }) => {
await page.goto('/login');
await page.fill('[name="rut"]', '12345678-5');
await page.fill('[name="clave"]', 'password');
await page.click('button:has-text("Ingresar")');
await page.clock.install();
await page.clock.fastForward(1800001);
await expect(page).toHaveURL(/\/login/);
});
test('reportes ventas carga datos', async ({ page }) => {
await page.goto('/login');
await page.fill('[name="rut"]', '12345678-5');
await page.fill('[name="clave"]', 'password');
await page.click('button:has-text("Ingresar")');
await page.goto('/reportes/ventas');
await page.click('button:has-text("Generar")');
await expect(page.locator('table')).toBeVisible();
});
+30
View File
@@ -0,0 +1,30 @@
import { test, expect } from '@playwright/test';
test('listar leads carga tabla', async ({ page }) => {
await page.goto('/login');
await page.fill('[name="rut"]', '12345678-5');
await page.fill('[name="clave"]', 'password');
await page.click('button:has-text("Ingresar")');
await page.goto('/leads');
await expect(page.locator('table')).toBeVisible();
});
test('crear lead desde formulario', async ({ page }) => {
await page.goto('/login');
await page.fill('[name="rut"]', '12345678-5');
await page.fill('[name="clave"]', 'password');
await page.click('button:has-text("Ingresar")');
await page.goto('/leads/nuevo');
await page.fill('input[placeholder*="Nombre"]', 'Test Lead');
await page.click('button:has-text("Guardar")');
await expect(page).toHaveURL(/\/leads$/);
});
test('cerrar sesion redirige a login', async ({ page }) => {
await page.goto('/login');
await page.fill('[name="rut"]', '12345678-5');
await page.fill('[name="clave"]', 'password');
await page.click('button:has-text("Ingresar")');
await page.click('button:has-text("Cerrar Sesión")');
await expect(page).toHaveURL(/\/login/);
});
+28
View File
@@ -0,0 +1,28 @@
import { test, expect } from '@playwright/test';
test('login exitoso redirige a dashboard', async ({ page }) => {
await page.goto('/login');
await page.fill('[name="rut"]', '12345678-5');
await page.fill('[name="clave"]', 'password');
await page.click('button:has-text("Ingresar")');
await expect(page).toHaveURL(/\/dashboard/);
});
test('login fallido muestra error', async ({ page }) => {
await page.goto('/login');
await page.fill('[name="rut"]', '1-9');
await page.fill('[name="clave"]', 'invalida');
await page.click('button:has-text("Ingresar")');
await expect(page.locator('.alert-danger')).toBeVisible();
});
test('sidebar navegacion funciona', async ({ page }) => {
await page.goto('/login');
await page.fill('[name="rut"]', '12345678-5');
await page.fill('[name="clave"]', 'password');
await page.click('button:has-text("Ingresar")');
await page.click('text=Leads');
await expect(page).toHaveURL(/\/leads/);
await page.click('text=Cursos');
await expect(page).toHaveURL(/\/cursos/);
});
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
timeout: 30000,
retries: 1,
use: {
baseURL: 'http://localhost:3000',
headless: true,
viewport: { width: 1280, height: 720 },
},
projects: [
{ name: 'chromium', use: { browserName: 'chromium' } },
],
});