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>
291 lines
7.6 KiB
TypeScript
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>
|
|
);
|
|
}
|