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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user