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,381 +1,191 @@
|
||||
// src/pages/concentrators/ConcentratorsModal.tsx
|
||||
import type React from "react";
|
||||
import type { Concentrator } from "../../api/concentrators";
|
||||
import type { GatewayData } from "./ConcentratorsPage";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ConcentratorInput } from "../../api/concentrators";
|
||||
import { fetchProjects, type Project } from "../../api/projects";
|
||||
|
||||
type Props = {
|
||||
editingSerial: string | null;
|
||||
|
||||
form: Omit<Concentrator, "id">;
|
||||
setForm: React.Dispatch<React.SetStateAction<Omit<Concentrator, "id">>>;
|
||||
|
||||
gatewayForm: GatewayData;
|
||||
setGatewayForm: React.Dispatch<React.SetStateAction<GatewayData>>;
|
||||
|
||||
editingId: string | null;
|
||||
form: ConcentratorInput;
|
||||
setForm: React.Dispatch<React.SetStateAction<ConcentratorInput>>;
|
||||
errors: Record<string, boolean>;
|
||||
setErrors: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
|
||||
|
||||
toDatetimeLocalValue: (value?: string) => string;
|
||||
fromDatetimeLocalValue: (value: string) => string;
|
||||
|
||||
allProjects: string[];
|
||||
onClose: () => void;
|
||||
onSave: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
export default function ConcentratorsModal({
|
||||
editingSerial,
|
||||
editingId,
|
||||
form,
|
||||
setForm,
|
||||
gatewayForm,
|
||||
setGatewayForm,
|
||||
errors,
|
||||
setErrors,
|
||||
toDatetimeLocalValue,
|
||||
fromDatetimeLocalValue,
|
||||
onClose,
|
||||
onSave,
|
||||
}: Props) {
|
||||
const title = editingSerial ? "Edit Concentrator" : "Add Concentrator";
|
||||
const title = editingId ? "Editar Concentrador" : "Agregar Concentrador";
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [loadingProjects, setLoadingProjects] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const data = await fetchProjects();
|
||||
setProjects(data);
|
||||
} catch (error) {
|
||||
console.error("Error loading projects:", error);
|
||||
} finally {
|
||||
setLoadingProjects(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">
|
||||
Concentrator Information
|
||||
Información del Concentrador
|
||||
</h3>
|
||||
|
||||
<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 bg-gray-50"
|
||||
placeholder="Area Name"
|
||||
value={form["Area Name"] ?? ""}
|
||||
disabled
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
El proyecto seleccionado define el Area Name.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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["Device S/N"] ? "border-red-500" : ""
|
||||
errors["serialNumber"] ? "border-red-500" : ""
|
||||
}`}
|
||||
placeholder="Device S/N *"
|
||||
value={form["Device S/N"]}
|
||||
placeholder="Número de serie"
|
||||
value={form.serialNumber}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, "Device S/N": e.target.value });
|
||||
if (errors["Device S/N"])
|
||||
setErrors({ ...errors, "Device S/N": false });
|
||||
setForm({ ...form, serialNumber: e.target.value });
|
||||
if (errors["serialNumber"]) setErrors({ ...errors, serialNumber: false });
|
||||
}}
|
||||
required
|
||||
/>
|
||||
{errors["Device S/N"] && (
|
||||
<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["Device Name"] ? "border-red-500" : ""
|
||||
}`}
|
||||
placeholder="Device Name *"
|
||||
value={form["Device Name"]}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, "Device Name": e.target.value });
|
||||
if (errors["Device Name"])
|
||||
setErrors({ ...errors, "Device Name": false });
|
||||
}}
|
||||
required
|
||||
/>
|
||||
{errors["Device Name"] && (
|
||||
<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>
|
||||
<select
|
||||
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
value={form["Device Status"]}
|
||||
onChange={(e) =>
|
||||
setForm({
|
||||
...form,
|
||||
"Device Status": e.target.value as any,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="ACTIVE">ACTIVE</option>
|
||||
<option value="INACTIVE">INACTIVE</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<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["Operator"] ? "border-red-500" : ""
|
||||
errors["name"] ? "border-red-500" : ""
|
||||
}`}
|
||||
placeholder="Operator *"
|
||||
value={form["Operator"]}
|
||||
placeholder="Nombre del concentrador"
|
||||
value={form.name}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, Operator: e.target.value });
|
||||
if (errors["Operator"])
|
||||
setErrors({ ...errors, Operator: false });
|
||||
setForm({ ...form, name: e.target.value });
|
||||
if (errors["name"]) setErrors({ ...errors, name: false });
|
||||
}}
|
||||
required
|
||||
/>
|
||||
{errors["Operator"] && (
|
||||
<p className="text-red-500 text-xs mt-1">
|
||||
This field is required
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input
|
||||
type="date"
|
||||
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||
errors["Installed Time"] ? "border-red-500" : ""
|
||||
}`}
|
||||
value={(form["Installed Time"] ?? "").slice(0, 10)}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, "Installed Time": e.target.value });
|
||||
if (errors["Installed Time"])
|
||||
setErrors({ ...errors, "Installed Time": false });
|
||||
}}
|
||||
required
|
||||
/>
|
||||
{errors["Installed Time"] && (
|
||||
<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
|
||||
type="datetime-local"
|
||||
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||
errors["Device Time"] ? "border-red-500" : ""
|
||||
}`}
|
||||
value={toDatetimeLocalValue(form["Device Time"])}
|
||||
onChange={(e) => {
|
||||
setForm({
|
||||
...form,
|
||||
"Device Time": fromDatetimeLocalValue(e.target.value),
|
||||
});
|
||||
if (errors["Device Time"])
|
||||
setErrors({ ...errors, "Device Time": false });
|
||||
}}
|
||||
required
|
||||
/>
|
||||
{errors["Device Time"] && (
|
||||
<p className="text-red-500 text-xs mt-1">
|
||||
This field is required
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input
|
||||
type="datetime-local"
|
||||
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||
errors["Communication Time"] ? "border-red-500" : ""
|
||||
}`}
|
||||
value={toDatetimeLocalValue(form["Communication Time"])}
|
||||
onChange={(e) => {
|
||||
setForm({
|
||||
...form,
|
||||
"Communication Time": fromDatetimeLocalValue(e.target.value),
|
||||
});
|
||||
if (errors["Communication Time"])
|
||||
setErrors({ ...errors, "Communication Time": false });
|
||||
}}
|
||||
required
|
||||
/>
|
||||
{errors["Communication Time"] && (
|
||||
<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>
|
||||
|
||||
<div>
|
||||
<input
|
||||
<label className="block text-sm text-gray-600 mb-1">Proyecto *</label>
|
||||
<select
|
||||
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||
errors["Instruction Manual"] ? "border-red-500" : ""
|
||||
errors["projectId"] ? "border-red-500" : ""
|
||||
}`}
|
||||
placeholder="Instruction Manual *"
|
||||
value={form["Instruction Manual"]}
|
||||
value={form.projectId}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, "Instruction Manual": e.target.value });
|
||||
if (errors["Instruction Manual"])
|
||||
setErrors({ ...errors, "Instruction Manual": false });
|
||||
setForm({ ...form, projectId: e.target.value });
|
||||
if (errors["projectId"]) setErrors({ ...errors, projectId: false });
|
||||
}}
|
||||
disabled={loadingProjects}
|
||||
required
|
||||
/>
|
||||
{errors["Instruction Manual"] && (
|
||||
<p className="text-red-500 text-xs mt-1">
|
||||
This field is required
|
||||
</p>
|
||||
>
|
||||
<option value="">
|
||||
{loadingProjects ? "Cargando..." : "Selecciona un proyecto"}
|
||||
</option>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors["projectId"] && (
|
||||
<p className="text-red-500 text-xs mt-1">Selecciona un proyecto</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GATEWAY */}
|
||||
<div className="space-y-3 pt-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 border-b pb-2">
|
||||
Gateway Configuration
|
||||
</h3>
|
||||
|
||||
<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["Gateway ID"] ? "border-red-500" : ""
|
||||
}`}
|
||||
placeholder="Gateway ID *"
|
||||
value={gatewayForm["Gateway ID"] || ""}
|
||||
onChange={(e) => {
|
||||
setGatewayForm({
|
||||
...gatewayForm,
|
||||
"Gateway ID": parseInt(e.target.value) || 0,
|
||||
});
|
||||
if (errors["Gateway ID"])
|
||||
setErrors({ ...errors, "Gateway ID": false });
|
||||
}}
|
||||
required
|
||||
min={1}
|
||||
/>
|
||||
{errors["Gateway ID"] && (
|
||||
<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["Gateway EUI"] ? "border-red-500" : ""
|
||||
}`}
|
||||
placeholder="Gateway EUI *"
|
||||
value={gatewayForm["Gateway EUI"]}
|
||||
onChange={(e) => {
|
||||
setGatewayForm({
|
||||
...gatewayForm,
|
||||
"Gateway EUI": e.target.value,
|
||||
});
|
||||
if (errors["Gateway EUI"])
|
||||
setErrors({ ...errors, "Gateway EUI": false });
|
||||
}}
|
||||
required
|
||||
/>
|
||||
{errors["Gateway EUI"] && (
|
||||
<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">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 concentrador (opcional)"
|
||||
value={form.location ?? ""}
|
||||
onChange={(e) => setForm({ ...form, location: e.target.value || undefined })}
|
||||
/>
|
||||
</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["Gateway Name"] ? "border-red-500" : ""
|
||||
}`}
|
||||
placeholder="Gateway Name *"
|
||||
value={gatewayForm["Gateway Name"]}
|
||||
onChange={(e) => {
|
||||
setGatewayForm({
|
||||
...gatewayForm,
|
||||
"Gateway Name": e.target.value,
|
||||
});
|
||||
if (errors["Gateway Name"])
|
||||
setErrors({ ...errors, "Gateway Name": false });
|
||||
}}
|
||||
required
|
||||
/>
|
||||
{errors["Gateway Name"] && (
|
||||
<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 as "LORA" | "LORAWAN" | "GRANDES" })}
|
||||
>
|
||||
<option value="LORA">LoRa</option>
|
||||
<option value="LORAWAN">LoRaWAN</option>
|
||||
<option value="GRANDES">Grandes Consumidores</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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={gatewayForm["Antenna Placement"]}
|
||||
onChange={(e) =>
|
||||
setGatewayForm({
|
||||
...gatewayForm,
|
||||
"Antenna Placement": e.target.value as "Indoor" | "Outdoor",
|
||||
})
|
||||
}
|
||||
value={form.status ?? "ACTIVE"}
|
||||
onChange={(e) => setForm({ ...form, status: e.target.value })}
|
||||
>
|
||||
<option value="Indoor">Indoor</option>
|
||||
<option value="Outdoor">Outdoor</option>
|
||||
<option value="ACTIVE">Activo</option>
|
||||
<option value="INACTIVE">Inactivo</option>
|
||||
<option value="MAINTENANCE">Mantenimiento</option>
|
||||
<option value="OFFLINE">Sin conexión</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600 mb-1">Dirección IP</label>
|
||||
<input
|
||||
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||
errors["Gateway Description"] ? "border-red-500" : ""
|
||||
}`}
|
||||
placeholder="Gateway Description *"
|
||||
value={gatewayForm["Gateway Description"]}
|
||||
onChange={(e) => {
|
||||
setGatewayForm({
|
||||
...gatewayForm,
|
||||
"Gateway Description": e.target.value,
|
||||
});
|
||||
if (errors["Gateway Description"])
|
||||
setErrors({ ...errors, "Gateway Description": false });
|
||||
}}
|
||||
required
|
||||
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="192.168.1.100"
|
||||
value={form.ipAddress ?? ""}
|
||||
onChange={(e) => setForm({ ...form, ipAddress: e.target.value || undefined })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600 mb-1">Versión de Firmware</label>
|
||||
<input
|
||||
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="v1.0.0"
|
||||
value={form.firmwareVersion ?? ""}
|
||||
onChange={(e) => setForm({ ...form, firmwareVersion: e.target.value || undefined })}
|
||||
/>
|
||||
{errors["Gateway Description"] && (
|
||||
<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
|
||||
<button onClick={onClose} className="px-4 py-2 rounded hover:bg-gray-100">
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={onSave}
|
||||
className="bg-[#4c5f9e] text-white px-4 py-2 rounded hover:bg-[#3d4d7e]"
|
||||
>
|
||||
Save
|
||||
Guardar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -59,7 +59,9 @@ export default function ConcentratorsSidebar({
|
||||
Tipo: <span className="font-semibold">{sampleViewLabel}</span>
|
||||
{" • "}
|
||||
Seleccionado:{" "}
|
||||
<span className="font-semibold">{selectedProject || "—"}</span>
|
||||
<span className="font-semibold">
|
||||
{projects.find((p) => p.id === selectedProject)?.name || "—"}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -132,12 +134,12 @@ export default function ConcentratorsSidebar({
|
||||
</div>
|
||||
) : (
|
||||
projects.map((p) => {
|
||||
const active = p.name === selectedProject;
|
||||
const active = p.id === selectedProject;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={p.name}
|
||||
onClick={() => onSelectProject(p.name)}
|
||||
key={p.id}
|
||||
onClick={() => onSelectProject(p.id)}
|
||||
className={[
|
||||
"rounded-xl border p-4 transition cursor-pointer",
|
||||
active
|
||||
@@ -211,7 +213,7 @@ export default function ConcentratorsSidebar({
|
||||
].join(" ")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelectProject(p.name);
|
||||
onSelectProject(p.id);
|
||||
}}
|
||||
>
|
||||
{active ? "Seleccionado" : "Seleccionar"}
|
||||
|
||||
@@ -23,45 +23,69 @@ export default function ConcentratorsTable({
|
||||
isLoading={isLoading}
|
||||
columns={[
|
||||
{
|
||||
title: "Device Name",
|
||||
field: "Device Name",
|
||||
render: (rowData: any) => rowData["Device Name"] || "-",
|
||||
title: "Serial",
|
||||
field: "serialNumber",
|
||||
render: (rowData: Concentrator) => rowData.serialNumber || "-",
|
||||
},
|
||||
{
|
||||
title: "Device S/N",
|
||||
field: "Device S/N",
|
||||
render: (rowData: any) => rowData["Device S/N"] || "-",
|
||||
title: "Nombre",
|
||||
field: "name",
|
||||
render: (rowData: Concentrator) => rowData.name || "-",
|
||||
},
|
||||
{
|
||||
title: "Device Status",
|
||||
field: "Device Status",
|
||||
render: (rowData: any) => (
|
||||
title: "Tipo",
|
||||
field: "type",
|
||||
render: (rowData: Concentrator) => {
|
||||
const typeLabels: Record<string, string> = {
|
||||
LORA: "LoRa",
|
||||
LORAWAN: "LoRaWAN",
|
||||
GRANDES: "Grandes Consumidores",
|
||||
};
|
||||
const typeColors: Record<string, string> = {
|
||||
LORA: "text-green-600 border-green-600",
|
||||
LORAWAN: "text-purple-600 border-purple-600",
|
||||
GRANDES: "text-orange-600 border-orange-600",
|
||||
};
|
||||
const type = rowData.type || "LORA";
|
||||
return (
|
||||
<span
|
||||
className={`px-3 py-1 rounded-full text-xs font-semibold border ${typeColors[type] || "text-gray-600 border-gray-600"}`}
|
||||
>
|
||||
{typeLabels[type] || type}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Estado",
|
||||
field: "status",
|
||||
render: (rowData: Concentrator) => (
|
||||
<span
|
||||
className={`px-3 py-1 rounded-full text-xs font-semibold border ${
|
||||
rowData["Device Status"] === "ACTIVE"
|
||||
rowData.status === "ACTIVE"
|
||||
? "text-blue-600 border-blue-600"
|
||||
: "text-red-600 border-red-600"
|
||||
}`}
|
||||
>
|
||||
{rowData["Device Status"] || "-"}
|
||||
{rowData.status || "-"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Operator",
|
||||
field: "Operator",
|
||||
render: (rowData: any) => rowData["Operator"] || "-",
|
||||
title: "Ubicación",
|
||||
field: "location",
|
||||
render: (rowData: Concentrator) => rowData.location || "-",
|
||||
},
|
||||
{
|
||||
title: "Area Name",
|
||||
field: "Area Name",
|
||||
render: (rowData: any) => rowData["Area Name"] || "-",
|
||||
title: "IP",
|
||||
field: "ipAddress",
|
||||
render: (rowData: Concentrator) => rowData.ipAddress || "-",
|
||||
},
|
||||
{
|
||||
title: "Installed Time",
|
||||
field: "Installed Time",
|
||||
type: "date",
|
||||
render: (rowData: any) => rowData["Installed Time"] || "-",
|
||||
title: "Última Comunicación",
|
||||
field: "lastCommunication",
|
||||
type: "datetime",
|
||||
render: (rowData: Concentrator) => rowData.lastCommunication ? new Date(rowData.lastCommunication).toLocaleString() : "-",
|
||||
},
|
||||
]}
|
||||
data={data}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
fetchConcentrators,
|
||||
type Concentrator,
|
||||
} from "../../api/concentrators";
|
||||
import { fetchProjects, type Project } from "../../api/projects";
|
||||
import type { ProjectCard, SampleView } from "./ConcentratorsPage";
|
||||
|
||||
type User = {
|
||||
@@ -16,6 +17,7 @@ export function useConcentrators(currentUser: User) {
|
||||
const [loadingProjects, setLoadingProjects] = useState(true);
|
||||
const [loadingConcentrators, setLoadingConcentrators] = useState(true);
|
||||
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [allProjects, setAllProjects] = useState<string[]>([]);
|
||||
const [selectedProject, setSelectedProject] = useState("");
|
||||
|
||||
@@ -51,58 +53,49 @@ export function useConcentrators(currentUser: User) {
|
||||
[allProjects, currentUser.role, currentUser.project]
|
||||
);
|
||||
|
||||
const loadConcentrators = async () => {
|
||||
if (!isGeneral) return;
|
||||
|
||||
setLoadingConcentrators(true);
|
||||
const loadProjects = async () => {
|
||||
setLoadingProjects(true);
|
||||
|
||||
try {
|
||||
const raw = await fetchConcentrators();
|
||||
|
||||
const normalized = raw.map((c: any) => {
|
||||
const preferredName =
|
||||
c["Device Alias"] ||
|
||||
c["Device Label"] ||
|
||||
c["Device Display Name"] ||
|
||||
c.deviceName ||
|
||||
c.name ||
|
||||
c["Device Name"] ||
|
||||
"";
|
||||
|
||||
return {
|
||||
...c,
|
||||
"Device Name": preferredName,
|
||||
};
|
||||
});
|
||||
|
||||
const projectsArray = [
|
||||
...new Set(normalized.map((r: any) => r["Area Name"])),
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
setAllProjects(projectsArray);
|
||||
setConcentrators(normalized);
|
||||
const projectsData = await fetchProjects();
|
||||
setProjects(projectsData);
|
||||
const projectIds = projectsData.map((p) => p.id);
|
||||
setAllProjects(projectIds);
|
||||
|
||||
setSelectedProject((prev) => {
|
||||
if (prev) return prev;
|
||||
if (currentUser.role !== "SUPER_ADMIN" && currentUser.project) {
|
||||
return currentUser.project;
|
||||
}
|
||||
return projectsArray[0] ?? "";
|
||||
return projectIds[0] ?? "";
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error loading concentrators:", err);
|
||||
console.error("Error loading projects:", err);
|
||||
setProjects([]);
|
||||
setAllProjects([]);
|
||||
setConcentrators([]);
|
||||
setSelectedProject("");
|
||||
} finally {
|
||||
setLoadingConcentrators(false);
|
||||
setLoadingProjects(false);
|
||||
}
|
||||
};
|
||||
|
||||
// init
|
||||
const loadConcentrators = async () => {
|
||||
if (!isGeneral) return;
|
||||
|
||||
setLoadingConcentrators(true);
|
||||
|
||||
try {
|
||||
const data = await fetchConcentrators();
|
||||
setConcentrators(data);
|
||||
} catch (err) {
|
||||
console.error("Error loading concentrators:", err);
|
||||
setConcentrators([]);
|
||||
} finally {
|
||||
setLoadingConcentrators(false);
|
||||
}
|
||||
};
|
||||
|
||||
// init - load projects and concentrators
|
||||
useEffect(() => {
|
||||
loadProjects();
|
||||
loadConcentrators();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
@@ -110,6 +103,7 @@ export function useConcentrators(currentUser: User) {
|
||||
// view changes
|
||||
useEffect(() => {
|
||||
if (isGeneral) {
|
||||
loadProjects();
|
||||
loadConcentrators();
|
||||
} else {
|
||||
setLoadingProjects(false);
|
||||
@@ -136,7 +130,7 @@ export function useConcentrators(currentUser: User) {
|
||||
|
||||
if (selectedProject) {
|
||||
setFilteredConcentrators(
|
||||
concentrators.filter((c) => c["Area Name"] === selectedProject)
|
||||
concentrators.filter((c) => c.projectId === selectedProject)
|
||||
);
|
||||
} else {
|
||||
setFilteredConcentrators(concentrators);
|
||||
@@ -146,8 +140,13 @@ export function useConcentrators(currentUser: User) {
|
||||
// sidebar cards (general)
|
||||
const projectsDataGeneral: ProjectCard[] = useMemo(() => {
|
||||
const counts = concentrators.reduce<Record<string, number>>((acc, c) => {
|
||||
const area = c["Area Name"] ?? "SIN PROYECTO";
|
||||
acc[area] = (acc[area] ?? 0) + 1;
|
||||
const project = c.projectId ?? "SIN PROYECTO";
|
||||
acc[project] = (acc[project] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const projectNameMap = projects.reduce<Record<string, string>>((acc, p) => {
|
||||
acc[p.id] = p.name;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
@@ -155,17 +154,18 @@ export function useConcentrators(currentUser: User) {
|
||||
const baseContact = "Operaciones";
|
||||
const baseLastSync = "Hace 1 h";
|
||||
|
||||
return visibleProjects.map((name) => ({
|
||||
name,
|
||||
return visibleProjects.map((projectId) => ({
|
||||
id: projectId,
|
||||
name: projectNameMap[projectId] ?? projectId,
|
||||
region: baseRegion,
|
||||
projects: 1,
|
||||
concentrators: counts[name] ?? 0,
|
||||
concentrators: counts[projectId] ?? 0,
|
||||
activeAlerts: 0,
|
||||
lastSync: baseLastSync,
|
||||
contact: baseContact,
|
||||
status: "ACTIVO",
|
||||
status: "ACTIVO" as const,
|
||||
}));
|
||||
}, [concentrators, visibleProjects]);
|
||||
}, [concentrators, visibleProjects, projects]);
|
||||
|
||||
// sidebar cards (mock)
|
||||
const projectsDataMock: Record<Exclude<SampleView, "GENERAL">, ProjectCard[]> =
|
||||
@@ -173,6 +173,7 @@ export function useConcentrators(currentUser: User) {
|
||||
() => ({
|
||||
LORA: [
|
||||
{
|
||||
id: "mock-lora-centro",
|
||||
name: "LoRa - Zona Centro",
|
||||
region: "Baja California",
|
||||
projects: 1,
|
||||
@@ -183,6 +184,7 @@ export function useConcentrators(currentUser: User) {
|
||||
status: "ACTIVO",
|
||||
},
|
||||
{
|
||||
id: "mock-lora-este",
|
||||
name: "LoRa - Zona Este",
|
||||
region: "Baja California",
|
||||
projects: 1,
|
||||
@@ -195,6 +197,7 @@ export function useConcentrators(currentUser: User) {
|
||||
],
|
||||
LORAWAN: [
|
||||
{
|
||||
id: "mock-lorawan-industrial",
|
||||
name: "LoRaWAN - Industrial",
|
||||
region: "Baja California",
|
||||
projects: 1,
|
||||
@@ -207,6 +210,7 @@ export function useConcentrators(currentUser: User) {
|
||||
],
|
||||
GRANDES: [
|
||||
{
|
||||
id: "mock-grandes-convenios",
|
||||
name: "Grandes - Convenios",
|
||||
region: "Baja California",
|
||||
projects: 1,
|
||||
|
||||
Reference in New Issue
Block a user