Files
GRH/src/App.tsx
Exteban08 c81a18987f Migrar backend a PostgreSQL + Node.js/Express con nuevas funcionalidades
Backend (water-api/):
- Crear API REST completa con Express + TypeScript
- Implementar autenticación JWT con refresh tokens
- CRUD completo para: projects, concentrators, meters, gateways, devices, users, roles
- Agregar validación con Zod para todas las entidades
- Implementar webhooks para The Things Stack (LoRaWAN)
- Agregar endpoint de lecturas con filtros y resumen de consumo
- Implementar carga masiva de medidores via Excel (.xlsx)

Frontend:
- Crear cliente HTTP con manejo automático de JWT y refresh
- Actualizar todas las APIs para usar nuevo backend
- Agregar sistema de autenticación real (login, logout, me)
- Agregar selector de tipo (LORA, LoRaWAN, Grandes) en concentradores y medidores
- Agregar campo Meter ID en medidores
- Crear modal de carga masiva para medidores
- Agregar página de consumo con gráficas y filtros
- Corregir carga de proyectos independiente de datos existentes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 10:13:26 +00:00

291 lines
7.6 KiB
TypeScript

import { useEffect, useState } from "react";
import Sidebar from "./components/layout/Sidebar";
import TopMenu from "./components/layout/TopMenu";
import Home from "./pages/Home";
import MetersPage from "./pages/meters/MeterPage";
import ConcentratorsPage from "./pages/concentrators/ConcentratorsPage";
import ProjectsPage from "./pages/projects/ProjectsPage";
import UsersPage from "./pages/UsersPage";
import RolesPage from "./pages/RolesPage";
import ConsumptionPage from "./pages/consumption/ConsumptionPage";
import ProfileModal from "./components/layout/common/ProfileModal";
import { updateMyProfile } from "./api/me";
import SettingsModal, {
type AppSettings,
loadSettings,
} from "./components/SettingsModals";
import LoginPage from "./pages/LoginPage";
// Auth imports
import {
isAuthenticated,
getMe,
logout as authLogout,
clearAuth,
type AuthUser,
} from "./api/auth";
import ConfirmModal from "./components/layout/common/ConfirmModal";
import Watermark from "./components/layout/common/Watermark";
export type Page =
| "home"
| "projects"
| "meters"
| "concentrators"
| "consumption"
| "users"
| "roles";
export default function App() {
const [isAuth, setIsAuth] = useState<boolean>(false);
const [user, setUser] = useState<AuthUser | null>(null);
const [authLoading, setAuthLoading] = useState(true);
// Check authentication on mount
useEffect(() => {
const checkAuth = async () => {
if (isAuthenticated()) {
try {
const userData = await getMe();
setUser(userData);
setIsAuth(true);
} catch {
clearAuth();
setIsAuth(false);
setUser(null);
}
}
setAuthLoading(false);
};
checkAuth();
}, []);
const handleLogin = () => {
// After successful login, fetch user data
const fetchUser = async () => {
try {
const userData = await getMe();
setUser(userData);
setIsAuth(true);
} catch {
clearAuth();
setIsAuth(false);
}
};
fetchUser();
};
const handleLogout = async () => {
try {
await authLogout();
} catch {
// Ignore logout errors
}
clearAuth();
setUser(null);
setIsAuth(false);
// Reset navigation
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 [subPage, setSubPage] = useState<string>("default");
const [selectedProject, setSelectedProject] = useState<string>("");
const [profileOpen, setProfileOpen] = useState(false);
const [savingProfile, setSavingProfile] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [settings, setSettings] = useState<AppSettings>(() => loadSettings());
const navigateToMetersWithProject = (projectName: string) => {
setSelectedProject(projectName);
setSubPage(projectName);
setPage("meters");
};
const handleUploadAvatar = async (file: File) => {
const base64 = await fileToBase64(file);
localStorage.setItem("mock_avatar", base64);
setUser((prev) => prev ? { ...prev, avatar_url: base64 } : null);
};
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);
});
}
const handleSaveProfile = async (next: {
name: string;
email: string;
organismName?: string;
}) => {
if (!user) return;
setSavingProfile(true);
try {
const updated = await updateMyProfile(next);
setUser((prev) => prev ? ({
...prev,
name: updated.name ?? next.name ?? prev.name,
email: updated.email ?? next.email ?? prev.email,
avatar_url: updated.avatarUrl ?? prev.avatar_url,
}) : null);
setProfileOpen(false);
} finally {
setSavingProfile(false);
}
};
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 = () => {
switch (page) {
case "projects":
return <ProjectsPage />;
case "meters":
return <MetersPage selectedProject={selectedProject} />;
case "concentrators":
return <ConcentratorsPage />;
case "consumption":
return <ConsumptionPage />;
case "users":
return <UsersPage />;
case "roles":
return <RolesPage />;
case "home":
default:
return (
<Home
setPage={(p) => {
setPage(p);
setSubPage("default");
}}
navigateToMetersWithProject={navigateToMetersWithProject}
/>
);
}
};
// Show loading while checking authentication
if (authLoading) {
return (
<div className="flex h-screen w-full items-center justify-center bg-slate-50">
<div className="text-slate-500">Cargando...</div>
</div>
);
}
if (!isAuth) {
return <LoginPage onSuccess={handleLogin} />;
}
return (
<div
className={[
"flex h-screen w-full overflow-hidden",
settings.compactMode ? "text-sm" : "",
].join(" ")}
>
<div className="shrink-0">
<Sidebar
setPage={(p) => {
setPage(p);
setSubPage("default");
if (p !== "meters") setSelectedProject("");
}}
/>
</div>
<div className="flex min-w-0 flex-1 flex-col">
<div className="shrink-0">
<TopMenu
page={page}
subPage={subPage}
setSubPage={setSubPage}
userName={user?.name ?? "Usuario"}
userEmail={user?.email ?? ""}
avatarUrl={user?.avatar_url ?? null}
onOpenProfile={() => setProfileOpen(true)}
onRequestLogout={() => setLogoutOpen(true)}
/>
</div>
{/* ✅ AQUÍ VA LA MARCA DE AGUA */}
<main className="relative min-w-0 flex-1 overflow-auto">
<Watermark />
<div className="relative z-10">{renderPage()}</div>
</main>
</div>
<SettingsModal
open={settingsOpen}
onClose={() => setSettingsOpen(false)}
settings={settings}
setSettings={setSettings}
/>
<ProfileModal
open={profileOpen}
loading={savingProfile}
avatarUrl={user?.avatar_url ?? null}
initial={{
name: user?.name ?? "",
email: user?.email ?? "",
organismName: "",
}}
onClose={() => setProfileOpen(false)}
onSave={handleSaveProfile}
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 {
await handleLogout();
setLogoutOpen(false);
} finally {
setLoggingOut(false);
}
}}
/>
</div>
);
}