diff --git a/index.html b/index.html index e4b78ea..f65e38f 100644 --- a/index.html +++ b/index.html @@ -2,9 +2,9 @@ - + - Vite + React + TS + GRH
diff --git a/src/App.tsx b/src/App.tsx index a70479c..6650b12 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,13 +9,19 @@ import ProjectsPage from "./pages/projects/ProjectsPage"; import UsersPage from "./pages/UsersPage"; import RolesPage from "./pages/RolesPage"; import ProfileModal from "./components/layout/common/ProfileModal"; -import { uploadMyAvatar, updateMyProfile } from "./api/me"; +import { updateMyProfile } from "./api/me"; import SettingsModal, { type AppSettings, loadSettings, } from "./components/SettingsModals"; +import LoginPage from "./pages/LoginPage"; + +// ✅ NUEVO +import ConfirmModal from "./components/layout/common/ConfirmModal"; +import Watermark from "./components/layout/common/Watermark"; + export type Page = | "home" | "projects" @@ -24,12 +30,38 @@ export type Page = | "users" | "roles"; +const AUTH_KEY = "grh_auth"; + export default function App() { + const [isAuth, setIsAuth] = useState(() => { + return Boolean(localStorage.getItem(AUTH_KEY)); + }); + + const handleLogin = (payload?: { token?: string }) => { + localStorage.setItem( + AUTH_KEY, + JSON.stringify({ token: payload?.token ?? "demo", ts: Date.now() }) + ); + setIsAuth(true); + }; + + const handleLogout = () => { + localStorage.removeItem(AUTH_KEY); + setIsAuth(false); + // opcional: reset de navegación + setPage("home"); + setSubPage("default"); + setSelectedProject(""); + }; + + // ✅ confirm logout modal state + const [logoutOpen, setLogoutOpen] = useState(false); + const [loggingOut, setLoggingOut] = useState(false); + const [page, setPage] = useState("home"); const [subPage, setSubPage] = useState("default"); const [selectedProject, setSelectedProject] = useState(""); - // ✅ perfil usuario + modal const [profileOpen, setProfileOpen] = useState(false); const [savingProfile, setSavingProfile] = useState(false); @@ -37,29 +69,24 @@ export default function App() { name: "CESPT Admin", email: "admin@cespt.gob.mx", avatarUrl: null as string | null, - organismName: "CESPT", // ✅ NUEVO: Empresa/Organismo + organismName: "CESPT", }); - // Settings state const [settingsOpen, setSettingsOpen] = useState(false); const [settings, setSettings] = useState(() => loadSettings()); const navigateToMetersWithProject = (projectName: string) => { setSelectedProject(projectName); - setSubPage(projectName); // útil para breadcrumb si lo usas + setSubPage(projectName); setPage("meters"); }; - // ✅ handlers const handleUploadAvatar = async (file: File) => { - // 1) Guardar como base64 en localStorage (demo) const base64 = await fileToBase64(file); - localStorage.setItem("mock_avatar", base64 as string); - - // 2) Guardar en state para que se vea inmediato - setUser((prev) => ({ ...prev, avatarUrl: base64 as string })); + localStorage.setItem("mock_avatar", base64); + setUser((prev) => ({ ...prev, avatarUrl: base64 })); }; - + function fileToBase64(file: File) { return new Promise((resolve, reject) => { const reader = new FileReader(); @@ -69,7 +96,6 @@ export default function App() { }); } - // ✅ ahora también recibe organismName const handleSaveProfile = async (next: { name: string; email: string; @@ -81,11 +107,11 @@ export default function App() { setUser((prev) => ({ ...prev, - // si backend regresa valores, los usamos; si no, usamos "next" o lo anterior name: updated.name ?? next.name ?? prev.name, email: updated.email ?? next.email ?? prev.email, avatarUrl: updated.avatarUrl ?? prev.avatarUrl, - organismName: updated.organismName ?? next.organismName ?? prev.organismName, + organismName: + updated.organismName ?? next.organismName ?? prev.organismName, })); setProfileOpen(false); @@ -94,7 +120,6 @@ export default function App() { } }; - // Aplica theme al cargar / cambiar (para cubrir refresh) useEffect(() => { const root = document.documentElement; root.classList.remove("dark"); @@ -134,15 +159,17 @@ export default function App() { } }; + if (!isAuth) { + return ; + } + return ( - // Blindaje global del layout
- {/* Sidebar no debe encogerse */}
{ @@ -153,29 +180,28 @@ export default function App() { />
- {/* min-w-0: evita que páginas anchas (tablas) empujen el layout */}
setSettingsOpen(true)} - // props de perfil userName={user.name} userEmail={user.email} avatarUrl={user.avatarUrl} onOpenProfile={() => setProfileOpen(true)} - onUploadAvatar={handleUploadAvatar} + // ✅ en vez de cerrar, abrimos confirm modal + onRequestLogout={() => setLogoutOpen(true)} />
- {/* Scroll solo aquí */} -
{renderPage()}
+ {/* ✅ AQUÍ VA LA MARCA DE AGUA */} +
+ +
{renderPage()}
+
- {/* Settings modal */} setSettingsOpen(false)} @@ -183,19 +209,39 @@ export default function App() { setSettings={setSettings} /> - {/* ✅ Profile modal (con avatar + cambiar img + empresa) */} setProfileOpen(false)} onSave={handleSaveProfile} - onUploadAvatar={handleUploadAvatar} // ✅ NUEVO (botón Cambiar img en modal) + onUploadAvatar={handleUploadAvatar} + /> + + {/* ✅ ConfirmModal: Cerrar sesión */} + setLogoutOpen(false)} + onConfirm={async () => { + setLoggingOut(true); + try { + handleLogout(); + setLogoutOpen(false); + } finally { + setLoggingOut(false); + } + }} />
); diff --git a/src/assets/images/grhWatermark.jpg b/src/assets/images/grhWatermark.jpg deleted file mode 100644 index d7fe2f4..0000000 Binary files a/src/assets/images/grhWatermark.jpg and /dev/null differ diff --git a/src/assets/images/grhWatermark.png b/src/assets/images/grhWatermark.png new file mode 100644 index 0000000..62739fa Binary files /dev/null and b/src/assets/images/grhWatermark.png differ diff --git a/src/components/layout/TopMenu.tsx b/src/components/layout/TopMenu.tsx index 881a36b..02499f5 100644 --- a/src/components/layout/TopMenu.tsx +++ b/src/components/layout/TopMenu.tsx @@ -10,8 +10,10 @@ interface TopMenuProps { userEmail?: string; avatarUrl?: string | null; - onLogout?: () => void; onOpenProfile?: () => void; + + // ✅ NUEVO: en vez de cerrar, pedimos confirmación desde App + onRequestLogout?: () => void; } const TopMenu: React.FC = ({ @@ -23,11 +25,10 @@ const TopMenu: React.FC = ({ userEmail, avatarUrl = null, - onLogout, onOpenProfile, + onRequestLogout, }) => { const [openUserMenu, setOpenUserMenu] = useState(false); - const menuRef = useRef(null); const initials = useMemo(() => { @@ -37,7 +38,6 @@ const TopMenu: React.FC = ({ return (a + b).toUpperCase(); }, [userName]); - // Cerrar al click afuera useEffect(() => { function handleClickOutside(e: MouseEvent) { if (!openUserMenu) return; @@ -48,7 +48,6 @@ const TopMenu: React.FC = ({ return () => document.removeEventListener("mousedown", handleClickOutside); }, [openUserMenu]); - // Cerrar con ESC useEffect(() => { function handleEsc(e: KeyboardEvent) { if (e.key === "Escape") setOpenUserMenu(false); @@ -117,12 +116,8 @@ const TopMenu: React.FC = ({ role="menu" className=" absolute right-0 mt-2 w-80 - rounded-2xl - bg-white - border border-slate-200 - shadow-xl - overflow-hidden - z-50 + rounded-2xl bg-white border border-slate-200 + shadow-xl overflow-hidden z-50 " > {/* Header usuario */} @@ -157,7 +152,6 @@ const TopMenu: React.FC = ({ - {/* Items (solo 2) */} { @@ -174,11 +168,7 @@ const TopMenu: React.FC = ({ tone="danger" onClick={() => { setOpenUserMenu(false); - if (onLogout) onLogout(); - else { - localStorage.removeItem("token"); - window.location.href = "/login"; - } + onRequestLogout?.(); // ✅ abre confirm modal en App }} left={} /> @@ -225,4 +215,3 @@ function MenuItem({ } export default TopMenu; - diff --git a/src/components/layout/common/ProfileModal.tsx b/src/components/layout/common/ProfileModal.tsx index 44c70fa..a6d7978 100644 --- a/src/components/layout/common/ProfileModal.tsx +++ b/src/components/layout/common/ProfileModal.tsx @@ -54,12 +54,17 @@ export default function ProfileModal({ // Limpieza de object URLs useEffect(() => { return () => { - if (lastPreviewUrlRef.current) URL.revokeObjectURL(lastPreviewUrlRef.current); + if (lastPreviewUrlRef.current) { + URL.revokeObjectURL(lastPreviewUrlRef.current); + } }; }, []); const initials = useMemo(() => { - const parts = (name || "").trim().split(/\s+/).filter(Boolean); + const parts = (name || "") + .trim() + .split(/\s+/) + .filter(Boolean); const a = parts[0]?.[0] ?? "U"; const b = parts[1]?.[0] ?? ""; return (a + b).toUpperCase(); @@ -148,7 +153,9 @@ export default function ProfileModal({
{/* Header */}
-
Editar perfil
+
+ Editar perfil +
{/* Body */} @@ -206,7 +213,6 @@ export default function ProfileModal({ {/* RIGHT: Form */}
- {/* “correo electronico” como en tu dibujo */}
correo electrónico
@@ -253,20 +259,20 @@ export default function ProfileModal({ > Cancelar - +
diff --git a/src/components/layout/common/Watermark.tsx b/src/components/layout/common/Watermark.tsx new file mode 100644 index 0000000..6b8f411 --- /dev/null +++ b/src/components/layout/common/Watermark.tsx @@ -0,0 +1,51 @@ +import React from "react"; +import grhWatermark from "../../../assets/images/grhWatermark.png"; + +export default function Watermark({ + opacity = 0.08, + size = 520, +}: { + opacity?: number; + size?: number; +}) { + return ( +
+ {/* Marca centrada (SIN rotación) */} +
+ GRH Watermark +
+ + {/* Marca secundaria (SIN rotación) */} +
+ GRH Watermark +
+
+ ); +} diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index e380092..3280aed 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -11,7 +11,7 @@ import { } from "recharts"; import { fetchMeters, type Meter } from "../api/meters"; import type { Page } from "../App"; -import grhWatermark from "../assets/images/grhWatermark.jpg"; +import grhWatermark from "../assets/images/grhWatermark.png"; /* ================= TYPES ================= */ diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx new file mode 100644 index 0000000..3b62e6b --- /dev/null +++ b/src/pages/LoginPage.tsx @@ -0,0 +1,218 @@ +import { useMemo, useState } from "react"; +import { Lock, User, Eye, EyeOff, Loader2, Check } from "lucide-react"; +import grhWatermark from "../assets/images/grhWatermark.png"; + +type Form = { usuario: string; contrasena: string }; + +type LoginPageProps = { + onSuccess: (payload?: { token?: string }) => void; +}; + +export default function LoginPage({ onSuccess }: LoginPageProps) { + const [form, setForm] = useState
({ usuario: "", contrasena: "" }); + const [showPass, setShowPass] = useState(false); + const [loading, setLoading] = useState(false); + const [serverError, setServerError] = useState(""); + const [notRobot, setNotRobot] = useState(false); + + const errors = useMemo(() => { + const e: Partial> = {}; + if (!form.usuario.trim()) e.usuario = "El usuario es obligatorio."; + if (!form.contrasena) e.contrasena = "La contraseña es obligatoria."; + if (!notRobot) e.robot = "Confirma que no eres un robot."; + return e; + }, [form.usuario, form.contrasena, notRobot]); + + const canSubmit = Object.keys(errors).length === 0 && !loading; + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setServerError(""); + if (!canSubmit) return; + + try { + setLoading(true); + await new Promise((r) => setTimeout(r, 700)); + onSuccess({ token: "demo" }); + } catch { + setServerError("No se pudo iniciar sesión. Verifica tus datos."); + } finally { + setLoading(false); + } + } + + return ( +
+
+
+ +
+ {/* IZQUIERDA */} +
+
+ +
+ +
+
+

+ ¡Bienvenido! +

+

+ Ingresa con tus credenciales para acceder al panel GRH. +

+
+
+
+ + {/* DERECHA */} +
+
+
+
+ GRH +
+

+ Iniciar sesión +

+

+ Gestión de Recursos Hídricos +

+
+
+ + + {serverError && ( +
+ {serverError} +
+ )} + + {/* Usuario */} +
+ +
+ + setForm((s) => ({ ...s, usuario: e.target.value })) + } + className="w-full border-b border-slate-300 py-2 pr-10 outline-none focus:border-slate-600" + /> + +
+ {errors.usuario && ( +

+ {errors.usuario} +

+ )} +
+ + {/* Contraseña */} +
+ +
+ + setForm((s) => ({ ...s, contrasena: e.target.value })) + } + type={showPass ? "text" : "password"} + className="w-full border-b border-slate-300 py-2 pr-16 outline-none focus:border-slate-600" + /> + + +
+ {errors.contrasena && ( +

+ {errors.contrasena} +

+ )} +
+ + {/* NO SOY UN ROBOT */} +
+ + No soy un robot + + reCAPTCHA + +
+ + {errors.robot && ( +

{errors.robot}

+ )} + + {/* Botón */} + + +
+
+
+
+
+
+ ); +}