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