Merge pull request #5 from luanngel/DevMarlene

Se quitaron los labels de meter
This commit is contained in:
juancho1127
2026-01-07 18:29:36 -06:00
committed by GitHub
13 changed files with 2809 additions and 897 deletions

View File

@@ -6,6 +6,7 @@ body, html, #root {
padding: 0; padding: 0;
height: 100%; height: 100%;
font-family: "Inter", "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-family: "Inter", "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
overflow-x: hidden;
} }
/* ========================= /* =========================
@@ -21,7 +22,7 @@ body, html, #root {
grid-template-columns: var(--sidebar-width) 1fr; grid-template-columns: var(--sidebar-width) 1fr;
grid-template-rows: 100vh; grid-template-rows: 100vh;
height: 100vh; height: 100vh;
width: 100vw; width: 100%;
overflow: hidden; overflow: hidden;
transition: grid-template-columns 0.3s ease; transition: grid-template-columns 0.3s ease;
} }

View File

@@ -1,4 +1,4 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import Sidebar from "./components/layout/Sidebar"; import Sidebar from "./components/layout/Sidebar";
import TopMenu from "./components/layout/TopMenu"; import TopMenu from "./components/layout/TopMenu";
@@ -8,6 +8,13 @@ import ConcentratorsPage from "./pages/concentrators/ConcentratorsPage";
import ProjectsPage from "./pages/projects/ProjectsPage"; 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 { uploadMyAvatar, updateMyProfile } from "./api/me";
import SettingsModal, {
type AppSettings,
loadSettings,
} from "./components/SettingsModals";
export type Page = export type Page =
| "home" | "home"
@@ -22,11 +29,85 @@ export default function App() {
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 [savingProfile, setSavingProfile] = useState(false);
const [user, setUser] = useState({
name: "CESPT Admin",
email: "admin@cespt.gob.mx",
avatarUrl: null as string | null,
organismName: "CESPT", // ✅ NUEVO: Empresa/Organismo
});
// Settings state
const [settingsOpen, setSettingsOpen] = useState(false);
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
setPage("meters"); 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 }));
};
function fileToBase64(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
// ✅ ahora también recibe organismName
const handleSaveProfile = async (next: {
name: string;
email: string;
organismName?: string;
}) => {
setSavingProfile(true);
try {
const updated = await updateMyProfile(next);
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,
}));
setProfileOpen(false);
} finally {
setSavingProfile(false);
}
};
// Aplica theme al cargar / cambiar (para cubrir refresh)
useEffect(() => {
const root = document.documentElement;
root.classList.remove("dark");
if (settings.theme === "dark") root.classList.add("dark");
if (settings.theme === "system") {
const prefersDark = window.matchMedia?.(
"(prefers-color-scheme: dark)"
)?.matches;
if (prefersDark) root.classList.add("dark");
}
}, [settings.theme]);
const renderPage = () => { const renderPage = () => {
switch (page) { switch (page) {
case "projects": case "projects":
@@ -41,19 +122,81 @@ export default function App() {
return <RolesPage />; return <RolesPage />;
case "home": case "home":
default: default:
return <Home setPage={setPage} navigateToMetersWithProject={navigateToMetersWithProject} />; return (
<Home
setPage={(p) => {
setPage(p);
setSubPage("default");
}}
navigateToMetersWithProject={navigateToMetersWithProject}
/>
);
} }
}; };
return ( return (
<div className="flex h-screen"> // Blindaje global del layout
<Sidebar setPage={setPage} /> <div
<div className="flex-1 flex flex-col"> className={[
<TopMenu page={page} subPage={subPage} setSubPage={setSubPage} /> "flex h-screen w-full overflow-hidden",
<main className="flex-1 overflow-auto"> settings.compactMode ? "text-sm" : "",
{renderPage()} ].join(" ")}
</main> >
{/* Sidebar no debe encogerse */}
<div className="shrink-0">
<Sidebar
setPage={(p) => {
setPage(p);
setSubPage("default");
if (p !== "meters") setSelectedProject("");
}}
/>
</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="shrink-0">
<TopMenu
page={page}
subPage={subPage}
setSubPage={setSubPage}
setPage={setPage}
onOpenSettings={() => setSettingsOpen(true)}
// props de perfil
userName={user.name}
userEmail={user.email}
avatarUrl={user.avatarUrl}
onOpenProfile={() => setProfileOpen(true)}
onUploadAvatar={handleUploadAvatar}
/>
</div>
{/* Scroll solo aquí */}
<main className="min-w-0 flex-1 overflow-auto">{renderPage()}</main>
</div>
{/* Settings modal */}
<SettingsModal
open={settingsOpen}
onClose={() => setSettingsOpen(false)}
settings={settings}
setSettings={setSettings}
/>
{/* ✅ Profile modal (con avatar + cambiar img + empresa) */}
<ProfileModal
open={profileOpen}
loading={savingProfile}
avatarUrl={user.avatarUrl} // ✅ NUEVO
initial={{
name: user.name,
email: user.email,
organismName: user.organismName, // ✅ NUEVO
}}
onClose={() => setProfileOpen(false)}
onSave={handleSaveProfile}
onUploadAvatar={handleUploadAvatar} // ✅ NUEVO (botón Cambiar img en modal)
/>
</div> </div>
); );
} }

45
src/api/me.ts Normal file
View File

@@ -0,0 +1,45 @@
export async function uploadMyAvatar(file: File): Promise<{ avatarUrl: string }> {
const form = new FormData();
form.append("avatar", file);
const res = await fetch("/api/me/avatar", {
method: "POST",
body: form,
// NO pongas Content-Type; el browser lo agrega con boundary
headers: {
// Si usas token:
// Authorization: `Bearer ${localStorage.getItem("token") ?? ""}`,
},
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Upload avatar failed: ${res.status} ${text}`);
}
const data = await res.json();
if (!data?.avatarUrl) throw new Error("Respuesta sin avatarUrl");
return { avatarUrl: data.avatarUrl };
}
export async function updateMyProfile(input: {
name: string;
email: string;
}): Promise<{ name?: string; email?: string; avatarUrl?: string | null }> {
const res = await fetch("/api/me", {
method: "PUT",
headers: {
"Content-Type": "application/json",
// Authorization: `Bearer ${localStorage.getItem("token") ?? ""}`,
},
body: JSON.stringify(input),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Update profile failed: ${res.status} ${text}`);
}
return res.json();
}

View File

@@ -3,8 +3,9 @@ export const PROJECTS_API_URL = `${API_BASE_URL}/api/v3/data/ppfu31vhv5gf6i0/m05
const API_TOKEN = import.meta.env.VITE_API_TOKEN; const API_TOKEN = import.meta.env.VITE_API_TOKEN;
export const getAuthHeaders = () => ({ export const getAuthHeaders = () => ({
Authorization: `Bearer ${API_TOKEN}`,
"Content-Type": "application/json", "Content-Type": "application/json",
"xc-token": API_TOKEN, // NocoDB style
Authorization: `Bearer ${API_TOKEN}`, // fallback por si el backend usa Bearer
}); });
export interface ProjectRecord { export interface ProjectRecord {

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View File

@@ -0,0 +1,201 @@
import React, { useEffect, useState } from "react";
import { X } from "lucide-react";
type Theme = "system" | "light" | "dark";
export interface AppSettings {
theme: Theme;
compactMode: boolean;
}
const STORAGE_KEY = "water_project_settings_v1";
export const defaultSettings: AppSettings = {
theme: "system",
compactMode: false,
};
export const loadSettings = (): AppSettings => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return defaultSettings;
const parsed = JSON.parse(raw) as Partial<AppSettings>;
return {
theme: parsed.theme ?? defaultSettings.theme,
compactMode: parsed.compactMode ?? defaultSettings.compactMode,
};
} catch {
return defaultSettings;
}
};
export const saveSettings = (s: AppSettings) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
};
const applyTheme = (theme: Theme) => {
const root = document.documentElement;
root.classList.remove("dark");
if (theme === "dark") root.classList.add("dark");
if (theme === "system") {
const prefersDark = window.matchMedia?.("(prefers-color-scheme: dark)")?.matches;
if (prefersDark) root.classList.add("dark");
}
};
export default function SettingsModal({
open,
onClose,
settings,
setSettings,
}: {
open: boolean;
onClose: () => void;
settings: AppSettings;
setSettings: (s: AppSettings) => void;
}) {
const [local, setLocal] = useState<AppSettings>(settings);
useEffect(() => {
if (open) setLocal(settings);
}, [open, settings]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
if (open) window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [open, onClose]);
if (!open) return null;
const onSave = () => {
setSettings(local);
saveSettings(local);
applyTheme(local.theme);
onClose();
};
const onReset = () => setLocal(defaultSettings);
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
role="dialog"
aria-modal="true"
aria-label="Configuración"
>
{/* Overlay */}
<button
className="absolute inset-0 bg-black/40"
onClick={onClose}
aria-label="Cerrar"
/>
{/* Panel */}
<div className="relative w-[92vw] max-w-lg rounded-xl bg-white shadow-lg">
<div className="flex items-center justify-between px-5 py-4 border-b">
<h2 className="text-base font-semibold text-gray-800">Configuración</h2>
<button
type="button"
aria-label="Cerrar"
className="p-2 rounded-lg hover:bg-gray-100 transition"
onClick={onClose}
>
<X size={18} />
</button>
</div>
<div className="px-5 py-4 space-y-5">
{/* Tema */}
<div className="space-y-2">
<p className="text-sm font-semibold text-gray-800">Tema</p>
<div className="flex flex-wrap gap-2">
{([
{ key: "system", label: "Sistema" },
{ key: "light", label: "Claro" },
{ key: "dark", label: "Oscuro" },
] as const).map((t) => {
const active = local.theme === t.key;
return (
<button
key={t.key}
type="button"
onClick={() => setLocal((p) => ({ ...p, theme: t.key }))}
className={[
"px-3 py-1 rounded-full text-sm border transition",
active
? "bg-blue-600 text-white border-blue-600"
: "bg-white text-gray-700 border-gray-300 hover:bg-gray-50",
].join(" ")}
>
{t.label}
</button>
);
})}
</div>
</div>
{/* Compact mode */}
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-semibold text-gray-800">Modo compacto</p>
<p className="text-xs text-gray-500">
Reduce paddings/espaciado en tablas y tarjetas.
</p>
</div>
<button
type="button"
onClick={() =>
setLocal((p) => ({ ...p, compactMode: !p.compactMode }))
}
className={[
"w-12 h-7 rounded-full transition relative",
local.compactMode ? "bg-blue-600" : "bg-gray-300",
].join(" ")}
aria-label="Alternar modo compacto"
>
<span
className={[
"absolute top-0.5 w-6 h-6 rounded-full bg-white shadow transition",
local.compactMode ? "left-5" : "left-0.5",
].join(" ")}
/>
</button>
</div>
</div>
<div className="flex items-center justify-between px-5 py-4 border-t">
<button
type="button"
onClick={onReset}
className="text-sm font-semibold text-gray-600 hover:text-gray-800 transition"
>
Restablecer
</button>
<div className="flex items-center gap-2">
<button
type="button"
onClick={onClose}
className="px-4 py-2 rounded-lg text-sm font-semibold border border-gray-300 hover:bg-gray-50 transition"
>
Cancelar
</button>
<button
type="button"
onClick={onSave}
className="px-4 py-2 rounded-lg text-sm font-semibold bg-blue-600 text-white hover:bg-blue-700 transition"
>
Guardar
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,16 +1,65 @@
import React from "react"; import React, { useEffect, useMemo, useRef, useState } from "react";
import { Bell, User, Settings } from "lucide-react"; import { Bell, User, LogOut } from "lucide-react";
interface TopMenuProps { interface TopMenuProps {
page: string; page: string;
subPage: string; subPage: string;
setSubPage: (subPage: string) => void; setSubPage: (subPage: string) => void;
userName?: string;
userEmail?: string;
avatarUrl?: string | null;
onLogout?: () => void;
onOpenProfile?: () => void;
} }
const TopMenu: React.FC<TopMenuProps> = ({ page, subPage, setSubPage }) => { const TopMenu: React.FC<TopMenuProps> = ({
page,
subPage,
setSubPage,
userName = "Usuario",
userEmail,
avatarUrl = null,
onLogout,
onOpenProfile,
}) => {
const [openUserMenu, setOpenUserMenu] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
const initials = useMemo(() => {
const parts = (userName || "").trim().split(/\s+/).filter(Boolean);
const a = parts[0]?.[0] ?? "U";
const b = parts[1]?.[0] ?? "";
return (a + b).toUpperCase();
}, [userName]);
// Cerrar al click afuera
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (!openUserMenu) return;
const el = menuRef.current;
if (el && !el.contains(e.target as Node)) setOpenUserMenu(false);
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [openUserMenu]);
// Cerrar con ESC
useEffect(() => {
function handleEsc(e: KeyboardEvent) {
if (e.key === "Escape") setOpenUserMenu(false);
}
document.addEventListener("keydown", handleEsc);
return () => document.removeEventListener("keydown", handleEsc);
}, []);
return ( return (
<header <header
className="h-14 shrink-0 flex items-center justify-between px-4 text-white" className="relative z-40 h-14 shrink-0 flex items-center justify-between px-4 text-white"
style={{ style={{
background: background:
"linear-gradient(135deg, #4c5f9e, #2a355d, #566bb8, #3d4e87)", "linear-gradient(135deg, #4c5f9e, #2a355d, #566bb8, #3d4e87)",
@@ -36,26 +85,144 @@ const TopMenu: React.FC<TopMenuProps> = ({ page, subPage, setSubPage }) => {
<button <button
aria-label="Notificaciones" aria-label="Notificaciones"
className="p-2 rounded-full hover:bg-white/10 transition" className="p-2 rounded-full hover:bg-white/10 transition"
type="button"
> >
<Bell size={20} /> <Bell size={20} />
</button> </button>
<button {/* USER MENU */}
aria-label="Configuración" <div className="relative" ref={menuRef}>
className="p-2 rounded-full hover:bg-white/10 transition" <button
> type="button"
<Settings size={20} /> aria-label="Perfil"
</button> aria-haspopup="menu"
aria-expanded={openUserMenu}
onClick={() => setOpenUserMenu((v) => !v)}
className="w-9 h-9 rounded-full bg-white/15 flex items-center justify-center cursor-pointer hover:bg-white/25 transition overflow-hidden"
title="Perfil"
>
{avatarUrl ? (
<img
src={avatarUrl}
alt="Avatar"
className="w-full h-full object-cover"
/>
) : (
<span className="text-xs font-bold opacity-90">{initials}</span>
)}
</button>
<div {openUserMenu && (
className="w-9 h-9 rounded-full bg-white/15 flex items-center justify-center cursor-pointer hover:bg-white/25 transition" <div
title="Perfil" role="menu"
> className="
<User size={20} /> absolute right-0 mt-2 w-80
rounded-2xl
bg-white
border border-slate-200
shadow-xl
overflow-hidden
z-50
"
>
{/* Header usuario */}
<div className="px-5 py-4 border-b border-slate-200">
<div className="flex items-center gap-3">
<div className="w-11 h-11 rounded-full bg-slate-100 overflow-hidden flex items-center justify-center">
{avatarUrl ? (
<img
src={avatarUrl}
alt="Avatar"
className="w-full h-full object-cover"
/>
) : (
<span className="text-sm font-semibold text-slate-700">
{initials}
</span>
)}
</div>
<div className="min-w-0">
<div className="text-sm font-semibold text-slate-900 truncate">
{userName}
</div>
{userEmail ? (
<div className="text-xs text-slate-500 truncate">
{userEmail}
</div>
) : (
<div className="text-xs text-slate-400 truncate"></div>
)}
</div>
</div>
</div>
{/* Items (solo 2) */}
<MenuItem
label="Ver / editar perfil"
onClick={() => {
setOpenUserMenu(false);
onOpenProfile?.();
}}
left={<User size={16} />}
/>
<div className="h-px bg-slate-200 my-1" />
<MenuItem
label="Cerrar sesión"
tone="danger"
onClick={() => {
setOpenUserMenu(false);
if (onLogout) onLogout();
else {
localStorage.removeItem("token");
window.location.href = "/login";
}
}}
left={<LogOut size={16} />}
/>
</div>
)}
</div> </div>
</div> </div>
</header> </header>
); );
}; };
function MenuItem({
label,
onClick,
disabled,
tone = "default",
left,
}: {
label: string;
onClick: () => void;
disabled?: boolean;
tone?: "default" | "danger";
left?: React.ReactNode;
}) {
return (
<button
type="button"
role="menuitem"
disabled={disabled}
onClick={onClick}
className={[
"w-full flex items-center gap-3 px-5 py-3 text-sm text-left",
"transition-colors",
disabled ? "opacity-40 cursor-not-allowed" : "hover:bg-slate-100",
tone === "danger"
? "text-red-600 hover:text-red-700"
: "text-slate-700",
].join(" ")}
>
<span className="text-slate-400">{left}</span>
<span className="font-medium">{label}</span>
</button>
);
}
export default TopMenu; export default TopMenu;

View File

@@ -0,0 +1,99 @@
import React, { useEffect, useRef } from "react";
export default function ConfirmModal({
open,
title = "Confirmar",
message = "¿Estás seguro?",
confirmText = "Confirmar",
cancelText = "Cancelar",
danger = false,
loading = false,
onConfirm,
onClose,
}: {
open: boolean;
title?: string;
message?: string;
confirmText?: string;
cancelText?: string;
danger?: boolean;
loading?: boolean;
onConfirm: () => void | Promise<void>;
onClose: () => void;
}) {
const panelRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
useEffect(() => {
if (!open) return;
// enfoque inicial para accesibilidad
const t = setTimeout(() => panelRef.current?.focus(), 0);
return () => clearTimeout(t);
}, [open]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50">
{/* Backdrop */}
<button
type="button"
aria-label="Cerrar"
onClick={onClose}
className="absolute inset-0 bg-black/40"
/>
{/* Panel */}
<div className="relative mx-auto mt-28 w-[min(520px,calc(100vw-32px))]">
<div
ref={panelRef}
tabIndex={-1}
className="rounded-2xl bg-white border border-slate-200 shadow-xl overflow-hidden outline-none"
>
<div className="px-6 py-4 border-b border-slate-200">
<div className="text-base font-semibold text-slate-900">{title}</div>
</div>
<div className="px-6 py-5">
<p className="text-sm text-slate-700">{message}</p>
</div>
<div className="px-6 py-4 border-t border-slate-200 flex justify-end gap-3">
<button
type="button"
onClick={onClose}
disabled={loading}
className="rounded-xl px-4 py-2 text-sm font-medium border border-slate-200 bg-white text-slate-700 hover:bg-slate-100 transition disabled:opacity-60"
>
{cancelText}
</button>
<button
type="button"
onClick={onConfirm}
disabled={loading}
className={[
"rounded-xl px-4 py-2 text-sm font-semibold text-white transition",
danger ? "bg-red-600 hover:bg-red-700" : "bg-blue-600 hover:bg-blue-700",
"focus:outline-none focus:ring-2",
danger ? "focus:ring-red-500" : "focus:ring-blue-500",
"focus:ring-offset-2",
"disabled:opacity-60 disabled:cursor-not-allowed",
].join(" ")}
>
{loading ? "Procesando..." : confirmText}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,284 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
type ProfileForm = {
name: string;
email: string;
organismName?: string; // "Empresa" / "Organismo" (CESPT, etc.)
};
export default function ProfileModal({
open,
loading = false,
initial,
avatarUrl = null,
onClose,
onSave,
onUploadAvatar,
}: {
open: boolean;
loading?: boolean;
initial: ProfileForm;
avatarUrl?: string | null;
onClose: () => void;
onSave: (next: ProfileForm) => void | Promise<void>;
onUploadAvatar?: (file: File) => void | Promise<void>;
}) {
const [name, setName] = useState(initial?.name ?? "");
const [email, setEmail] = useState(initial?.email ?? "");
const [organismName, setOrganismName] = useState(initial?.organismName ?? "");
// Avatar preview local (si el usuario selecciona imagen)
const [localAvatar, setLocalAvatar] = useState<string | null>(null);
const lastPreviewUrlRef = useRef<string | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
// Mantener el form sincronizado cuando se abre o cambia initial
useEffect(() => {
if (!open) return;
setName(initial?.name ?? "");
setEmail(initial?.email ?? "");
setOrganismName(initial?.organismName ?? "");
setLocalAvatar(null);
}, [open, initial?.name, initial?.email, initial?.organismName]);
// Cerrar con ESC
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
// Limpieza de object URLs
useEffect(() => {
return () => {
if (lastPreviewUrlRef.current) URL.revokeObjectURL(lastPreviewUrlRef.current);
};
}, []);
const initials = useMemo(() => {
const parts = (name || "").trim().split(/\s+/).filter(Boolean);
const a = parts[0]?.[0] ?? "U";
const b = parts[1]?.[0] ?? "";
return (a + b).toUpperCase();
}, [name]);
const computedAvatarSrc = useMemo(() => {
const src = localAvatar ?? avatarUrl;
if (!src) return null;
if (src.startsWith("blob:")) return src;
// cache-bust por si el backend mantiene la misma URL
const sep = src.includes("?") ? "&" : "?";
return `${src}${sep}t=${Date.now()}`;
}, [localAvatar, avatarUrl]);
const triggerFilePicker = () => {
if (!onUploadAvatar) return;
fileInputRef.current?.click();
};
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const isImage = file.type.startsWith("image/");
const maxMb = 5;
const sizeOk = file.size <= maxMb * 1024 * 1024;
if (!isImage) {
alert("Selecciona un archivo de imagen.");
e.target.value = "";
return;
}
if (!sizeOk) {
alert(`La imagen debe pesar máximo ${maxMb}MB.`);
e.target.value = "";
return;
}
// Preview inmediato
const previewUrl = URL.createObjectURL(file);
if (lastPreviewUrlRef.current) URL.revokeObjectURL(lastPreviewUrlRef.current);
lastPreviewUrlRef.current = previewUrl;
setLocalAvatar(previewUrl);
try {
await onUploadAvatar?.(file);
} catch (err) {
console.error(err);
alert("No se pudo subir la imagen. Intenta de nuevo.");
} finally {
e.target.value = "";
}
};
const handleSubmit = async () => {
if (!name.trim()) {
alert("El nombre es obligatorio.");
return;
}
if (!email.trim()) {
alert("El correo es obligatorio.");
return;
}
await onSave({
name: name.trim(),
email: email.trim(),
organismName: organismName.trim() || undefined,
});
};
if (!open) return null;
return (
<div className="fixed inset-0 z-50">
{/* Backdrop */}
<button
type="button"
aria-label="Cerrar"
onClick={onClose}
className="absolute inset-0 bg-black/40"
/>
{/* Modal */}
<div className="relative mx-auto mt-16 w-[min(860px,calc(100vw-32px))]">
<div className="rounded-2xl bg-white shadow-xl border border-slate-200 overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b border-slate-200">
<div className="text-base font-semibold text-slate-900">Editar perfil</div>
</div>
{/* Body */}
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-[260px_1fr] gap-6">
{/* LEFT: Avatar */}
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-5">
<div className="flex flex-col items-center text-center">
<div className="w-28 h-28 rounded-2xl bg-white border border-slate-200 overflow-hidden flex items-center justify-center">
{computedAvatarSrc ? (
<img
src={computedAvatarSrc}
alt="Avatar"
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-slate-700 font-semibold text-2xl">
{initials}
</div>
)}
</div>
<div className="mt-4">
<div className="text-sm font-semibold text-slate-900 truncate max-w-[220px]">
{name || "Usuario"}
</div>
<div className="text-xs text-slate-500 truncate max-w-[220px]">
{email || "correo@ejemplo.gob.mx"}
</div>
</div>
<button
type="button"
onClick={triggerFilePicker}
disabled={!onUploadAvatar}
className={[
"mt-4 w-full rounded-xl px-4 py-2 text-sm font-medium",
"border border-slate-200 bg-white text-slate-700",
"hover:bg-slate-100 transition",
!onUploadAvatar ? "opacity-50 cursor-not-allowed" : "",
].join(" ")}
>
Cambiar imagen
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFileChange}
/>
</div>
</div>
{/* RIGHT: Form */}
<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">
correo electrónico
</div>
<div className="mt-4 space-y-4">
<Field label="Nombre:">
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm text-slate-900 outline-none focus:ring-2 focus:ring-slate-200"
placeholder="Nombre del usuario"
/>
</Field>
<Field label="Correo:">
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm text-slate-900 outline-none focus:ring-2 focus:ring-slate-200"
placeholder="correo@organismo.gob.mx"
/>
</Field>
<Field label="Empresa:">
<input
value={organismName}
onChange={(e) => setOrganismName(e.target.value)}
className="w-full rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm text-slate-900 outline-none focus:ring-2 focus:ring-slate-200"
placeholder="Organismo operador"
/>
</Field>
</div>
</div>
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-slate-200 flex items-center justify-end gap-3">
<button
type="button"
onClick={onClose}
className="rounded-xl px-4 py-2 text-sm font-medium border border-slate-200 bg-white text-slate-700 hover:bg-slate-100 transition"
disabled={loading}
>
Cancelar
</button>
<button
type="button"
onClick={handleSubmit}
disabled={loading}
className={[
"rounded-xl px-4 py-2 text-sm font-semibold",
"bg-blue-600 text-white hover:bg-blue-700",
"focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2",
loading ? "opacity-60 cursor-not-allowed" : "",
].join(" ")}
>
{loading ? "Guardando..." : "Guardar"}
</button>
</div>
</div>
</div>
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="grid grid-cols-[90px_1fr] items-center gap-3">
<div className="text-sm font-medium text-slate-700">{label}</div>
{children}
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { Cpu, Settings, BarChart3, Bell } from "lucide-react"; import { Cpu, Settings, BarChart3, Bell } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { import {
BarChart, BarChart,
Bar, Bar,
@@ -9,58 +9,152 @@ import {
ResponsiveContainer, ResponsiveContainer,
CartesianGrid, CartesianGrid,
} from "recharts"; } from "recharts";
import { fetchMeters, Meter } from "../api/meters"; import { fetchMeters, type Meter } from "../api/meters";
import { Page } from "../App"; import type { Page } from "../App";
import grhWatermark from "../assets/images/grhWatermark.jpg";
export default function Home({ /* ================= TYPES ================= */
setPage,
navigateToMetersWithProject type OrganismStatus = "ACTIVO" | "INACTIVO";
}: {
type Organism = {
name: string;
region: string;
projects: number;
meters: number;
activeAlerts: number;
lastSync: string;
contact: string;
status: OrganismStatus;
};
type AlertItem = { company: string; type: string; time: string };
type HistoryItem = {
user: string;
action: string;
target: string;
time: string;
};
/* ================= COMPONENT ================= */
export default function Home({
setPage,
navigateToMetersWithProject,
}: {
setPage: (page: Page) => void; setPage: (page: Page) => void;
navigateToMetersWithProject: (projectName: string) => void; navigateToMetersWithProject: (projectName: string) => void;
}) { }) {
const [allProjects, setAllProjects] = useState<string[]>([]); /* ================= ORGANISMS (MOCK) ================= */
const organismsData: Organism[] = [
{
name: "CESPT TIJUANA",
region: "Tijuana, BC",
projects: 6,
meters: 128,
activeAlerts: 0,
lastSync: "Hace 12 min",
contact: "Operaciones CESPT",
status: "ACTIVO",
},
{
name: "CESPT TECATE",
region: "Tecate, BC",
projects: 3,
meters: 54,
activeAlerts: 1,
lastSync: "Hace 40 min",
contact: "Mantenimiento",
status: "ACTIVO",
},
{
name: "CESPT MEXICALI",
region: "Mexicali, BC",
projects: 4,
meters: 92,
activeAlerts: 0,
lastSync: "Hace 1 h",
contact: "Supervisión",
status: "ACTIVO",
},
];
const [selectedOrganism, setSelectedOrganism] = useState<string>(
organismsData[0]?.name ?? "CESPT TIJUANA"
);
const [showOrganisms, setShowOrganisms] = useState(false);
const [organismQuery, setOrganismQuery] = useState("");
/* ================= METERS ================= */
const [meters, setMeters] = useState<Meter[]>([]); const [meters, setMeters] = useState<Meter[]>([]);
const loadMeters = async () => { const loadMeters = async () => {
const data = await fetchMeters(); try {
setMeters(data); const data = await fetchMeters();
const projectsArray = [...new Set(data.map((record: Meter) => record["areaName"]))]; setMeters(data);
setAllProjects(projectsArray); } catch (err) {
} console.error("Error loading meters:", err);
setMeters([]);
}
};
useEffect(() => { useEffect(() => {
loadMeters(); loadMeters();
}, []); }, []);
const chartData = allProjects.map((projectName) => ({ // TODO: Reemplazar cuando el backend mande el organismo real (ej: meter.organismName)
name: projectName, // eslint-disable-next-line @typescript-eslint/no-unused-vars
meterCount: meters.filter((meter) => meter.areaName === projectName).length, const getOrganismFromMeter = (_m: Meter): string => {
})); return "CESPT TIJUANA";
};
const filteredMeters = useMemo(
() => meters.filter((m) => getOrganismFromMeter(m) === selectedOrganism),
[meters, selectedOrganism]
);
const filteredProjects = useMemo(
() => [...new Set(filteredMeters.map((m) => m.areaName))],
[filteredMeters]
);
const chartData = useMemo(
() =>
filteredProjects.map((projectName) => ({
name: projectName,
meterCount: filteredMeters.filter((m) => m.areaName === projectName)
.length,
})),
[filteredProjects, filteredMeters]
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleBarClick = (data: any) => { const handleBarClick = (data: any) => {
if (data.activeLabel) { if (data?.activeLabel) {
navigateToMetersWithProject(data.activeLabel); navigateToMetersWithProject(data.activeLabel);
} }
}; };
// Datos de ejemplo para empresas /* ================= ORGANISM FILTER (DRAWER) ================= */
const companies = [
{ name: "Empresa A", tomas: 12, alerts: 2, consumption: 320 },
{ name: "Empresa B", tomas: 8, alerts: 0, consumption: 210 },
{ name: "Empresa C", tomas: 15, alerts: 1, consumption: 450 },
];
// Alertas recientes const filteredOrganisms = useMemo(() => {
const alerts = [ const q = organismQuery.trim().toLowerCase();
if (!q) return organismsData;
return organismsData.filter((o) => o.name.toLowerCase().includes(q));
}, [organismQuery]);
/* ================= MOCK ALERTS / HISTORY ================= */
const alerts: AlertItem[] = [
{ company: "Empresa A", type: "Fuga", time: "Hace 2 horas" }, { company: "Empresa A", type: "Fuga", time: "Hace 2 horas" },
{ company: "Empresa C", type: "Consumo alto", time: "Hace 5 horas" }, { company: "Empresa C", type: "Consumo alto", time: "Hace 5 horas" },
{ company: "Empresa B", type: "Inactividad", time: "Hace 8 horas" }, { company: "Empresa B", type: "Inactividad", time: "Hace 8 horas" },
]; ];
// Historial tipo Google const history: HistoryItem[] = [
const history = [
{ {
user: "GRH", user: "GRH",
action: "Creó un nuevo medidor", action: "Creó un nuevo medidor",
@@ -93,68 +187,258 @@ export default function Home({
}, },
]; ];
/* ================= KPIs (Optional) ================= */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const totalMeters = filteredMeters.length;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const totalProjects = filteredProjects.length;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const totalActiveAlerts = 0;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const avgMetersPerProject =
totalProjects > 0 ? totalMeters / totalProjects : 0;
return ( return (
<div className="flex flex-col p-6 gap-8 w-full"> <div className="flex flex-col p-6 gap-8 w-full">
{/* Título */} {/* Título + Selector */}
<div> <div className="flex flex-col gap-3">
<h1 className="text-3xl font-bold text-gray-800"> {/* ✅ Título + logo a la derecha */}
Sistema de Tomas de Agua <div className="relative flex items-start justify-between gap-6">
</h1> <div className="relative z-10">
<p className="text-gray-600 mt-2"> <h1 className="text-3xl font-bold text-gray-800">
Monitorea, administra y controla tus operaciones en un solo lugar. Sistema de Tomas de Agua
</p> </h1>
</div> <p className="text-gray-600 mt-2">
Monitorea, administra y controla tus operaciones en un solo lugar.
</p>
</div>
{/* Cards de Secciones */} {/* ✅ Logo con z-index bajo para NO tapar menús */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6"> <img
<div src={grhWatermark}
className="bg-white rounded-xl shadow p-6 flex flex-col items-center justify-center gap-2 hover:bg-blue-50 transition" alt="Gestión de Recursos Hídricos"
onClick={() => setPage("meters")} className="relative z-0 h-10 w-auto opacity-80 select-none pointer-events-none shrink-0"
> draggable={false}
<Cpu size={40} className="text-blue-600" /> />
<span className="font-semibold text-gray-700">Tomas</span> </div>
</div>
<div className="bg-white rounded-xl shadow p-6 flex flex-col items-center justify-center gap-2 hover:bg-red-50 transition">
<Bell size={40} className="text-red-600" />
<span className="font-semibold text-gray-700">Alertas</span>
</div>
<div className="bg-white rounded-xl shadow p-6 flex flex-col items-center justify-center gap-2 hover:bg-yellow-50 transition">
<Settings size={40} className="text-yellow-600" />
<span className="font-semibold text-gray-700">Mantenimiento</span>
</div>
<div className="bg-white rounded-xl shadow p-6 flex flex-col items-center justify-center gap-2 hover:bg-green-50 transition">
<BarChart3 size={40} className="text-green-600" />
<span className="font-semibold text-gray-700">Reportes</span>
</div>
</div>
{/* Resumen de tomas por empresa */} {/* Cards de Secciones */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{companies.map((c) => (
<div <div
key={c.name} className="bg-white rounded-xl shadow p-6 flex flex-col items-center justify-center gap-2 hover:bg-blue-50 transition cursor-pointer"
className="bg-white rounded-xl shadow p-4 flex flex-col gap-1" onClick={() => setPage("meters")}
> >
<span className="text-gray-500 text-sm">{c.name}</span> <Cpu size={40} className="text-blue-600" />
<span className="text-2xl font-bold text-gray-800"> <span className="font-semibold text-gray-700">Tomas</span>
{c.tomas} Tomas
</span>
<span
className={`text-sm font-medium ${
c.alerts > 0 ? "text-red-500" : "text-green-500"
}`}
>
{c.alerts} Alertas
</span>
</div> </div>
))}
<div className="bg-white rounded-xl shadow p-6 flex flex-col items-center justify-center gap-2 hover:bg-red-50 transition">
<Bell size={40} className="text-red-600" />
<span className="font-semibold text-gray-700">Alertas</span>
</div>
<div className="bg-white rounded-xl shadow p-6 flex flex-col items-center justify-center gap-2 hover:bg-yellow-50 transition">
<Settings size={40} className="text-yellow-600" />
<span className="font-semibold text-gray-700">Mantenimiento</span>
</div>
<div className="bg-white rounded-xl shadow p-6 flex flex-col items-center justify-center gap-2 hover:bg-green-50 transition">
<BarChart3 size={40} className="text-green-600" />
<span className="font-semibold text-gray-700">Reportes</span>
</div>
</div>
{/* Organismos Operadores */}
<div className="bg-white rounded-xl shadow p-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<p className="text-sm text-gray-500">Organismos Operadores</p>
<p className="text-xs text-gray-400">
Seleccionado:{" "}
<span className="font-semibold">{selectedOrganism}</span>
</p>
</div>
<button
type="button"
className="inline-flex items-center justify-center rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-blue-700 transition"
onClick={() => setShowOrganisms(true)}
>
Organismos Operadores
</button>
</div>
{showOrganisms && (
<div className="fixed inset-0 z-50">
{/* Overlay */}
<div
className="absolute inset-0 bg-black/40"
onClick={() => {
setShowOrganisms(false);
setOrganismQuery("");
}}
/>
{/* Panel */}
<div className="absolute right-0 top-0 h-full w-full sm:w-[520px] bg-white shadow-2xl flex flex-col">
{/* Header */}
<div className="p-5 border-b flex items-start justify-between gap-3">
<div>
<h3 className="text-lg font-semibold text-gray-800">
Organismos Operadores
</h3>
<p className="text-sm text-gray-500">
Selecciona un organismo para filtrar la información del
dashboard.
</p>
</div>
<button
type="button"
className="rounded-lg px-3 py-2 text-sm border border-gray-300 hover:bg-gray-50"
onClick={() => {
setShowOrganisms(false);
setOrganismQuery("");
}}
>
Cerrar
</button>
</div>
{/* Search */}
<div className="p-5 border-b">
<input
value={organismQuery}
onChange={(e) => setOrganismQuery(e.target.value)}
placeholder="Buscar organismo…"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-200"
/>
</div>
{/* List */}
<div className="p-5 overflow-y-auto flex-1 space-y-3">
{filteredOrganisms.map((o) => {
const active = o.name === selectedOrganism;
return (
<div
key={o.name}
className={[
"rounded-xl border p-4 transition",
active
? "border-blue-600 bg-blue-50/40"
: "border-gray-200 bg-white hover:bg-gray-50",
].join(" ")}
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-gray-800">
{o.name}
</p>
<p className="text-xs text-gray-500">{o.region}</p>
</div>
<span
className={[
"text-xs font-semibold px-2 py-1 rounded-full",
o.status === "ACTIVO"
? "bg-green-100 text-green-700"
: "bg-gray-200 text-gray-700",
].join(" ")}
>
{o.status}
</span>
</div>
<div className="mt-3 grid grid-cols-2 gap-2 text-xs">
<div className="flex justify-between gap-2">
<span className="text-gray-500">Proyectos</span>
<span className="font-medium text-gray-800">
{o.projects}
</span>
</div>
<div className="flex justify-between gap-2">
<span className="text-gray-500">Medidores</span>
<span className="font-medium text-gray-800">
{o.meters}
</span>
</div>
<div className="flex justify-between gap-2">
<span className="text-gray-500">Alertas activas</span>
<span className="font-medium text-gray-800">
{o.activeAlerts}
</span>
</div>
<div className="flex justify-between gap-2">
<span className="text-gray-500">Última sync</span>
<span className="font-medium text-gray-800">
{o.lastSync}
</span>
</div>
<div className="col-span-2 flex justify-between gap-2">
<span className="text-gray-500">Responsable</span>
<span className="font-medium text-gray-800">
{o.contact}
</span>
</div>
</div>
<div className="mt-4 flex items-center justify-end gap-2">
<button
type="button"
className={[
"rounded-lg px-3 py-2 text-sm font-semibold shadow transition",
active
? "bg-blue-600 text-white hover:bg-blue-700"
: "bg-gray-900 text-white hover:bg-gray-800",
].join(" ")}
onClick={() => {
setSelectedOrganism(o.name);
setShowOrganisms(false);
setOrganismQuery("");
}}
>
{active ? "Seleccionado" : "Seleccionar"}
</button>
</div>
</div>
);
})}
{filteredOrganisms.length === 0 && (
<div className="text-sm text-gray-500 text-center py-10">
No se encontraron organismos.
</div>
)}
</div>
{/* Footer */}
<div className="p-5 border-t text-xs text-gray-500">
Nota: Las propiedades están en modo demostración hasta integrar
backend.
</div>
</div>
</div>
)}
</div>
</div> </div>
{/* Gráfica de consumo */} {/* Gráfica */}
<div className="bg-white rounded-xl shadow p-6"> <div className="bg-white rounded-xl shadow p-6">
<h2 className="text-lg font-semibold mb-4"> <div className="flex items-center justify-between gap-4 mb-4">
Número de Medidores por Proyecto <h2 className="text-lg font-semibold">
</h2> Número de Medidores por Proyecto
</h2>
<span className="text-xs text-gray-400">
Click en barra para ver tomas
</span>
</div>
<div className="h-60"> <div className="h-60">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart <BarChart
@@ -166,17 +450,13 @@ export default function Home({
<XAxis dataKey="name" /> <XAxis dataKey="name" />
<YAxis /> <YAxis />
<Tooltip /> <Tooltip />
<Bar <Bar dataKey="meterCount" fill="#4c5f9e" cursor="pointer" />
dataKey="meterCount"
fill="#4c5f9e"
cursor="pointer"
/>
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
</div> </div>
{/* Historial tipo Google */} {/* Historial */}
<div className="bg-white rounded-xl shadow p-6"> <div className="bg-white rounded-xl shadow p-6">
<h2 className="text-lg font-semibold mb-4">Historial Reciente</h2> <h2 className="text-lg font-semibold mb-4">Historial Reciente</h2>
<ul className="divide-y divide-gray-200 max-h-60 overflow-y-auto"> <ul className="divide-y divide-gray-200 max-h-60 overflow-y-auto">

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -2,16 +2,12 @@ import { defineConfig } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
// https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
server: { server: {
allowedHosts: [ host: true, // expone en 0.0.0.0
"localhost", allowedHosts: "all", // permite dominios como *.ngrok-free.dev
"127.0.0.1",
"reyna-compressive-shaunna.ngrok-free.dev",
],
port: 5173, port: 5173,
strictPort: true, // no brinca de puerto
}, },
}); });