Migrar backend a PostgreSQL + Node.js/Express con nuevas funcionalidades

Backend (water-api/):
- Crear API REST completa con Express + TypeScript
- Implementar autenticación JWT con refresh tokens
- CRUD completo para: projects, concentrators, meters, gateways, devices, users, roles
- Agregar validación con Zod para todas las entidades
- Implementar webhooks para The Things Stack (LoRaWAN)
- Agregar endpoint de lecturas con filtros y resumen de consumo
- Implementar carga masiva de medidores via Excel (.xlsx)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Exteban08
2026-01-23 10:13:26 +00:00
parent 2b5735d78d
commit c81a18987f
92 changed files with 14088 additions and 1866 deletions

View File

@@ -1,381 +1,191 @@
// src/pages/concentrators/ConcentratorsModal.tsx
import type React from "react";
import type { Concentrator } from "../../api/concentrators";
import type { GatewayData } from "./ConcentratorsPage";
import { useEffect, useState } from "react";
import type { ConcentratorInput } from "../../api/concentrators";
import { fetchProjects, type Project } from "../../api/projects";
type Props = {
editingSerial: string | null;
form: Omit<Concentrator, "id">;
setForm: React.Dispatch<React.SetStateAction<Omit<Concentrator, "id">>>;
gatewayForm: GatewayData;
setGatewayForm: React.Dispatch<React.SetStateAction<GatewayData>>;
editingId: string | null;
form: ConcentratorInput;
setForm: React.Dispatch<React.SetStateAction<ConcentratorInput>>;
errors: Record<string, boolean>;
setErrors: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
toDatetimeLocalValue: (value?: string) => string;
fromDatetimeLocalValue: (value: string) => string;
allProjects: string[];
onClose: () => void;
onSave: () => void | Promise<void>;
};
export default function ConcentratorsModal({
editingSerial,
editingId,
form,
setForm,
gatewayForm,
setGatewayForm,
errors,
setErrors,
toDatetimeLocalValue,
fromDatetimeLocalValue,
onClose,
onSave,
}: Props) {
const title = editingSerial ? "Edit Concentrator" : "Add Concentrator";
const title = editingId ? "Editar Concentrador" : "Agregar Concentrador";
const [projects, setProjects] = useState<Project[]>([]);
const [loadingProjects, setLoadingProjects] = useState(true);
useEffect(() => {
const load = async () => {
try {
const data = await fetchProjects();
setProjects(data);
} catch (error) {
console.error("Error loading projects:", error);
} finally {
setLoadingProjects(false);
}
};
load();
}, []);
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-[700px] max-h-[90vh] overflow-y-auto space-y-4">
<div className="bg-white rounded-xl p-6 w-[500px] max-h-[90vh] overflow-y-auto space-y-4">
<h2 className="text-lg font-semibold">{title}</h2>
{/* FORM */}
<div className="space-y-3">
<h3 className="text-sm font-semibold text-gray-700 border-b pb-2">
Concentrator Information
Información del Concentrador
</h3>
<div className="grid grid-cols-2 gap-3">
<div>
<input
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-gray-50"
placeholder="Area Name"
value={form["Area Name"] ?? ""}
disabled
/>
<p className="text-xs text-gray-400 mt-1">
El proyecto seleccionado define el Area Name.
</p>
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">Serial *</label>
<input
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["Device S/N"] ? "border-red-500" : ""
errors["serialNumber"] ? "border-red-500" : ""
}`}
placeholder="Device S/N *"
value={form["Device S/N"]}
placeholder="Número de serie"
value={form.serialNumber}
onChange={(e) => {
setForm({ ...form, "Device S/N": e.target.value });
if (errors["Device S/N"])
setErrors({ ...errors, "Device S/N": false });
setForm({ ...form, serialNumber: e.target.value });
if (errors["serialNumber"]) setErrors({ ...errors, serialNumber: false });
}}
required
/>
{errors["Device S/N"] && (
<p className="text-red-500 text-xs mt-1">
This field is required
</p>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<input
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["Device Name"] ? "border-red-500" : ""
}`}
placeholder="Device Name *"
value={form["Device Name"]}
onChange={(e) => {
setForm({ ...form, "Device Name": e.target.value });
if (errors["Device Name"])
setErrors({ ...errors, "Device Name": false });
}}
required
/>
{errors["Device Name"] && (
<p className="text-red-500 text-xs mt-1">
This field is required
</p>
{errors["serialNumber"] && (
<p className="text-red-500 text-xs mt-1">Campo requerido</p>
)}
</div>
<div>
<select
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={form["Device Status"]}
onChange={(e) =>
setForm({
...form,
"Device Status": e.target.value as any,
})
}
>
<option value="ACTIVE">ACTIVE</option>
<option value="INACTIVE">INACTIVE</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-gray-600 mb-1">Nombre *</label>
<input
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["Operator"] ? "border-red-500" : ""
errors["name"] ? "border-red-500" : ""
}`}
placeholder="Operator *"
value={form["Operator"]}
placeholder="Nombre del concentrador"
value={form.name}
onChange={(e) => {
setForm({ ...form, Operator: e.target.value });
if (errors["Operator"])
setErrors({ ...errors, Operator: false });
setForm({ ...form, name: e.target.value });
if (errors["name"]) setErrors({ ...errors, name: false });
}}
required
/>
{errors["Operator"] && (
<p className="text-red-500 text-xs mt-1">
This field is required
</p>
)}
</div>
<div>
<input
type="date"
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["Installed Time"] ? "border-red-500" : ""
}`}
value={(form["Installed Time"] ?? "").slice(0, 10)}
onChange={(e) => {
setForm({ ...form, "Installed Time": e.target.value });
if (errors["Installed Time"])
setErrors({ ...errors, "Installed Time": false });
}}
required
/>
{errors["Installed Time"] && (
<p className="text-red-500 text-xs mt-1">
This field is required
</p>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<input
type="datetime-local"
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["Device Time"] ? "border-red-500" : ""
}`}
value={toDatetimeLocalValue(form["Device Time"])}
onChange={(e) => {
setForm({
...form,
"Device Time": fromDatetimeLocalValue(e.target.value),
});
if (errors["Device Time"])
setErrors({ ...errors, "Device Time": false });
}}
required
/>
{errors["Device Time"] && (
<p className="text-red-500 text-xs mt-1">
This field is required
</p>
)}
</div>
<div>
<input
type="datetime-local"
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["Communication Time"] ? "border-red-500" : ""
}`}
value={toDatetimeLocalValue(form["Communication Time"])}
onChange={(e) => {
setForm({
...form,
"Communication Time": fromDatetimeLocalValue(e.target.value),
});
if (errors["Communication Time"])
setErrors({ ...errors, "Communication Time": false });
}}
required
/>
{errors["Communication Time"] && (
<p className="text-red-500 text-xs mt-1">
This field is required
</p>
)}
{errors["name"] && <p className="text-red-500 text-xs mt-1">Campo requerido</p>}
</div>
</div>
<div>
<input
<label className="block text-sm text-gray-600 mb-1">Proyecto *</label>
<select
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["Instruction Manual"] ? "border-red-500" : ""
errors["projectId"] ? "border-red-500" : ""
}`}
placeholder="Instruction Manual *"
value={form["Instruction Manual"]}
value={form.projectId}
onChange={(e) => {
setForm({ ...form, "Instruction Manual": e.target.value });
if (errors["Instruction Manual"])
setErrors({ ...errors, "Instruction Manual": false });
setForm({ ...form, projectId: e.target.value });
if (errors["projectId"]) setErrors({ ...errors, projectId: false });
}}
disabled={loadingProjects}
required
/>
{errors["Instruction Manual"] && (
<p className="text-red-500 text-xs mt-1">
This field is required
</p>
>
<option value="">
{loadingProjects ? "Cargando..." : "Selecciona un proyecto"}
</option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
{errors["projectId"] && (
<p className="text-red-500 text-xs mt-1">Selecciona un proyecto</p>
)}
</div>
</div>
{/* GATEWAY */}
<div className="space-y-3 pt-4">
<h3 className="text-sm font-semibold text-gray-700 border-b pb-2">
Gateway Configuration
</h3>
<div className="grid grid-cols-2 gap-3">
<div>
<input
type="number"
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["Gateway ID"] ? "border-red-500" : ""
}`}
placeholder="Gateway ID *"
value={gatewayForm["Gateway ID"] || ""}
onChange={(e) => {
setGatewayForm({
...gatewayForm,
"Gateway ID": parseInt(e.target.value) || 0,
});
if (errors["Gateway ID"])
setErrors({ ...errors, "Gateway ID": false });
}}
required
min={1}
/>
{errors["Gateway ID"] && (
<p className="text-red-500 text-xs mt-1">
This field is required
</p>
)}
</div>
<div>
<input
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["Gateway EUI"] ? "border-red-500" : ""
}`}
placeholder="Gateway EUI *"
value={gatewayForm["Gateway EUI"]}
onChange={(e) => {
setGatewayForm({
...gatewayForm,
"Gateway EUI": e.target.value,
});
if (errors["Gateway EUI"])
setErrors({ ...errors, "Gateway EUI": false });
}}
required
/>
{errors["Gateway EUI"] && (
<p className="text-red-500 text-xs mt-1">
This field is required
</p>
)}
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">Ubicación</label>
<input
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Ubicación del concentrador (opcional)"
value={form.location ?? ""}
onChange={(e) => setForm({ ...form, location: e.target.value || undefined })}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<input
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["Gateway Name"] ? "border-red-500" : ""
}`}
placeholder="Gateway Name *"
value={gatewayForm["Gateway Name"]}
onChange={(e) => {
setGatewayForm({
...gatewayForm,
"Gateway Name": e.target.value,
});
if (errors["Gateway Name"])
setErrors({ ...errors, "Gateway Name": false });
}}
required
/>
{errors["Gateway Name"] && (
<p className="text-red-500 text-xs mt-1">
This field is required
</p>
)}
<label className="block text-sm text-gray-600 mb-1">Tipo *</label>
<select
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={form.type ?? "LORA"}
onChange={(e) => setForm({ ...form, type: e.target.value as "LORA" | "LORAWAN" | "GRANDES" })}
>
<option value="LORA">LoRa</option>
<option value="LORAWAN">LoRaWAN</option>
<option value="GRANDES">Grandes Consumidores</option>
</select>
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">Estado</label>
<select
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={gatewayForm["Antenna Placement"]}
onChange={(e) =>
setGatewayForm({
...gatewayForm,
"Antenna Placement": e.target.value as "Indoor" | "Outdoor",
})
}
value={form.status ?? "ACTIVE"}
onChange={(e) => setForm({ ...form, status: e.target.value })}
>
<option value="Indoor">Indoor</option>
<option value="Outdoor">Outdoor</option>
<option value="ACTIVE">Activo</option>
<option value="INACTIVE">Inactivo</option>
<option value="MAINTENANCE">Mantenimiento</option>
<option value="OFFLINE">Sin conexión</option>
</select>
</div>
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">Dirección IP</label>
<input
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["Gateway Description"] ? "border-red-500" : ""
}`}
placeholder="Gateway Description *"
value={gatewayForm["Gateway Description"]}
onChange={(e) => {
setGatewayForm({
...gatewayForm,
"Gateway Description": e.target.value,
});
if (errors["Gateway Description"])
setErrors({ ...errors, "Gateway Description": false });
}}
required
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="192.168.1.100"
value={form.ipAddress ?? ""}
onChange={(e) => setForm({ ...form, ipAddress: e.target.value || undefined })}
/>
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">Versión de Firmware</label>
<input
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="v1.0.0"
value={form.firmwareVersion ?? ""}
onChange={(e) => setForm({ ...form, firmwareVersion: e.target.value || undefined })}
/>
{errors["Gateway Description"] && (
<p className="text-red-500 text-xs mt-1">
This field is required
</p>
)}
</div>
</div>
{/* ACTIONS */}
<div className="flex justify-end gap-2 pt-3 border-t">
<button
onClick={onClose}
className="px-4 py-2 rounded hover:bg-gray-100"
>
Cancel
<button onClick={onClose} className="px-4 py-2 rounded hover:bg-gray-100">
Cancelar
</button>
<button
onClick={onSave}
className="bg-[#4c5f9e] text-white px-4 py-2 rounded hover:bg-[#3d4d7e]"
>
Save
Guardar
</button>
</div>
</div>

View File

@@ -1,23 +1,23 @@
// src/pages/concentrators/ConcentratorsPage.tsx
import { useMemo, useState } from "react";
import { Plus, Trash2, Pencil, RefreshCcw } from "lucide-react";
import ConfirmModal from "../../components/layout/common/ConfirmModal";
import { createConcentrator, deleteConcentrator, updateConcentrator, type Concentrator } from "../../api/concentrators";
// ✅ hook es named export y pide currentUser
import {
createConcentrator,
deleteConcentrator,
updateConcentrator,
type Concentrator,
type ConcentratorInput,
} from "../../api/concentrators";
import { useConcentrators } from "./useConcentrators";
// ✅ UI pieces
import ConcentratorsSidebar from "./ConcentratorsSidebar";
import ConcentratorsTable from "./ConcentratorsTable";
import ConcentratorsModal from "./ConcentratorsModal";
export type SampleView = "GENERAL" | "LORA" | "LORAWAN" | "GRANDES";
export type ProjectStatus = "ACTIVO" | "INACTIVO";
export type ProjectCard = {
id: string;
name: string;
region: string;
projects: number;
@@ -33,91 +33,53 @@ type User = {
project?: string;
};
export type GatewayData = {
"Gateway ID": number;
"Gateway EUI": string;
"Gateway Name": string;
"Gateway Description": string;
"Antenna Placement": "Indoor" | "Outdoor";
concentratorId?: string;
};
export default function ConcentratorsPage() {
// ✅ Simulación de usuario actual
const currentUser: User = {
role: "SUPER_ADMIN",
project: "CESPT",
};
// ✅ 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<Concentrator | null>(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const [showModal, setShowModal] = useState(false);
const [editingSerial, setEditingSerial] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const getEmptyConcentrator = (): Omit<Concentrator, "id"> => ({
"Area Name": c.selectedProject,
"Device S/N": "",
"Device Name": "",
"Device Time": new Date().toISOString(),
"Device Status": "ACTIVE",
Operator: "",
"Installed Time": new Date().toISOString().slice(0, 10),
"Communication Time": new Date().toISOString(),
"Instruction Manual": "",
const getEmptyForm = (): ConcentratorInput => ({
serialNumber: "",
name: "",
projectId: "",
location: "",
type: "LORA",
status: "ACTIVE",
ipAddress: "",
firmwareVersion: "",
});
const getEmptyGatewayData = (): GatewayData => ({
"Gateway ID": 0,
"Gateway EUI": "",
"Gateway Name": "",
"Gateway Description": "",
"Antenna Placement": "Indoor",
});
const [form, setForm] = useState<ConcentratorInput>(getEmptyForm());
const [errors, setErrors] = useState<Record<string, boolean>>({});
const [form, setForm] = useState<Omit<Concentrator, "id">>(getEmptyConcentrator());
const [gatewayForm, setGatewayForm] = useState<GatewayData>(getEmptyGatewayData());
const [errors, setErrors] = useState<{ [key: string]: boolean }>({});
// ✅ 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();
const name = (row.name ?? "").toLowerCase();
const sn = (row.serialNumber ?? "").toLowerCase();
return name.includes(q) || sn.includes(q);
});
}, [c.filteredConcentrators, c.isGeneral, search]);
// =========================
// CRUD (solo GENERAL)
// =========================
const validateForm = () => {
const next: { [key: string]: boolean } = {};
const next: Record<string, boolean> = {};
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) 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;
if (!form.name.trim()) next["name"] = true;
if (!form.serialNumber.trim()) next["serialNumber"] = true;
if (!form.projectId.trim()) next["projectId"] = true;
setErrors(next);
return Object.keys(next).length === 0;
@@ -128,23 +90,17 @@ export default function ConcentratorsPage() {
if (!validateForm()) return;
try {
if (editingSerial) {
const toUpdate = c.concentrators.find((x) => x["Device S/N"] === editingSerial);
if (!toUpdate) throw new Error("Concentrator not found");
const updated = await updateConcentrator(toUpdate.id, form);
// actualiza en memoria (el hook expone setConcentrators)
c.setConcentrators((prev) => prev.map((x) => (x.id === toUpdate.id ? updated : x)));
if (editingId) {
const updated = await updateConcentrator(editingId, form);
c.setConcentrators((prev) => prev.map((x) => (x.id === editingId ? updated : x)));
} else {
const created = await createConcentrator(form);
c.setConcentrators((prev) => [...prev, created]);
}
setShowModal(false);
setEditingSerial(null);
setForm({ ...getEmptyConcentrator(), "Area Name": c.selectedProject });
setGatewayForm(getEmptyGatewayData());
setEditingId(null);
setForm(getEmptyForm());
setErrors({});
setActiveConcentrator(null);
} catch (err) {
@@ -167,28 +123,32 @@ export default function ConcentratorsPage() {
}
};
// =========================
// Date helpers para modal
// =========================
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}`;
}
const openEditModal = () => {
if (!c.isGeneral || !activeConcentrator) return;
function fromDatetimeLocalValue(value: string) {
if (!value) return "";
const d = new Date(value);
if (Number.isNaN(d.getTime())) return "";
return d.toISOString();
}
setEditingId(activeConcentrator.id);
setForm({
serialNumber: activeConcentrator.serialNumber,
name: activeConcentrator.name,
projectId: activeConcentrator.projectId,
location: activeConcentrator.location ?? "",
type: activeConcentrator.type ?? "LORA",
status: activeConcentrator.status,
ipAddress: activeConcentrator.ipAddress ?? "",
firmwareVersion: activeConcentrator.firmwareVersion ?? "",
});
setErrors({});
setShowModal(true);
};
const openCreateModal = () => {
if (!c.isGeneral) return;
setForm(getEmptyForm());
setErrors({});
setEditingId(null);
setShowModal(true);
};
return (
<div className="flex gap-6 p-6 w-full bg-gray-100">
@@ -202,8 +162,6 @@ export default function ConcentratorsPage() {
onChangeSampleView={(next: SampleView) => {
c.setSampleView(next);
setTypesMenuOpen(false);
// resets UI
c.setSelectedProject("");
setActiveConcentrator(null);
setSearch("");
@@ -238,46 +196,15 @@ export default function ConcentratorsPage() {
<div className="flex gap-3">
<button
onClick={() => {
if (!c.isGeneral) return;
if (!c.selectedProject) return;
setForm({ ...getEmptyConcentrator(), "Area Name": c.selectedProject });
setGatewayForm(getEmptyGatewayData());
setErrors({});
setEditingSerial(null);
setShowModal(true);
}}
disabled={!c.isGeneral || !c.selectedProject}
onClick={openCreateModal}
disabled={!c.isGeneral || c.allProjects.length === 0}
className="flex items-center gap-2 px-4 py-2 bg-white text-[#4c5f9e] rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
<Plus size={16} /> Agregar
</button>
<button
onClick={() => {
if (!c.isGeneral) return;
if (!activeConcentrator) return;
const a = activeConcentrator;
setEditingSerial(a["Device S/N"]);
setForm({
"Area Name": a["Area Name"],
"Device S/N": a["Device S/N"],
"Device Name": a["Device Name"],
"Device Time": a["Device Time"],
"Device Status": a["Device Status"],
Operator: a["Operator"],
"Installed Time": a["Installed Time"],
"Communication Time": a["Communication Time"],
"Instruction Manual": a["Instruction Manual"],
});
setGatewayForm(getEmptyGatewayData());
setErrors({});
setShowModal(true);
}}
onClick={openEditModal}
disabled={!c.isGeneral || !activeConcentrator}
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60"
>
@@ -304,7 +231,7 @@ export default function ConcentratorsPage() {
<input
className="bg-white rounded-lg shadow px-4 py-2 text-sm"
placeholder={c.isGeneral ? "Search concentrator..." : "Search disabled in mock views"}
placeholder={c.isGeneral ? "Buscar concentrador..." : "Search disabled in mock views"}
value={search}
onChange={(e) => setSearch(e.target.value)}
disabled={!c.isGeneral || !c.selectedProject}
@@ -320,10 +247,10 @@ export default function ConcentratorsPage() {
!c.isGeneral
? `Vista "${c.sampleViewLabel}" está en modo mock (sin backend todavía).`
: !c.selectedProject
? "Select a project to view concentrators."
? "Selecciona un proyecto para ver los concentradores."
: c.loadingConcentrators
? "Loading concentrators..."
: "No concentrators found. Click 'Add' to create your first concentrator."
? "Cargando concentradores..."
: "No hay concentradores. Haz clic en 'Agregar' para crear uno."
}
/>
</div>
@@ -332,8 +259,8 @@ export default function ConcentratorsPage() {
open={confirmOpen}
title="Eliminar concentrador"
message={`¿Estás seguro que quieres eliminar "${
activeConcentrator?.["Device Name"] ?? "este concentrador"
}"? Esta acción no se puede deshacer.`}
activeConcentrator?.name ?? "este concentrador"
}" (${activeConcentrator?.serialNumber ?? "—"})? Esta acción no se puede deshacer.`}
confirmText="Eliminar"
cancelText="Cancelar"
danger
@@ -354,18 +281,14 @@ export default function ConcentratorsPage() {
{showModal && c.isGeneral && (
<ConcentratorsModal
editingSerial={editingSerial}
editingId={editingId}
form={form}
setForm={setForm}
gatewayForm={gatewayForm}
setGatewayForm={setGatewayForm}
errors={errors}
setErrors={setErrors}
toDatetimeLocalValue={toDatetimeLocalValue}
fromDatetimeLocalValue={fromDatetimeLocalValue}
allProjects={c.allProjects}
onClose={() => {
setShowModal(false);
setGatewayForm(getEmptyGatewayData());
setErrors({});
}}
onSave={handleSave}

View File

@@ -59,7 +59,9 @@ export default function ConcentratorsSidebar({
Tipo: <span className="font-semibold">{sampleViewLabel}</span>
{" • "}
Seleccionado:{" "}
<span className="font-semibold">{selectedProject || "—"}</span>
<span className="font-semibold">
{projects.find((p) => p.id === selectedProject)?.name || "—"}
</span>
</p>
</div>
@@ -132,12 +134,12 @@ export default function ConcentratorsSidebar({
</div>
) : (
projects.map((p) => {
const active = p.name === selectedProject;
const active = p.id === selectedProject;
return (
<div
key={p.name}
onClick={() => onSelectProject(p.name)}
key={p.id}
onClick={() => onSelectProject(p.id)}
className={[
"rounded-xl border p-4 transition cursor-pointer",
active
@@ -211,7 +213,7 @@ export default function ConcentratorsSidebar({
].join(" ")}
onClick={(e) => {
e.stopPropagation();
onSelectProject(p.name);
onSelectProject(p.id);
}}
>
{active ? "Seleccionado" : "Seleccionar"}

View File

@@ -23,45 +23,69 @@ export default function ConcentratorsTable({
isLoading={isLoading}
columns={[
{
title: "Device Name",
field: "Device Name",
render: (rowData: any) => rowData["Device Name"] || "-",
title: "Serial",
field: "serialNumber",
render: (rowData: Concentrator) => rowData.serialNumber || "-",
},
{
title: "Device S/N",
field: "Device S/N",
render: (rowData: any) => rowData["Device S/N"] || "-",
title: "Nombre",
field: "name",
render: (rowData: Concentrator) => rowData.name || "-",
},
{
title: "Device Status",
field: "Device Status",
render: (rowData: any) => (
title: "Tipo",
field: "type",
render: (rowData: Concentrator) => {
const typeLabels: Record<string, string> = {
LORA: "LoRa",
LORAWAN: "LoRaWAN",
GRANDES: "Grandes Consumidores",
};
const typeColors: Record<string, string> = {
LORA: "text-green-600 border-green-600",
LORAWAN: "text-purple-600 border-purple-600",
GRANDES: "text-orange-600 border-orange-600",
};
const type = rowData.type || "LORA";
return (
<span
className={`px-3 py-1 rounded-full text-xs font-semibold border ${typeColors[type] || "text-gray-600 border-gray-600"}`}
>
{typeLabels[type] || type}
</span>
);
},
},
{
title: "Estado",
field: "status",
render: (rowData: Concentrator) => (
<span
className={`px-3 py-1 rounded-full text-xs font-semibold border ${
rowData["Device Status"] === "ACTIVE"
rowData.status === "ACTIVE"
? "text-blue-600 border-blue-600"
: "text-red-600 border-red-600"
}`}
>
{rowData["Device Status"] || "-"}
{rowData.status || "-"}
</span>
),
},
{
title: "Operator",
field: "Operator",
render: (rowData: any) => rowData["Operator"] || "-",
title: "Ubicación",
field: "location",
render: (rowData: Concentrator) => rowData.location || "-",
},
{
title: "Area Name",
field: "Area Name",
render: (rowData: any) => rowData["Area Name"] || "-",
title: "IP",
field: "ipAddress",
render: (rowData: Concentrator) => rowData.ipAddress || "-",
},
{
title: "Installed Time",
field: "Installed Time",
type: "date",
render: (rowData: any) => rowData["Installed Time"] || "-",
title: "Última Comunicación",
field: "lastCommunication",
type: "datetime",
render: (rowData: Concentrator) => rowData.lastCommunication ? new Date(rowData.lastCommunication).toLocaleString() : "-",
},
]}
data={data}

View File

@@ -3,6 +3,7 @@ import {
fetchConcentrators,
type Concentrator,
} from "../../api/concentrators";
import { fetchProjects, type Project } from "../../api/projects";
import type { ProjectCard, SampleView } from "./ConcentratorsPage";
type User = {
@@ -16,6 +17,7 @@ export function useConcentrators(currentUser: User) {
const [loadingProjects, setLoadingProjects] = useState(true);
const [loadingConcentrators, setLoadingConcentrators] = useState(true);
const [projects, setProjects] = useState<Project[]>([]);
const [allProjects, setAllProjects] = useState<string[]>([]);
const [selectedProject, setSelectedProject] = useState("");
@@ -51,58 +53,49 @@ export function useConcentrators(currentUser: User) {
[allProjects, currentUser.role, currentUser.project]
);
const loadConcentrators = async () => {
if (!isGeneral) return;
setLoadingConcentrators(true);
const loadProjects = async () => {
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);
const projectsData = await fetchProjects();
setProjects(projectsData);
const projectIds = projectsData.map((p) => p.id);
setAllProjects(projectIds);
setSelectedProject((prev) => {
if (prev) return prev;
if (currentUser.role !== "SUPER_ADMIN" && currentUser.project) {
return currentUser.project;
}
return projectsArray[0] ?? "";
return projectIds[0] ?? "";
});
} catch (err) {
console.error("Error loading concentrators:", err);
console.error("Error loading projects:", err);
setProjects([]);
setAllProjects([]);
setConcentrators([]);
setSelectedProject("");
} finally {
setLoadingConcentrators(false);
setLoadingProjects(false);
}
};
// init
const loadConcentrators = async () => {
if (!isGeneral) return;
setLoadingConcentrators(true);
try {
const data = await fetchConcentrators();
setConcentrators(data);
} catch (err) {
console.error("Error loading concentrators:", err);
setConcentrators([]);
} finally {
setLoadingConcentrators(false);
}
};
// init - load projects and concentrators
useEffect(() => {
loadProjects();
loadConcentrators();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -110,6 +103,7 @@ export function useConcentrators(currentUser: User) {
// view changes
useEffect(() => {
if (isGeneral) {
loadProjects();
loadConcentrators();
} else {
setLoadingProjects(false);
@@ -136,7 +130,7 @@ export function useConcentrators(currentUser: User) {
if (selectedProject) {
setFilteredConcentrators(
concentrators.filter((c) => c["Area Name"] === selectedProject)
concentrators.filter((c) => c.projectId === selectedProject)
);
} else {
setFilteredConcentrators(concentrators);
@@ -146,8 +140,13 @@ export function useConcentrators(currentUser: User) {
// sidebar cards (general)
const projectsDataGeneral: ProjectCard[] = useMemo(() => {
const counts = concentrators.reduce<Record<string, number>>((acc, c) => {
const area = c["Area Name"] ?? "SIN PROYECTO";
acc[area] = (acc[area] ?? 0) + 1;
const project = c.projectId ?? "SIN PROYECTO";
acc[project] = (acc[project] ?? 0) + 1;
return acc;
}, {});
const projectNameMap = projects.reduce<Record<string, string>>((acc, p) => {
acc[p.id] = p.name;
return acc;
}, {});
@@ -155,17 +154,18 @@ export function useConcentrators(currentUser: User) {
const baseContact = "Operaciones";
const baseLastSync = "Hace 1 h";
return visibleProjects.map((name) => ({
name,
return visibleProjects.map((projectId) => ({
id: projectId,
name: projectNameMap[projectId] ?? projectId,
region: baseRegion,
projects: 1,
concentrators: counts[name] ?? 0,
concentrators: counts[projectId] ?? 0,
activeAlerts: 0,
lastSync: baseLastSync,
contact: baseContact,
status: "ACTIVO",
status: "ACTIVO" as const,
}));
}, [concentrators, visibleProjects]);
}, [concentrators, visibleProjects, projects]);
// sidebar cards (mock)
const projectsDataMock: Record<Exclude<SampleView, "GENERAL">, ProjectCard[]> =
@@ -173,6 +173,7 @@ export function useConcentrators(currentUser: User) {
() => ({
LORA: [
{
id: "mock-lora-centro",
name: "LoRa - Zona Centro",
region: "Baja California",
projects: 1,
@@ -183,6 +184,7 @@ export function useConcentrators(currentUser: User) {
status: "ACTIVO",
},
{
id: "mock-lora-este",
name: "LoRa - Zona Este",
region: "Baja California",
projects: 1,
@@ -195,6 +197,7 @@ export function useConcentrators(currentUser: User) {
],
LORAWAN: [
{
id: "mock-lorawan-industrial",
name: "LoRaWAN - Industrial",
region: "Baja California",
projects: 1,
@@ -207,6 +210,7 @@ export function useConcentrators(currentUser: User) {
],
GRANDES: [
{
id: "mock-grandes-convenios",
name: "Grandes - Convenios",
region: "Baja California",
projects: 1,