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,15 +1,13 @@
import type React from "react";
import type { Meter } from "../../api/meters";
import type { DeviceData } from "./MeterPage";
import { useEffect, useState } from "react";
import type { MeterInput } from "../../api/meters";
import { fetchConcentrators, type Concentrator } from "../../api/concentrators";
type Props = {
editingId: string | null;
form: Omit<Meter, "id">;
setForm: React.Dispatch<React.SetStateAction<Omit<Meter, "id">>>;
deviceForm: DeviceData;
setDeviceForm: React.Dispatch<React.SetStateAction<DeviceData>>;
form: MeterInput;
setForm: React.Dispatch<React.SetStateAction<MeterInput>>;
errors: Record<string, boolean>;
setErrors: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
@@ -22,245 +20,183 @@ export default function MetersModal({
editingId,
form,
setForm,
deviceForm,
setDeviceForm,
errors,
setErrors,
onClose,
onSave,
}: Props) {
const title = editingId ? "Edit Meter" : "Add Meter";
const title = editingId ? "Editar Medidor" : "Agregar Medidor";
const [concentrators, setConcentrators] = useState<Concentrator[]>([]);
const [loadingConcentrators, setLoadingConcentrators] = useState(true);
// Load concentrators for the dropdown
useEffect(() => {
const load = async () => {
try {
const data = await fetchConcentrators();
setConcentrators(data);
} catch (error) {
console.error("Error loading concentrators:", error);
} finally {
setLoadingConcentrators(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">
Meter Information
Información del Medidor
</h3>
<div className="grid grid-cols-2 gap-3">
<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["areaName"] ? "border-red-500" : ""
errors["serialNumber"] ? "border-red-500" : ""
}`}
placeholder="Area Name *"
value={form.areaName}
placeholder="Número de serie"
value={form.serialNumber}
onChange={(e) => {
setForm({ ...form, areaName: e.target.value });
if (errors["areaName"]) setErrors({ ...errors, areaName: false });
setForm({ ...form, serialNumber: e.target.value });
if (errors["serialNumber"]) setErrors({ ...errors, serialNumber: false });
}}
required
/>
{errors["areaName"] && <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"
placeholder="Account Number (optional)"
value={form.accountNumber ?? ""}
onChange={(e) =>
setForm({ ...form, accountNumber: e.target.value || null })
}
/>
</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"
placeholder="User Name (optional)"
value={form.userName ?? ""}
onChange={(e) =>
setForm({ ...form, userName: e.target.value || null })
}
/>
</div>
<div>
<input
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="User Address (optional)"
value={form.userAddress ?? ""}
onChange={(e) =>
setForm({ ...form, userAddress: e.target.value || null })
}
/>
</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["meterSerialNumber"] ? "border-red-500" : ""
}`}
placeholder="Meter S/N *"
value={form.meterSerialNumber}
onChange={(e) => {
setForm({ ...form, meterSerialNumber: e.target.value });
if (errors["meterSerialNumber"])
setErrors({ ...errors, meterSerialNumber: false });
}}
required
/>
{errors["meterSerialNumber"] && (
<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>
<input
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["meterName"] ? "border-red-500" : ""
}`}
placeholder="Meter Name *"
value={form.meterName}
onChange={(e) => {
setForm({ ...form, meterName: e.target.value });
if (errors["meterName"]) setErrors({ ...errors, meterName: false });
}}
required
/>
{errors["meterName"] && <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["protocolType"] ? "border-red-500" : ""
}`}
placeholder="Protocol Type *"
value={form.protocolType}
onChange={(e) => {
setForm({ ...form, protocolType: e.target.value });
if (errors["protocolType"]) setErrors({ ...errors, protocolType: false });
}}
required
/>
{errors["protocolType"] && <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">Meter ID</label>
<input
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Device ID (optional)"
value={form.deviceId ?? ""}
onChange={(e) => setForm({ ...form, deviceId: e.target.value || "" })}
placeholder="ID del medidor (opcional)"
value={form.meterId ?? ""}
onChange={(e) => setForm({ ...form, meterId: e.target.value || undefined })}
/>
</div>
</div>
<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["deviceName"] ? "border-red-500" : ""
errors["name"] ? "border-red-500" : ""
}`}
placeholder="Device Name *"
value={form.deviceName}
placeholder="Nombre del medidor"
value={form.name}
onChange={(e) => {
setForm({ ...form, deviceName: e.target.value });
if (errors["deviceName"]) setErrors({ ...errors, deviceName: false });
setForm({ ...form, name: e.target.value });
if (errors["name"]) setErrors({ ...errors, name: false });
}}
required
/>
{errors["deviceName"] && <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>
{/* DEVICE CONFIG */}
<div className="space-y-3 pt-4">
<h3 className="text-sm font-semibold text-gray-700 border-b pb-2">
Device Configuration
</h3>
<div>
<label className="block text-sm text-gray-600 mb-1">Concentrador *</label>
<select
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["concentratorId"] ? "border-red-500" : ""
}`}
value={form.concentratorId}
onChange={(e) => {
setForm({ ...form, concentratorId: e.target.value });
if (errors["concentratorId"]) setErrors({ ...errors, concentratorId: false });
}}
disabled={loadingConcentrators}
required
>
<option value="">
{loadingConcentrators ? "Cargando..." : "Selecciona un concentrador"}
</option>
{concentrators.map((c) => (
<option key={c.id} value={c.id}>
{c.name} ({c.serialNumber})
</option>
))}
</select>
{errors["concentratorId"] && (
<p className="text-red-500 text-xs mt-1">Selecciona un concentrador</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 medidor (opcional)"
value={form.location ?? ""}
onChange={(e) => setForm({ ...form, location: e.target.value || undefined })}
/>
</div>
<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["Device ID"] ? "border-red-500" : ""
}`}
placeholder="Device ID *"
value={deviceForm["Device ID"] || ""}
onChange={(e) => {
setDeviceForm({ ...deviceForm, "Device ID": parseInt(e.target.value) || 0 });
if (errors["Device ID"]) setErrors({ ...errors, "Device ID": false });
}}
required
min={1}
/>
{errors["Device ID"] && <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 })}
>
<option value="LORA">LoRa</option>
<option value="LORAWAN">LoRaWAN</option>
<option value="GRANDES">Grandes Consumidores</option>
</select>
</div>
<div>
<input
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["Device EUI"] ? "border-red-500" : ""
}`}
placeholder="Device EUI *"
value={deviceForm["Device EUI"]}
onChange={(e) => {
setDeviceForm({ ...deviceForm, "Device EUI": e.target.value });
if (errors["Device EUI"]) setErrors({ ...errors, "Device EUI": false });
}}
required
/>
{errors["Device EUI"] && <p className="text-red-500 text-xs mt-1">This field is required</p>}
<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={form.status ?? "ACTIVE"}
onChange={(e) => setForm({ ...form, status: e.target.value })}
>
<option value="ACTIVE">Activo</option>
<option value="INACTIVE">Inactivo</option>
<option value="MAINTENANCE">Mantenimiento</option>
<option value="FAULTY">Averiado</option>
<option value="REPLACED">Reemplazado</option>
</select>
</div>
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">Fecha de Instalación</label>
<input
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["Join EUI"] ? "border-red-500" : ""
}`}
placeholder="Join EUI *"
value={deviceForm["Join EUI"]}
onChange={(e) => {
setDeviceForm({ ...deviceForm, "Join EUI": e.target.value });
if (errors["Join EUI"]) setErrors({ ...errors, "Join EUI": false });
}}
required
type="date"
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={form.installationDate?.split("T")[0] ?? ""}
onChange={(e) =>
setForm({
...form,
installationDate: e.target.value ? new Date(e.target.value).toISOString() : undefined,
})
}
/>
{errors["Join EUI"] && <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["AppKey"] ? "border-red-500" : ""
}`}
placeholder="AppKey *"
value={deviceForm["AppKey"]}
onChange={(e) => {
setDeviceForm({ ...deviceForm, AppKey: e.target.value });
if (errors["AppKey"]) setErrors({ ...errors, AppKey: false });
}}
required
/>
{errors["AppKey"] && <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
Cancelar
</button>
<button
onClick={onSave}
className="bg-[#4c5f9e] text-white px-4 py-2 rounded hover:bg-[#3d4d7e]"
>
Save
Guardar
</button>
</div>
</div>