diff --git a/src/App.css b/src/App.css index 08cef4d..0d97e27 100644 --- a/src/App.css +++ b/src/App.css @@ -6,6 +6,7 @@ body, html, #root { padding: 0; height: 100%; 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-rows: 100vh; height: 100vh; - width: 100vw; + width: 100%; overflow: hidden; transition: grid-template-columns 0.3s ease; } diff --git a/src/App.tsx b/src/App.tsx index c6d2faf..a70479c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import Sidebar from "./components/layout/Sidebar"; import TopMenu from "./components/layout/TopMenu"; @@ -8,6 +8,13 @@ import ConcentratorsPage from "./pages/concentrators/ConcentratorsPage"; import ProjectsPage from "./pages/projects/ProjectsPage"; import UsersPage from "./pages/UsersPage"; 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 = | "home" @@ -22,11 +29,85 @@ export default function App() { const [subPage, setSubPage] = useState("default"); const [selectedProject, setSelectedProject] = useState(""); + // ✅ 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(() => loadSettings()); + const navigateToMetersWithProject = (projectName: string) => { setSelectedProject(projectName); + setSubPage(projectName); // útil para breadcrumb si lo usas 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((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 = () => { switch (page) { case "projects": @@ -41,19 +122,81 @@ export default function App() { return ; case "home": default: - return ; + return ( + { + setPage(p); + setSubPage("default"); + }} + navigateToMetersWithProject={navigateToMetersWithProject} + /> + ); } }; return ( -
- -
- -
- {renderPage()} -
+ // Blindaje global del layout +
+ {/* Sidebar no debe encogerse */} +
+ { + setPage(p); + setSubPage("default"); + if (p !== "meters") setSelectedProject(""); + }} + />
+ + {/* min-w-0: evita que páginas anchas (tablas) empujen el layout */} +
+
+ setSettingsOpen(true)} + // props de perfil + userName={user.name} + userEmail={user.email} + avatarUrl={user.avatarUrl} + onOpenProfile={() => setProfileOpen(true)} + onUploadAvatar={handleUploadAvatar} + /> +
+ + {/* Scroll solo aquí */} +
{renderPage()}
+
+ + {/* Settings modal */} + setSettingsOpen(false)} + settings={settings} + setSettings={setSettings} + /> + + {/* ✅ Profile modal (con avatar + cambiar img + empresa) */} + setProfileOpen(false)} + onSave={handleSaveProfile} + onUploadAvatar={handleUploadAvatar} // ✅ NUEVO (botón Cambiar img en modal) + />
); } diff --git a/src/api/me.ts b/src/api/me.ts new file mode 100644 index 0000000..2313db9 --- /dev/null +++ b/src/api/me.ts @@ -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(); + } + \ No newline at end of file diff --git a/src/api/projects.ts b/src/api/projects.ts index 755cbec..78cfac4 100644 --- a/src/api/projects.ts +++ b/src/api/projects.ts @@ -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; export const getAuthHeaders = () => ({ - Authorization: `Bearer ${API_TOKEN}`, "Content-Type": "application/json", + "xc-token": API_TOKEN, // NocoDB style + Authorization: `Bearer ${API_TOKEN}`, // fallback por si el backend usa Bearer }); export interface ProjectRecord { diff --git a/src/assets/images/grhWatermark.jpg b/src/assets/images/grhWatermark.jpg new file mode 100644 index 0000000..d7fe2f4 Binary files /dev/null and b/src/assets/images/grhWatermark.jpg differ diff --git a/src/components/SettingsModals.tsx b/src/components/SettingsModals.tsx new file mode 100644 index 0000000..c850351 --- /dev/null +++ b/src/components/SettingsModals.tsx @@ -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; + 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(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 ( +
+ {/* Overlay */} + +
+ +
+ {/* Tema */} +
+

Tema

+
+ {([ + { key: "system", label: "Sistema" }, + { key: "light", label: "Claro" }, + { key: "dark", label: "Oscuro" }, + ] as const).map((t) => { + const active = local.theme === t.key; + return ( + + ); + })} +
+
+ + {/* Compact mode */} +
+
+

Modo compacto

+

+ Reduce paddings/espaciado en tablas y tarjetas. +

+
+ + +
+
+ +
+ + +
+ + +
+
+
+
+ ); +} diff --git a/src/components/layout/TopMenu.tsx b/src/components/layout/TopMenu.tsx index d1fa31c..881a36b 100644 --- a/src/components/layout/TopMenu.tsx +++ b/src/components/layout/TopMenu.tsx @@ -1,16 +1,65 @@ -import React from "react"; -import { Bell, User, Settings } from "lucide-react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { Bell, User, LogOut } from "lucide-react"; interface TopMenuProps { page: string; subPage: string; setSubPage: (subPage: string) => void; + + userName?: string; + userEmail?: string; + avatarUrl?: string | null; + + onLogout?: () => void; + onOpenProfile?: () => void; } -const TopMenu: React.FC = ({ page, subPage, setSubPage }) => { +const TopMenu: React.FC = ({ + page, + subPage, + setSubPage, + + userName = "Usuario", + userEmail, + avatarUrl = null, + + onLogout, + onOpenProfile, +}) => { + const [openUserMenu, setOpenUserMenu] = useState(false); + + const menuRef = useRef(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 (
= ({ page, subPage, setSubPage }) => { - + {/* USER MENU */} +
+ -
- + {openUserMenu && ( +
+ {/* Header usuario */} +
+
+
+ {avatarUrl ? ( + Avatar + ) : ( + + {initials} + + )} +
+ +
+
+ {userName} +
+ {userEmail ? ( +
+ {userEmail} +
+ ) : ( +
+ )} +
+
+
+ + {/* Items (solo 2) */} + { + setOpenUserMenu(false); + onOpenProfile?.(); + }} + left={} + /> + +
+ + { + setOpenUserMenu(false); + if (onLogout) onLogout(); + else { + localStorage.removeItem("token"); + window.location.href = "/login"; + } + }} + left={} + /> +
+ )}
); }; +function MenuItem({ + label, + onClick, + disabled, + tone = "default", + left, +}: { + label: string; + onClick: () => void; + disabled?: boolean; + tone?: "default" | "danger"; + left?: React.ReactNode; +}) { + return ( + + ); +} + export default TopMenu; + diff --git a/src/components/layout/common/ConfirmModal.tsx b/src/components/layout/common/ConfirmModal.tsx index e69de29..042679a 100644 --- a/src/components/layout/common/ConfirmModal.tsx +++ b/src/components/layout/common/ConfirmModal.tsx @@ -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; + onClose: () => void; +}) { + const panelRef = useRef(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 ( +
+ {/* Backdrop */} + + + +
+ + + + ); +} diff --git a/src/components/layout/common/ProfileModal.tsx b/src/components/layout/common/ProfileModal.tsx new file mode 100644 index 0000000..44c70fa --- /dev/null +++ b/src/components/layout/common/ProfileModal.tsx @@ -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; + onUploadAvatar?: (file: File) => void | Promise; +}) { + 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(null); + const lastPreviewUrlRef = useRef(null); + const fileInputRef = useRef(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) => { + 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 ( +
+ {/* Backdrop */} + + + +
+ + + {/* RIGHT: Form */} +
+ {/* “correo electronico” como en tu dibujo */} +
+ correo electrónico +
+ +
+ + 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" + /> + + + + 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" + /> + + + + 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" + /> + +
+
+ + + + {/* Footer */} +
+ + + +
+ + + + ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+
{label}
+ {children} +
+ ); +} diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index fd5780c..e380092 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -1,5 +1,5 @@ import { Cpu, Settings, BarChart3, Bell } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { BarChart, Bar, @@ -9,58 +9,152 @@ import { ResponsiveContainer, CartesianGrid, } from "recharts"; -import { fetchMeters, Meter } from "../api/meters"; -import { Page } from "../App"; +import { fetchMeters, type Meter } from "../api/meters"; +import type { Page } from "../App"; +import grhWatermark from "../assets/images/grhWatermark.jpg"; -export default function Home({ - setPage, - navigateToMetersWithProject -}: { +/* ================= TYPES ================= */ + +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; navigateToMetersWithProject: (projectName: string) => void; }) { - const [allProjects, setAllProjects] = useState([]); + /* ================= 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( + organismsData[0]?.name ?? "CESPT TIJUANA" + ); + const [showOrganisms, setShowOrganisms] = useState(false); + const [organismQuery, setOrganismQuery] = useState(""); + + /* ================= METERS ================= */ + const [meters, setMeters] = useState([]); - const loadMeters = async () => { - const data = await fetchMeters(); - setMeters(data); - const projectsArray = [...new Set(data.map((record: Meter) => record["areaName"]))]; - setAllProjects(projectsArray); - } + try { + const data = await fetchMeters(); + setMeters(data); + } catch (err) { + console.error("Error loading meters:", err); + setMeters([]); + } + }; + useEffect(() => { loadMeters(); }, []); - const chartData = allProjects.map((projectName) => ({ - name: projectName, - meterCount: meters.filter((meter) => meter.areaName === projectName).length, - })); + // TODO: Reemplazar cuando el backend mande el organismo real (ej: meter.organismName) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + 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 const handleBarClick = (data: any) => { - if (data.activeLabel) { + if (data?.activeLabel) { navigateToMetersWithProject(data.activeLabel); } }; - // Datos de ejemplo para empresas - 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 }, - ]; + /* ================= ORGANISM FILTER (DRAWER) ================= */ - // Alertas recientes - const alerts = [ + const filteredOrganisms = useMemo(() => { + 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 C", type: "Consumo alto", time: "Hace 5 horas" }, { company: "Empresa B", type: "Inactividad", time: "Hace 8 horas" }, ]; - // Historial tipo Google - const history = [ + const history: HistoryItem[] = [ { user: "GRH", 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 (
- {/* Título */} -
-

- Sistema de Tomas de Agua -

-

- Monitorea, administra y controla tus operaciones en un solo lugar. -

-
+ {/* Título + Selector */} +
+ {/* ✅ Título + logo a la derecha */} +
+
+

+ Sistema de Tomas de Agua +

+

+ Monitorea, administra y controla tus operaciones en un solo lugar. +

+
- {/* Cards de Secciones */} -
-
setPage("meters")} - > - - Tomas -
-
- - Alertas -
-
- - Mantenimiento -
-
- - Reportes -
-
+ {/* ✅ Logo con z-index bajo para NO tapar menús */} + Gestión de Recursos Hídricos +
- {/* Resumen de tomas por empresa */} -
- {companies.map((c) => ( + {/* Cards de Secciones */} +
setPage("meters")} > - {c.name} - - {c.tomas} Tomas - - 0 ? "text-red-500" : "text-green-500" - }`} - > - {c.alerts} Alertas - + + Tomas
- ))} + +
+ + Alertas +
+ +
+ + Mantenimiento +
+ +
+ + Reportes +
+
+ + {/* Organismos Operadores */} +
+
+
+

Organismos Operadores

+

+ Seleccionado:{" "} + {selectedOrganism} +

+
+ + +
+ + {showOrganisms && ( +
+ {/* Overlay */} +
{ + setShowOrganisms(false); + setOrganismQuery(""); + }} + /> + + {/* Panel */} +
+ {/* Header */} +
+
+

+ Organismos Operadores +

+

+ Selecciona un organismo para filtrar la información del + dashboard. +

+
+ + +
+ + {/* Search */} +
+ 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" + /> +
+ + {/* List */} +
+ {filteredOrganisms.map((o) => { + const active = o.name === selectedOrganism; + + return ( +
+
+
+

+ {o.name} +

+

{o.region}

+
+ + + {o.status} + +
+ +
+
+ Proyectos + + {o.projects} + +
+ +
+ Medidores + + {o.meters} + +
+ +
+ Alertas activas + + {o.activeAlerts} + +
+ +
+ Última sync + + {o.lastSync} + +
+ +
+ Responsable + + {o.contact} + +
+
+ +
+ +
+
+ ); + })} + + {filteredOrganisms.length === 0 && ( +
+ No se encontraron organismos. +
+ )} +
+ + {/* Footer */} +
+ Nota: Las propiedades están en modo demostración hasta integrar + backend. +
+
+
+ )} +
- {/* Gráfica de consumo */} + {/* Gráfica */}
-

- Número de Medidores por Proyecto -

+
+

+ Número de Medidores por Proyecto +

+ + Click en barra para ver tomas + +
+
- +
- {/* Historial tipo Google */} + {/* Historial */}

Historial Reciente

    diff --git a/src/pages/concentrators/ConcentratorsPage.tsx b/src/pages/concentrators/ConcentratorsPage.tsx index 4e45f4a..c6dfdfa 100644 --- a/src/pages/concentrators/ConcentratorsPage.tsx +++ b/src/pages/concentrators/ConcentratorsPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Plus, Trash2, Pencil, RefreshCcw } from "lucide-react"; import MaterialTable from "@material-table/core"; import { @@ -8,15 +8,29 @@ import { deleteConcentrator, type Concentrator, } from "../../api/concentrators"; +import ConfirmModal from "../../components/layout/common/ConfirmModal"; /* ================= TYPES ================= */ interface User { name: string; role: "SUPER_ADMIN" | "USER"; - project?: string; // asignado si no es superadmin + project?: string; } +type ProjectStatus = "ACTIVO" | "INACTIVO"; + +type ProjectCard = { + name: string; + region: string; + projects: number; + concentrators: number; + activeAlerts: number; + lastSync: string; + contact: string; + status: ProjectStatus; +}; + interface GatewayData { "Gateway ID": number; "Gateway EUI": string; @@ -31,46 +45,120 @@ export default function ConcentratorsPage() { // Simulación de usuario actual const currentUser: User = { name: "Admin GRH", - role: "SUPER_ADMIN", // cambiar a USER para probar otro caso + role: "SUPER_ADMIN", project: "CESPT", }; + // ✅ Modal confirmación delete (bonito) + const [confirmOpen, setConfirmOpen] = useState(false); + const [deleting, setDeleting] = useState(false); + const [allProjects, setAllProjects] = useState([]); const [loadingProjects, setLoadingProjects] = useState(true); const [loadingConcentrators, setLoadingConcentrators] = useState(true); - // Proyectos visibles según el usuario - const visibleProjects = useMemo(() => - currentUser.role === "SUPER_ADMIN" - ? allProjects - : currentUser.project - ? [currentUser.project] - : [], + const [selectedProject, setSelectedProject] = useState(""); + const [concentrators, setConcentrators] = useState([]); + const [filteredConcentrators, setFilteredConcentrators] = useState< + Concentrator[] + >([]); + + const [activeConcentrator, setActiveConcentrator] = + useState(null); + const [search, setSearch] = useState(""); + const [projectQuery, setProjectQuery] = useState(""); + + const [showModal, setShowModal] = useState(false); + const [editingSerial, setEditingSerial] = useState(null); + + /* ================= PROJECTS VISIBLE ================= */ + const visibleProjects = useMemo( + () => + currentUser.role === "SUPER_ADMIN" + ? allProjects + : currentUser.project + ? [currentUser.project] + : [], [allProjects, currentUser.role, currentUser.project] ); - const [selectedProject, setSelectedProject] = useState(""); - const [concentrators, setConcentrators] = useState([]); - const [filteredConcentrators, setFilteredConcentrators] = useState([]); - - useEffect(() => { - if (selectedProject) { - const filtered = concentrators.filter( - (c) => c["Area Name"] === selectedProject - ); - setFilteredConcentrators(filtered); - } else { - setFilteredConcentrators(concentrators); - } - }, [selectedProject, concentrators]); - + /* ================= LOAD ================= */ const loadConcentrators = async () => { setLoadingConcentrators(true); + setLoadingProjects(true); try { - const data = await fetchConcentrators(); - const projectsArray = [...new Set(data.map((record) => record["Area Name"]))]; + const raw = await fetchConcentrators(); + + // ============================================================ + // ✅ DEBUG: Ver payload crudo y comparar por proyecto/Area Name + // ============================================================ + console.log("RAW concentrators sample (first 5):", raw.slice(0, 5)); + + const byArea = raw.reduce>((acc, c: any) => { + const area = c["Area Name"] ?? "SIN AREA"; + (acc[area] ||= []).push(c); + return acc; + }, {}); + + Object.entries(byArea).forEach(([area, rows]) => { + const first: any = rows[0]; + console.log(`AREA=${area} COUNT=${rows.length}`); + console.log("keys:", Object.keys(first)); + console.log("Device Name:", first["Device Name"]); + console.log("Device S/N:", first["Device S/N"]); + console.log("Possible alt fields:", { + deviceName: first.deviceName, + name: first.name, + device_code: first["Device Code"], + device_alias: first["Device Alias"], + device_label: first["Device Label"], + device_display_name: first["Device Display Name"], + deviceDescription: first["Device Description"], + }); + }); + + // ============================================================ + // ✅ NORMALIZE: Forzar que "Device Name" sea el nombre “humano” + // - Prioriza posibles campos alternos + // - Deja el "Device Name" original al final como fallback + // ============================================================ + const normalized = raw.map((c: any) => { + const preferredName = + c["Device Alias"] || + c["Device Label"] || + c["Device Display Name"] || + c.deviceName || + c.name || + c["Device Name"] || + ""; + + return { + ...c, + "Device Name": preferredName, + }; + }); + + console.log("NORMALIZED sample (first 5):", normalized.slice(0, 5)); + + const projectsArray = [ + ...new Set(normalized.map((r: any) => r["Area Name"])), + ].filter(Boolean) as string[]; + setAllProjects(projectsArray); - setConcentrators(data); + setConcentrators(normalized); + + // ✅ FIX: si no hay proyecto seleccionado, autoselecciona el primero visible + setSelectedProject((prev) => { + if (prev) return prev; + + // si es USER y tiene proyecto asignado, respétalo + if (currentUser.role !== "SUPER_ADMIN" && currentUser.project) { + return currentUser.project; + } + + // para SUPER_ADMIN: si hay visibles, toma el primero + return projectsArray[0] ?? ""; + }); } catch (error) { console.error("Error loading concentrators:", error); setAllProjects([]); @@ -83,14 +171,63 @@ export default function ConcentratorsPage() { useEffect(() => { loadConcentrators(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const [activeConcentrator, setActiveConcentrator] = useState(null); - const [search, setSearch] = useState(""); + // Si el usuario solo tiene 1 proyecto visible, lo auto-selecciona + useEffect(() => { + if (!selectedProject && visibleProjects.length === 1) { + setSelectedProject(visibleProjects[0]); + } + }, [visibleProjects, selectedProject]); - const [showModal, setShowModal] = useState(false); - const [editingSerial, setEditingSerial] = useState(null); + // ============================================================ + // ✅ MISMA LÓGICA QUE TU SEGUNDO CÓDIGO: + // - Si hay selectedProject => filtra por Area Name + // - Si NO hay selectedProject => muestra TODOS (no vacío) + // ============================================================ + useEffect(() => { + if (selectedProject) { + const filtered = concentrators.filter( + (c) => c["Area Name"] === selectedProject + ); + setFilteredConcentrators(filtered); + } else { + setFilteredConcentrators(concentrators); + } + }, [selectedProject, concentrators]); + /* ================= SIDEBAR (HOME-LIKE LIST ALWAYS OPEN) ================= */ + const projectsData: ProjectCard[] = useMemo(() => { + const counts = concentrators.reduce>((acc, c) => { + const area = c["Area Name"] ?? "SIN PROYECTO"; + acc[area] = (acc[area] ?? 0) + 1; + return acc; + }, {}); + + const baseRegion = "Baja California"; + const baseContact = "Operaciones"; + const baseLastSync = "Hace 1 h"; + + return visibleProjects.map((name) => ({ + name, + region: baseRegion, + projects: 1, + concentrators: counts[name] ?? 0, + activeAlerts: 0, + lastSync: baseLastSync, + contact: baseContact, + status: "ACTIVO", + })); + }, [concentrators, visibleProjects]); + + const filteredProjects = useMemo(() => { + const q = projectQuery.trim().toLowerCase(); + if (!q) return projectsData; + return projectsData.filter((p) => p.name.toLowerCase().includes(q)); + }, [projectQuery, projectsData]); + + /* ================= FORM HELPERS ================= */ const getEmptyConcentrator = (): Omit => ({ "Area Name": selectedProject, "Device S/N": "", @@ -111,17 +248,22 @@ export default function ConcentratorsPage() { "Antenna Placement": "Indoor", }); - const [form, setForm] = useState>(getEmptyConcentrator()); - const [gatewayForm, setGatewayForm] = useState(getEmptyGatewayData()); + // ✅ FIX: gatewayForm debe inicializarse con el OBJETO, no con la función + const [form, setForm] = useState>( + getEmptyConcentrator() + ); + const [gatewayForm, setGatewayForm] = useState( + getEmptyGatewayData() + ); const [errors, setErrors] = useState<{ [key: string]: boolean }>({}); /* ================= CRUD ================= */ - const createOrUpdateGateway = async (gatewayData: GatewayData): Promise => { - //await fetch('/api/gateways', { method: 'POST', body: JSON.stringify(gatewayData) }) - + const createOrUpdateGateway = async ( + gatewayData: GatewayData + ): Promise => { return new Promise((resolve) => { setTimeout(() => { - console.log('Gateway data that would be sent to API:', gatewayData); + console.log("Gateway data that would be sent to API:", gatewayData); resolve(); }, 500); }); @@ -133,37 +275,47 @@ export default function ConcentratorsPage() { if (!form["Device Name"].trim()) newErrors["Device Name"] = true; if (!form["Device S/N"].trim()) newErrors["Device S/N"] = true; if (!form["Operator"].trim()) newErrors["Operator"] = true; - if (!form["Instruction Manual"].trim()) newErrors["Instruction Manual"] = true; + if (!form["Instruction Manual"].trim()) + newErrors["Instruction Manual"] = true; if (!form["Installed Time"]) newErrors["Installed Time"] = true; if (!form["Device Time"]) newErrors["Device Time"] = true; if (!form["Communication Time"]) newErrors["Communication Time"] = true; - if (!gatewayForm["Gateway ID"] || gatewayForm["Gateway ID"] === 0) { + if (!gatewayForm["Gateway ID"] || gatewayForm["Gateway ID"] === 0) newErrors["Gateway ID"] = true; - } if (!gatewayForm["Gateway EUI"].trim()) newErrors["Gateway EUI"] = true; if (!gatewayForm["Gateway Name"].trim()) newErrors["Gateway Name"] = true; - if (!gatewayForm["Gateway Description"].trim()) newErrors["Gateway Description"] = true; + if (!gatewayForm["Gateway Description"].trim()) + newErrors["Gateway Description"] = true; setErrors(newErrors); return Object.keys(newErrors).length === 0; }; const handleSave = async () => { - if (!validateForm()) { - return; - } + if (!validateForm()) return; try { let savedConcentrator: Concentrator; - if (editingSerial) { - const concentratorToUpdate = concentrators.find(c => c["Device S/N"] === editingSerial); - if (!concentratorToUpdate) { - throw new Error("Concentrator to update not found"); - } + // ✅ DEBUG: ver qué se manda al API + console.log("FORM SENT:", form); + console.log("editingSerial:", editingSerial); + + if (editingSerial) { + const concentratorToUpdate = concentrators.find( + (c) => c["Device S/N"] === editingSerial + ); + if (!concentratorToUpdate) throw new Error("Concentrator not found"); + + const updatedConcentrator = await updateConcentrator( + concentratorToUpdate.id, + form + ); + + // ✅ DEBUG: ver respuesta del API + console.log("UPDATED RESPONSE:", updatedConcentrator); - const updatedConcentrator = await updateConcentrator(concentratorToUpdate.id, form); setConcentrators((prev) => prev.map((c) => c.id === concentratorToUpdate.id ? updatedConcentrator : c @@ -172,6 +324,10 @@ export default function ConcentratorsPage() { savedConcentrator = updatedConcentrator; } else { const newConcentrator = await createConcentrator(form); + + // ✅ DEBUG: ver respuesta del API al crear + console.log("CREATED RESPONSE:", newConcentrator); + setConcentrators((prev) => [...prev, newConcentrator]); savedConcentrator = newConcentrator; } @@ -182,10 +338,9 @@ export default function ConcentratorsPage() { concentratorId: savedConcentrator.id, }; await createOrUpdateGateway(gatewayDataWithRef); - console.log('Gateway data saved successfully'); } catch (gatewayError) { - console.error('Error saving gateway data:', gatewayError); - alert('Concentrator saved, but there was an error saving gateway data.'); + console.error("Error saving gateway data:", gatewayError); + alert("Concentrator saved, but there was an error saving gateway data."); } setShowModal(false); @@ -195,7 +350,7 @@ export default function ConcentratorsPage() { setErrors({}); setActiveConcentrator(null); } catch (error) { - console.error('Error saving concentrator:', error); + console.error("Error saving concentrator:", error); alert( `Error saving concentrator: ${ error instanceof Error ? error.message : "Please try again." @@ -204,18 +359,15 @@ export default function ConcentratorsPage() { } }; + // ✅ MISMA lógica de delete, solo sin window.confirm (el confirm lo hace el modal) const handleDelete = async () => { if (!activeConcentrator) return; - const confirmDelete = window.confirm( - `Are you sure you want to delete the concentrator "${activeConcentrator["Device Name"]}"?` - ); - - if (!confirmDelete) return; - try { await deleteConcentrator(activeConcentrator.id); - setConcentrators((prev) => prev.filter((c) => c.id !== activeConcentrator.id)); + setConcentrators((prev) => + prev.filter((c) => c.id !== activeConcentrator.id) + ); setActiveConcentrator(null); } catch (error) { console.error("Error deleting concentrator:", error); @@ -227,67 +379,202 @@ export default function ConcentratorsPage() { } }; - const searchFiltered = filteredConcentrators.filter( - (c) => - c["Device Name"].toLowerCase().includes(search.toLowerCase()) || - c["Device S/N"].toLowerCase().includes(search.toLowerCase()) - ); + // ============================================================ + // ✅ MISMA LÓGICA DE TABLA/BÚSQUEDA QUE TU SEGUNDO CÓDIGO: + // - filtra sobre filteredConcentrators (que ya puede ser "all") + // - búsqueda case-insensitive sin romper por undefined + // ============================================================ + const searchFiltered = filteredConcentrators.filter((c) => { + const name = (c["Device Name"] ?? "").toLowerCase(); + const sn = (c["Device S/N"] ?? "").toLowerCase(); + const q = search.toLowerCase(); + return name.includes(q) || sn.includes(q); + }); /* ================= UI ================= */ return (
    - {/* LEFT INFO SIDEBAR */} -
    -

    - Project Information -

    + {/* SIDEBAR */} +
    + {/* MAIN */} -
    - {/* HEADER */} +
    + {/* HEADER + ACTIONS */}

    Concentrator Management

    -

    Concentradores registrados

    +

    + {selectedProject + ? `Proyecto: ${selectedProject}` + : "Selecciona un proyecto desde el panel izquierdo"} +

    + {/* ✅ EDIT */} + {/* ✅ Delete confirm modal */}
    @@ -347,129 +640,291 @@ export default function ConcentratorsPage() { placeholder="Search concentrator..." value={search} onChange={(e) => setSearch(e.target.value)} + disabled={!selectedProject} /> {/* TABLE */} - rowData["Device Name"] || "-" }, - { title: "Device S/N", field: "Device S/N", render: (rowData) => rowData["Device S/N"] || "-" }, - { - title: "Device Status", - field: "Device Status", - render: (rowData) => ( - - {rowData["Device Status"] || "-"} - - ), - }, - { title: "Operator", field: "Operator", render: (rowData) => rowData["Operator"] || "-" }, - { title: "Area Name", field: "Area Name", render: (rowData) => rowData["Area Name"] || "-" }, - { title: "Installed Time", field: "Installed Time", type: "date", render: (rowData) => rowData["Installed Time"] || "-" }, - ]} - data={searchFiltered} - onRowClick={(_, rowData) => setActiveConcentrator(rowData as Concentrator)} - options={{ - actionsColumnIndex: -1, - search: false, - paging: true, - sorting: true, - rowStyle: (rowData) => ({ - backgroundColor: - activeConcentrator?.id === (rowData as Concentrator).id - ? "#EEF2FF" - : "#FFFFFF", - }), - }} - localization={{ - body: { - emptyDataSourceMessage: loadingConcentrators - ? "Loading concentrators..." - : "No concentrators found. Click 'Add' to create your first concentrator.", - }, +
    + rowData["Device Name"] || "-", + }, + { + title: "Device S/N", + field: "Device S/N", + render: (rowData: any) => rowData["Device S/N"] || "-", + }, + { + title: "Device Status", + field: "Device Status", + render: (rowData: any) => ( + + {rowData["Device Status"] || "-"} + + ), + }, + { + title: "Operator", + field: "Operator", + render: (rowData: any) => rowData["Operator"] || "-", + }, + { + title: "Area Name", + field: "Area Name", + render: (rowData: any) => rowData["Area Name"] || "-", + }, + { + title: "Installed Time", + field: "Installed Time", + type: "date", + render: (rowData: any) => rowData["Installed Time"] || "-", + }, + ]} + data={searchFiltered} + onRowClick={(_, rowData) => + setActiveConcentrator(rowData as Concentrator) + } + options={{ + actionsColumnIndex: -1, + search: false, + paging: true, + sorting: true, + rowStyle: (rowData) => ({ + backgroundColor: + activeConcentrator?.id === (rowData as Concentrator).id + ? "#EEF2FF" + : "#FFFFFF", + }), + }} + localization={{ + body: { + emptyDataSourceMessage: !selectedProject + ? "Select a project to view concentrators." + : loadingConcentrators + ? "Loading concentrators..." + : "No concentrators found. Click 'Add' to create your first concentrator.", + }, + }} + /> +
    + + {/* ✅ ConfirmModal bonito */} + setConfirmOpen(false)} + onConfirm={async () => { + setDeleting(true); + try { + await handleDelete(); + setConfirmOpen(false); + } finally { + setDeleting(false); + } }} /> -
    + - {/* MODAL */} + {/* MODAL ADD/EDIT */} {showModal && (
    -
    +

    {editingSerial ? "Edit Concentrator" : "Add Concentrator"}

    + {/* ================= FORM ================= */}

    Concentrator Information

    - -
    - { - setForm({ ...form, "Device Name": e.target.value }); - if (errors["Device Name"]) { - setErrors({ ...errors, "Device Name": false }); - } - }} - required - /> - {errors["Device Name"] && ( -

    This field is required

    - )} + +
    +
    + +

    + El proyecto seleccionado define el Area Name. +

    +
    + +
    + { + setForm({ ...form, "Device S/N": e.target.value }); + if (errors["Device S/N"]) + setErrors({ ...errors, "Device S/N": false }); + }} + required + /> + {errors["Device S/N"] && ( +

    + This field is required +

    + )} +
    -
    - { - setForm({ ...form, "Device S/N": e.target.value }); - if (errors["Device S/N"]) { - setErrors({ ...errors, "Device S/N": false }); +
    +
    + { + setForm({ ...form, "Device Name": e.target.value }); + if (errors["Device Name"]) + setErrors({ ...errors, "Device Name": false }); + }} + required + /> + {errors["Device Name"] && ( +

    + This field is required +

    + )} +
    + +
    + +
    -
    - { - setForm({ ...form, "Operator": e.target.value }); - if (errors["Operator"]) { - setErrors({ ...errors, "Operator": false }); - } - }} - required - /> - {errors["Operator"] && ( -

    This field is required

    - )} +
    +
    + { + setForm({ ...form, Operator: e.target.value }); + if (errors["Operator"]) + setErrors({ ...errors, Operator: false }); + }} + required + /> + {errors["Operator"] && ( +

    + This field is required +

    + )} +
    + +
    + { + setForm({ ...form, "Installed Time": e.target.value }); + if (errors["Installed Time"]) + setErrors({ ...errors, "Installed Time": false }); + }} + required + /> + {errors["Installed Time"] && ( +

    + This field is required +

    + )} +
    +
    + +
    +
    + { + setForm({ + ...form, + "Device Time": fromDatetimeLocalValue(e.target.value), + }); + if (errors["Device Time"]) + setErrors({ ...errors, "Device Time": false }); + }} + required + /> + {errors["Device Time"] && ( +

    + This field is required +

    + )} +
    + +
    + { + setForm({ + ...form, + "Communication Time": fromDatetimeLocalValue( + e.target.value + ), + }); + if (errors["Communication Time"]) + setErrors({ ...errors, "Communication Time": false }); + }} + required + /> + {errors["Communication Time"] && ( +

    + This field is required +

    + )} +
    @@ -481,168 +936,118 @@ export default function ConcentratorsPage() { value={form["Instruction Manual"]} onChange={(e) => { setForm({ ...form, "Instruction Manual": e.target.value }); - if (errors["Instruction Manual"]) { + if (errors["Instruction Manual"]) setErrors({ ...errors, "Instruction Manual": false }); - } }} required /> {errors["Instruction Manual"] && ( -

    This field is required

    - )} -
    - - - -
    - { - setForm({ ...form, "Installed Time": e.target.value }); - if (errors["Installed Time"]) { - setErrors({ ...errors, "Installed Time": false }); - } - }} - required - /> - {errors["Installed Time"] && ( -

    This field is required

    - )} -
    - -
    - { - setForm({ - ...form, - "Device Time": new Date(e.target.value).toISOString(), - }); - if (errors["Device Time"]) { - setErrors({ ...errors, "Device Time": false }); - } - }} - required - /> - {errors["Device Time"] && ( -

    This field is required

    - )} -
    - -
    - { - setForm({ - ...form, - "Communication Time": new Date(e.target.value).toISOString(), - }); - if (errors["Communication Time"]) { - setErrors({ ...errors, "Communication Time": false }); - } - }} - required - /> - {errors["Communication Time"] && ( -

    This field is required

    +

    + This field is required +

    )}

    - Gateway Information + Gateway Configuration

    -
    - { - setGatewayForm({ - ...gatewayForm, - "Gateway ID": parseInt(e.target.value) || 0, - }); - if (errors["Gateway ID"]) { - setErrors({ ...errors, "Gateway ID": false }); - } - }} - required - min="1" - /> - {errors["Gateway ID"] && ( -

    This field is required

    - )} +
    +
    + { + setGatewayForm({ + ...gatewayForm, + "Gateway ID": parseInt(e.target.value) || 0, + }); + if (errors["Gateway ID"]) + setErrors({ ...errors, "Gateway ID": false }); + }} + required + min={1} + /> + {errors["Gateway ID"] && ( +

    + This field is required +

    + )} +
    + +
    + { + setGatewayForm({ + ...gatewayForm, + "Gateway EUI": e.target.value, + }); + if (errors["Gateway EUI"]) + setErrors({ ...errors, "Gateway EUI": false }); + }} + required + /> + {errors["Gateway EUI"] && ( +

    + This field is required +

    + )} +
    -
    - { - setGatewayForm({ ...gatewayForm, "Gateway EUI": e.target.value }); - if (errors["Gateway EUI"]) { - setErrors({ ...errors, "Gateway EUI": false }); - } - }} - required - /> - {errors["Gateway EUI"] && ( -

    This field is required

    - )} -
    +
    +
    + { + setGatewayForm({ + ...gatewayForm, + "Gateway Name": e.target.value, + }); + if (errors["Gateway Name"]) + setErrors({ ...errors, "Gateway Name": false }); + }} + required + /> + {errors["Gateway Name"] && ( +

    + This field is required +

    + )} +
    -
    - { - setGatewayForm({ ...gatewayForm, "Gateway Name": e.target.value }); - if (errors["Gateway Name"]) { - setErrors({ ...errors, "Gateway Name": false }); +
    + +
    @@ -657,31 +1062,17 @@ export default function ConcentratorsPage() { ...gatewayForm, "Gateway Description": e.target.value, }); - if (errors["Gateway Description"]) { + if (errors["Gateway Description"]) setErrors({ ...errors, "Gateway Description": false }); - } }} required /> {errors["Gateway Description"] && ( -

    This field is required

    +

    + This field is required +

    )}
    - -
    @@ -707,4 +1098,25 @@ export default function ConcentratorsPage() { )}
    ); + + function toDatetimeLocalValue(value?: string) { + if (!value) return ""; + const d = new Date(value); + if (Number.isNaN(d.getTime())) return ""; + const pad = (n: number) => String(n).padStart(2, "0"); + const yyyy = d.getFullYear(); + const mm = pad(d.getMonth() + 1); + const dd = pad(d.getDate()); + const hh = pad(d.getHours()); + const mi = pad(d.getMinutes()); + return `${yyyy}-${mm}-${dd}T${hh}:${mi}`; + } + + function fromDatetimeLocalValue(value: string) { + if (!value) return ""; + // interpreta como hora local del navegador y lo pasa a ISO + const d = new Date(value); + if (Number.isNaN(d.getTime())) return ""; + return d.toISOString(); + } } diff --git a/src/pages/meters/MeterPage.tsx b/src/pages/meters/MeterPage.tsx index 80c0e54..daef8f9 100644 --- a/src/pages/meters/MeterPage.tsx +++ b/src/pages/meters/MeterPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Plus, Trash2, Pencil, RefreshCcw } from "lucide-react"; import MaterialTable from "@material-table/core"; import { @@ -8,21 +8,36 @@ import { deleteMeter, type Meter, } from "../../api/meters"; +import ConfirmModal from "../../components/layout/common/ConfirmModal"; // ✅ NUEVO interface DeviceData { "Device ID": number; "Device EUI": string; "Join EUI": string; - "AppKey": string; + AppKey: string; meterId?: string; } +type ProjectStatus = "ACTIVO" | "INACTIVO"; + +type ProjectCard = { + name: string; + region: string; + projects: number; // placeholder + meters: number; + activeAlerts: number; + lastSync: string; + contact: string; + status: ProjectStatus; +}; + /* ================= COMPONENT ================= */ -export default function MeterManagement({ selectedProject: initialProject }: { selectedProject?: string } = {}) { +export default function MeterManagement({ + selectedProject: initialProject, +}: { selectedProject?: string } = {}) { const [allProjects, setAllProjects] = useState([]); const [loadingProjects, setLoadingProjects] = useState(true); - const [selectedProject, setSelectedProject] = useState(initialProject || ""); const [meters, setMeters] = useState([]); @@ -31,9 +46,15 @@ export default function MeterManagement({ selectedProject: initialProject }: { s const [activeMeter, setActiveMeter] = useState(null); const [search, setSearch] = useState(""); + const [projectQuery, setProjectQuery] = useState(""); + const [showModal, setShowModal] = useState(false); const [editingId, setEditingId] = useState(null); + // ✅ NUEVO: confirm modal delete + const [confirmOpen, setConfirmOpen] = useState(false); + const [deleting, setDeleting] = useState(false); + const emptyMeter: Omit = { createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), @@ -60,29 +81,32 @@ export default function MeterManagement({ selectedProject: initialProject }: { s "Device ID": 0, "Device EUI": "", "Join EUI": "", - "AppKey": "", + AppKey: "", }; - useEffect(() => { - if (selectedProject) { - const filtered = meters.filter((meter) => meter.areaName === selectedProject); - setFilteredMeters(filtered); - } else { - setFilteredMeters(meters); - } - }, [selectedProject, meters]); - const [form, setForm] = useState>(emptyMeter); const [deviceForm, setDeviceForm] = useState(emptyDeviceData); const [errors, setErrors] = useState<{ [key: string]: boolean }>({}); + /* ================= LOAD ================= */ const loadMeters = async () => { setLoadingMeters(true); + setLoadingProjects(true); try { const data = await fetchMeters(); - const projectsArray = [...new Set(data.map((record) => record["areaName"]))]; + + const projectsArray = [...new Set(data.map((r) => r.areaName))] + .filter(Boolean) as string[]; + setAllProjects(projectsArray); setMeters(data); + + // ✅ FIX: si no hay proyecto seleccionado, autoselecciona el primero disponible + setSelectedProject((prev) => { + if (prev) return prev; + if (initialProject) return initialProject; + return projectsArray[0] ?? ""; + }); } catch (error) { console.error("Error loading meters:", error); setAllProjects([]); @@ -95,39 +119,74 @@ export default function MeterManagement({ selectedProject: initialProject }: { s useEffect(() => { loadMeters(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { - if (initialProject) { - setSelectedProject(initialProject); - } + if (initialProject) setSelectedProject(initialProject); }, [initialProject]); + // Filtrado por proyecto + useEffect(() => { + if (!selectedProject) { + setFilteredMeters([]); + return; + } + setFilteredMeters(meters.filter((m) => m.areaName === selectedProject)); + }, [selectedProject, meters]); + + /* ================= SIDEBAR PROJECT CARDS (ALWAYS OPEN) ================= */ + const projectsData: ProjectCard[] = useMemo(() => { + const counts = meters.reduce>((acc, m) => { + const area = m.areaName ?? "SIN PROYECTO"; + acc[area] = (acc[area] ?? 0) + 1; + return acc; + }, {}); + + const baseRegion = "Baja California"; + const baseContact = "Operaciones"; + const baseLastSync = "Hace 1 h"; + + return allProjects.map((name) => ({ + name, + region: baseRegion, + projects: 1, + meters: counts[name] ?? 0, + activeAlerts: 0, + lastSync: baseLastSync, + contact: baseContact, + status: "ACTIVO", + })); + }, [meters, allProjects]); + + const filteredProjects = useMemo(() => { + const q = projectQuery.trim().toLowerCase(); + if (!q) return projectsData; + return projectsData.filter((p) => p.name.toLowerCase().includes(q)); + }, [projectQuery, projectsData]); + + /* ================= DEVICE CONFIG MOCK ================= */ const createOrUpdateDevice = async (deviceData: DeviceData): Promise => { - //await fetch('/api/devices', { method: 'POST', body: JSON.stringify(deviceData) }) - return new Promise((resolve) => { setTimeout(() => { - console.log('Device data that would be sent to API:', deviceData); + console.log("Device data that would be sent to API:", deviceData); resolve(); }, 500); }); }; + /* ================= VALIDATION ================= */ const validateForm = (): boolean => { const newErrors: { [key: string]: boolean } = {}; - // Required fields if (!form.meterName.trim()) newErrors["meterName"] = true; if (!form.meterSerialNumber.trim()) newErrors["meterSerialNumber"] = true; if (!form.areaName.trim()) newErrors["areaName"] = true; if (!form.deviceName.trim()) newErrors["deviceName"] = true; if (!form.protocolType.trim()) newErrors["protocolType"] = true; - // Device Configuration - Required - if (!deviceForm["Device ID"] || deviceForm["Device ID"] === 0) { + if (!deviceForm["Device ID"] || deviceForm["Device ID"] === 0) newErrors["Device ID"] = true; - } if (!deviceForm["Device EUI"].trim()) newErrors["Device EUI"] = true; if (!deviceForm["Join EUI"].trim()) newErrors["Join EUI"] = true; if (!deviceForm["AppKey"].trim()) newErrors["AppKey"] = true; @@ -136,25 +195,20 @@ export default function MeterManagement({ selectedProject: initialProject }: { s return Object.keys(newErrors).length === 0; }; + /* ================= CRUD ================= */ const handleSave = async () => { - if (!validateForm()) { - return; - } + if (!validateForm()) return; try { let savedMeter: Meter; if (editingId) { - const meterToUpdate = meters.find(m => m.id === editingId); - if (!meterToUpdate) { - throw new Error("Meter to update not found"); - } + const meterToUpdate = meters.find((m) => m.id === editingId); + if (!meterToUpdate) throw new Error("Meter to update not found"); const updatedMeter = await updateMeter(editingId, form); setMeters((prev) => - prev.map((m) => - m.id === editingId ? updatedMeter : m - ) + prev.map((m) => (m.id === editingId ? updatedMeter : m)) ); savedMeter = updatedMeter; } else { @@ -164,15 +218,11 @@ export default function MeterManagement({ selectedProject: initialProject }: { s } try { - const deviceDataWithRef = { - ...deviceForm, - meterId: savedMeter.id, - }; + const deviceDataWithRef = { ...deviceForm, meterId: savedMeter.id }; await createOrUpdateDevice(deviceDataWithRef); - console.log('Device data saved successfully'); } catch (deviceError) { - console.error('Error saving device data:', deviceError); - alert('Meter saved, but there was an error saving device data.'); + console.error("Error saving device data:", deviceError); + alert("Meter saved, but there was an error saving device data."); } setShowModal(false); @@ -182,24 +232,23 @@ export default function MeterManagement({ selectedProject: initialProject }: { s setErrors({}); setActiveMeter(null); } catch (error) { - console.error('Error saving meter:', error); + console.error("Error saving meter:", error); alert( `Error saving meter: ${ error instanceof Error ? error.message : "Please try again." }` + + ); + } + }; + // ✅ MISMA lógica de delete, solo sin window.confirm const handleDelete = async () => { if (!activeMeter) return; - const confirmDelete = window.confirm( - `Are you sure you want to delete the meter "${activeMeter.meterName}" (${activeMeter.meterSerialNumber})?` - ); - - if (!confirmDelete) return; - try { await deleteMeter(activeMeter.id); setMeters((prev) => prev.filter((m) => m.id !== activeMeter.id)); @@ -219,63 +268,204 @@ export default function MeterManagement({ selectedProject: initialProject }: { s setActiveMeter(null); }; + /* ================= SEARCH (CLIENT) ================= */ + const searchFiltered = filteredMeters.filter((m) => { + const q = search.trim().toLowerCase(); + if (!q) return true; + + return ( + (m.meterName ?? "").toLowerCase().includes(q) || + (m.meterSerialNumber ?? "").toLowerCase().includes(q) || + (m.deviceId ?? "").toLowerCase().includes(q) || + (m.areaName ?? "").toLowerCase().includes(q) + ); + }); + /* ================= UI ================= */ return (
    - {/* LEFT INFO SIDEBAR */} -
    -

    - Project Information -

    + {/* SIDEBAR */} +
    + {/* MAIN */} -
    +
    {/* HEADER */}

    Meter Management

    -

    Medidores registrados

    +

    + {selectedProject + ? `Proyecto: ${selectedProject}` + : "Selecciona un proyecto desde el panel izquierdo"} +

    + {/* ✅ CAMBIO: antes llamaba handleDelete, ahora abre modal */}
    @@ -345,328 +536,420 @@ export default function MeterManagement({ selectedProject: initialProject }: { s placeholder="Search by meter name, serial number, device ID, or area..." value={search} onChange={(e) => setSearch(e.target.value)} + disabled={!selectedProject} /> {/* TABLE */} - rowData.areaName || "-" }, - { title: "Account Number", field: "accountNumber", render: (rowData) => rowData.accountNumber || "-" }, - { title: "User Name", field: "userName", render: (rowData) => rowData.userName || "-" }, - { title: "User Address", field: "userAddress", render: (rowData) => rowData.userAddress || "-" }, - { title: "Meter S/N", field: "meterSerialNumber", render: (rowData) => rowData.meterSerialNumber || "-" }, - { title: "Meter Name", field: "meterName", render: (rowData) => rowData.meterName || "-" }, - { title: "Protocol Type", field: "protocolType", render: (rowData) => rowData.protocolType || "-" }, - { title: "Device ID", field: "deviceId", render: (rowData) => rowData.deviceId || "-" }, - { title: "Device Name", field: "deviceName", render: (rowData) => rowData.deviceName || "-" }, - ]} - data={filteredMeters} - onRowClick={(_, rowData) => setActiveMeter(rowData as Meter)} - options={{ - actionsColumnIndex: -1, - search: false, - paging: true, - sorting: true, - rowStyle: (rowData) => ({ - backgroundColor: - activeMeter?.id === (rowData as Meter).id - ? "#EEF2FF" - : "#FFFFFF", - }), - }} - localization={{ - body: { - emptyDataSourceMessage: loadingMeters - ? "Loading meters..." - : "No meters found. Click 'Add' to create your first meter.", - }, +
    + rowData.areaName || "-", + }, + { + title: "Account Number", + field: "accountNumber", + render: (rowData) => rowData.accountNumber || "-", + }, + { + title: "User Name", + field: "userName", + render: (rowData) => rowData.userName || "-", + }, + { + title: "User Address", + field: "userAddress", + render: (rowData) => rowData.userAddress || "-", + }, + { + title: "Meter S/N", + field: "meterSerialNumber", + render: (rowData) => rowData.meterSerialNumber || "-", + }, + { + title: "Meter Name", + field: "meterName", + render: (rowData) => rowData.meterName || "-", + }, + { + title: "Protocol Type", + field: "protocolType", + render: (rowData) => rowData.protocolType || "-", + }, + { + title: "Device ID", + field: "deviceId", + render: (rowData) => rowData.deviceId || "-", + }, + { + title: "Device Name", + field: "deviceName", + render: (rowData) => rowData.deviceName || "-", + }, + ]} + data={searchFiltered} + onRowClick={(_, rowData) => setActiveMeter(rowData as Meter)} + options={{ + actionsColumnIndex: -1, + search: false, + paging: true, + sorting: true, + rowStyle: (rowData) => ({ + backgroundColor: + activeMeter?.id === (rowData as Meter).id + ? "#EEF2FF" + : "#FFFFFF", + }), + }} + localization={{ + body: { + emptyDataSourceMessage: !selectedProject + ? "Select a project to view meters." + : loadingMeters + ? "Loading meters..." + : "No meters found. Click 'Add' to create your first meter.", + }, + }} + /> +
    + + {/* ✅ NUEVO: ConfirmModal para borrar */} + setConfirmOpen(false)} + onConfirm={async () => { + setDeleting(true); + try { + await handleDelete(); + setConfirmOpen(false); + } finally { + setDeleting(false); + } }} /> -
    + {/* MODAL */} -{showModal && ( -
    -
    -

    - {editingId ? "Edit Meter" : "Add Meter"} -

    + {showModal && ( +
    +
    +

    + {editingId ? "Edit Meter" : "Add Meter"} +

    -
    -

    - Meter Information -

    + {/* ✅ FORMULARIO (REINTEGRADO) */} +
    +

    + Meter Information +

    -
    -
    - { - setForm({ ...form, areaName: e.target.value }); - if (errors["areaName"]) { - setErrors({ ...errors, "areaName": false }); - } - }} - required - /> - {errors["areaName"] && ( -

    This field is required

    - )} -
    +
    +
    + { + setForm({ ...form, areaName: e.target.value }); + if (errors["areaName"]) { + setErrors({ ...errors, areaName: false }); + } + }} + required + /> + {errors["areaName"] && ( +

    + This field is required +

    + )} +
    -
    - - setForm({ ...form, accountNumber: e.target.value || null }) - } - /> +
    + + setForm({ + ...form, + accountNumber: e.target.value || null, + }) + } + /> +
    +
    + +
    +
    + + setForm({ ...form, userName: e.target.value || null }) + } + /> +
    + +
    + + setForm({ ...form, userAddress: e.target.value || null }) + } + /> +
    +
    + +
    +
    + { + setForm({ ...form, meterSerialNumber: e.target.value }); + if (errors["meterSerialNumber"]) { + setErrors({ ...errors, meterSerialNumber: false }); + } + }} + required + /> + {errors["meterSerialNumber"] && ( +

    + This field is required +

    + )} +
    + +
    + { + setForm({ ...form, meterName: e.target.value }); + if (errors["meterName"]) { + setErrors({ ...errors, meterName: false }); + } + }} + required + /> + {errors["meterName"] && ( +

    + This field is required +

    + )} +
    +
    + +
    +
    + { + setForm({ ...form, protocolType: e.target.value }); + if (errors["protocolType"]) { + setErrors({ ...errors, protocolType: false }); + } + }} + required + /> + {errors["protocolType"] && ( +

    + This field is required +

    + )} +
    + +
    + + setForm({ ...form, deviceId: e.target.value || "" }) + } + /> +
    +
    + +
    + { + setForm({ ...form, deviceName: e.target.value }); + if (errors["deviceName"]) { + setErrors({ ...errors, deviceName: false }); + } + }} + required + /> + {errors["deviceName"] && ( +

    + This field is required +

    + )} +
    +
    + +
    +

    + Device Configuration +

    + +
    +
    + { + setDeviceForm({ + ...deviceForm, + "Device ID": parseInt(e.target.value) || 0, + }); + if (errors["Device ID"]) { + setErrors({ ...errors, "Device ID": false }); + } + }} + required + min={1} + /> + {errors["Device ID"] && ( +

    + This field is required +

    + )} +
    + +
    + { + setDeviceForm({ + ...deviceForm, + "Device EUI": e.target.value, + }); + if (errors["Device EUI"]) { + setErrors({ ...errors, "Device EUI": false }); + } + }} + required + /> + {errors["Device EUI"] && ( +

    + This field is required +

    + )} +
    +
    + +
    + { + setDeviceForm({ + ...deviceForm, + "Join EUI": e.target.value, + }); + if (errors["Join EUI"]) { + setErrors({ ...errors, "Join EUI": false }); + } + }} + required + /> + {errors["Join EUI"] && ( +

    + This field is required +

    + )} +
    + +
    + { + setDeviceForm({ ...deviceForm, AppKey: e.target.value }); + if (errors["AppKey"]) { + setErrors({ ...errors, AppKey: false }); + } + }} + required + /> + {errors["AppKey"] && ( +

    + This field is required +

    + )} +
    +
    + +
    + + +
    - -
    -
    - - setForm({ ...form, userName: e.target.value || null }) - } - /> -
    - -
    - - setForm({ ...form, userAddress: e.target.value || null }) - } - /> -
    -
    - -
    -
    - { - setForm({ ...form, meterSerialNumber: e.target.value }); - if (errors["meterSerialNumber"]) { - setErrors({ ...errors, "meterSerialNumber": false }); - } - }} - required - /> - {errors["meterSerialNumber"] && ( -

    This field is required

    - )} -
    - -
    - { - setForm({ ...form, meterName: e.target.value }); - if (errors["meterName"]) { - setErrors({ ...errors, "meterName": false }); - } - }} - required - /> - {errors["meterName"] && ( -

    This field is required

    - )} -
    -
    - -
    -
    - { - setForm({ ...form, protocolType: e.target.value }); - if (errors["protocolType"]) { - setErrors({ ...errors, "protocolType": false }); - } - }} - required - /> - {errors["protocolType"] && ( -

    This field is required

    - )} -
    - -
    - - setForm({ ...form, deviceId: e.target.value || "" }) - } - /> -
    -
    - -
    - { - setForm({ ...form, deviceName: e.target.value }); - if (errors["deviceName"]) { - setErrors({ ...errors, "deviceName": false }); - } - }} - required - /> - {errors["deviceName"] && ( -

    This field is required

    - )} -
    -
    - -
    -

    - Device Configuration -

    - -
    -
    - { - setDeviceForm({ - ...deviceForm, - "Device ID": parseInt(e.target.value) || 0, - }); - if (errors["Device ID"]) { - setErrors({ ...errors, "Device ID": false }); - } - }} - required - min="1" - /> - {errors["Device ID"] && ( -

    This field is required

    - )} -
    - -
    - { - setDeviceForm({ ...deviceForm, "Device EUI": e.target.value }); - if (errors["Device EUI"]) { - setErrors({ ...errors, "Device EUI": false }); - } - }} - required - /> - {errors["Device EUI"] && ( -

    This field is required

    - )} -
    -
    - -
    - { - setDeviceForm({ ...deviceForm, "Join EUI": e.target.value }); - if (errors["Join EUI"]) { - setErrors({ ...errors, "Join EUI": false }); - } - }} - required - /> - {errors["Join EUI"] && ( -

    This field is required

    - )} -
    - -
    - { - setDeviceForm({ ...deviceForm, "AppKey": e.target.value }); - if (errors["AppKey"]) { - setErrors({ ...errors, "AppKey": false }); - } - }} - required - /> - {errors["AppKey"] && ( -

    This field is required

    - )} -
    -
    - -
    - - -
    -
    -
    -)} - + )}
    ); } +