Merge pull request #9 from luanngel/DevMarlene
Refactor Concentrators & Meters – modularización y tipos de tomas
This commit is contained in:
@@ -58,7 +58,7 @@ const TopMenu: React.FC<TopMenuProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<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={{
|
style={{
|
||||||
background:
|
background:
|
||||||
"linear-gradient(135deg, #4c5f9e, #2a355d, #566bb8, #3d4e87)",
|
"linear-gradient(135deg, #4c5f9e, #2a355d, #566bb8, #3d4e87)",
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ export default function Home({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showOrganisms && (
|
{showOrganisms && (
|
||||||
<div className="fixed inset-0 z-50">
|
<div className="fixed inset-0 z-30">
|
||||||
{/* Overlay */}
|
{/* Overlay */}
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 bg-black/40"
|
className="absolute inset-0 bg-black/40"
|
||||||
|
|||||||
384
src/pages/concentrators/ConcentratorsModal.tsx
Normal file
384
src/pages/concentrators/ConcentratorsModal.tsx
Normal 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
232
src/pages/concentrators/ConcentratorsSidebar.tsx
Normal file
232
src/pages/concentrators/ConcentratorsSidebar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
src/pages/concentrators/ConcentratorsTable.tsx
Normal file
86
src/pages/concentrators/ConcentratorsTable.tsx
Normal 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 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
255
src/pages/concentrators/useConcentrators.ts
Normal file
255
src/pages/concentrators/useConcentrators.ts
Normal 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
269
src/pages/meters/MetersModal.tsx
Normal file
269
src/pages/meters/MetersModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
293
src/pages/meters/MetersSidebar.tsx
Normal file
293
src/pages/meters/MetersSidebar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
src/pages/meters/MetersTable.tsx
Normal file
67
src/pages/meters/MetersTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
src/pages/meters/useMeters.ts
Normal file
94
src/pages/meters/useMeters.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user