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