diff --git a/ROADMAP.md b/ROADMAP.md index d67bb6c..a8eb814 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | | | | | | | | | | | diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..5ca29a3 --- /dev/null +++ b/frontend/Dockerfile @@ -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"] diff --git a/frontend/next.config.js b/frontend/next.config.js new file mode 100644 index 0000000..dd5147e --- /dev/null +++ b/frontend/next.config.js @@ -0,0 +1,9 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + output: 'standalone', + images: { + unoptimized: true, + }, +}; + +module.exports = nextConfig; diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..45a9c91 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/public/css/ichn.css b/frontend/public/css/ichn.css new file mode 100644 index 0000000..db1e1d6 --- /dev/null +++ b/frontend/public/css/ichn.css @@ -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; + } + diff --git a/frontend/public/css/ichnPaginas.css b/frontend/public/css/ichnPaginas.css new file mode 100644 index 0000000..f012cb3 --- /dev/null +++ b/frontend/public/css/ichnPaginas.css @@ -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) + } +} \ No newline at end of file diff --git a/frontend/public/css/style.css b/frontend/public/css/style.css new file mode 100644 index 0000000..0d9fe3b --- /dev/null +++ b/frontend/public/css/style.css @@ -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; + } +} diff --git a/frontend/public/img/Invertido_ICN.png b/frontend/public/img/Invertido_ICN.png new file mode 100644 index 0000000..45cd452 Binary files /dev/null and b/frontend/public/img/Invertido_ICN.png differ diff --git a/frontend/public/img/favicon.png b/frontend/public/img/favicon.png new file mode 100644 index 0000000..7ce4c34 Binary files /dev/null and b/frontend/public/img/favicon.png differ diff --git a/frontend/public/img/ichnHD.png b/frontend/public/img/ichnHD.png new file mode 100644 index 0000000..fd7aa03 Binary files /dev/null and b/frontend/public/img/ichnHD.png differ diff --git a/frontend/public/img/samito.png b/frontend/public/img/samito.png new file mode 100644 index 0000000..af4b795 Binary files /dev/null and b/frontend/public/img/samito.png differ diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx new file mode 100644 index 0000000..9129933 --- /dev/null +++ b/frontend/src/app/dashboard/page.tsx @@ -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 ; + + return ( +
+

Bienvenido, {user?.nombre}

+
+ {cards.map(card => ( +
+
+
+
+
+
{card.title}
+
-
+
+
+ +
+
+
+
+
+ ))} +
+
+ ); +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx new file mode 100644 index 0000000..4c15f94 --- /dev/null +++ b/frontend/src/app/layout.tsx @@ -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 ( + + + + + + + + + + +
+
+
+ {children} +
+
+
+ + + ); +} diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx new file mode 100644 index 0000000..562a67d --- /dev/null +++ b/frontend/src/app/login/page.tsx @@ -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) => { + 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 ( +
+
+
+
+
+
+ ICHN +

Iniciar Sesión

+
+
+
+ + +
+
+ + setClave(e.target.value)} /> +
+ {error &&
{error}
} + +
+
+
+
+
+
+ ); +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx new file mode 100644 index 0000000..c76ee5d --- /dev/null +++ b/frontend/src/app/page.tsx @@ -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 ; +} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx new file mode 100644 index 0000000..71b9b08 --- /dev/null +++ b/frontend/src/components/Header.tsx @@ -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 ( +
+
+ Menu de Aplicaciones +
+ + {user?.nombre || 'Usuario'} + + +
+
+
+ ); +} diff --git a/frontend/src/components/LoadingSpinner.tsx b/frontend/src/components/LoadingSpinner.tsx new file mode 100644 index 0000000..97c5bd7 --- /dev/null +++ b/frontend/src/components/LoadingSpinner.tsx @@ -0,0 +1,8 @@ +export default function LoadingSpinner({ text = 'Cargando...' }: { text?: string }) { + return ( +
+
+

{text}

+
+ ); +} diff --git a/frontend/src/components/ModalConfirmacion.tsx b/frontend/src/components/ModalConfirmacion.tsx new file mode 100644 index 0000000..367198e --- /dev/null +++ b/frontend/src/components/ModalConfirmacion.tsx @@ -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 ( +
+
e.stopPropagation()}> +

{title}

+

{message}

+
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx new file mode 100644 index 0000000..e4202b7 --- /dev/null +++ b/frontend/src/components/Sidebar.tsx @@ -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 ( +
+
+ SAM +
SAM
+ setCollapsed(!collapsed)}> +
+
    + {menuItems.map(item => ( +
  • + + + {item.label} + +
  • + ))} +
+
+ ); +} diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts new file mode 100644 index 0000000..0edb484 --- /dev/null +++ b/frontend/src/hooks/useAuth.ts @@ -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; + logout: () => void; +} + +const AuthContext = createContext({} as AuthContextType); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(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 ( + + {children} + + ); +} + +export const useAuth = () => useContext(AuthContext); diff --git a/frontend/src/hooks/useInactividad.ts b/frontend/src/hooks/useInactividad.ts new file mode 100644 index 0000000..f257cc9 --- /dev/null +++ b/frontend/src/hooks/useInactividad.ts @@ -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(); + + 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]); +} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 0000000..6c46077 --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -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(' '); +} diff --git a/frontend/src/lib/validarRut.ts b/frontend/src/lib/validarRut.ts new file mode 100644 index 0000000..f38b150 --- /dev/null +++ b/frontend/src/lib/validarRut.ts @@ -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}`; +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts new file mode 100644 index 0000000..b98dfae --- /dev/null +++ b/frontend/src/services/api.ts @@ -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; +} + +async function request(endpoint: string, options: ApiOptions = {}): Promise { + 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: (endpoint: string, params?: Record) => + request(endpoint, { params }), + + post: (endpoint: string, body?: unknown) => + request(endpoint, { method: 'POST', body }), + + put: (endpoint: string, body?: unknown) => + request(endpoint, { method: 'PUT', body }), +}; diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css new file mode 100644 index 0000000..dcea04f --- /dev/null +++ b/frontend/src/styles/globals.css @@ -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); + } +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..b8cdcc7 --- /dev/null +++ b/frontend/tsconfig.json @@ -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"] +}