Files
GRH/src/pages/concentrators/ConcentratorsPage.tsx
Exteban08 c81a18987f 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>
2026-01-23 10:13:26 +00:00

300 lines
9.7 KiB
TypeScript

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,
type ConcentratorInput,
} from "../../api/concentrators";
import { useConcentrators } from "./useConcentrators";
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;
concentrators: number;
activeAlerts: number;
lastSync: string;
contact: string;
status: ProjectStatus;
};
type User = {
role: "SUPER_ADMIN" | "USER";
project?: string;
};
export default function ConcentratorsPage() {
const currentUser: User = {
role: "SUPER_ADMIN",
project: "CESPT",
};
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 [editingId, setEditingId] = useState<string | null>(null);
const getEmptyForm = (): ConcentratorInput => ({
serialNumber: "",
name: "",
projectId: "",
location: "",
type: "LORA",
status: "ACTIVE",
ipAddress: "",
firmwareVersion: "",
});
const [form, setForm] = useState<ConcentratorInput>(getEmptyForm());
const [errors, setErrors] = useState<Record<string, boolean>>({});
const searchFiltered = useMemo(() => {
if (!c.isGeneral) return [];
return c.filteredConcentrators.filter((row) => {
const q = search.trim().toLowerCase();
if (!q) return true;
const name = (row.name ?? "").toLowerCase();
const sn = (row.serialNumber ?? "").toLowerCase();
return name.includes(q) || sn.includes(q);
});
}, [c.filteredConcentrators, c.isGeneral, search]);
const validateForm = () => {
const next: Record<string, boolean> = {};
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;
};
const handleSave = async () => {
if (!c.isGeneral) return;
if (!validateForm()) return;
try {
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);
setEditingId(null);
setForm(getEmptyForm());
setErrors({});
setActiveConcentrator(null);
} catch (err) {
console.error(err);
alert(`Error saving concentrator: ${err instanceof Error ? err.message : "Please try again."}`);
}
};
const handleDelete = async () => {
if (!c.isGeneral) return;
if (!activeConcentrator) return;
try {
await deleteConcentrator(activeConcentrator.id);
c.setConcentrators((prev) => prev.filter((x) => x.id !== activeConcentrator.id));
setActiveConcentrator(null);
} catch (err) {
console.error(err);
alert(`Error deleting concentrator: ${err instanceof Error ? err.message : "Please try again."}`);
}
};
const openEditModal = () => {
if (!c.isGeneral || !activeConcentrator) return;
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">
<aside className="w-[420px] shrink-0">
<ConcentratorsSidebar
loadingProjects={c.loadingProjects}
sampleView={c.sampleView}
sampleViewLabel={c.sampleViewLabel}
typesMenuOpen={typesMenuOpen}
setTypesMenuOpen={setTypesMenuOpen}
onChangeSampleView={(next: SampleView) => {
c.setSampleView(next);
setTypesMenuOpen(false);
c.setSelectedProject("");
setActiveConcentrator(null);
setSearch("");
}}
selectedProject={c.selectedProject}
onSelectProject={(name) => {
c.setSelectedProject(name);
setActiveConcentrator(null);
setSearch("");
}}
projects={c.projectsData}
onRefresh={c.loadConcentrators}
refreshDisabled={c.loadingProjects || !c.isGeneral}
/>
</aside>
<main className="flex-1 flex flex-col gap-6 min-w-0">
<div
className="rounded-xl shadow p-6 text-white flex justify-between items-center"
style={{ background: "linear-gradient(135deg, #4c5f9e, #2a355d, #566bb8)" }}
>
<div>
<h1 className="text-2xl font-bold">Concentrator Management</h1>
<p className="text-sm text-blue-100">
{!c.isGeneral
? `Vista: ${c.sampleViewLabel} (mock)`
: c.selectedProject
? `Proyecto: ${c.selectedProject}`
: "Selecciona un proyecto desde el panel izquierdo"}
</p>
</div>
<div className="flex gap-3">
<button
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={openEditModal}
disabled={!c.isGeneral || !activeConcentrator}
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60"
>
<Pencil size={16} /> Editar
</button>
<button
onClick={() => setConfirmOpen(true)}
disabled={!c.isGeneral || !activeConcentrator}
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60"
>
<Trash2 size={16} /> Eliminar
</button>
<button
onClick={c.loadConcentrators}
disabled={!c.isGeneral}
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60"
>
<RefreshCcw size={16} /> Actualizar
</button>
</div>
</div>
<input
className="bg-white rounded-lg shadow px-4 py-2 text-sm"
placeholder={c.isGeneral ? "Buscar concentrador..." : "Search disabled in mock views"}
value={search}
onChange={(e) => setSearch(e.target.value)}
disabled={!c.isGeneral || !c.selectedProject}
/>
<div className={!c.isGeneral || !c.selectedProject ? "opacity-60 pointer-events-none" : ""}>
<ConcentratorsTable
isLoading={c.isGeneral ? c.loadingConcentrators : false}
data={c.isGeneral ? searchFiltered : []}
activeRowId={activeConcentrator?.id}
onRowClick={(row) => setActiveConcentrator(row)}
emptyMessage={
!c.isGeneral
? `Vista "${c.sampleViewLabel}" está en modo mock (sin backend todavía).`
: !c.selectedProject
? "Selecciona un proyecto para ver los concentradores."
: c.loadingConcentrators
? "Cargando concentradores..."
: "No hay concentradores. Haz clic en 'Agregar' para crear uno."
}
/>
</div>
<ConfirmModal
open={confirmOpen}
title="Eliminar concentrador"
message={`¿Estás seguro que quieres eliminar "${
activeConcentrator?.name ?? "este concentrador"
}" (${activeConcentrator?.serialNumber ?? "—"})? Esta acción no se puede deshacer.`}
confirmText="Eliminar"
cancelText="Cancelar"
danger
loading={deleting}
onClose={() => setConfirmOpen(false)}
onConfirm={async () => {
if (!c.isGeneral) return;
setDeleting(true);
try {
await handleDelete();
setConfirmOpen(false);
} finally {
setDeleting(false);
}
}}
/>
</main>
{showModal && c.isGeneral && (
<ConcentratorsModal
editingId={editingId}
form={form}
setForm={setForm}
errors={errors}
setErrors={setErrors}
allProjects={c.allProjects}
onClose={() => {
setShowModal(false);
setErrors({});
}}
onSave={handleSave}
/>
)}
</div>
);
}