Login GRH, marca de agua tipo membretado y logo en PNG sin fondo

This commit is contained in:
Marlene-Angel
2026-01-12 13:45:25 -08:00
parent 4d807babf7
commit dd3997a3a8
9 changed files with 378 additions and 68 deletions

View File

@@ -2,9 +2,9 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/jpeg" href="/grhWatermark.jpg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title> <title>GRH</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -9,13 +9,19 @@ import ProjectsPage from "./pages/projects/ProjectsPage";
import UsersPage from "./pages/UsersPage"; import UsersPage from "./pages/UsersPage";
import RolesPage from "./pages/RolesPage"; import RolesPage from "./pages/RolesPage";
import ProfileModal from "./components/layout/common/ProfileModal"; import ProfileModal from "./components/layout/common/ProfileModal";
import { uploadMyAvatar, updateMyProfile } from "./api/me"; import { updateMyProfile } from "./api/me";
import SettingsModal, { import SettingsModal, {
type AppSettings, type AppSettings,
loadSettings, loadSettings,
} from "./components/SettingsModals"; } 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 = export type Page =
| "home" | "home"
| "projects" | "projects"
@@ -24,12 +30,38 @@ export type Page =
| "users" | "users"
| "roles"; | "roles";
const AUTH_KEY = "grh_auth";
export default function App() { export default function App() {
const [isAuth, setIsAuth] = useState<boolean>(() => {
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<Page>("home"); const [page, setPage] = useState<Page>("home");
const [subPage, setSubPage] = useState<string>("default"); const [subPage, setSubPage] = useState<string>("default");
const [selectedProject, setSelectedProject] = useState<string>(""); const [selectedProject, setSelectedProject] = useState<string>("");
// ✅ perfil usuario + modal
const [profileOpen, setProfileOpen] = useState(false); const [profileOpen, setProfileOpen] = useState(false);
const [savingProfile, setSavingProfile] = useState(false); const [savingProfile, setSavingProfile] = useState(false);
@@ -37,27 +69,22 @@ export default function App() {
name: "CESPT Admin", name: "CESPT Admin",
email: "admin@cespt.gob.mx", email: "admin@cespt.gob.mx",
avatarUrl: null as string | null, avatarUrl: null as string | null,
organismName: "CESPT", // ✅ NUEVO: Empresa/Organismo organismName: "CESPT",
}); });
// Settings state
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const [settings, setSettings] = useState<AppSettings>(() => loadSettings()); const [settings, setSettings] = useState<AppSettings>(() => loadSettings());
const navigateToMetersWithProject = (projectName: string) => { const navigateToMetersWithProject = (projectName: string) => {
setSelectedProject(projectName); setSelectedProject(projectName);
setSubPage(projectName); // útil para breadcrumb si lo usas setSubPage(projectName);
setPage("meters"); setPage("meters");
}; };
// ✅ handlers
const handleUploadAvatar = async (file: File) => { const handleUploadAvatar = async (file: File) => {
// 1) Guardar como base64 en localStorage (demo)
const base64 = await fileToBase64(file); const base64 = await fileToBase64(file);
localStorage.setItem("mock_avatar", base64 as string); localStorage.setItem("mock_avatar", base64);
setUser((prev) => ({ ...prev, avatarUrl: base64 }));
// 2) Guardar en state para que se vea inmediato
setUser((prev) => ({ ...prev, avatarUrl: base64 as string }));
}; };
function fileToBase64(file: File) { function fileToBase64(file: File) {
@@ -69,7 +96,6 @@ export default function App() {
}); });
} }
// ✅ ahora también recibe organismName
const handleSaveProfile = async (next: { const handleSaveProfile = async (next: {
name: string; name: string;
email: string; email: string;
@@ -81,11 +107,11 @@ export default function App() {
setUser((prev) => ({ setUser((prev) => ({
...prev, ...prev,
// si backend regresa valores, los usamos; si no, usamos "next" o lo anterior
name: updated.name ?? next.name ?? prev.name, name: updated.name ?? next.name ?? prev.name,
email: updated.email ?? next.email ?? prev.email, email: updated.email ?? next.email ?? prev.email,
avatarUrl: updated.avatarUrl ?? prev.avatarUrl, avatarUrl: updated.avatarUrl ?? prev.avatarUrl,
organismName: updated.organismName ?? next.organismName ?? prev.organismName, organismName:
updated.organismName ?? next.organismName ?? prev.organismName,
})); }));
setProfileOpen(false); setProfileOpen(false);
@@ -94,7 +120,6 @@ export default function App() {
} }
}; };
// Aplica theme al cargar / cambiar (para cubrir refresh)
useEffect(() => { useEffect(() => {
const root = document.documentElement; const root = document.documentElement;
root.classList.remove("dark"); root.classList.remove("dark");
@@ -134,15 +159,17 @@ export default function App() {
} }
}; };
if (!isAuth) {
return <LoginPage onSuccess={handleLogin} />;
}
return ( return (
// Blindaje global del layout
<div <div
className={[ className={[
"flex h-screen w-full overflow-hidden", "flex h-screen w-full overflow-hidden",
settings.compactMode ? "text-sm" : "", settings.compactMode ? "text-sm" : "",
].join(" ")} ].join(" ")}
> >
{/* Sidebar no debe encogerse */}
<div className="shrink-0"> <div className="shrink-0">
<Sidebar <Sidebar
setPage={(p) => { setPage={(p) => {
@@ -153,29 +180,28 @@ export default function App() {
/> />
</div> </div>
{/* min-w-0: evita que páginas anchas (tablas) empujen el layout */}
<div className="flex min-w-0 flex-1 flex-col"> <div className="flex min-w-0 flex-1 flex-col">
<div className="shrink-0"> <div className="shrink-0">
<TopMenu <TopMenu
page={page} page={page}
subPage={subPage} subPage={subPage}
setSubPage={setSubPage} setSubPage={setSubPage}
setPage={setPage}
onOpenSettings={() => setSettingsOpen(true)}
// props de perfil
userName={user.name} userName={user.name}
userEmail={user.email} userEmail={user.email}
avatarUrl={user.avatarUrl} avatarUrl={user.avatarUrl}
onOpenProfile={() => setProfileOpen(true)} onOpenProfile={() => setProfileOpen(true)}
onUploadAvatar={handleUploadAvatar} // ✅ en vez de cerrar, abrimos confirm modal
onRequestLogout={() => setLogoutOpen(true)}
/> />
</div> </div>
{/* Scroll solo aquí */} {/* ✅ AQUÍ VA LA MARCA DE AGUA */}
<main className="min-w-0 flex-1 overflow-auto">{renderPage()}</main> <main className="relative min-w-0 flex-1 overflow-auto">
<Watermark />
<div className="relative z-10">{renderPage()}</div>
</main>
</div> </div>
{/* Settings modal */}
<SettingsModal <SettingsModal
open={settingsOpen} open={settingsOpen}
onClose={() => setSettingsOpen(false)} onClose={() => setSettingsOpen(false)}
@@ -183,19 +209,39 @@ export default function App() {
setSettings={setSettings} setSettings={setSettings}
/> />
{/* ✅ Profile modal (con avatar + cambiar img + empresa) */}
<ProfileModal <ProfileModal
open={profileOpen} open={profileOpen}
loading={savingProfile} loading={savingProfile}
avatarUrl={user.avatarUrl} // ✅ NUEVO avatarUrl={user.avatarUrl}
initial={{ initial={{
name: user.name, name: user.name,
email: user.email, email: user.email,
organismName: user.organismName, // ✅ NUEVO organismName: user.organismName,
}} }}
onClose={() => setProfileOpen(false)} onClose={() => setProfileOpen(false)}
onSave={handleSaveProfile} onSave={handleSaveProfile}
onUploadAvatar={handleUploadAvatar} // ✅ NUEVO (botón Cambiar img en modal) onUploadAvatar={handleUploadAvatar}
/>
{/* ✅ ConfirmModal: Cerrar sesión */}
<ConfirmModal
open={logoutOpen}
title="Cerrar sesión"
message="¿Estás seguro que deseas cerrar sesión?"
confirmText="Cerrar sesión"
cancelText="Cancelar"
danger
loading={loggingOut}
onClose={() => setLogoutOpen(false)}
onConfirm={async () => {
setLoggingOut(true);
try {
handleLogout();
setLogoutOpen(false);
} finally {
setLoggingOut(false);
}
}}
/> />
</div> </div>
); );

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

View File

@@ -10,8 +10,10 @@ interface TopMenuProps {
userEmail?: string; userEmail?: string;
avatarUrl?: string | null; avatarUrl?: string | null;
onLogout?: () => void;
onOpenProfile?: () => void; onOpenProfile?: () => void;
// ✅ NUEVO: en vez de cerrar, pedimos confirmación desde App
onRequestLogout?: () => void;
} }
const TopMenu: React.FC<TopMenuProps> = ({ const TopMenu: React.FC<TopMenuProps> = ({
@@ -23,11 +25,10 @@ const TopMenu: React.FC<TopMenuProps> = ({
userEmail, userEmail,
avatarUrl = null, avatarUrl = null,
onLogout,
onOpenProfile, onOpenProfile,
onRequestLogout,
}) => { }) => {
const [openUserMenu, setOpenUserMenu] = useState(false); const [openUserMenu, setOpenUserMenu] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null); const menuRef = useRef<HTMLDivElement | null>(null);
const initials = useMemo(() => { const initials = useMemo(() => {
@@ -37,7 +38,6 @@ const TopMenu: React.FC<TopMenuProps> = ({
return (a + b).toUpperCase(); return (a + b).toUpperCase();
}, [userName]); }, [userName]);
// Cerrar al click afuera
useEffect(() => { useEffect(() => {
function handleClickOutside(e: MouseEvent) { function handleClickOutside(e: MouseEvent) {
if (!openUserMenu) return; if (!openUserMenu) return;
@@ -48,7 +48,6 @@ const TopMenu: React.FC<TopMenuProps> = ({
return () => document.removeEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside);
}, [openUserMenu]); }, [openUserMenu]);
// Cerrar con ESC
useEffect(() => { useEffect(() => {
function handleEsc(e: KeyboardEvent) { function handleEsc(e: KeyboardEvent) {
if (e.key === "Escape") setOpenUserMenu(false); if (e.key === "Escape") setOpenUserMenu(false);
@@ -117,12 +116,8 @@ const TopMenu: React.FC<TopMenuProps> = ({
role="menu" role="menu"
className=" className="
absolute right-0 mt-2 w-80 absolute right-0 mt-2 w-80
rounded-2xl rounded-2xl bg-white border border-slate-200
bg-white shadow-xl overflow-hidden z-50
border border-slate-200
shadow-xl
overflow-hidden
z-50
" "
> >
{/* Header usuario */} {/* Header usuario */}
@@ -157,7 +152,6 @@ const TopMenu: React.FC<TopMenuProps> = ({
</div> </div>
</div> </div>
{/* Items (solo 2) */}
<MenuItem <MenuItem
label="Ver / editar perfil" label="Ver / editar perfil"
onClick={() => { onClick={() => {
@@ -174,11 +168,7 @@ const TopMenu: React.FC<TopMenuProps> = ({
tone="danger" tone="danger"
onClick={() => { onClick={() => {
setOpenUserMenu(false); setOpenUserMenu(false);
if (onLogout) onLogout(); onRequestLogout?.(); // ✅ abre confirm modal en App
else {
localStorage.removeItem("token");
window.location.href = "/login";
}
}} }}
left={<LogOut size={16} />} left={<LogOut size={16} />}
/> />
@@ -225,4 +215,3 @@ function MenuItem({
} }
export default TopMenu; export default TopMenu;

View File

@@ -54,12 +54,17 @@ export default function ProfileModal({
// Limpieza de object URLs // Limpieza de object URLs
useEffect(() => { useEffect(() => {
return () => { return () => {
if (lastPreviewUrlRef.current) URL.revokeObjectURL(lastPreviewUrlRef.current); if (lastPreviewUrlRef.current) {
URL.revokeObjectURL(lastPreviewUrlRef.current);
}
}; };
}, []); }, []);
const initials = useMemo(() => { 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 a = parts[0]?.[0] ?? "U";
const b = parts[1]?.[0] ?? ""; const b = parts[1]?.[0] ?? "";
return (a + b).toUpperCase(); return (a + b).toUpperCase();
@@ -148,7 +153,9 @@ export default function ProfileModal({
<div className="rounded-2xl bg-white shadow-xl border border-slate-200 overflow-hidden"> <div className="rounded-2xl bg-white shadow-xl border border-slate-200 overflow-hidden">
{/* Header */} {/* Header */}
<div className="px-6 py-4 border-b border-slate-200"> <div className="px-6 py-4 border-b border-slate-200">
<div className="text-base font-semibold text-slate-900">Editar perfil</div> <div className="text-base font-semibold text-slate-900">
Editar perfil
</div>
</div> </div>
{/* Body */} {/* Body */}
@@ -206,7 +213,6 @@ export default function ProfileModal({
{/* RIGHT: Form */} {/* RIGHT: Form */}
<div className="rounded-2xl border border-slate-200 p-5"> <div className="rounded-2xl border border-slate-200 p-5">
{/* “correo electronico” como en tu dibujo */}
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wide"> <div className="text-xs font-semibold text-slate-500 uppercase tracking-wide">
correo electrónico correo electrónico
</div> </div>
@@ -253,6 +259,7 @@ export default function ProfileModal({
> >
Cancelar Cancelar
</button> </button>
<button <button
type="button" type="button"
onClick={handleSubmit} onClick={handleSubmit}
@@ -263,10 +270,9 @@ export default function ProfileModal({
"focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2", "focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2",
loading ? "opacity-60 cursor-not-allowed" : "", loading ? "opacity-60 cursor-not-allowed" : "",
].join(" ")} ].join(" ")}
> >
{loading ? "Guardando..." : "Guardar"} {loading ? "Guardando..." : "Guardar"}
</button> </button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -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 (
<div className="pointer-events-none fixed inset-0 z-20 overflow-hidden">
{/* Marca centrada (SIN rotación) */}
<div
className="absolute left-1/2 top-1/2"
style={{
transform: "translate(-50%, -50%)",
opacity,
}}
>
<img
src={grhWatermark}
alt="GRH Watermark"
width={size}
height={size}
className="select-none object-contain"
draggable={false}
style={{ filter: "grayscale(100%)" }} // opcional
/>
</div>
{/* Marca secundaria (SIN rotación) */}
<div
className="absolute right-[-140px] bottom-[-180px]"
style={{
opacity: Math.max(0.04, opacity * 0.55),
}}
>
<img
src={grhWatermark}
alt="GRH Watermark"
width={Math.round(size * 0.75)}
height={Math.round(size * 0.75)}
className="select-none object-contain"
draggable={false}
style={{ filter: "grayscale(100%)" }} // opcional
/>
</div>
</div>
);
}

View File

@@ -11,7 +11,7 @@ import {
} from "recharts"; } from "recharts";
import { fetchMeters, type Meter } from "../api/meters"; import { fetchMeters, type Meter } from "../api/meters";
import type { Page } from "../App"; import type { Page } from "../App";
import grhWatermark from "../assets/images/grhWatermark.jpg"; import grhWatermark from "../assets/images/grhWatermark.png";
/* ================= TYPES ================= */ /* ================= TYPES ================= */

218
src/pages/LoginPage.tsx Normal file
View File

@@ -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<Form>({ 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<Record<keyof Form | "robot", string>> = {};
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<HTMLFormElement>) {
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 (
<div className="h-screen w-screen font-sans bg-slate-50">
<div className="relative h-full w-full overflow-hidden bg-white">
<div
className="absolute left-0 top-0 h-[3px] w-full opacity-90"
style={{
background:
"linear-gradient(90deg, transparent, rgba(86,107,184,0.9), rgba(76,95,158,0.9), transparent)",
}}
/>
<div className="grid h-full grid-cols-1 md:grid-cols-2">
{/* IZQUIERDA */}
<section className="relative overflow-hidden">
<div
className="absolute inset-0"
style={{
background:
"linear-gradient(135deg, #2a355d 10%, #4c5f9e 55%, #566bb8 100%)",
}}
/>
<div
className="absolute inset-0"
style={{
clipPath: "polygon(0 0, 80% 0, 55% 100%, 0 100%)",
background:
"linear-gradient(135deg, rgba(255,255,255,0.10), rgba(255,255,255,0.02))",
}}
/>
<div className="relative h-full flex items-center px-10 md:px-12">
<div className="max-w-sm text-white">
<h2 className="text-4xl font-semibold tracking-tight">
¡Bienvenido!
</h2>
<p className="mt-3 text-sm leading-relaxed text-white/90">
Ingresa con tus credenciales para acceder al panel GRH.
</p>
</div>
</div>
</section>
{/* DERECHA */}
<section className="bg-white flex items-center justify-center">
<div className="w-full max-w-lg px-6">
<div className="rounded-3xl border border-slate-200 bg-white/90 p-10 md:p-12 shadow-lg">
<div className="flex items-center gap-4">
<img
src={grhWatermark}
alt="GRH"
className="h-20 w-20 object-contain rounded-lg"
/>
<div className="leading-tight">
<h1 className="text-2xl font-semibold text-slate-900">
Iniciar sesión
</h1>
<p className="text-xs text-slate-500">
Gestión de Recursos Hídricos
</p>
</div>
</div>
<form onSubmit={onSubmit} className="mt-8 space-y-6">
{serverError && (
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{serverError}
</div>
)}
{/* Usuario */}
<div>
<label className="block text-sm font-medium text-slate-700">
Usuario
</label>
<div className="relative mt-2">
<input
value={form.usuario}
onChange={(e) =>
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"
/>
<User
className="absolute right-1 top-1/2 -translate-y-1/2 text-slate-500"
size={18}
/>
</div>
{errors.usuario && (
<p className="mt-1 text-xs text-red-600">
{errors.usuario}
</p>
)}
</div>
{/* Contraseña */}
<div>
<label className="block text-sm font-medium text-slate-700">
Contraseña
</label>
<div className="relative mt-2">
<input
value={form.contrasena}
onChange={(e) =>
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"
/>
<button
type="button"
onClick={() => setShowPass((v) => !v)}
className="absolute right-8 top-1/2 -translate-y-1/2 text-slate-500"
>
{showPass ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
<Lock
className="absolute right-1 top-1/2 -translate-y-1/2 text-slate-500"
size={18}
/>
</div>
{errors.contrasena && (
<p className="mt-1 text-xs text-red-600">
{errors.contrasena}
</p>
)}
</div>
{/* NO SOY UN ROBOT */}
<div className="flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3">
<button
type="button"
onClick={() => setNotRobot((v) => !v)}
className={`h-5 w-5 rounded border flex items-center justify-center ${
notRobot
? "bg-blue-600 border-blue-600 text-white"
: "bg-white border-slate-300"
}`}
>
{notRobot && <Check size={14} />}
</button>
<span className="text-sm text-slate-700">No soy un robot</span>
<span className="ml-auto text-xs text-slate-400">
reCAPTCHA
</span>
</div>
{errors.robot && (
<p className="text-xs text-red-600">{errors.robot}</p>
)}
{/* Botón */}
<button
type="submit"
disabled={!canSubmit}
className="mt-2 w-full rounded-full bg-gradient-to-r from-[#4c5f9e] to-[#566bb8] py-2.5 text-white shadow-md flex items-center justify-center gap-2 disabled:opacity-60 disabled:cursor-not-allowed"
>
{loading ? (
<>
<Loader2 className="animate-spin" size={18} />
Entrando...
</>
) : (
"Iniciar sesión"
)}
</button>
</form>
</div>
</div>
</section>
</div>
</div>
</div>
);
}