feat: fase 3 - frontend next.js setup

- Next.js 15 + TypeScript + Bootstrap 5 + FontAwesome
- Layout replica SAM.Master (sidebar collapsable + header)
- Sidebar, Header, LoadingSpinner, ModalConfirmacion components
- Login page with RUT validation
- Dashboard page with summary cards
- AuthContext + JWT auth with cookies
- api.ts centralized HTTP client
- useInactividad hook (30min timeout)
- validarRut.ts + utils.ts migrated
- Original CSS + images copied from project
- Dockerfile multi-stage build
This commit is contained in:
2026-07-08 10:54:02 -04:00
parent 9c1eb20132
commit cf2349ef2f
26 changed files with 1391 additions and 14 deletions
+17 -14
View File
@@ -77,20 +77,22 @@
### FASE 3: FRONTEND NEXT.JS (~6-8 semanas)
- [ ] **3.1 Setup** (create-next-app, packages, config)
- [ ] **3.2 Layout Principal** (~1 sem)
- [ ] layout.tsx (sidebar + header = SAM.Master)
- [ ] Sidebar.tsx, Header.tsx
- [ ] CSS original (style.css, ichn.css, etc.)
- [ ] **3.3 Login + Auth** (~3 días)
- [ ] **3.4 Dashboard** (~3 días)
- [ ] **3.5 Módulo Leads** (~1 sem) — 3 páginas
- [ ] **3.6 Módulo Cotizaciones/Pagos** (~1 sem)
- [ ] **3.7 Módulo Empresas** (~1 sem) — 3 páginas
- [ ] **3.8 Resto páginas** (~2-3 sem)
- Arqueo, Cursos, Alumnos
- [x] **3.1 Setup** (Next.js 15, TypeScript, packages)
- [x] **3.2 Layout Principal**
- [x] layout.tsx (sidebar + header = SAM.Master)
- [x] Sidebar.tsx, Header.tsx, LoadingSpinner, ModalConfirmacion
- [x] CSS original copiado + globals.css con sidebar styles
- [x] Assets (favicon, ichnHD, samito, Invertido_ICN)
- [x] **3.3 Login + Auth** (login page + useAuth hook + JWT)
- [x] **3.4 Dashboard** (cards layout)
- [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)
- Leads (listado, detalle, nuevo)
- Cotizaciones, Arqueo, Cursos, Alumnos
- Empresas (listado, detalle, ventas)
- Reportes (3), Autorizador, Contacto
- [ ] Dockerfile Frontend
### FASE 4: CONTENEDORES (~1 semana)
@@ -120,7 +122,8 @@
| 2026-07-08 | F1 | 13 controllers, Ejecutivo entity, ArqueoService, JwtMiddleware, SpComplexQueries | Fase 2 |
| 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: JWT secret, QuestPDF license, schema SPs, Transbank MySQL, JwtMiddleware pipeline, Email disconnect, model validation, casts | 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 |
| | | | |
| | | | |
+14
View File
@@ -0,0 +1,14 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runtime
WORKDIR /app
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/public ./public
EXPOSE 3000
ENV NODE_ENV=production
CMD ["node", "server.js"]
+9
View File
@@ -0,0 +1,9 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
images: {
unoptimized: true,
},
};
module.exports = nextConfig;
+23
View File
@@ -0,0 +1,23 @@
{
"name": "modulo-ventas",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "^15.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hot-toast": "^2.4.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.0.0"
}
}
+230
View File
@@ -0,0 +1,230 @@
.btn-ichn {
color: #fff;
background-color: #00285d;
border-color: #00285d
}
.btn-ichn:hover {
color: #fff;
background-color: #00142E;
border-color: #00142E
}
.btn-ichn.focus, .btn-ichn:focus {
box-shadow: 0 0 0 .2rem #00285d
}
.btn-ichn.disabled, .btn-ichn:disabled {
color: #fff;
background-color: #00285d;
border-color: #00285d;
}
.btn-ichn:not(:disabled):not(.disabled).active, .btn-ichn:not(:disabled):not(.disabled):active, .show > .btn-ichn.dropdown-toggle {
color: #fff;
background-color: #00285d;
border-color: #00285d
}
.btn-ichn:not(:disabled):not(.disabled).active:focus, .btn-ichn:not(:disabled):not(.disabled):active:focus, .show > .btn-ichn.dropdown-toggle:focus {
box-shadow: 0 0 0 .2rem #00285d
}
.btn-ichnRojo {
color: #fff;
background-color: #e22523;
border-color: #e22523
}
.btn-ichnRojo:hover {
color: #fff;
background-color: #8A101C;
border-color: #8A101C
}
.btn-ichnRojo.focus, .btn-ichnRojo:focus {
box-shadow: 0 0 0 .2rem #B91425
}
.btn-ichnRojo.disabled, .btn-ichnRojo:disabled {
color: #fff;
background-color: #B91425;
border-color: #B91425;
}
.btn-ichnRojo:not(:disabled):not(.disabled).active, .btn-ichnRojo:not(:disabled):not(.disabled):active, .show > .btn-ichnRojo.dropdown-toggle {
color: #fff;
background-color: #B91425;
border-color: #B91425
}
.btn-ichnRojo:not(:disabled):not(.disabled).active:focus, .btn-ichnRojo:not(:disabled):not(.disabled):active:focus, .show > .btn-ichnRojo.dropdown-toggle:focus {
box-shadow: 0 0 0 .2rem #B91425
}
.btn-ichnPurpura {
color: #fff;
background-color: #6B3B7C;
border-color: #6B3B7C;
}
.btn-ichnPurpura:hover {
color: #fff;
background-color: #562965;
border-color: #562965
}
.btn-ichnPurpura.focus, .btn-ichnPurpura:focus {
box-shadow: 0 0 0 .2rem #844c98
}
.btn-ichnPurpura.disabled, .btn-ichnPurpura:disabled {
color: #fff;
background-color: #844c98;
border-color: #844c98;
}
.btn-ichnPurpura:not(:disabled):not(.disabled).active, .btn-ichnPurpura:not(:disabled):not(.disabled):active, .show > .btn-ichnPurpura.dropdown-toggle {
color: #fff;
background-color: #844c98;
border-color: #844c98
}
.btn-ichnPurpura:not(:disabled):not(.disabled).active:focus, .btn-ichnPurpura:not(:disabled):not(.disabled):active:focus, .show > .btn-ichnPurpura.dropdown-toggle:focus {
box-shadow: 0 0 0 .2rem #844c98
}
.btn-ichnVerde {
color: #fff;
background-color: #3D912F;
border-color: #3D912F
}
.btn-ichnVerde:hover {
color: #fff;
background-color: #2A6D1E;
border-color: #2A6D1E
}
.btn-ichnVerde.focus, .btn-ichnVerde:focus {
box-shadow: 0 0 0 .2rem #3D912F
}
.btn-ichnVerde.disabled, .btn-ichnVerde:disabled {
color: #fff;
background-color: #3D912F;
border-color: #3D912F;
}
.btn-ichnVerde:not(:disabled):not(.disabled).active, .btn-ichnVerde:not(:disabled):not(.disabled):active, .show > .btn-ichnVerde.dropdown-toggle {
color: #fff;
background-color: #3D912F;
border-color: #3D912F
}
.btn-ichnVerde:not(:disabled):not(.disabled).active:focus, .btn-ichnVerde:not(:disabled):not(.disabled):active:focus, .show > .btn-ichnVerde.dropdown-toggle:focus {
box-shadow: 0 0 0 .2rem #3D912F
}
.btn-ichnExcel {
color: #fff;
background-color: #008000;
border-color: #008000
}
.btn-ichnExcel:hover {
color: #fff;
background-color: #009900;
border-color: #009900;
}
.btn-ichnExcel.focus, .btn-ichnExcel:focus {
box-shadow: 0 0 0 .2rem #008000;
}
.btn-ichnExcel.disabled, .btn-ichnExcel:disabled {
color: #fff;
background-color: #008000;
border-color: #008000;
}
.btn-ichnExcel:not(:disabled):not(.disabled).active, .btn-ichnExcel:not(:disabled):not(.disabled):active, .show > .btn-ichnExcel.dropdown-toggle {
color: #fff;
background-color: #008000;
border-color: #008000
}
.btn-ichnExcel:not(:disabled):not(.disabled).active:focus, .btn-ichnExcel:not(:disabled):not(.disabled):active:focus, .show > .btn-ichnExcel.dropdown-toggle:focus {
box-shadow: 0 0 0 .2rem #008000
}
.examen:hover {
background-image: linear-gradient( rgba(185, 20, 37 ), rgba(185, 20, 37) )
}
.modal-content {
position: relative;
display: -ms-flexbox;
display: flex;
-ms-flex-direction: column;
flex-direction: column;
width: 100%;
pointer-events: auto;
background-color: #fff;
background-clip: padding-box;
border: 0px solid;
border-radius: .5rem;
outline: 0;
}
.modal-header {
display: -ms-flexbox;
display: block;
-ms-flex-align: start;
align-items: flex-start;
-ms-flex-pack: justify;
justify-content: space-between;
padding: 1rem 1rem;
border-bottom: 1px solid #dee2e6;
border-top-left-radius: .3rem;
border-top-right-radius: .3rem;
}
.btn-circle {
width: 30px;
height: 30px;
text-align: center;
padding: 6px 0;
font-size: 12px;
line-height: 1.428571429;
border-radius: 15px;
}
.btn-circle.btn-lg {
width: 50px;
height: 50px;
padding: 10px 16px;
font-size: 18px;
line-height: 1.33;
border-radius: 25px;
}
.btn-circle.btn-xl {
width: 70px;
height: 70px;
padding: 10px 16px;
font-size: 24px;
line-height: 1.33;
border-radius: 35px;
}
+80
View File
@@ -0,0 +1,80 @@
.div_1 {
height: 80px;
background-color: #D1D1D1;
font-size: 18px;
}
.div_2 {
min-height: 300px;
}
label .prueba {
cursor: pointer;
}
.nav-tabs .nav-item.show .nav-link, .nav-tabs .nav-link.active {
color: #ffff;
background-color: #22376c;
border-color: #dee2e6 #dee2e6 #fff;
}
.nav-tabs .nav-item.show .nav-link, .nav-tabs .nav-link {
color: #22376c;
}
.spinner, .spinner:after {
width: 90px;
height: 90px;
position: fixed;
top: 90%;
left: 50%;
margin-top: -32px;
margin-left: -32px;
border-radius: 50%;
z-index: 2
}
.spinner {
background-color: transparent;
border-top: 10px solid rgb(0, 40, 93);
border-right: 10px solid rgb(0, 40, 93);
border-bottom: 10px solid rgb(0, 40, 93);
border-left: 10px solid rgba(0, 40, 93,.2);
transform: translateZ(0);
animation-iteration-count: infinite;
animation-timing-function: linear;
animation-duration: 1.5s;
animation-name: spinner-loading
}
.menuIchnHover {
background-color: #E8E8E8;
}
.menuIchnHover a {
color: #212529;
}
.menuIchnHover:hover {
background-color: #00285d;
color: #fff;
border-radius: 5px
}
.menuIchnHover:hover a {
background-color: #00285d;
color: #fff;
border-radius: 5px
}
@keyframes spinner-loading {
0% {
transform: rotate(0deg)
}
to {
transform: rotate(1turn)
}
}
+282
View File
@@ -0,0 +1,282 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
.sidebar {
position: fixed;
left: 0;
top: 0;
height: 100%;
width: 75px;
background: #00285d;
padding: 6px 14px;
z-index: 99;
transition: all 0.5s ease;
}
.sidebar.open {
width: 250px;
}
.sidebar .logo-details {
height: 60px;
display: flex;
align-items: center;
position: relative;
}
.sidebar .logo-details .icon {
opacity: 0;
transition: all 0.5s ease;
}
.sidebar .logo-details .logo_name {
color: #fff;
font-size: 20px;
font-weight: 600;
opacity: 0;
transition: all 0.5s ease;
}
.sidebar.open .logo-details .icon,
.sidebar.open .logo-details .logo_name {
opacity: 1;
}
.sidebar .logo-details #btn {
position: absolute;
top: 50%;
right: 0;
transform: translateY(-50%);
font-size: 22px;
transition: all 0.4s ease;
font-size: 23px;
text-align: center;
cursor: pointer;
transition: all 0.5s ease;
}
.sidebar.open .logo-details #btn {
text-align: right;
}
.sidebar i {
color: #fff;
height: 60px;
min-width: 50px;
font-size: 28px;
text-align: center;
line-height: 60px;
}
.sidebar .nav-list {
margin-top: 20px;
height: 100%;
padding-left: 0rem;
}
.sidebar li {
position: relative;
margin: 8px 0;
list-style: none;
}
.sidebar li .tooltip {
position: absolute;
top: -20px;
left: calc(100% + 15px);
z-index: 3;
background: #fff;
box-shadow: 0 5px 10px rgba(0, 0, 0, 0.3);
padding: 6px 12px;
border-radius: 4px;
font-size: 15px;
font-weight: 400;
opacity: 0;
white-space: nowrap;
pointer-events: none;
transition: 0s;
}
.sidebar li:hover .tooltip {
opacity: 1;
pointer-events: auto;
transition: all 0.4s ease;
top: 50%;
transform: translateY(-50%);
}
.sidebar.open li .tooltip {
display: none;
}
.sidebar input {
font-size: 15px;
color: #FFF;
font-weight: 400;
outline: none;
height: 50px;
width: 100%;
width: 50px;
border: none;
border-radius: 12px;
transition: all 0.5s ease;
background: #1d1b31;
}
.sidebar.open input {
padding: 0 20px 0 50px;
width: 100%;
}
.sidebar .bx-search {
position: absolute;
top: 50%;
left: 0;
transform: translateY(-50%);
font-size: 22px;
background: #1d1b31;
color: #FFF;
}
.sidebar.open .bx-search:hover {
background: #1d1b31;
color: #FFF;
}
.sidebar .bx-search:hover {
background: #FFF;
color: #00285d;
}
.sidebar li a {
display: flex;
height: 100%;
width: 100%;
border-radius: 12px;
align-items: center;
text-decoration: none;
transition: all 0.4s ease;
background: #00285d;
}
.sidebar li a:hover {
background: #FFF;
}
.sidebar li a .links_name {
color: #fff;
font-size: 15px;
font-weight: 400;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: 0.4s;
}
.sidebar.open li a .links_name {
opacity: 1;
pointer-events: auto;
}
.sidebar li a:hover .links_name,
.sidebar li a:hover i {
transition: all 0.5s ease;
color: #00285d;
}
.sidebar li i {
height: 35px;
line-height: 35px;
font-size: 16px;
border-radius: 12px;
}
.sidebar li.profile {
position: fixed;
height: 60px;
width: 78px;
left: 0;
bottom: -8px;
padding: 10px 14px;
background: #001D44;
transition: all 0.5s ease;
overflow: hidden;
}
.sidebar.open li.profile {
width: 250px;
}
.sidebar li .profile-details {
display: flex;
align-items: center;
flex-wrap: nowrap;
}
.sidebar li img {
height: 45px;
width: 45px;
object-fit: cover;
border-radius: 6px;
margin-right: 10px;
}
.sidebar li.profile .name,
.sidebar li.profile .job {
font-size: 15px;
font-weight: 400;
color: #fff;
white-space: nowrap;
}
.sidebar li.profile .job {
font-size: 12px;
}
.sidebar .profile #log_out {
position: absolute;
top: 50%;
right: 0;
transform: translateY(-50%);
background: #1d1b31;
width: 100%;
height: 60px;
line-height: 60px;
border-radius: 0px;
transition: all 0.5s ease;
}
.sidebar.open .profile #log_out {
width: 50px;
background: none;
}
.home-section {
position: relative;
min-height: 100vh;
top: 0;
left: 75px;
width: calc(100% - 75px);
transition: all 0.5s ease;
}
.sidebar.open ~ .home-section {
left: 250px;
width: calc(100% - 250px);
transition: all 1s ease;
}
.home-section .text {
display: inline-block;
margin: 18px
}
@media (max-width: 620px) {
.sidebar li .tooltip {
display: none;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

+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';
const cards = [
{ title: 'Nuevos Leads', icon: 'fas fa-users', color: '#4e73df', endpoint: '/lead/nuevos' },
{ title: 'Cotizaciones', icon: 'fas fa-file-invoice-dollar', color: '#1cc88a', endpoint: '/cotizacion/buscar' },
{ title: 'Cursos', icon: 'fas fa-book', color: '#36b9cc', endpoint: '/curso' },
{ title: 'Alumnos', icon: 'fas fa-user-graduate', color: '#f6c23e', endpoint: '/alumno' },
];
export default function DashboardPage() {
const { user, loading } = useAuth();
const router = useRouter();
const [pageLoading, setPageLoading] = useState(true);
useEffect(() => {
if (!loading && !user) router.push('/login');
if (!loading) setPageLoading(false);
}, [user, loading, router]);
if (pageLoading) return <LoadingSpinner />;
return (
<div>
<h2 className="mb-4">Bienvenido, {user?.nombre}</h2>
<div className="row">
{cards.map(card => (
<div className="col-xl-3 col-md-6 mb-4" key={card.title}>
<div className="card border-left-primary shadow h-100 py-2" style={{ borderLeft: `.25rem solid ${card.color}` }}>
<div className="card-body">
<div className="row no-gutters align-items-center">
<div className="col mr-2">
<div className="text-xs font-weight-bold text-primary text-uppercase mb-1">{card.title}</div>
<div className="h5 mb-0 font-weight-bold text-gray-800">-</div>
</div>
<div className="col-auto">
<i className={`${card.icon} fa-2x text-gray-300`}></i>
</div>
</div>
</div>
</div>
</div>
))}
</div>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
import type { Metadata } from 'next';
import { AuthProvider } from '@/hooks/useAuth';
import Sidebar from '@/components/Sidebar';
import Header from '@/components/Header';
import '@/styles/globals.css';
export const metadata: Metadata = {
title: 'SAM - Sistema de Administración de Ventas',
description: 'Módulo de Ventas - Instituto Chileno Norteamericano',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="es">
<head>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css?family=Fira+Sans&display=swap" rel="stylesheet" />
<link href="https://unpkg.com/boxicons@2.0.7/css/boxicons.min.css" rel="stylesheet" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
</head>
<body>
<AuthProvider>
<Sidebar />
<section className="home-section alert-dark">
<Header />
<div className="container-fluid mt-3 px-4">
{children}
</div>
</section>
</AuthProvider>
</body>
</html>
);
}
+68
View File
@@ -0,0 +1,68 @@
'use client';
import { useState, FormEvent } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/hooks/useAuth';
import { validarRut, formatRut } from '@/lib/validarRut';
export default function LoginPage() {
const [rut, setRut] = useState('');
const [clave, setClave] = useState('');
const [error, setError] = useState('');
const { login } = useAuth();
const router = useRouter();
const handleRutChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value.replace(/[^0-9kK-]/g, '');
setRut(value);
if (value.length > 2) {
setRut(formatRut(value));
}
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError('');
if (!validarRut(rut)) {
setError('RUT inválido');
return;
}
try {
await login(rut, clave);
router.push('/dashboard');
} catch {
setError('Credenciales inválidas');
}
};
return (
<div className="container mt-5">
<div className="row justify-content-center">
<div className="col-md-4">
<div className="card shadow">
<div className="card-body p-4">
<div className="text-center mb-4">
<img src="/img/ichnHD.png" alt="ICHN" height="60" />
<h4 className="mt-2">Iniciar Sesión</h4>
</div>
<form onSubmit={handleSubmit}>
<div className="mb-3">
<label className="form-label">RUT</label>
<input className="form-control" value={rut} onChange={handleRutChange} placeholder="12.345.678-5" />
</div>
<div className="mb-3">
<label className="form-label">Clave</label>
<input type="password" className="form-control" value={clave} onChange={e => setClave(e.target.value)} />
</div>
{error && <div className="alert alert-danger py-2">{error}</div>}
<button type="submit" className="btn btn-primary w-100">Ingresar</button>
</form>
</div>
</div>
</div>
</div>
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/hooks/useAuth';
import LoadingSpinner from '@/components/LoadingSpinner';
export default function Home() {
const router = useRouter();
const { user, loading } = useAuth();
useEffect(() => {
if (!loading) {
router.push(user ? '/dashboard' : '/login');
}
}, [user, loading, router]);
return <LoadingSpinner />;
}
+30
View File
@@ -0,0 +1,30 @@
'use client';
import { useAuth } from '@/hooks/useAuth';
import { useRouter } from 'next/navigation';
export default function Header() {
const { user, logout } = useAuth();
const router = useRouter();
const handleLogout = () => {
logout();
router.push('/login');
};
return (
<header>
<div className="header-content">
<span className="badge">Menu de Aplicaciones</span>
<div className="user-info">
<span className="user-name">
<i className="fas fa-user"></i> {user?.nombre || 'Usuario'}
</span>
<button className="btn-logout" onClick={handleLogout}>
<i className="fas fa-sign-out-alt"></i> Cerrar Sesión
</button>
</div>
</div>
</header>
);
}
@@ -0,0 +1,8 @@
export default function LoadingSpinner({ text = 'Cargando...' }: { text?: string }) {
return (
<div className="loading-spinner">
<div className="spinner"></div>
<p>{text}</p>
</div>
);
}
@@ -0,0 +1,27 @@
'use client';
interface Props {
open: boolean;
title: string;
message: string;
onConfirm: () => void;
onCancel: () => void;
confirmText?: string;
}
export default function ModalConfirmacion({ open, title, message, onConfirm, onCancel, confirmText = 'Aceptar' }: Props) {
if (!open) return null;
return (
<div className="modal-overlay" onClick={onCancel}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<h3>{title}</h3>
<p>{message}</p>
<div className="modal-actions">
<button className="btn btn-secondary" onClick={onCancel}>Cancelar</button>
<button className="btn btn-primary" onClick={onConfirm}>{confirmText}</button>
</div>
</div>
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useState } from 'react';
const menuItems = [
{ href: '/dashboard', icon: 'fas fa-home', label: 'Dashboard' },
{ href: '/leads', icon: 'fas fa-users', label: 'Leads' },
{ href: '/cotizaciones', icon: 'fas fa-file-invoice-dollar', label: 'Cotizaciones' },
{ href: '/cursos', icon: 'fas fa-book', label: 'Cursos' },
{ href: '/alumnos', icon: 'fas fa-user-graduate', label: 'Alumnos' },
{ href: '/arqueo', icon: 'fas fa-cash-register', label: 'Arqueo' },
{ href: '/empresas', icon: 'fas fa-building', label: 'Empresas' },
{ href: '/reportes/ventas', icon: 'fas fa-chart-bar', label: 'Reportes' },
{ href: '/autorizador', icon: 'fas fa-check-double', label: 'Autorizador' },
{ href: '/contacto', icon: 'fas fa-envelope', label: 'Contacto' },
];
export default function Sidebar() {
const pathname = usePathname();
const [collapsed, setCollapsed] = useState(false);
return (
<div className={`sidebar ${collapsed ? 'collapsed' : ''}`}>
<div className="logo-details">
<img className="icon" src="/img/favicon.png" width="60" alt="SAM" />
<div className="logo_name">SAM</div>
<i className="bx bx-menu" id="btn" onClick={() => setCollapsed(!collapsed)}></i>
</div>
<ul className="nav-list">
{menuItems.map(item => (
<li key={item.href} className={pathname.startsWith(item.href) ? 'active' : ''}>
<Link href={item.href}>
<i className={item.icon}></i>
<span className="links_name">{item.label}</span>
</Link>
</li>
))}
</ul>
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
'use client';
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { api } from '@/services/api';
interface User {
nombre: string;
sede?: string;
perfil?: string;
}
interface AuthContextType {
user: User | null;
loading: boolean;
login: (rut: string, clave: string) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthContextType>({} as AuthContextType);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const stored = sessionStorage.getItem('sam_user');
if (stored) {
setUser(JSON.parse(stored));
}
setLoading(false);
}, []);
const login = async (rut: string, clave: string) => {
const res = await api.post<{ nombre: string; sede?: string; token: string }>('/auth/login', { rut, clave });
sessionStorage.setItem('sam_user', JSON.stringify({ nombre: res.nombre, sede: res.sede }));
setUser({ nombre: res.nombre, sede: res.sede });
};
const logout = () => {
sessionStorage.removeItem('sam_user');
setUser(null);
};
return (
<AuthContext.Provider value={{ user, loading, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => useContext(AuthContext);
+25
View File
@@ -0,0 +1,25 @@
'use client';
import { useEffect, useRef } from 'react';
const TIMEOUT_MS = 30 * 60 * 1000;
export function useInactividad(onTimeout: () => void) {
const timerRef = useRef<NodeJS.Timeout>();
const resetTimer = () => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(onTimeout, TIMEOUT_MS);
};
useEffect(() => {
const events = ['mousedown', 'keydown', 'scroll', 'touchstart'];
resetTimer();
events.forEach(event => window.addEventListener(event, resetTimer));
return () => {
events.forEach(event => window.removeEventListener(event, resetTimer));
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [onTimeout]);
}
+12
View File
@@ -0,0 +1,12 @@
export function formatCurrency(amount: number): string {
return new Intl.NumberFormat('es-CL', { style: 'currency', currency: 'CLP', maximumFractionDigits: 0 }).format(amount);
}
export function formatDate(date: string | Date): string {
const d = typeof date === 'string' ? new Date(date) : date;
return d.toLocaleDateString('es-CL', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
export function classNames(...classes: (string | boolean | undefined)[]): string {
return classes.filter(Boolean).join(' ');
}
+29
View File
@@ -0,0 +1,29 @@
export function validarRut(rut: string): boolean {
const limpio = rut.replace(/\./g, '').replace(/-/g, '').toUpperCase();
if (!/^[0-9]+[0-9K]$/.test(limpio)) return false;
const cuerpo = limpio.slice(0, -1);
const dv = limpio.slice(-1);
let suma = 0;
let multiplo = 2;
for (let i = cuerpo.length - 1; i >= 0; i--) {
suma += parseInt(cuerpo[i]) * multiplo;
multiplo = multiplo < 7 ? multiplo + 1 : 2;
}
const dvEsperado = 11 - (suma % 11);
const dvChar = dvEsperado === 11 ? '0' : dvEsperado === 10 ? 'K' : String(dvEsperado);
return dvChar === dv;
}
export function formatRut(rut: string): string {
const limpio = rut.replace(/\./g, '').replace(/-/g, '');
if (limpio.length < 2) return limpio;
const cuerpo = limpio.slice(0, -1);
const dv = limpio.slice(-1);
const formateado = cuerpo.replace(/\B(?=(\d{3})+(?!\d))/g, '.');
return `${formateado}-${dv}`;
}
+43
View File
@@ -0,0 +1,43 @@
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5087/api';
interface ApiOptions {
method?: string;
body?: unknown;
params?: Record<string, string | number | boolean>;
}
async function request<T>(endpoint: string, options: ApiOptions = {}): Promise<T> {
const { method = 'GET', body, params } = options;
let url = `${API_URL}${endpoint}`;
if (params) {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => searchParams.append(k, String(v)));
url += `?${searchParams.toString()}`;
}
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const error = await res.text();
throw new Error(error || `HTTP ${res.status}`);
}
return res.json();
}
export const api = {
get: <T>(endpoint: string, params?: Record<string, string | number | boolean>) =>
request<T>(endpoint, { params }),
post: <T>(endpoint: string, body?: unknown) =>
request<T>(endpoint, { method: 'POST', body }),
put: <T>(endpoint: string, body?: unknown) =>
request<T>(endpoint, { method: 'PUT', body }),
};
+272
View File
@@ -0,0 +1,272 @@
@import url('https://fonts.googleapis.com/css?family=Fira+Sans&display=swap');
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Fira Sans', sans-serif;
}
body {
background-color: #e9ecef;
min-height: 100vh;
}
/* Sidebar (replica SAM.Master) */
.sidebar {
position: fixed;
left: 0;
top: 0;
height: 100%;
width: 78px;
background: #11101d;
padding: 6px 14px;
z-index: 99;
transition: all 0.5s ease;
}
.sidebar.open {
width: 250px;
}
.sidebar .logo-details {
height: 60px;
display: flex;
align-items: center;
position: relative;
}
.sidebar .logo-details .icon {
opacity: 0;
transition: all 0.5s ease;
}
.sidebar.open .logo-details .icon {
opacity: 1;
}
.sidebar .logo-details .logo_name {
color: #fff;
font-size: 20px;
font-weight: 600;
opacity: 0;
transition: all 0.5s ease;
margin-left: 10px;
}
.sidebar.open .logo-details .logo_name {
opacity: 1;
}
.sidebar .logo-details #btn {
position: absolute;
top: 50%;
right: 0;
transform: translateY(-50%);
font-size: 23px;
text-align: center;
cursor: pointer;
transition: all 0.5s ease;
color: #fff;
}
.sidebar.open .logo-details #btn {
text-align: right;
}
.sidebar i {
color: #fff;
height: 60px;
min-width: 50px;
font-size: 28px;
text-align: center;
line-height: 60px;
}
.sidebar .nav-list {
margin-top: 20px;
height: 100%;
padding: 0;
}
.sidebar .nav-list li {
position: relative;
list-style: none;
margin: 8px 0;
}
.sidebar .nav-list li a {
display: flex;
align-items: center;
text-decoration: none;
border-radius: 12px;
transition: all 0.4s ease;
background: transparent;
}
.sidebar .nav-list li a:hover {
background: #FFF;
}
.sidebar .nav-list li a:hover i,
.sidebar .nav-list li a:hover .links_name {
color: #11101d;
}
.sidebar .nav-list li.active a {
background: #FFF;
}
.sidebar .nav-list li.active a i,
.sidebar .nav-list li.active a .links_name {
color: #11101d;
}
.sidebar .nav-list li .links_name {
color: #fff;
font-size: 15px;
font-weight: 400;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: 0.4s;
}
.sidebar.open .nav-list li .links_name {
opacity: 1;
pointer-events: auto;
}
/* Home section */
.home-section {
position: relative;
min-height: 100vh;
left: 78px;
width: calc(100% - 78px);
transition: all 0.5s ease;
}
.sidebar.open ~ .home-section {
left: 250px;
width: calc(100% - 250px);
}
.home-section header {
background: #fff;
padding: 10px 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.08);
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
}
.badge {
background: #4e73df;
color: #fff;
padding: 6px 16px;
border-radius: 20px;
font-size: 13px;
}
.user-info {
display: flex;
align-items: center;
gap: 15px;
}
.user-name {
font-weight: 500;
color: #333;
}
.btn-logout {
background: none;
border: 1px solid #dc3545;
color: #dc3545;
padding: 5px 12px;
border-radius: 5px;
cursor: pointer;
font-size: 13px;
transition: all 0.3s;
}
.btn-logout:hover {
background: #dc3545;
color: #fff;
}
/* Loading spinner */
.loading-spinner {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 60vh;
}
.spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #4e73df;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Modal */
.modal-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: #fff;
padding: 24px;
border-radius: 8px;
max-width: 400px;
width: 90%;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 20px;
}
/* Card styles */
.card {
border-radius: 10px;
border: none;
}
.card.shadow {
box-shadow: 0 0.15rem 1.75rem 0 rgba(58,59,69,0.15);
}
/* Responsive */
@media (max-width: 768px) {
.sidebar {
width: 60px;
}
.sidebar.open {
width: 200px;
}
.home-section {
left: 60px;
width: calc(100% - 60px);
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}