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>
300 lines
9.7 KiB
TypeScript
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>
|
|
);
|
|
}
|