Login GRH, marca de agua tipo membretado y logo en PNG sin fondo
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
104
src/App.tsx
104
src/App.tsx
@@ -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 |
BIN
src/assets/images/grhWatermark.png
Normal file
BIN
src/assets/images/grhWatermark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -266,7 +273,6 @@ export default function ProfileModal({
|
|||||||
>
|
>
|
||||||
{loading ? "Guardando..." : "Guardar"}
|
{loading ? "Guardando..." : "Guardar"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
51
src/components/layout/common/Watermark.tsx
Normal file
51
src/components/layout/common/Watermark.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
218
src/pages/LoginPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user