Conflicts

This commit is contained in:
Exteban08
2026-01-14 21:40:17 +00:00
12 changed files with 2151 additions and 1739 deletions

View File

@@ -58,7 +58,7 @@ const TopMenu: React.FC<TopMenuProps> = ({
return (
<header
className="relative z-40 h-14 shrink-0 flex items-center justify-between px-4 text-white"
className="relative z-20 h-14 shrink-0 flex items-center justify-between px-4 text-white"
style={{
background:
"linear-gradient(135deg, #4c5f9e, #2a355d, #566bb8, #3d4e87)",

View File

@@ -270,7 +270,7 @@ export default function Home({
</div>
{showOrganisms && (
<div className="fixed inset-0 z-50">
<div className="fixed inset-0 z-30">
{/* Overlay */}
<div
className="absolute inset-0 bg-black/40"

View File

@@ -0,0 +1,384 @@
// src/pages/concentrators/ConcentratorsModal.tsx
import type React from "react";
import type { Concentrator } from "../../api/concentrators";
import type { GatewayData } from "./ConcentratorsPage";
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>>;
errors: Record<string, boolean>;
setErrors: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
toDatetimeLocalValue: (value?: string) => string;
fromDatetimeLocalValue: (value: string) => string;
onClose: () => void;
onSave: () => void | Promise<void>;
};
export default function ConcentratorsModal({
editingSerial,
form,
setForm,
gatewayForm,
setGatewayForm,
errors,
setErrors,
toDatetimeLocalValue,
fromDatetimeLocalValue,
onClose,
onSave,
}: Props) {
const title = editingSerial ? "Edit Concentrator" : "Add Concentrator";
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">
<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
</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>
<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" : ""
}`}
placeholder="Device S/N *"
value={form["Device S/N"]}
onChange={(e) => {
setForm({ ...form, "Device S/N": e.target.value });
if (errors["Device S/N"])
setErrors({ ...errors, "Device S/N": 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>
)}
</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>
<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" : ""
}`}
placeholder="Operator *"
value={form["Operator"]}
onChange={(e) => {
setForm({ ...form, Operator: e.target.value });
if (errors["Operator"])
setErrors({ ...errors, Operator: 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>
)}
</div>
</div>
<div>
<input
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" : ""
}`}
placeholder="Instruction Manual *"
value={form["Instruction Manual"]}
onChange={(e) => {
setForm({ ...form, "Instruction Manual": e.target.value });
if (errors["Instruction Manual"])
setErrors({ ...errors, "Instruction Manual": false });
}}
required
/>
{errors["Instruction Manual"] && (
<p className="text-red-500 text-xs mt-1">
This field is required
</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>
<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>
)}
</div>
<div>
<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",
})
}
>
<option value="Indoor">Indoor</option>
<option value="Outdoor">Outdoor</option>
</select>
</div>
</div>
<div>
<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
/>
{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>
<button
onClick={onSave}
className="bg-[#4c5f9e] text-white px-4 py-2 rounded hover:bg-[#3d4d7e]"
>
Save
</button>
</div>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,232 @@
// src/pages/concentrators/ConcentratorsSidebar.tsx
import { useMemo } from "react";
import { ChevronDown, Check, RefreshCcw } from "lucide-react";
import type { ProjectCard, SampleView } from "./ConcentratorsPage";
type Props = {
loadingProjects: boolean;
sampleView: SampleView;
sampleViewLabel: string;
// ✅ ahora lo controla el Page
typesMenuOpen: boolean;
setTypesMenuOpen: React.Dispatch<React.SetStateAction<boolean>>;
onChangeSampleView: (next: SampleView) => void;
selectedProject: string;
onSelectProject: (name: string) => void;
// ✅ el Page manda projects={c.projectsData}
projects: ProjectCard[];
onRefresh: () => void;
refreshDisabled: boolean;
};
export default function ConcentratorsSidebar({
loadingProjects,
sampleView,
sampleViewLabel,
typesMenuOpen,
setTypesMenuOpen,
onChangeSampleView,
selectedProject,
onSelectProject,
projects,
onRefresh,
refreshDisabled,
}: Props) {
const options = useMemo(
() =>
[
{ key: "GENERAL", label: "General" },
{ key: "LORA", label: "LoRa" },
{ key: "LORAWAN", label: "LoRaWAN" },
{ key: "GRANDES", label: "Grandes consumidores" },
] as Array<{ key: SampleView; label: string }>,
[]
);
return (
<div className="bg-white rounded-xl shadow p-4 flex flex-col h-[calc(100vh-48px)]">
{/* Header */}
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm text-gray-500">Proyectos</p>
<p className="text-xs text-gray-400">
Tipo: <span className="font-semibold">{sampleViewLabel}</span>
{" • "}
Seleccionado:{" "}
<span className="font-semibold">{selectedProject || "—"}</span>
</p>
</div>
<button
type="button"
className="inline-flex items-center justify-center rounded-lg bg-blue-600 px-3 py-2 text-xs font-semibold text-white shadow hover:bg-blue-700 transition disabled:opacity-60"
onClick={onRefresh}
disabled={loadingProjects || refreshDisabled}
title="Actualizar"
>
<RefreshCcw size={14} />
</button>
</div>
{/* Tipos de tomas (dropdown) */}
<div className="mt-4 relative">
<button
type="button"
onClick={() => setTypesMenuOpen((v) => !v)}
className="w-full inline-flex items-center justify-between rounded-xl border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-700 shadow-sm hover:bg-gray-50"
>
<span className="flex items-center gap-2">
Tipos de tomas
<span className="text-xs font-semibold text-gray-500">
({sampleViewLabel})
</span>
</span>
<ChevronDown
size={16}
className={`${typesMenuOpen ? "rotate-180" : ""} transition`}
/>
</button>
{typesMenuOpen && (
<div className="absolute z-50 mt-2 w-full rounded-xl border border-gray-200 bg-white shadow-lg overflow-hidden">
{options.map((opt) => {
const active = sampleView === opt.key;
return (
<button
key={opt.key}
type="button"
onClick={() => onChangeSampleView(opt.key)}
className={[
"w-full px-3 py-2 text-left text-sm flex items-center justify-between hover:bg-gray-50",
active ? "bg-blue-50/60" : "bg-white",
].join(" ")}
>
<span
className={`font-semibold ${
active ? "text-blue-700" : "text-gray-700"
}`}
>
{opt.label}
</span>
{active && <Check size={16} className="text-blue-700" />}
</button>
);
})}
</div>
)}
</div>
{/* List */}
<div className="mt-4 overflow-y-auto flex-1 space-y-3 pr-1">
{loadingProjects && sampleView === "GENERAL" ? (
<div className="text-sm text-gray-500">Loading projects...</div>
) : projects.length === 0 ? (
<div className="text-sm text-gray-500">
No projects available. Please contact your administrator.
</div>
) : (
projects.map((p) => {
const active = p.name === selectedProject;
return (
<div
key={p.name}
onClick={() => onSelectProject(p.name)}
className={[
"rounded-xl border p-4 transition cursor-pointer",
active
? "border-blue-600 bg-blue-50/40"
: "border-gray-200 bg-white hover:bg-gray-50",
].join(" ")}
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-gray-800">
{p.name}
</p>
<p className="text-xs text-gray-500">{p.region}</p>
</div>
<span
className={[
"text-xs font-semibold px-2 py-1 rounded-full",
p.status === "ACTIVO"
? "bg-green-100 text-green-700"
: "bg-gray-200 text-gray-700",
].join(" ")}
>
{p.status}
</span>
</div>
<div className="mt-3 grid grid-cols-2 gap-2 text-xs">
<div className="flex justify-between gap-2">
<span className="text-gray-500">Subproyectos</span>
<span className="font-medium text-gray-800">
{p.projects}
</span>
</div>
<div className="flex justify-between gap-2">
<span className="text-gray-500">Concentradores</span>
<span className="font-medium text-gray-800">
{p.concentrators}
</span>
</div>
<div className="flex justify-between gap-2">
<span className="text-gray-500">Alertas activas</span>
<span className="font-medium text-gray-800">
{p.activeAlerts}
</span>
</div>
<div className="flex justify-between gap-2">
<span className="text-gray-500">Última sync</span>
<span className="font-medium text-gray-800">{p.lastSync}</span>
</div>
<div className="col-span-2 flex justify-between gap-2">
<span className="text-gray-500">Responsable</span>
<span className="font-medium text-gray-800">
{p.contact}
</span>
</div>
</div>
<div className="mt-4 flex items-center justify-end gap-2">
<button
type="button"
className={[
"rounded-lg px-3 py-2 text-sm font-semibold shadow transition",
active
? "bg-blue-600 text-white hover:bg-blue-700"
: "bg-gray-900 text-white hover:bg-gray-800",
].join(" ")}
onClick={(e) => {
e.stopPropagation();
onSelectProject(p.name);
}}
>
{active ? "Seleccionado" : "Seleccionar"}
</button>
</div>
</div>
);
})
)}
</div>
<div className="pt-3 border-t text-xs text-gray-500">
Nota: region/alertas/última sync están en modo demostración hasta integrar
backend.
</div>
</div>
);
}

View File

@@ -0,0 +1,86 @@
// src/pages/concentrators/ConcentratorsTable.tsx
import MaterialTable from "@material-table/core";
import type { Concentrator } from "../../api/concentrators";
type Props = {
isLoading: boolean; // ✅ ahora se llama así (como en Page)
data: Concentrator[];
activeRowId?: string;
onRowClick: (row: Concentrator) => void;
emptyMessage: string; // ✅ mensaje ya viene resuelto desde Page
};
export default function ConcentratorsTable({
isLoading,
data,
activeRowId,
onRowClick,
emptyMessage,
}: Props) {
return (
<MaterialTable
title="Concentrators"
isLoading={isLoading}
columns={[
{
title: "Device Name",
field: "Device Name",
render: (rowData: any) => rowData["Device Name"] || "-",
},
{
title: "Device S/N",
field: "Device S/N",
render: (rowData: any) => rowData["Device S/N"] || "-",
},
{
title: "Device Status",
field: "Device Status",
render: (rowData: any) => (
<span
className={`px-3 py-1 rounded-full text-xs font-semibold border ${
rowData["Device Status"] === "ACTIVE"
? "text-blue-600 border-blue-600"
: "text-red-600 border-red-600"
}`}
>
{rowData["Device Status"] || "-"}
</span>
),
},
{
title: "Operator",
field: "Operator",
render: (rowData: any) => rowData["Operator"] || "-",
},
{
title: "Area Name",
field: "Area Name",
render: (rowData: any) => rowData["Area Name"] || "-",
},
{
title: "Installed Time",
field: "Installed Time",
type: "date",
render: (rowData: any) => rowData["Installed Time"] || "-",
},
]}
data={data}
onRowClick={(_, rowData) => onRowClick(rowData as Concentrator)}
options={{
actionsColumnIndex: -1,
search: false,
paging: true,
sorting: true,
rowStyle: (rowData) => ({
backgroundColor:
activeRowId === (rowData as Concentrator).id
? "#EEF2FF"
: "#FFFFFF",
}),
}}
localization={{
body: { emptyDataSourceMessage: emptyMessage },
}}
/>
);
}

View File

@@ -0,0 +1,255 @@
import { useEffect, useMemo, useState } from "react";
import {
fetchConcentrators,
type Concentrator,
} from "../../api/concentrators";
import type { ProjectCard, SampleView } from "./ConcentratorsPage";
type User = {
role: "SUPER_ADMIN" | "USER";
project?: string;
};
export function useConcentrators(currentUser: User) {
const [sampleView, setSampleView] = useState<SampleView>("GENERAL");
const [loadingProjects, setLoadingProjects] = useState(true);
const [loadingConcentrators, setLoadingConcentrators] = useState(true);
const [allProjects, setAllProjects] = useState<string[]>([]);
const [selectedProject, setSelectedProject] = useState("");
const [concentrators, setConcentrators] = useState<Concentrator[]>([]);
const [filteredConcentrators, setFilteredConcentrators] = useState<
Concentrator[]
>([]);
const isGeneral = sampleView === "GENERAL";
const sampleViewLabel = useMemo(() => {
switch (sampleView) {
case "GENERAL":
return "General";
case "LORA":
return "LoRa";
case "LORAWAN":
return "LoRaWAN";
case "GRANDES":
return "Grandes consumidores";
default:
return "General";
}
}, [sampleView]);
const visibleProjects = useMemo(
() =>
currentUser.role === "SUPER_ADMIN"
? allProjects
: currentUser.project
? [currentUser.project]
: [],
[allProjects, currentUser.role, currentUser.project]
);
const loadConcentrators = async () => {
if (!isGeneral) return;
setLoadingConcentrators(true);
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);
setSelectedProject((prev) => {
if (prev) return prev;
if (currentUser.role !== "SUPER_ADMIN" && currentUser.project) {
return currentUser.project;
}
return projectsArray[0] ?? "";
});
} catch (err) {
console.error("Error loading concentrators:", err);
setAllProjects([]);
setConcentrators([]);
setSelectedProject("");
} finally {
setLoadingConcentrators(false);
setLoadingProjects(false);
}
};
// init
useEffect(() => {
loadConcentrators();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// view changes
useEffect(() => {
if (isGeneral) {
loadConcentrators();
} else {
setLoadingProjects(false);
setLoadingConcentrators(false);
setSelectedProject("");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sampleView]);
// auto select single visible project
useEffect(() => {
if (!isGeneral) return;
if (!selectedProject && visibleProjects.length === 1) {
setSelectedProject(visibleProjects[0]);
}
}, [visibleProjects, selectedProject, isGeneral]);
// filter by project
useEffect(() => {
if (!isGeneral) {
setFilteredConcentrators([]);
return;
}
if (selectedProject) {
setFilteredConcentrators(
concentrators.filter((c) => c["Area Name"] === selectedProject)
);
} else {
setFilteredConcentrators(concentrators);
}
}, [selectedProject, concentrators, isGeneral]);
// 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;
return acc;
}, {});
const baseRegion = "Baja California";
const baseContact = "Operaciones";
const baseLastSync = "Hace 1 h";
return visibleProjects.map((name) => ({
name,
region: baseRegion,
projects: 1,
concentrators: counts[name] ?? 0,
activeAlerts: 0,
lastSync: baseLastSync,
contact: baseContact,
status: "ACTIVO",
}));
}, [concentrators, visibleProjects]);
// sidebar cards (mock)
const projectsDataMock: Record<Exclude<SampleView, "GENERAL">, ProjectCard[]> =
useMemo(
() => ({
LORA: [
{
name: "LoRa - Zona Centro",
region: "Baja California",
projects: 1,
concentrators: 12,
activeAlerts: 1,
lastSync: "Hace 15 min",
contact: "Operaciones",
status: "ACTIVO",
},
{
name: "LoRa - Zona Este",
region: "Baja California",
projects: 1,
concentrators: 8,
activeAlerts: 0,
lastSync: "Hace 40 min",
contact: "Operaciones",
status: "ACTIVO",
},
],
LORAWAN: [
{
name: "LoRaWAN - Industrial",
region: "Baja California",
projects: 1,
concentrators: 5,
activeAlerts: 0,
lastSync: "Hace 1 h",
contact: "Operaciones",
status: "ACTIVO",
},
],
GRANDES: [
{
name: "Grandes - Convenios",
region: "Baja California",
projects: 1,
concentrators: 3,
activeAlerts: 0,
lastSync: "Hace 2 h",
contact: "Operaciones",
status: "ACTIVO",
},
],
}),
[]
);
const projectsData: ProjectCard[] = useMemo(() => {
if (isGeneral) return projectsDataGeneral;
return projectsDataMock[sampleView as Exclude<SampleView, "GENERAL">];
}, [isGeneral, projectsDataGeneral, projectsDataMock, sampleView]);
return {
// view
sampleView,
setSampleView,
sampleViewLabel,
isGeneral,
// loading
loadingProjects,
loadingConcentrators,
// projects
allProjects,
visibleProjects,
projectsData,
selectedProject,
setSelectedProject,
// data
concentrators,
setConcentrators,
filteredConcentrators,
// actions
loadConcentrators,
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,269 @@
import type React from "react";
import type { Meter } from "../../api/meters";
import type { DeviceData } from "./MeterPage";
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>>;
errors: Record<string, boolean>;
setErrors: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
onClose: () => void;
onSave: () => void | Promise<void>;
};
export default function MetersModal({
editingId,
form,
setForm,
deviceForm,
setDeviceForm,
errors,
setErrors,
onClose,
onSave,
}: Props) {
const title = editingId ? "Edit Meter" : "Add Meter";
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">
<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
</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 ${
errors["areaName"] ? "border-red-500" : ""
}`}
placeholder="Area Name *"
value={form.areaName}
onChange={(e) => {
setForm({ ...form, areaName: e.target.value });
if (errors["areaName"]) setErrors({ ...errors, areaName: 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>
)}
</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>
<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 || "" })}
/>
</div>
</div>
<div>
<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" : ""
}`}
placeholder="Device Name *"
value={form.deviceName}
onChange={(e) => {
setForm({ ...form, deviceName: e.target.value });
if (errors["deviceName"]) setErrors({ ...errors, deviceName: false });
}}
required
/>
{errors["deviceName"] && <p className="text-red-500 text-xs mt-1">This field is required</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 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>}
</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>}
</div>
</div>
<div>
<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
/>
{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
</button>
<button
onClick={onSave}
className="bg-[#4c5f9e] text-white px-4 py-2 rounded hover:bg-[#3d4d7e]"
>
Save
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,293 @@
// src/pages/meters/MetersSidebar.tsx
import { useEffect, useMemo, useRef, useState } from "react";
import { ChevronDown, RefreshCcw, Check } from "lucide-react";
import type React from "react";
import type { ProjectCard, TakeType } from "./MeterPage";
type Props = {
loadingProjects: boolean;
takeType: TakeType;
setTakeType: (t: TakeType) => void;
selectedProject: string;
setSelectedProject: React.Dispatch<React.SetStateAction<string>>;
isMockMode: boolean;
projects: ProjectCard[];
onRefresh: () => void;
refreshDisabled?: boolean;
allProjects: string[];
onResetSelection?: () => void;
};
type TakeTypeOption = { key: TakeType; label: string };
const TAKE_TYPE_OPTIONS: TakeTypeOption[] = [
{ key: "GENERAL", label: "General" },
{ key: "LORA", label: "LoRa" },
{ key: "LORAWAN", label: "LoRaWAN" },
{ key: "GRANDES", label: "Grandes consumidores" },
];
export default function MetersSidebar({
loadingProjects,
takeType,
setTakeType,
selectedProject,
setSelectedProject,
isMockMode,
projects,
onRefresh,
refreshDisabled,
allProjects,
onResetSelection,
}: Props) {
const [typesMenuOpen, setTypesMenuOpen] = useState(false);
// para detectar click fuera (igual a tu implementación)
const menuRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const onClickOutside = (e: MouseEvent) => {
if (!menuRef.current) return;
if (!menuRef.current.contains(e.target as Node)) {
setTypesMenuOpen(false);
}
};
document.addEventListener("mousedown", onClickOutside);
return () => document.removeEventListener("mousedown", onClickOutside);
}, []);
const takeTypeLabel = useMemo(
() => TAKE_TYPE_OPTIONS.find((o) => o.key === takeType)?.label ?? "General",
[takeType]
);
return (
<aside className="w-[420px] shrink-0">
<div className="bg-white rounded-xl shadow p-4 flex flex-col h-[calc(100vh-48px)]">
{/* Header */}
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm text-gray-500">Proyectos</p>
<p className="text-xs text-gray-400">
Tipo: <span className="font-semibold">{takeTypeLabel}</span>
{" • "}
Seleccionado:{" "}
<span className="font-semibold">
{selectedProject || (isMockMode ? "— (modo demo)" : "—")}
</span>
</p>
</div>
<button
type="button"
className="inline-flex items-center justify-center rounded-lg bg-blue-600 px-3 py-2 text-xs font-semibold text-white shadow hover:bg-blue-700 transition disabled:opacity-60"
onClick={onRefresh}
disabled={loadingProjects || !!refreshDisabled}
title="Actualizar"
>
<RefreshCcw size={14} />
</button>
</div>
{/* ✅ Tipos de tomas (dropdown) — mismo UI que Concentrators */}
<div className="mt-4 relative" ref={menuRef}>
<button
type="button"
onClick={() => setTypesMenuOpen((v) => !v)}
disabled={loadingProjects}
className="w-full inline-flex items-center justify-between rounded-xl border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-700 shadow-sm hover:bg-gray-50 disabled:opacity-60"
>
<span className="flex items-center gap-2">
Tipos de tomas
<span className="text-xs font-semibold text-gray-500">
({takeTypeLabel})
</span>
</span>
<ChevronDown
size={16}
className={`${typesMenuOpen ? "rotate-180" : ""} transition`}
/>
</button>
{typesMenuOpen && (
<div className="absolute z-50 mt-2 w-full rounded-xl border border-gray-200 bg-white shadow-lg overflow-hidden">
{TAKE_TYPE_OPTIONS.map((opt) => {
const active = takeType === opt.key;
return (
<button
key={opt.key}
type="button"
onClick={() => {
setTakeType(opt.key);
setTypesMenuOpen(false);
// Reset selection/search desde el parent
onResetSelection?.();
if (opt.key !== "GENERAL") {
// mock mode -> limpia selección real
setSelectedProject("");
} else {
// vuelve a GENERAL -> autoselecciona real si no hay
setSelectedProject((prev) => prev || allProjects[0] || "");
}
}}
className={[
"w-full px-3 py-2 text-left text-sm flex items-center justify-between hover:bg-gray-50",
active ? "bg-blue-50/60" : "bg-white",
].join(" ")}
>
<span
className={`font-semibold ${
active ? "text-blue-700" : "text-gray-700"
}`}
>
{opt.label}
</span>
{active && <Check size={16} className="text-blue-700" />}
</button>
);
})}
</div>
)}
</div>
{/* List */}
<div className="mt-4 overflow-y-auto flex-1 space-y-3 pr-1">
{loadingProjects ? (
<div className="text-sm text-gray-500">Loading projects...</div>
) : projects.length === 0 ? (
<div className="text-sm text-gray-500 text-center py-10">
{isMockMode
? "No hay datos demo para este tipo."
: "No se encontraron proyectos."}
</div>
) : (
projects.map((p) => {
const active = !isMockMode && p.name === selectedProject;
return (
<div
key={p.name}
onClick={() => {
if (isMockMode) return;
setSelectedProject(p.name);
onResetSelection?.();
}}
className={[
"rounded-xl border p-4 transition cursor-pointer",
active
? "border-blue-600 bg-blue-50/40"
: "border-gray-200 bg-white hover:bg-gray-50",
isMockMode ? "opacity-90" : "",
].join(" ")}
title={
isMockMode
? "Modo demo: estas tarjetas son mocks"
: "Seleccionar proyecto"
}
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-gray-800">
{p.name}
</p>
<p className="text-xs text-gray-500">{p.region}</p>
</div>
<span
className={[
"text-xs font-semibold px-2 py-1 rounded-full",
p.status === "ACTIVO"
? "bg-green-100 text-green-700"
: "bg-gray-200 text-gray-700",
].join(" ")}
>
{p.status}
</span>
</div>
<div className="mt-3 grid grid-cols-2 gap-2 text-xs">
<div className="flex justify-between gap-2">
<span className="text-gray-500">Subproyectos</span>
<span className="font-medium text-gray-800">
{p.projects}
</span>
</div>
<div className="flex justify-between gap-2">
<span className="text-gray-500">Medidores</span>
<span className="font-medium text-gray-800">
{p.meters}
</span>
</div>
<div className="flex justify-between gap-2">
<span className="text-gray-500">Alertas activas</span>
<span className="font-medium text-gray-800">
{p.activeAlerts}
</span>
</div>
<div className="flex justify-between gap-2">
<span className="text-gray-500">Última sync</span>
<span className="font-medium text-gray-800">
{p.lastSync}
</span>
</div>
<div className="col-span-2 flex justify-between gap-2">
<span className="text-gray-500">Responsable</span>
<span className="font-medium text-gray-800">
{p.contact}
</span>
</div>
</div>
<div className="mt-4 flex items-center justify-end gap-2">
<button
type="button"
className={[
"rounded-lg px-3 py-2 text-sm font-semibold shadow transition",
active
? "bg-blue-600 text-white hover:bg-blue-700"
: "bg-gray-900 text-white hover:bg-gray-800",
isMockMode ? "opacity-50 cursor-not-allowed" : "",
].join(" ")}
onClick={(e) => {
e.stopPropagation();
if (isMockMode) return;
setSelectedProject(p.name);
onResetSelection?.();
}}
disabled={isMockMode}
>
{isMockMode
? "Demo"
: active
? "Seleccionado"
: "Seleccionar"}
</button>
</div>
</div>
);
})
)}
</div>
<div className="pt-3 border-t text-xs text-gray-500">
Nota: region/alertas/última sync están en modo demostración hasta
integrar backend.
</div>
</div>
</aside>
);
}

View File

@@ -0,0 +1,67 @@
import MaterialTable from "@material-table/core";
import type { Meter } from "../../api/meters";
type Props = {
data: Meter[];
isLoading: boolean;
isMockMode: boolean;
selectedProject: string;
activeMeter: Meter | null;
onRowClick: (row: Meter) => void;
};
export default function MetersTable({
data,
isLoading,
isMockMode,
selectedProject,
activeMeter,
onRowClick,
}: Props) {
const disabled = isMockMode || !selectedProject;
return (
<div className={disabled ? "opacity-60 pointer-events-none" : ""}>
<MaterialTable
title="Meters"
isLoading={isLoading}
columns={[
{ title: "Area Name", field: "areaName", render: (r: any) => r.areaName || "-" },
{ title: "Account Number", field: "accountNumber", render: (r: any) => r.accountNumber || "-" },
{ title: "User Name", field: "userName", render: (r: any) => r.userName || "-" },
{ title: "User Address", field: "userAddress", render: (r: any) => r.userAddress || "-" },
{ title: "Meter S/N", field: "meterSerialNumber", render: (r: any) => r.meterSerialNumber || "-" },
{ title: "Meter Name", field: "meterName", render: (r: any) => r.meterName || "-" },
{ title: "Protocol Type", field: "protocolType", render: (r: any) => r.protocolType || "-" },
{ title: "Device ID", field: "deviceId", render: (r: any) => r.deviceId || "-" },
{ title: "Device Name", field: "deviceName", render: (r: any) => r.deviceName || "-" },
]}
data={disabled ? [] : data}
onRowClick={(_, rowData) => onRowClick(rowData as Meter)}
options={{
actionsColumnIndex: -1,
search: false,
paging: true,
sorting: true,
rowStyle: (rowData) => ({
backgroundColor:
activeMeter?.id === (rowData as Meter).id ? "#EEF2FF" : "#FFFFFF",
}),
}}
localization={{
body: {
emptyDataSourceMessage: isMockMode
? "Modo demo: selecciona 'General' para ver datos reales."
: !selectedProject
? "Select a project to view meters."
: isLoading
? "Loading meters..."
: "No meters found. Click 'Add' to create your first meter.",
},
}}
/>
</div>
);
}

View File

@@ -0,0 +1,94 @@
import { useEffect, useMemo, useState } from "react";
import { fetchMeters, type Meter } from "../../api/meters";
type UseMetersArgs = {
initialProject?: string;
};
export function useMeters({ initialProject }: UseMetersArgs) {
const [allProjects, setAllProjects] = useState<string[]>([]);
const [loadingProjects, setLoadingProjects] = useState(true);
const [selectedProject, setSelectedProject] = useState(initialProject || "");
const [meters, setMeters] = useState<Meter[]>([]);
const [filteredMeters, setFilteredMeters] = useState<Meter[]>([]);
const [loadingMeters, setLoadingMeters] = useState(true);
const loadMeters = async () => {
setLoadingMeters(true);
setLoadingProjects(true);
try {
const data = await fetchMeters();
const projectsArray = [...new Set(data.map((r) => r.areaName))]
.filter(Boolean) as string[];
setAllProjects(projectsArray);
setMeters(data);
setSelectedProject((prev) => {
if (prev) return prev;
if (initialProject) return initialProject;
return projectsArray[0] ?? "";
});
} catch (error) {
console.error("Error loading meters:", error);
setAllProjects([]);
setMeters([]);
setSelectedProject("");
} finally {
setLoadingMeters(false);
setLoadingProjects(false);
}
};
// init
useEffect(() => {
loadMeters();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// keep selectedProject synced if parent changes initialProject
useEffect(() => {
if (initialProject) setSelectedProject(initialProject);
}, [initialProject]);
// filter by project
useEffect(() => {
if (!selectedProject) {
setFilteredMeters([]);
return;
}
setFilteredMeters(meters.filter((m) => m.areaName === selectedProject));
}, [selectedProject, meters]);
const projectsCounts = useMemo(() => {
return meters.reduce<Record<string, number>>((acc, m) => {
const area = m.areaName ?? "SIN PROYECTO";
acc[area] = (acc[area] ?? 0) + 1;
return acc;
}, {});
}, [meters]);
return {
// loading
loadingProjects,
loadingMeters,
// projects
allProjects,
projectsCounts,
selectedProject,
setSelectedProject,
// data
meters,
setMeters,
filteredMeters,
// actions
loadMeters,
};
}