diff --git a/src/components/layout/TopMenu.tsx b/src/components/layout/TopMenu.tsx index 02499f5..a0ca694 100644 --- a/src/components/layout/TopMenu.tsx +++ b/src/components/layout/TopMenu.tsx @@ -58,7 +58,7 @@ const TopMenu: React.FC = ({ return (
{showOrganisms && ( -
- {/* Overlay */} -
{ - setShowOrganisms(false); - setOrganismQuery(""); - }} - /> +
+ {/* Overlay */} +
{ + setShowOrganisms(false); + setOrganismQuery(""); + }} + /> {/* Panel */}
diff --git a/src/pages/concentrators/ConcentratorsModal.tsx b/src/pages/concentrators/ConcentratorsModal.tsx new file mode 100644 index 0000000..2eece91 --- /dev/null +++ b/src/pages/concentrators/ConcentratorsModal.tsx @@ -0,0 +1,384 @@ +// src/pages/concentrators/ConcentratorsModal.tsx +import type React from "react"; +import type { Concentrator } from "../../api/concentrators"; +import type { GatewayData } from "./ConcentratorsPage"; + +type Props = { + editingSerial: string | null; + + form: Omit; + setForm: React.Dispatch>>; + + gatewayForm: GatewayData; + setGatewayForm: React.Dispatch>; + + errors: Record; + setErrors: React.Dispatch>>; + + toDatetimeLocalValue: (value?: string) => string; + fromDatetimeLocalValue: (value: string) => string; + + onClose: () => void; + onSave: () => void | Promise; +}; + +export default function ConcentratorsModal({ + editingSerial, + form, + setForm, + gatewayForm, + setGatewayForm, + errors, + setErrors, + toDatetimeLocalValue, + fromDatetimeLocalValue, + onClose, + onSave, +}: Props) { + const title = editingSerial ? "Edit Concentrator" : "Add Concentrator"; + + return ( +
+
+

{title}

+ + {/* FORM */} +
+

+ Concentrator Information +

+ +
+
+ +

+ 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 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, "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 +

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

+ This field is required +

+ )} +
+
+ + {/* GATEWAY */} +
+

+ 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 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 Description": e.target.value, + }); + if (errors["Gateway Description"]) + setErrors({ ...errors, "Gateway Description": false }); + }} + required + /> + {errors["Gateway Description"] && ( +

+ This field is required +

+ )} +
+
+ + {/* ACTIONS */} +
+ + +
+
+
+ ); +} diff --git a/src/pages/concentrators/ConcentratorsPage.tsx b/src/pages/concentrators/ConcentratorsPage.tsx index c6dfdfa..009d464 100644 --- a/src/pages/concentrators/ConcentratorsPage.tsx +++ b/src/pages/concentrators/ConcentratorsPage.tsx @@ -1,26 +1,23 @@ -import { useEffect, useMemo, useState } from "react"; +// src/pages/concentrators/ConcentratorsPage.tsx +import { useMemo, useState } from "react"; import { Plus, Trash2, Pencil, RefreshCcw } from "lucide-react"; -import MaterialTable from "@material-table/core"; -import { - fetchConcentrators, - createConcentrator, - updateConcentrator, - deleteConcentrator, - type Concentrator, -} from "../../api/concentrators"; + import ConfirmModal from "../../components/layout/common/ConfirmModal"; -/* ================= TYPES ================= */ +import { createConcentrator, deleteConcentrator, updateConcentrator, type Concentrator } from "../../api/concentrators"; -interface User { - name: string; - role: "SUPER_ADMIN" | "USER"; - project?: string; -} +// ✅ hook es named export y pide currentUser +import { useConcentrators } from "./useConcentrators"; -type ProjectStatus = "ACTIVO" | "INACTIVO"; +// ✅ UI pieces +import ConcentratorsSidebar from "./ConcentratorsSidebar"; +import ConcentratorsTable from "./ConcentratorsTable"; +import ConcentratorsModal from "./ConcentratorsModal"; -type ProjectCard = { + +export type SampleView = "GENERAL" | "LORA" | "LORAWAN" | "GRANDES"; +export type ProjectStatus = "ACTIVO" | "INACTIVO"; +export type ProjectCard = { name: string; region: string; projects: number; @@ -31,210 +28,49 @@ type ProjectCard = { status: ProjectStatus; }; -interface GatewayData { +type User = { + role: "SUPER_ADMIN" | "USER"; + project?: string; +}; + +export type GatewayData = { "Gateway ID": number; "Gateway EUI": string; "Gateway Name": string; "Gateway Description": string; "Antenna Placement": "Indoor" | "Outdoor"; concentratorId?: string; -} +}; -/* ================= COMPONENT ================= */ export default function ConcentratorsPage() { - // Simulación de usuario actual + // ✅ Simulación de usuario actual const currentUser: User = { - name: "Admin GRH", role: "SUPER_ADMIN", project: "CESPT", }; - // ✅ Modal confirmación delete (bonito) + // ✅ Hook (solo cubre: projects + fetch + sampleView + selectedProject + loading + projectsData) + const c = useConcentrators(currentUser); + + + const [typesMenuOpen, setTypesMenuOpen] = useState(false); + + const [search, setSearch] = useState(""); + const [activeConcentrator, setActiveConcentrator] = useState(null); + const [confirmOpen, setConfirmOpen] = useState(false); const [deleting, setDeleting] = useState(false); - const [allProjects, setAllProjects] = useState([]); - const [loadingProjects, setLoadingProjects] = useState(true); - const [loadingConcentrators, setLoadingConcentrators] = useState(true); - - 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] - ); - - /* ================= LOAD ================= */ - const loadConcentrators = async () => { - setLoadingConcentrators(true); - setLoadingProjects(true); - try { - 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(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([]); - setConcentrators([]); - } finally { - setLoadingConcentrators(false); - setLoadingProjects(false); - } - }; - - useEffect(() => { - loadConcentrators(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // Si el usuario solo tiene 1 proyecto visible, lo auto-selecciona - useEffect(() => { - if (!selectedProject && visibleProjects.length === 1) { - setSelectedProject(visibleProjects[0]); - } - }, [visibleProjects, selectedProject]); - - // ============================================================ - // ✅ 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, + "Area Name": c.selectedProject, "Device S/N": "", "Device Name": "", "Device Time": new Date().toISOString(), "Device Status": "ACTIVE", - "Operator": "", + Operator: "", "Installed Time": new Date().toISOString().slice(0, 10), "Communication Time": new Date().toISOString(), "Instruction Manual": "", @@ -248,857 +84,92 @@ export default function ConcentratorsPage() { "Antenna Placement": "Indoor", }); - // ✅ FIX: gatewayForm debe inicializarse con el OBJETO, no con la función - const [form, setForm] = useState>( - getEmptyConcentrator() - ); - const [gatewayForm, setGatewayForm] = useState( - getEmptyGatewayData() - ); + const [form, setForm] = useState>(getEmptyConcentrator()); + const [gatewayForm, setGatewayForm] = useState(getEmptyGatewayData()); const [errors, setErrors] = useState<{ [key: string]: boolean }>({}); - /* ================= CRUD ================= */ - const createOrUpdateGateway = async ( - gatewayData: GatewayData - ): Promise => { - return new Promise((resolve) => { - setTimeout(() => { - console.log("Gateway data that would be sent to API:", gatewayData); - resolve(); - }, 500); + // ✅ Tabla filtrada por search (usa lo que YA filtró el hook por proyecto) + const searchFiltered = useMemo(() => { + if (!c.isGeneral) return []; + return c.filteredConcentrators.filter((row) => { + const q = search.trim().toLowerCase(); + if (!q) return true; + const name = (row["Device Name"] ?? "").toLowerCase(); + const sn = (row["Device S/N"] ?? "").toLowerCase(); + return name.includes(q) || sn.includes(q); }); - }; + }, [c.filteredConcentrators, c.isGeneral, search]); - const validateForm = (): boolean => { - const newErrors: { [key: string]: boolean } = {}; + // ========================= + // CRUD (solo GENERAL) + // ========================= + const validateForm = () => { + const next: { [key: string]: boolean } = {}; - 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["Installed Time"]) newErrors["Installed Time"] = true; - if (!form["Device Time"]) newErrors["Device Time"] = true; - if (!form["Communication Time"]) newErrors["Communication Time"] = true; + if (!form["Device Name"].trim()) next["Device Name"] = true; + if (!form["Device S/N"].trim()) next["Device S/N"] = true; + if (!form["Operator"].trim()) next["Operator"] = true; + if (!form["Instruction Manual"].trim()) next["Instruction Manual"] = true; + if (!form["Installed Time"]) next["Installed Time"] = true; + if (!form["Device Time"]) next["Device Time"] = true; + if (!form["Communication Time"]) next["Communication Time"] = true; - 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 ID"] || gatewayForm["Gateway ID"] === 0) next["Gateway ID"] = true; + if (!gatewayForm["Gateway EUI"].trim()) next["Gateway EUI"] = true; + if (!gatewayForm["Gateway Name"].trim()) next["Gateway Name"] = true; + if (!gatewayForm["Gateway Description"].trim()) next["Gateway Description"] = true; - setErrors(newErrors); - return Object.keys(newErrors).length === 0; + setErrors(next); + return Object.keys(next).length === 0; }; const handleSave = async () => { + if (!c.isGeneral) return; if (!validateForm()) return; try { - let savedConcentrator: Concentrator; - - // ✅ 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 toUpdate = c.concentrators.find((x) => x["Device S/N"] === editingSerial); + if (!toUpdate) throw new Error("Concentrator not found"); - const updatedConcentrator = await updateConcentrator( - concentratorToUpdate.id, - form - ); + const updated = await updateConcentrator(toUpdate.id, form); - // ✅ DEBUG: ver respuesta del API - console.log("UPDATED RESPONSE:", updatedConcentrator); - - setConcentrators((prev) => - prev.map((c) => - c.id === concentratorToUpdate.id ? updatedConcentrator : c - ) - ); - savedConcentrator = updatedConcentrator; + // actualiza en memoria (el hook expone setConcentrators) + c.setConcentrators((prev) => prev.map((x) => (x.id === toUpdate.id ? updated : x))); } else { - const newConcentrator = await createConcentrator(form); - - // ✅ DEBUG: ver respuesta del API al crear - console.log("CREATED RESPONSE:", newConcentrator); - - setConcentrators((prev) => [...prev, newConcentrator]); - savedConcentrator = newConcentrator; - } - - try { - const gatewayDataWithRef = { - ...gatewayForm, - concentratorId: savedConcentrator.id, - }; - await createOrUpdateGateway(gatewayDataWithRef); - } catch (gatewayError) { - console.error("Error saving gateway data:", gatewayError); - alert("Concentrator saved, but there was an error saving gateway data."); + const created = await createConcentrator(form); + c.setConcentrators((prev) => [...prev, created]); } setShowModal(false); setEditingSerial(null); - setForm({ ...getEmptyConcentrator(), "Area Name": selectedProject }); + setForm({ ...getEmptyConcentrator(), "Area Name": c.selectedProject }); setGatewayForm(getEmptyGatewayData()); setErrors({}); setActiveConcentrator(null); - } catch (error) { - console.error("Error saving concentrator:", error); - alert( - `Error saving concentrator: ${ - error instanceof Error ? error.message : "Please try again." - }` - ); + } catch (err) { + console.error(err); + alert(`Error saving concentrator: ${err instanceof Error ? err.message : "Please try again."}`); } }; - // ✅ MISMA lógica de delete, solo sin window.confirm (el confirm lo hace el modal) const handleDelete = async () => { + if (!c.isGeneral) return; if (!activeConcentrator) return; try { await deleteConcentrator(activeConcentrator.id); - setConcentrators((prev) => - prev.filter((c) => c.id !== activeConcentrator.id) - ); + c.setConcentrators((prev) => prev.filter((x) => x.id !== activeConcentrator.id)); setActiveConcentrator(null); - } catch (error) { - console.error("Error deleting concentrator:", error); - alert( - `Error deleting concentrator: ${ - error instanceof Error ? error.message : "Please try again." - }` - ); + } catch (err) { + console.error(err); + alert(`Error deleting concentrator: ${err instanceof Error ? err.message : "Please try again."}`); } }; - // ============================================================ - // ✅ 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 ( -
- {/* SIDEBAR */} - - - {/* MAIN */} -
- {/* HEADER + ACTIONS */} -
-
-

Concentrator Management

-

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

-
- -
- - - {/* ✅ EDIT */} - - - {/* ✅ Delete confirm modal */} - - - -
-
- - {/* SEARCH */} - setSearch(e.target.value)} - disabled={!selectedProject} - /> - - {/* TABLE */} -
- 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 ADD/EDIT */} - {showModal && ( -
-
-

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

- - {/* ================= FORM ================= */} -
-

- Concentrator Information -

- -
-
- -

- 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 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, "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 -

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

- This field is required -

- )} -
-
- -
-

- 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 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 Description": e.target.value, - }); - if (errors["Gateway Description"]) - setErrors({ ...errors, "Gateway Description": false }); - }} - required - /> - {errors["Gateway Description"] && ( -

- This field is required -

- )} -
-
- -
- - -
-
-
- )} -
- ); - + // ========================= + // Date helpers para modal + // ========================= function toDatetimeLocalValue(value?: string) { if (!value) return ""; const d = new Date(value); @@ -1114,9 +185,192 @@ export default function ConcentratorsPage() { 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(); } + + return ( +
+ + +
+
+
+

Concentrator Management

+

+ {!c.isGeneral + ? `Vista: ${c.sampleViewLabel} (mock)` + : c.selectedProject + ? `Proyecto: ${c.selectedProject}` + : "Selecciona un proyecto desde el panel izquierdo"} +

+
+ +
+ + + + + + + +
+
+ + setSearch(e.target.value)} + disabled={!c.isGeneral || !c.selectedProject} + /> + +
+ setActiveConcentrator(row)} + emptyMessage={ + !c.isGeneral + ? `Vista "${c.sampleViewLabel}" está en modo mock (sin backend todavía).` + : !c.selectedProject + ? "Select a project to view concentrators." + : c.loadingConcentrators + ? "Loading concentrators..." + : "No concentrators found. Click 'Add' to create your first concentrator." + } + /> +
+ + setConfirmOpen(false)} + onConfirm={async () => { + if (!c.isGeneral) return; + setDeleting(true); + try { + await handleDelete(); + setConfirmOpen(false); + } finally { + setDeleting(false); + } + }} + /> +
+ + {showModal && c.isGeneral && ( + { + setShowModal(false); + setGatewayForm(getEmptyGatewayData()); + setErrors({}); + }} + onSave={handleSave} + /> + )} +
+ ); } diff --git a/src/pages/concentrators/ConcentratorsSidebar.tsx b/src/pages/concentrators/ConcentratorsSidebar.tsx new file mode 100644 index 0000000..db64c58 --- /dev/null +++ b/src/pages/concentrators/ConcentratorsSidebar.tsx @@ -0,0 +1,232 @@ +// src/pages/concentrators/ConcentratorsSidebar.tsx +import { useMemo } from "react"; +import { ChevronDown, Check, RefreshCcw } from "lucide-react"; +import type { ProjectCard, SampleView } from "./ConcentratorsPage"; + +type Props = { + loadingProjects: boolean; + + sampleView: SampleView; + sampleViewLabel: string; + + // ✅ ahora lo controla el Page + typesMenuOpen: boolean; + setTypesMenuOpen: React.Dispatch>; + + onChangeSampleView: (next: SampleView) => void; + + selectedProject: string; + onSelectProject: (name: string) => void; + + // ✅ el Page manda projects={c.projectsData} + projects: ProjectCard[]; + + onRefresh: () => void; + refreshDisabled: boolean; +}; + +export default function ConcentratorsSidebar({ + loadingProjects, + sampleView, + sampleViewLabel, + typesMenuOpen, + setTypesMenuOpen, + onChangeSampleView, + selectedProject, + onSelectProject, + projects, + onRefresh, + refreshDisabled, +}: Props) { + const options = useMemo( + () => + [ + { key: "GENERAL", label: "General" }, + { key: "LORA", label: "LoRa" }, + { key: "LORAWAN", label: "LoRaWAN" }, + { key: "GRANDES", label: "Grandes consumidores" }, + ] as Array<{ key: SampleView; label: string }>, + [] + ); + + return ( +
+ {/* Header */} +
+
+

Proyectos

+

+ Tipo: {sampleViewLabel} + {" • "} + Seleccionado:{" "} + {selectedProject || "—"} +

+
+ + +
+ + {/* Tipos de tomas (dropdown) */} +
+ + + {typesMenuOpen && ( +
+ {options.map((opt) => { + const active = sampleView === opt.key; + return ( + + ); + })} +
+ )} +
+ + {/* List */} +
+ {loadingProjects && sampleView === "GENERAL" ? ( +
Loading projects...
+ ) : projects.length === 0 ? ( +
+ No projects available. Please contact your administrator. +
+ ) : ( + projects.map((p) => { + const active = p.name === selectedProject; + + return ( +
onSelectProject(p.name)} + className={[ + "rounded-xl border p-4 transition cursor-pointer", + active + ? "border-blue-600 bg-blue-50/40" + : "border-gray-200 bg-white hover:bg-gray-50", + ].join(" ")} + > +
+
+

+ {p.name} +

+

{p.region}

+
+ + + {p.status} + +
+ +
+
+ Subproyectos + + {p.projects} + +
+ +
+ Concentradores + + {p.concentrators} + +
+ +
+ Alertas activas + + {p.activeAlerts} + +
+ +
+ Última sync + {p.lastSync} +
+ +
+ Responsable + + {p.contact} + +
+
+ +
+ +
+
+ ); + }) + )} +
+ +
+ Nota: region/alertas/última sync están en modo demostración hasta integrar + backend. +
+
+ ); +} diff --git a/src/pages/concentrators/ConcentratorsTable.tsx b/src/pages/concentrators/ConcentratorsTable.tsx new file mode 100644 index 0000000..b753913 --- /dev/null +++ b/src/pages/concentrators/ConcentratorsTable.tsx @@ -0,0 +1,86 @@ +// src/pages/concentrators/ConcentratorsTable.tsx +import MaterialTable from "@material-table/core"; +import type { Concentrator } from "../../api/concentrators"; + +type Props = { + isLoading: boolean; // ✅ ahora se llama así (como en Page) + data: Concentrator[]; + activeRowId?: string; + onRowClick: (row: Concentrator) => void; + emptyMessage: string; // ✅ mensaje ya viene resuelto desde Page +}; + +export default function ConcentratorsTable({ + isLoading, + data, + activeRowId, + onRowClick, + emptyMessage, +}: Props) { + return ( + 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={data} + onRowClick={(_, rowData) => onRowClick(rowData as Concentrator)} + options={{ + actionsColumnIndex: -1, + search: false, + paging: true, + sorting: true, + rowStyle: (rowData) => ({ + backgroundColor: + activeRowId === (rowData as Concentrator).id + ? "#EEF2FF" + : "#FFFFFF", + }), + }} + localization={{ + body: { emptyDataSourceMessage: emptyMessage }, + }} + /> + ); +} diff --git a/src/pages/concentrators/useConcentrators.ts b/src/pages/concentrators/useConcentrators.ts new file mode 100644 index 0000000..5e13f9a --- /dev/null +++ b/src/pages/concentrators/useConcentrators.ts @@ -0,0 +1,255 @@ +import { useEffect, useMemo, useState } from "react"; +import { + fetchConcentrators, + type Concentrator, +} from "../../api/concentrators"; +import type { ProjectCard, SampleView } from "./ConcentratorsPage"; + +type User = { + role: "SUPER_ADMIN" | "USER"; + project?: string; +}; + +export function useConcentrators(currentUser: User) { + const [sampleView, setSampleView] = useState("GENERAL"); + + const [loadingProjects, setLoadingProjects] = useState(true); + const [loadingConcentrators, setLoadingConcentrators] = useState(true); + + const [allProjects, setAllProjects] = useState([]); + const [selectedProject, setSelectedProject] = useState(""); + + const [concentrators, setConcentrators] = useState([]); + const [filteredConcentrators, setFilteredConcentrators] = useState< + Concentrator[] + >([]); + + const isGeneral = sampleView === "GENERAL"; + + const sampleViewLabel = useMemo(() => { + switch (sampleView) { + case "GENERAL": + return "General"; + case "LORA": + return "LoRa"; + case "LORAWAN": + return "LoRaWAN"; + case "GRANDES": + return "Grandes consumidores"; + default: + return "General"; + } + }, [sampleView]); + + const visibleProjects = useMemo( + () => + currentUser.role === "SUPER_ADMIN" + ? allProjects + : currentUser.project + ? [currentUser.project] + : [], + [allProjects, currentUser.role, currentUser.project] + ); + + const loadConcentrators = async () => { + if (!isGeneral) return; + + setLoadingConcentrators(true); + setLoadingProjects(true); + + try { + const raw = await fetchConcentrators(); + + 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, + }; + }); + + const projectsArray = [ + ...new Set(normalized.map((r: any) => r["Area Name"])), + ].filter(Boolean) as string[]; + + setAllProjects(projectsArray); + setConcentrators(normalized); + + setSelectedProject((prev) => { + if (prev) return prev; + if (currentUser.role !== "SUPER_ADMIN" && currentUser.project) { + return currentUser.project; + } + return projectsArray[0] ?? ""; + }); + } catch (err) { + console.error("Error loading concentrators:", err); + setAllProjects([]); + setConcentrators([]); + setSelectedProject(""); + } finally { + setLoadingConcentrators(false); + setLoadingProjects(false); + } + }; + + // init + useEffect(() => { + loadConcentrators(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // view changes + useEffect(() => { + if (isGeneral) { + loadConcentrators(); + } else { + setLoadingProjects(false); + setLoadingConcentrators(false); + setSelectedProject(""); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sampleView]); + + // auto select single visible project + useEffect(() => { + if (!isGeneral) return; + if (!selectedProject && visibleProjects.length === 1) { + setSelectedProject(visibleProjects[0]); + } + }, [visibleProjects, selectedProject, isGeneral]); + + // filter by project + useEffect(() => { + if (!isGeneral) { + setFilteredConcentrators([]); + return; + } + + if (selectedProject) { + setFilteredConcentrators( + concentrators.filter((c) => c["Area Name"] === selectedProject) + ); + } else { + setFilteredConcentrators(concentrators); + } + }, [selectedProject, concentrators, isGeneral]); + + // sidebar cards (general) + const projectsDataGeneral: 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]); + + // sidebar cards (mock) + const projectsDataMock: Record, ProjectCard[]> = + useMemo( + () => ({ + LORA: [ + { + name: "LoRa - Zona Centro", + region: "Baja California", + projects: 1, + concentrators: 12, + activeAlerts: 1, + lastSync: "Hace 15 min", + contact: "Operaciones", + status: "ACTIVO", + }, + { + name: "LoRa - Zona Este", + region: "Baja California", + projects: 1, + concentrators: 8, + activeAlerts: 0, + lastSync: "Hace 40 min", + contact: "Operaciones", + status: "ACTIVO", + }, + ], + LORAWAN: [ + { + name: "LoRaWAN - Industrial", + region: "Baja California", + projects: 1, + concentrators: 5, + activeAlerts: 0, + lastSync: "Hace 1 h", + contact: "Operaciones", + status: "ACTIVO", + }, + ], + GRANDES: [ + { + name: "Grandes - Convenios", + region: "Baja California", + projects: 1, + concentrators: 3, + activeAlerts: 0, + lastSync: "Hace 2 h", + contact: "Operaciones", + status: "ACTIVO", + }, + ], + }), + [] + ); + + const projectsData: ProjectCard[] = useMemo(() => { + if (isGeneral) return projectsDataGeneral; + return projectsDataMock[sampleView as Exclude]; + }, [isGeneral, projectsDataGeneral, projectsDataMock, sampleView]); + + return { + // view + sampleView, + setSampleView, + sampleViewLabel, + isGeneral, + + // loading + loadingProjects, + loadingConcentrators, + + // projects + allProjects, + visibleProjects, + projectsData, + selectedProject, + setSelectedProject, + + // data + concentrators, + setConcentrators, + filteredConcentrators, + + // actions + loadConcentrators, + }; +} diff --git a/src/pages/meters/MeterPage.tsx b/src/pages/meters/MeterPage.tsx index 93460a4..981b14d 100644 --- a/src/pages/meters/MeterPage.tsx +++ b/src/pages/meters/MeterPage.tsx @@ -1,16 +1,17 @@ -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { Plus, Trash2, Pencil, RefreshCcw } from "lucide-react"; -import MaterialTable from "@material-table/core"; -import { - fetchMeters, - createMeter, - updateMeter, - deleteMeter, - type Meter, -} from "../../api/meters"; -import ConfirmModal from "../../components/layout/common/ConfirmModal"; // ✅ NUEVO +import type { Meter } from "../../api/meters"; +import { createMeter, deleteMeter, updateMeter } from "../../api/meters"; +import ConfirmModal from "../../components/layout/common/ConfirmModal"; -interface DeviceData { +import { useMeters } from "./useMeters"; +import MetersSidebar from "./MetersSidebar"; +import MetersTable from "./MetersTable"; +import MetersModal from "./MetersModal"; + +/* ================= TYPES (exportables para otros componentes) ================= */ + +export interface DeviceData { "Device ID": number; "Device EUI": string; "Join EUI": string; @@ -18,12 +19,12 @@ interface DeviceData { meterId?: string; } -type ProjectStatus = "ACTIVO" | "INACTIVO"; +export type ProjectStatus = "ACTIVO" | "INACTIVO"; -type ProjectCard = { +export type ProjectCard = { name: string; region: string; - projects: number; // placeholder + projects: number; meters: number; activeAlerts: number; lastSync: string; @@ -31,141 +32,137 @@ type ProjectCard = { status: ProjectStatus; }; +export type TakeType = "GENERAL" | "LORA" | "LORAWAN" | "GRANDES"; + +/* ================= MOCKS (sin backend) ================= */ + +const MOCK_PROJECTS_BY_TYPE: Record< + Exclude, + Array<{ name: string; meters?: number }> +> = { + LORA: [ + { name: "LoRa - Demo 01", meters: 12 }, + { name: "LoRa - Demo 02", meters: 7 }, + ], + LORAWAN: [{ name: "LoRaWAN - Demo 01", meters: 4 }], + GRANDES: [{ name: "Grandes - Demo 01", meters: 2 }], +}; + /* ================= COMPONENT ================= */ -export default function MeterManagement({ + +export default function MetersPage({ selectedProject: initialProject, }: { selectedProject?: string } = {}) { - const [allProjects, setAllProjects] = useState([]); - const [loadingProjects, setLoadingProjects] = useState(true); + const m = useMeters({ initialProject }); - const [selectedProject, setSelectedProject] = useState(initialProject || ""); + // UI state + const [takeType, setTakeType] = useState("GENERAL"); + const isMockMode = takeType !== "GENERAL"; - const [meters, setMeters] = useState([]); - const [filteredMeters, setFilteredMeters] = useState([]); - const [loadingMeters, setLoadingMeters] = useState(true); 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(), - areaName: "", - accountNumber: null, - userName: null, - userAddress: null, - meterSerialNumber: "", - meterName: "", - meterStatus: "Installed", - protocolType: "", - priceNo: null, - priceName: null, - dmaPartition: null, - supplyTypes: "", - deviceId: "", - deviceName: "", - deviceType: "", - usageAnalysisType: "", - installedTime: new Date().toISOString(), - }; + const emptyMeter: Omit = useMemo( + () => ({ + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + areaName: "", + accountNumber: null, + userName: null, + userAddress: null, + meterSerialNumber: "", + meterName: "", + meterStatus: "Installed", + protocolType: "", + priceNo: null, + priceName: null, + dmaPartition: null, + supplyTypes: "", + deviceId: "", + deviceName: "", + deviceType: "", + usageAnalysisType: "", + installedTime: new Date().toISOString(), + }), + [] + ); - const emptyDeviceData: DeviceData = { - "Device ID": 0, - "Device EUI": "", - "Join EUI": "", - AppKey: "", - }; + const emptyDeviceData: DeviceData = useMemo( + () => ({ + "Device ID": 0, + "Device EUI": "", + "Join EUI": "", + AppKey: "", + }), + [] + ); 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((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([]); - setMeters([]); - } finally { - setLoadingMeters(false); - setLoadingProjects(false); - } - }; - - useEffect(() => { - loadMeters(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - useEffect(() => { - 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 [errors, setErrors] = useState>({}); + // Projects cards (real) + const projectsDataReal: ProjectCard[] = useMemo(() => { const baseRegion = "Baja California"; const baseContact = "Operaciones"; const baseLastSync = "Hace 1 h"; - return allProjects.map((name) => ({ + return m.allProjects.map((name) => ({ name, region: baseRegion, projects: 1, - meters: counts[name] ?? 0, + meters: m.projectsCounts[name] ?? 0, activeAlerts: 0, lastSync: baseLastSync, contact: baseContact, status: "ACTIVO", })); - }, [meters, allProjects]); + }, [m.allProjects, m.projectsCounts]); - const filteredProjects = useMemo(() => { - const q = projectQuery.trim().toLowerCase(); - if (!q) return projectsData; - return projectsData.filter((p) => p.name.toLowerCase().includes(q)); - }, [projectQuery, projectsData]); + // Projects cards (mock) + const projectsDataMock: ProjectCard[] = useMemo(() => { + const baseRegion = "Baja California"; + const baseContact = "Operaciones"; + const baseLastSync = "Hace 1 h"; - /* ================= DEVICE CONFIG MOCK ================= */ + const mocks = MOCK_PROJECTS_BY_TYPE[takeType as Exclude] ?? []; + return mocks.map((x) => ({ + name: x.name, + region: baseRegion, + projects: 1, + meters: x.meters ?? 0, + activeAlerts: 0, + lastSync: baseLastSync, + contact: baseContact, + status: "ACTIVO", + })); + }, [takeType]); + + const sidebarProjects = isMockMode ? projectsDataMock : projectsDataReal; + + // Search filtered + const searchFiltered = useMemo(() => { + if (isMockMode) return []; + const q = search.trim().toLowerCase(); + if (!q) return m.filteredMeters; + + return m.filteredMeters.filter((x) => { + return ( + (x.meterName ?? "").toLowerCase().includes(q) || + (x.meterSerialNumber ?? "").toLowerCase().includes(q) || + (x.deviceId ?? "").toLowerCase().includes(q) || + (x.areaName ?? "").toLowerCase().includes(q) + ); + }); + }, [isMockMode, search, m.filteredMeters]); + + // Device config mock const createOrUpdateDevice = async (deviceData: DeviceData): Promise => { return new Promise((resolve) => { setTimeout(() => { @@ -175,45 +172,43 @@ export default function MeterManagement({ }); }; - /* ================= VALIDATION ================= */ + // Validation const validateForm = (): boolean => { - const newErrors: { [key: string]: boolean } = {}; + const next: Record = {}; - 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; + if (!form.meterName.trim()) next["meterName"] = true; + if (!form.meterSerialNumber.trim()) next["meterSerialNumber"] = true; + if (!form.areaName.trim()) next["areaName"] = true; + if (!form.deviceName.trim()) next["deviceName"] = true; + if (!form.protocolType.trim()) next["protocolType"] = true; - 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; + if (!deviceForm["Device ID"] || deviceForm["Device ID"] === 0) next["Device ID"] = true; + if (!deviceForm["Device EUI"].trim()) next["Device EUI"] = true; + if (!deviceForm["Join EUI"].trim()) next["Join EUI"] = true; + if (!deviceForm["AppKey"].trim()) next["AppKey"] = true; - setErrors(newErrors); - return Object.keys(newErrors).length === 0; + setErrors(next); + return Object.keys(next).length === 0; }; - /* ================= CRUD ================= */ + // CRUD const handleSave = async () => { + if (isMockMode) return; if (!validateForm()) return; try { let savedMeter: Meter; if (editingId) { - const meterToUpdate = meters.find((m) => m.id === editingId); + const meterToUpdate = m.meters.find((x) => x.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)) - ); + m.setMeters((prev) => prev.map((x) => (x.id === editingId ? updatedMeter : x))); savedMeter = updatedMeter; } else { const newMeter = await createMeter(form); - setMeters((prev) => [...prev, newMeter]); + m.setMeters((prev) => [...prev, newMeter]); savedMeter = newMeter; } @@ -234,227 +229,67 @@ export default function MeterManagement({ } catch (error) { console.error("Error saving meter:", error); alert( - `Error saving meter: ${ - error instanceof Error ? error.message : "Please try again." - }` - - + `Error saving meter: ${error instanceof Error ? error.message : "Please try again."}` ); - } - }; - // ✅ MISMA lógica de delete, solo sin window.confirm const handleDelete = async () => { + if (isMockMode) return; if (!activeMeter) return; try { await deleteMeter(activeMeter.id); - setMeters((prev) => prev.filter((m) => m.id !== activeMeter.id)); + m.setMeters((prev) => prev.filter((x) => x.id !== activeMeter.id)); setActiveMeter(null); } catch (error) { console.error("Error deleting meter:", error); alert( - `Error deleting meter: ${ - error instanceof Error ? error.message : "Please try again." - }` + `Error deleting meter: ${error instanceof Error ? error.message : "Please try again."}` ); } }; const handleRefresh = () => { - loadMeters(); + m.loadMeters(); setActiveMeter(null); }; - /* ================= SEARCH (CLIENT) ================= */ - const searchFiltered = filteredMeters.filter((m) => { - const q = search.trim().toLowerCase(); - if (!q) return true; + const resetSelection = () => { + setActiveMeter(null); + setSearch(""); + }; - return ( - (m.meterName ?? "").toLowerCase().includes(q) || - (m.meterSerialNumber ?? "").toLowerCase().includes(q) || - (m.deviceId ?? "").toLowerCase().includes(q) || - (m.areaName ?? "").toLowerCase().includes(q) - ); - }); - - /* ================= UI ================= */ return (
{/* SIDEBAR */} - + {/* MAIN */}
- {/* HEADER */}

Meter Management

- {selectedProject - ? `Proyecto: ${selectedProject}` + {isMockMode + ? `Modo demo (${takeType}) - backend pendiente` + : m.selectedProject + ? `Proyecto: ${m.selectedProject}` : "Selecciona un proyecto desde el panel izquierdo"}

@@ -462,8 +297,10 @@ export default function MeterManagement({
- {/* ✅ CAMBIO: antes llamaba handleDelete, ahora abre modal */}
- {/* SEARCH */} setSearch(e.target.value)} - disabled={!selectedProject} + disabled={isMockMode || !m.selectedProject} /> - {/* TABLE */} -
- rowData.areaName || "-", - }, - { - 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 || "-", - }, - { - title: "Device Type", - field: "deviceType", - render: (rowData) => rowData.deviceType || "-", - }, - { - title: "Meter Status", - field: "meterStatus", - render: (rowData) => rowData.meterStatus || "-", - }, - { - title: "Installed Time", - field: "installedTime", - render: (rowData) => rowData.installedTime || "-", - }, - ]} - 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.", - }, - }} - /> -
+ setActiveMeter(row)} + /> - {/* ✅ NUEVO: ConfirmModal para borrar */}
- {/* MODAL */} {showModal && ( -
-
-

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

- - {/* ✅ 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, - 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 -

- )} -
-
- -
- - -
-
-
+ { + setShowModal(false); + setDeviceForm(emptyDeviceData); + setErrors({}); + }} + onSave={handleSave} + /> )}
); } - diff --git a/src/pages/meters/MetersModal.tsx b/src/pages/meters/MetersModal.tsx new file mode 100644 index 0000000..55f0f55 --- /dev/null +++ b/src/pages/meters/MetersModal.tsx @@ -0,0 +1,269 @@ +import type React from "react"; +import type { Meter } from "../../api/meters"; +import type { DeviceData } from "./MeterPage"; + +type Props = { + editingId: string | null; + + form: Omit; + setForm: React.Dispatch>>; + + deviceForm: DeviceData; + setDeviceForm: React.Dispatch>; + + errors: Record; + setErrors: React.Dispatch>>; + + onClose: () => void; + onSave: () => void | Promise; +}; + +export default function MetersModal({ + editingId, + form, + setForm, + deviceForm, + setDeviceForm, + errors, + setErrors, + onClose, + onSave, +}: Props) { + const title = editingId ? "Edit Meter" : "Add Meter"; + + return ( +
+
+

{title}

+ + {/* FORM */} +
+

+ Meter Information +

+ +
+
+ { + 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, 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 CONFIG */} +
+

+ 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

} +
+
+ + {/* ACTIONS */} +
+ + +
+
+
+ ); +} diff --git a/src/pages/meters/MetersSidebar.tsx b/src/pages/meters/MetersSidebar.tsx new file mode 100644 index 0000000..ae5b352 --- /dev/null +++ b/src/pages/meters/MetersSidebar.tsx @@ -0,0 +1,293 @@ +// src/pages/meters/MetersSidebar.tsx +import { useEffect, useMemo, useRef, useState } from "react"; +import { ChevronDown, RefreshCcw, Check } from "lucide-react"; +import type React from "react"; +import type { ProjectCard, TakeType } from "./MeterPage"; + +type Props = { + loadingProjects: boolean; + + takeType: TakeType; + setTakeType: (t: TakeType) => void; + + selectedProject: string; + setSelectedProject: React.Dispatch>; + + isMockMode: boolean; + projects: ProjectCard[]; + + onRefresh: () => void; + refreshDisabled?: boolean; + + allProjects: string[]; + onResetSelection?: () => void; +}; + +type TakeTypeOption = { key: TakeType; label: string }; + +const TAKE_TYPE_OPTIONS: TakeTypeOption[] = [ + { key: "GENERAL", label: "General" }, + { key: "LORA", label: "LoRa" }, + { key: "LORAWAN", label: "LoRaWAN" }, + { key: "GRANDES", label: "Grandes consumidores" }, +]; + +export default function MetersSidebar({ + loadingProjects, + takeType, + setTakeType, + selectedProject, + setSelectedProject, + isMockMode, + projects, + onRefresh, + refreshDisabled, + allProjects, + onResetSelection, +}: Props) { + const [typesMenuOpen, setTypesMenuOpen] = useState(false); + + // para detectar click fuera (igual a tu implementación) + const menuRef = useRef(null); + + useEffect(() => { + const onClickOutside = (e: MouseEvent) => { + if (!menuRef.current) return; + if (!menuRef.current.contains(e.target as Node)) { + setTypesMenuOpen(false); + } + }; + document.addEventListener("mousedown", onClickOutside); + return () => document.removeEventListener("mousedown", onClickOutside); + }, []); + + const takeTypeLabel = useMemo( + () => TAKE_TYPE_OPTIONS.find((o) => o.key === takeType)?.label ?? "General", + [takeType] + ); + + return ( + + ); +} diff --git a/src/pages/meters/MetersTable.tsx b/src/pages/meters/MetersTable.tsx new file mode 100644 index 0000000..9c5ea82 --- /dev/null +++ b/src/pages/meters/MetersTable.tsx @@ -0,0 +1,67 @@ +import MaterialTable from "@material-table/core"; +import type { Meter } from "../../api/meters"; + +type Props = { + data: Meter[]; + isLoading: boolean; + + isMockMode: boolean; + selectedProject: string; + + activeMeter: Meter | null; + onRowClick: (row: Meter) => void; +}; + +export default function MetersTable({ + data, + isLoading, + isMockMode, + selectedProject, + activeMeter, + onRowClick, +}: Props) { + const disabled = isMockMode || !selectedProject; + + return ( +
+ r.areaName || "-" }, + { title: "Account Number", field: "accountNumber", render: (r: any) => r.accountNumber || "-" }, + { title: "User Name", field: "userName", render: (r: any) => r.userName || "-" }, + { title: "User Address", field: "userAddress", render: (r: any) => r.userAddress || "-" }, + { title: "Meter S/N", field: "meterSerialNumber", render: (r: any) => r.meterSerialNumber || "-" }, + { title: "Meter Name", field: "meterName", render: (r: any) => r.meterName || "-" }, + { title: "Protocol Type", field: "protocolType", render: (r: any) => r.protocolType || "-" }, + { title: "Device ID", field: "deviceId", render: (r: any) => r.deviceId || "-" }, + { title: "Device Name", field: "deviceName", render: (r: any) => r.deviceName || "-" }, + ]} + data={disabled ? [] : data} + onRowClick={(_, rowData) => onRowClick(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: isMockMode + ? "Modo demo: selecciona 'General' para ver datos reales." + : !selectedProject + ? "Select a project to view meters." + : isLoading + ? "Loading meters..." + : "No meters found. Click 'Add' to create your first meter.", + }, + }} + /> +
+ ); +} diff --git a/src/pages/meters/useMeters.ts b/src/pages/meters/useMeters.ts new file mode 100644 index 0000000..6ab8641 --- /dev/null +++ b/src/pages/meters/useMeters.ts @@ -0,0 +1,94 @@ +import { useEffect, useMemo, useState } from "react"; +import { fetchMeters, type Meter } from "../../api/meters"; + +type UseMetersArgs = { + initialProject?: string; +}; + +export function useMeters({ initialProject }: UseMetersArgs) { + const [allProjects, setAllProjects] = useState([]); + const [loadingProjects, setLoadingProjects] = useState(true); + + const [selectedProject, setSelectedProject] = useState(initialProject || ""); + + const [meters, setMeters] = useState([]); + const [filteredMeters, setFilteredMeters] = useState([]); + const [loadingMeters, setLoadingMeters] = useState(true); + + const loadMeters = async () => { + setLoadingMeters(true); + setLoadingProjects(true); + + try { + const data = await fetchMeters(); + + const projectsArray = [...new Set(data.map((r) => r.areaName))] + .filter(Boolean) as string[]; + + setAllProjects(projectsArray); + setMeters(data); + + setSelectedProject((prev) => { + if (prev) return prev; + if (initialProject) return initialProject; + return projectsArray[0] ?? ""; + }); + } catch (error) { + console.error("Error loading meters:", error); + setAllProjects([]); + setMeters([]); + setSelectedProject(""); + } finally { + setLoadingMeters(false); + setLoadingProjects(false); + } + }; + + // init + useEffect(() => { + loadMeters(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // keep selectedProject synced if parent changes initialProject + useEffect(() => { + if (initialProject) setSelectedProject(initialProject); + }, [initialProject]); + + // filter by project + useEffect(() => { + if (!selectedProject) { + setFilteredMeters([]); + return; + } + setFilteredMeters(meters.filter((m) => m.areaName === selectedProject)); + }, [selectedProject, meters]); + + const projectsCounts = useMemo(() => { + return meters.reduce>((acc, m) => { + const area = m.areaName ?? "SIN PROYECTO"; + acc[area] = (acc[area] ?? 0) + 1; + return acc; + }, {}); + }, [meters]); + + return { + // loading + loadingProjects, + loadingMeters, + + // projects + allProjects, + projectsCounts, + selectedProject, + setSelectedProject, + + // data + meters, + setMeters, + filteredMeters, + + // actions + loadMeters, + }; +}