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:
@@ -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
@@ -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 |
|
||||
| | | | |
|
||||
| | | | |
|
||||
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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' } },
|
||||
],
|
||||
});
|
||||
Reference in New Issue
Block a user