Add 3-level role permissions, organismos operadores, and Histórico de Tomas page
Implements the full ADMIN → ORGANISMO_OPERADOR → OPERATOR permission hierarchy with scope-filtered data access across all backend services. Adds organismos operadores management (ADMIN only) and a new Histórico page for viewing per-meter reading history with chart, consumption stats, and CSV export. Key changes: - Backend: 3-level scope filtering on all services (meters, readings, projects, users) - Backend: Protect GET /meters routes with authenticateToken for role-based filtering - Backend: Pass requestingUser to reading service for scoped meter readings - Frontend: New HistoricoPage with meter selector, AreaChart, paginated table - Frontend: Consumption cards (Actual, Pasado, Diferencial) above date filters - Frontend: Meter search by name, serial, location, CESPT account, cadastral key - Frontend: OrganismosPage, updated Sidebar with 3-level visibility - SQL migrations for organismos_operadores table and FK columns Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
990
src/pages/historico/HistoricoPage.tsx
Normal file
990
src/pages/historico/HistoricoPage.tsx
Normal file
@@ -0,0 +1,990 @@
|
||||
import { useEffect, useState, useMemo, useRef } from "react";
|
||||
import {
|
||||
History,
|
||||
RefreshCw,
|
||||
Download,
|
||||
Search,
|
||||
X,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Droplets,
|
||||
MapPin,
|
||||
Radio,
|
||||
Calendar,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Minus,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import {
|
||||
fetchMeters,
|
||||
fetchMeterReadings,
|
||||
type Meter,
|
||||
type MeterReading,
|
||||
type PaginatedMeterReadings,
|
||||
} from "../../api/meters";
|
||||
|
||||
export default function HistoricoPage() {
|
||||
const [meters, setMeters] = useState<Meter[]>([]);
|
||||
const [metersLoading, setMetersLoading] = useState(true);
|
||||
const [meterSearch, setMeterSearch] = useState("");
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const [selectedMeter, setSelectedMeter] = useState<Meter | null>(null);
|
||||
|
||||
const [readings, setReadings] = useState<MeterReading[]>([]);
|
||||
const [pagination, setPagination] = useState({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
totalPages: 0,
|
||||
});
|
||||
const [loadingReadings, setLoadingReadings] = useState(false);
|
||||
|
||||
const [startDate, setStartDate] = useState("");
|
||||
const [endDate, setEndDate] = useState("");
|
||||
|
||||
const [consumoActual, setConsumoActual] = useState<number | null>(null);
|
||||
const [consumoPasado, setConsumoPasado] = useState<number | null>(null);
|
||||
const [loadingStats, setLoadingStats] = useState(false);
|
||||
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Load meters on mount
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const data = await fetchMeters();
|
||||
setMeters(data);
|
||||
} catch (err) {
|
||||
console.error("Error loading meters:", err);
|
||||
} finally {
|
||||
setMetersLoading(false);
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, []);
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, []);
|
||||
|
||||
// Filter meters by search
|
||||
const filteredMeters = useMemo(() => {
|
||||
if (!meterSearch.trim()) return meters;
|
||||
const q = meterSearch.toLowerCase();
|
||||
return meters.filter(
|
||||
(m) =>
|
||||
m.name.toLowerCase().includes(q) ||
|
||||
m.serialNumber.toLowerCase().includes(q) ||
|
||||
(m.location ?? "").toLowerCase().includes(q) ||
|
||||
(m.cesptAccount ?? "").toLowerCase().includes(q) ||
|
||||
(m.cadastralKey ?? "").toLowerCase().includes(q)
|
||||
);
|
||||
}, [meters, meterSearch]);
|
||||
|
||||
// Load readings when meter or filters change
|
||||
const loadReadings = async (page = 1, pageSize?: number) => {
|
||||
if (!selectedMeter) return;
|
||||
setLoadingReadings(true);
|
||||
try {
|
||||
const result: PaginatedMeterReadings = await fetchMeterReadings(
|
||||
selectedMeter.id,
|
||||
{
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
page,
|
||||
pageSize: pageSize ?? pagination.pageSize,
|
||||
}
|
||||
);
|
||||
setReadings(result.data);
|
||||
setPagination(result.pagination);
|
||||
} catch (err) {
|
||||
console.error("Error loading readings:", err);
|
||||
} finally {
|
||||
setLoadingReadings(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedMeter) {
|
||||
loadReadings(1);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedMeter, startDate, endDate]);
|
||||
|
||||
// Load consumption stats when meter changes
|
||||
useEffect(() => {
|
||||
if (!selectedMeter) {
|
||||
setConsumoActual(null);
|
||||
setConsumoPasado(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadStats = async () => {
|
||||
setLoadingStats(true);
|
||||
try {
|
||||
// Consumo Actual: latest reading (today or most recent)
|
||||
const today = new Date();
|
||||
const todayStr = today.toISOString().split("T")[0];
|
||||
const latestResult = await fetchMeterReadings(selectedMeter.id, {
|
||||
endDate: todayStr,
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
});
|
||||
const actual = latestResult.data.length > 0
|
||||
? Number(latestResult.data[0].readingValue)
|
||||
: null;
|
||||
setConsumoActual(actual);
|
||||
|
||||
// Consumo Pasado: reading closest to first day of last month
|
||||
const firstOfLastMonth = new Date(today.getFullYear(), today.getMonth() - 1, 1);
|
||||
const secondOfLastMonth = new Date(today.getFullYear(), today.getMonth() - 1, 2);
|
||||
const pastResult = await fetchMeterReadings(selectedMeter.id, {
|
||||
startDate: firstOfLastMonth.toISOString().split("T")[0],
|
||||
endDate: secondOfLastMonth.toISOString().split("T")[0],
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
});
|
||||
|
||||
if (pastResult.data.length > 0) {
|
||||
setConsumoPasado(Number(pastResult.data[0].readingValue));
|
||||
} else {
|
||||
// Fallback: get the oldest reading around that date range
|
||||
const endOfLastMonth = new Date(today.getFullYear(), today.getMonth(), 0);
|
||||
const fallbackResult = await fetchMeterReadings(selectedMeter.id, {
|
||||
startDate: firstOfLastMonth.toISOString().split("T")[0],
|
||||
endDate: endOfLastMonth.toISOString().split("T")[0],
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
});
|
||||
setConsumoPasado(
|
||||
fallbackResult.data.length > 0
|
||||
? Number(fallbackResult.data[0].readingValue)
|
||||
: null
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error loading consumption stats:", err);
|
||||
} finally {
|
||||
setLoadingStats(false);
|
||||
}
|
||||
};
|
||||
loadStats();
|
||||
}, [selectedMeter]);
|
||||
|
||||
const diferencial = useMemo(() => {
|
||||
if (consumoActual === null || consumoPasado === null) return null;
|
||||
return consumoActual - consumoPasado;
|
||||
}, [consumoActual, consumoPasado]);
|
||||
|
||||
const handleSelectMeter = (meter: Meter) => {
|
||||
setSelectedMeter(meter);
|
||||
setMeterSearch(meter.name || meter.serialNumber);
|
||||
setDropdownOpen(false);
|
||||
setReadings([]);
|
||||
setPagination({ page: 1, pageSize: pagination.pageSize, total: 0, totalPages: 0 });
|
||||
};
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
loadReadings(newPage);
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (newSize: number) => {
|
||||
setPagination((prev) => ({ ...prev, pageSize: newSize, page: 1 }));
|
||||
loadReadings(1, newSize);
|
||||
};
|
||||
|
||||
// Chart data: readings sorted ascending by date
|
||||
const chartData = useMemo(() => {
|
||||
return [...readings]
|
||||
.sort((a, b) => new Date(a.receivedAt).getTime() - new Date(b.receivedAt).getTime())
|
||||
.map((r) => ({
|
||||
date: new Date(r.receivedAt).toLocaleDateString("es-MX", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
}),
|
||||
fullDate: new Date(r.receivedAt).toLocaleString("es-MX", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}),
|
||||
value: Number(r.readingValue),
|
||||
}));
|
||||
}, [readings]);
|
||||
|
||||
// Compute tight Y-axis domain for chart
|
||||
const chartDomain = useMemo(() => {
|
||||
if (chartData.length === 0) return [0, 100];
|
||||
const values = chartData.map((d) => d.value);
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
const range = max - min;
|
||||
const padding = range > 0 ? range * 0.15 : max * 0.05 || 1;
|
||||
return [
|
||||
Math.max(0, Math.floor(min - padding)),
|
||||
Math.ceil(max + padding),
|
||||
];
|
||||
}, [chartData]);
|
||||
|
||||
const formatDate = (dateStr: string | null): string => {
|
||||
if (!dateStr) return "—";
|
||||
return new Date(dateStr).toLocaleString("es-MX", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
const exportToCSV = () => {
|
||||
if (!selectedMeter || readings.length === 0) return;
|
||||
const headers = ["Fecha/Hora", "Lectura (m³)", "Tipo", "Batería", "Señal"];
|
||||
const rows = readings.map((r) => [
|
||||
formatDate(r.receivedAt),
|
||||
Number(r.readingValue).toFixed(2),
|
||||
r.readingType || "—",
|
||||
r.batteryLevel !== null ? `${r.batteryLevel}%` : "—",
|
||||
r.signalStrength !== null ? `${r.signalStrength} dBm` : "—",
|
||||
]);
|
||||
const csv = [headers, ...rows].map((row) => row.join(",")).join("\n");
|
||||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
||||
const link = document.createElement("a");
|
||||
link.href = URL.createObjectURL(blob);
|
||||
const serial = selectedMeter.serialNumber || "meter";
|
||||
const date = new Date().toISOString().split("T")[0];
|
||||
link.download = `historico_${serial}_${date}.csv`;
|
||||
link.click();
|
||||
};
|
||||
|
||||
const clearDateFilters = () => {
|
||||
setStartDate("");
|
||||
setEndDate("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-full bg-gradient-to-br from-slate-50 via-blue-50/30 to-indigo-50/50 dark:from-zinc-950 dark:via-zinc-950 dark:to-zinc-950 p-6">
|
||||
<div className="max-w-[1600px] mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-800 dark:text-white flex items-center gap-2">
|
||||
<History size={28} />
|
||||
{"Histórico de Tomas"}
|
||||
</h1>
|
||||
<p className="text-slate-500 dark:text-zinc-400 text-sm mt-0.5">
|
||||
Consulta el historial de lecturas por medidor
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => loadReadings(pagination.page)}
|
||||
disabled={!selectedMeter}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-slate-600 dark:text-zinc-300 bg-white dark:bg-zinc-800 border border-slate-200 dark:border-zinc-700 rounded-xl hover:bg-slate-50 dark:hover:bg-zinc-700 hover:border-slate-300 dark:hover:border-zinc-600 transition-all shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
Actualizar
|
||||
</button>
|
||||
<button
|
||||
onClick={exportToCSV}
|
||||
disabled={readings.length === 0}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-gradient-to-r from-blue-600 to-indigo-600 rounded-xl hover:from-blue-700 hover:to-indigo-700 transition-all shadow-sm shadow-blue-500/25 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Download size={16} />
|
||||
Exportar CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Meter Selector */}
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-sm shadow-slate-200/50 dark:shadow-none border border-slate-200/60 dark:border-zinc-800 p-5">
|
||||
<label className="block text-xs font-medium text-slate-500 dark:text-zinc-400 uppercase tracking-wide mb-2">
|
||||
Seleccionar Medidor
|
||||
</label>
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<Search
|
||||
size={18}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={meterSearch}
|
||||
onChange={(e) => {
|
||||
setMeterSearch(e.target.value);
|
||||
setDropdownOpen(true);
|
||||
}}
|
||||
onFocus={() => setDropdownOpen(true)}
|
||||
placeholder={metersLoading ? "Cargando medidores..." : "Buscar por nombre, serial, ubicación, cuenta CESPT o clave catastral..."}
|
||||
disabled={metersLoading}
|
||||
className="w-full pl-10 pr-10 py-2.5 text-sm bg-slate-50 dark:bg-zinc-800 dark:text-zinc-100 border border-slate-200 dark:border-zinc-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400 transition-all"
|
||||
/>
|
||||
{meterSearch && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setMeterSearch("");
|
||||
setSelectedMeter(null);
|
||||
setReadings([]);
|
||||
setDropdownOpen(false);
|
||||
}}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 dark:hover:text-zinc-300"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Dropdown */}
|
||||
{dropdownOpen && filteredMeters.length > 0 && (
|
||||
<div className="absolute z-20 mt-1 w-full max-h-64 overflow-auto bg-white dark:bg-zinc-800 border border-slate-200 dark:border-zinc-700 rounded-xl shadow-lg">
|
||||
{filteredMeters.slice(0, 50).map((meter) => (
|
||||
<button
|
||||
key={meter.id}
|
||||
onClick={() => handleSelectMeter(meter)}
|
||||
className={`w-full text-left px-4 py-3 hover:bg-blue-50 dark:hover:bg-zinc-700 transition-colors border-b border-slate-100 dark:border-zinc-700 last:border-0 ${
|
||||
selectedMeter?.id === meter.id ? "bg-blue-50 dark:bg-zinc-700" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-800 dark:text-zinc-100">
|
||||
{meter.name}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 dark:text-zinc-400">
|
||||
{"Serial: "}{meter.serialNumber}
|
||||
{meter.location && ` · ${meter.location}`}
|
||||
</p>
|
||||
{(meter.cesptAccount || meter.cadastralKey) && (
|
||||
<p className="text-xs text-slate-400 dark:text-zinc-500">
|
||||
{meter.cesptAccount && `CESPT: ${meter.cesptAccount}`}
|
||||
{meter.cesptAccount && meter.cadastralKey && " · "}
|
||||
{meter.cadastralKey && `Catastral: ${meter.cadastralKey}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{meter.projectName && (
|
||||
<span className="text-xs text-slate-400 dark:text-zinc-500 shrink-0 ml-3">
|
||||
{meter.projectName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dropdownOpen && meterSearch && filteredMeters.length === 0 && !metersLoading && (
|
||||
<div className="absolute z-20 mt-1 w-full bg-white dark:bg-zinc-800 border border-slate-200 dark:border-zinc-700 rounded-xl shadow-lg p-4 text-center text-sm text-slate-500 dark:text-zinc-400">
|
||||
No se encontraron medidores
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* No meter selected state */}
|
||||
{!selectedMeter && (
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-sm shadow-slate-200/50 dark:shadow-none border border-slate-200/60 dark:border-zinc-800 p-16 text-center">
|
||||
<div className="w-20 h-20 bg-slate-100 dark:bg-zinc-800 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<Droplets size={40} className="text-slate-400" />
|
||||
</div>
|
||||
<p className="text-slate-600 dark:text-zinc-300 font-medium text-lg">
|
||||
Selecciona un medidor
|
||||
</p>
|
||||
<p className="text-slate-400 dark:text-zinc-500 text-sm mt-1">
|
||||
Busca y selecciona un medidor para ver su historial de lecturas
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content when meter is selected */}
|
||||
{selectedMeter && (
|
||||
<>
|
||||
{/* Meter Info Card */}
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-sm shadow-slate-200/50 dark:shadow-none border border-slate-200/60 dark:border-zinc-800 p-5">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
<InfoItem
|
||||
icon={<Radio size={16} />}
|
||||
label="Serial"
|
||||
value={selectedMeter.serialNumber}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={<Droplets size={16} />}
|
||||
label="Nombre"
|
||||
value={selectedMeter.name}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={<MapPin size={16} />}
|
||||
label="Proyecto"
|
||||
value={selectedMeter.projectName || "—"}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={<MapPin size={16} />}
|
||||
label="Ubicación"
|
||||
value={selectedMeter.location || "—"}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={<Calendar size={16} />}
|
||||
label="Última Lectura"
|
||||
value={
|
||||
selectedMeter.lastReadingValue !== null
|
||||
? `${Number(selectedMeter.lastReadingValue).toFixed(2)} m³`
|
||||
: "Sin datos"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Consumption Stats */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<ConsumptionCard
|
||||
label="Consumo Actual"
|
||||
sublabel="Lectura más reciente"
|
||||
value={consumoActual}
|
||||
loading={loadingStats}
|
||||
gradient="from-blue-500 to-blue-600"
|
||||
/>
|
||||
<ConsumptionCard
|
||||
label="Consumo Pasado"
|
||||
sublabel="1ro del mes anterior"
|
||||
value={consumoPasado}
|
||||
loading={loadingStats}
|
||||
gradient="from-slate-500 to-slate-600"
|
||||
/>
|
||||
<div className="relative bg-white dark:bg-zinc-900 rounded-2xl p-5 shadow-sm shadow-slate-200/50 dark:shadow-none border border-slate-200/60 dark:border-zinc-800 overflow-hidden group hover:shadow-md transition-all">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-slate-500 dark:text-zinc-400">
|
||||
Diferencial
|
||||
</p>
|
||||
{loadingStats ? (
|
||||
<div className="h-8 w-24 bg-slate-100 dark:bg-zinc-700 rounded-lg animate-pulse" />
|
||||
) : diferencial !== null ? (
|
||||
<p className={`text-2xl font-bold tabular-nums ${
|
||||
diferencial > 0
|
||||
? "text-emerald-600 dark:text-emerald-400"
|
||||
: diferencial < 0
|
||||
? "text-red-600 dark:text-red-400"
|
||||
: "text-slate-800 dark:text-white"
|
||||
}`}>
|
||||
{diferencial > 0 ? "+" : ""}{diferencial.toFixed(2)}
|
||||
<span className="text-sm font-normal ml-1">{"m³"}</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-2xl font-bold text-slate-400 dark:text-zinc-500">{"—"}</p>
|
||||
)}
|
||||
<p className="text-xs text-slate-400 dark:text-zinc-500">
|
||||
Actual - Pasado
|
||||
</p>
|
||||
</div>
|
||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center text-white shadow-lg group-hover:scale-110 transition-transform ${
|
||||
diferencial !== null && diferencial > 0
|
||||
? "bg-gradient-to-br from-emerald-500 to-emerald-600"
|
||||
: diferencial !== null && diferencial < 0
|
||||
? "bg-gradient-to-br from-red-500 to-red-600"
|
||||
: "bg-gradient-to-br from-slate-400 to-slate-500"
|
||||
}`}>
|
||||
{diferencial !== null && diferencial > 0 ? (
|
||||
<TrendingUp size={22} />
|
||||
) : diferencial !== null && diferencial < 0 ? (
|
||||
<TrendingDown size={22} />
|
||||
) : (
|
||||
<Minus size={22} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date Filters */}
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-sm shadow-slate-200/50 dark:shadow-none border border-slate-200/60 dark:border-zinc-800 px-5 py-4 flex flex-wrap items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs font-medium text-slate-500 dark:text-zinc-400 uppercase tracking-wide">
|
||||
Desde
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="px-3 py-1.5 text-sm bg-slate-50 dark:bg-zinc-800 dark:text-zinc-100 border border-slate-200 dark:border-zinc-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs font-medium text-slate-500 dark:text-zinc-400 uppercase tracking-wide">
|
||||
Hasta
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="px-3 py-1.5 text-sm bg-slate-50 dark:bg-zinc-800 dark:text-zinc-100 border border-slate-200 dark:border-zinc-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20"
|
||||
/>
|
||||
</div>
|
||||
{(startDate || endDate) && (
|
||||
<button
|
||||
onClick={clearDateFilters}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs text-slate-500 dark:text-zinc-400 hover:text-slate-700 dark:hover:text-zinc-200"
|
||||
>
|
||||
<X size={14} />
|
||||
Limpiar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
{chartData.length > 1 && (
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-sm shadow-slate-200/50 dark:shadow-none border border-slate-200/60 dark:border-zinc-800 p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-slate-800 dark:text-zinc-100">
|
||||
{"Consumo en el Tiempo"}
|
||||
</h2>
|
||||
<p className="text-xs text-slate-500 dark:text-zinc-400 mt-0.5">
|
||||
{`${chartData.length} lecturas en el período`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500 dark:text-zinc-400">
|
||||
<span className="inline-block w-3 h-3 rounded-full bg-blue-500" />
|
||||
{"Lectura (m³)"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-80">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData} margin={{ top: 5, right: 20, left: 10, bottom: 5 }}>
|
||||
<defs>
|
||||
<linearGradient id="colorValue" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" strokeOpacity={0.5} vertical={false} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fontSize: 11, fill: "#94a3b8" }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: "#e2e8f0" }}
|
||||
dy={8}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11, fill: "#94a3b8" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
unit=" m³"
|
||||
width={70}
|
||||
domain={chartDomain}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "#1e293b",
|
||||
border: "none",
|
||||
borderRadius: "0.75rem",
|
||||
color: "#f1f5f9",
|
||||
fontSize: "0.875rem",
|
||||
padding: "12px 16px",
|
||||
boxShadow: "0 10px 25px rgba(0,0,0,0.2)",
|
||||
}}
|
||||
formatter={(value: number | undefined) => [
|
||||
`${(value ?? 0).toFixed(2)} m³`,
|
||||
"Lectura",
|
||||
]}
|
||||
labelFormatter={(_label, payload) =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(payload as any)?.[0]?.payload?.fullDate || _label
|
||||
}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2.5}
|
||||
fill="url(#colorValue)"
|
||||
dot={{ r: 3, fill: "#3b82f6", stroke: "#fff", strokeWidth: 2 }}
|
||||
activeDot={{ r: 6, stroke: "#3b82f6", strokeWidth: 2, fill: "#fff" }}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-sm shadow-slate-200/50 dark:shadow-none border border-slate-200/60 dark:border-zinc-800 overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-slate-100 dark:border-zinc-800 flex items-center justify-between">
|
||||
<span className="text-sm text-slate-500 dark:text-zinc-400">
|
||||
<span className="font-semibold text-slate-700 dark:text-zinc-200">
|
||||
{pagination.total}
|
||||
</span>{" "}
|
||||
lecturas encontradas
|
||||
</span>
|
||||
|
||||
{pagination.totalPages > 1 && (
|
||||
<div className="flex items-center gap-1 bg-slate-50 dark:bg-zinc-800 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => handlePageChange(pagination.page - 1)}
|
||||
disabled={pagination.page === 1}
|
||||
className="p-1.5 rounded-md hover:bg-white dark:hover:bg-zinc-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronLeft size={16} className="dark:text-zinc-300" />
|
||||
</button>
|
||||
<span className="px-2 text-xs font-medium dark:text-zinc-300">
|
||||
{pagination.page} / {pagination.totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handlePageChange(pagination.page + 1)}
|
||||
disabled={pagination.page === pagination.totalPages}
|
||||
className="p-1.5 rounded-md hover:bg-white dark:hover:bg-zinc-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronRight size={16} className="dark:text-zinc-300" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-slate-50/80 dark:bg-zinc-800">
|
||||
<th className="px-5 py-3 text-left text-xs font-semibold text-slate-500 dark:text-zinc-400 uppercase tracking-wider">
|
||||
Fecha / Hora
|
||||
</th>
|
||||
<th className="px-5 py-3 text-right text-xs font-semibold text-slate-500 dark:text-zinc-400 uppercase tracking-wider">
|
||||
{"Lectura (m³)"}
|
||||
</th>
|
||||
<th className="px-5 py-3 text-center text-xs font-semibold text-slate-500 dark:text-zinc-400 uppercase tracking-wider">
|
||||
Tipo
|
||||
</th>
|
||||
<th className="px-5 py-3 text-center text-xs font-semibold text-slate-500 dark:text-zinc-400 uppercase tracking-wider">
|
||||
{"Batería"}
|
||||
</th>
|
||||
<th className="px-5 py-3 text-center text-xs font-semibold text-slate-500 dark:text-zinc-400 uppercase tracking-wider">
|
||||
{"Señal"}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100 dark:divide-zinc-700">
|
||||
{loadingReadings ? (
|
||||
Array.from({ length: 8 }).map((_, i) => (
|
||||
<tr key={i}>
|
||||
{Array.from({ length: 5 }).map((_, j) => (
|
||||
<td key={j} className="px-5 py-4">
|
||||
<div className="h-4 bg-slate-100 dark:bg-zinc-700 rounded-md animate-pulse" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
) : readings.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-5 py-16 text-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-16 h-16 bg-slate-100 dark:bg-zinc-800 rounded-2xl flex items-center justify-center mb-4">
|
||||
<Droplets size={32} className="text-slate-400" />
|
||||
</div>
|
||||
<p className="text-slate-600 dark:text-zinc-300 font-medium">
|
||||
No hay lecturas disponibles
|
||||
</p>
|
||||
<p className="text-slate-400 dark:text-zinc-500 text-sm mt-1">
|
||||
{startDate || endDate
|
||||
? "Intenta ajustar el rango de fechas"
|
||||
: "Este medidor aún no tiene lecturas registradas"}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
readings.map((reading, idx) => (
|
||||
<tr
|
||||
key={reading.id}
|
||||
className={`group hover:bg-blue-50/40 dark:hover:bg-zinc-800 transition-colors ${
|
||||
idx % 2 === 0
|
||||
? "bg-white dark:bg-zinc-900"
|
||||
: "bg-slate-50/30 dark:bg-zinc-800/50"
|
||||
}`}
|
||||
>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className="text-sm text-slate-600 dark:text-zinc-300">
|
||||
{formatDate(reading.receivedAt)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-right">
|
||||
<span className="text-sm font-semibold text-slate-800 dark:text-zinc-100 tabular-nums">
|
||||
{Number(reading.readingValue).toFixed(2)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-center">
|
||||
<TypeBadge type={reading.readingType} />
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-center">
|
||||
{reading.batteryLevel !== null ? (
|
||||
<BatteryIndicator level={reading.batteryLevel} />
|
||||
) : (
|
||||
<span className="text-slate-400 dark:text-zinc-500">{"—"}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-center">
|
||||
{reading.signalStrength !== null ? (
|
||||
<SignalIndicator strength={reading.signalStrength} />
|
||||
) : (
|
||||
<span className="text-slate-400 dark:text-zinc-500">{"—"}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Footer pagination */}
|
||||
{!loadingReadings && readings.length > 0 && (
|
||||
<div className="px-5 py-4 border-t border-slate-100 dark:border-zinc-700 flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="text-sm text-slate-600 dark:text-zinc-300">
|
||||
Mostrando{" "}
|
||||
<span className="font-semibold text-slate-800 dark:text-zinc-200">
|
||||
{(pagination.page - 1) * pagination.pageSize + 1}
|
||||
</span>{" "}
|
||||
a{" "}
|
||||
<span className="font-semibold text-slate-800 dark:text-zinc-200">
|
||||
{Math.min(pagination.page * pagination.pageSize, pagination.total)}
|
||||
</span>{" "}
|
||||
de{" "}
|
||||
<span className="font-semibold text-slate-800 dark:text-zinc-200">
|
||||
{pagination.total}
|
||||
</span>{" "}
|
||||
resultados
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-slate-600 dark:text-zinc-300">
|
||||
{"Filas por página:"}
|
||||
</span>
|
||||
<select
|
||||
value={pagination.pageSize}
|
||||
onChange={(e) => handlePageSizeChange(Number(e.target.value))}
|
||||
className="px-3 py-1.5 text-sm bg-white dark:bg-zinc-800 dark:text-zinc-100 border border-slate-200 dark:border-zinc-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20"
|
||||
>
|
||||
<option value={10}>10</option>
|
||||
<option value={20}>20</option>
|
||||
<option value={50}>50</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => handlePageChange(pagination.page - 1)}
|
||||
disabled={pagination.page === 1}
|
||||
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-zinc-800 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronLeft size={18} className="text-slate-600 dark:text-zinc-400" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: pagination.totalPages }, (_, i) => i + 1)
|
||||
.filter((pageNum) => {
|
||||
if (pageNum === 1 || pageNum === pagination.totalPages) return true;
|
||||
if (Math.abs(pageNum - pagination.page) <= 1) return true;
|
||||
return false;
|
||||
})
|
||||
.map((pageNum, idx, arr) => {
|
||||
const prevNum = arr[idx - 1];
|
||||
const showEllipsis = prevNum && pageNum - prevNum > 1;
|
||||
return (
|
||||
<div key={pageNum} className="flex items-center">
|
||||
{showEllipsis && (
|
||||
<span className="px-2 text-slate-400 dark:text-zinc-500">
|
||||
...
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handlePageChange(pageNum)}
|
||||
className={`min-w-[36px] px-3 py-1.5 text-sm rounded-lg transition-colors ${
|
||||
pageNum === pagination.page
|
||||
? "bg-blue-600 text-white font-semibold"
|
||||
: "text-slate-600 dark:text-zinc-300 hover:bg-slate-100 dark:hover:bg-zinc-800"
|
||||
}`}
|
||||
>
|
||||
{pageNum}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handlePageChange(pagination.page + 1)}
|
||||
disabled={pagination.page === pagination.totalPages}
|
||||
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-zinc-800 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronRight size={18} className="text-slate-600 dark:text-zinc-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoItem({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="mt-0.5 text-slate-400 dark:text-zinc-500">{icon}</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-500 dark:text-zinc-400 uppercase tracking-wide">
|
||||
{label}
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-slate-800 dark:text-zinc-100 mt-0.5">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConsumptionCard({
|
||||
label,
|
||||
sublabel,
|
||||
value,
|
||||
loading,
|
||||
gradient,
|
||||
}: {
|
||||
label: string;
|
||||
sublabel: string;
|
||||
value: number | null;
|
||||
loading: boolean;
|
||||
gradient: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative bg-white dark:bg-zinc-900 rounded-2xl p-5 shadow-sm shadow-slate-200/50 dark:shadow-none border border-slate-200/60 dark:border-zinc-800 overflow-hidden group hover:shadow-md transition-all">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-slate-500 dark:text-zinc-400">{label}</p>
|
||||
{loading ? (
|
||||
<div className="h-8 w-24 bg-slate-100 dark:bg-zinc-700 rounded-lg animate-pulse" />
|
||||
) : value !== null ? (
|
||||
<p className="text-2xl font-bold text-slate-800 dark:text-white tabular-nums">
|
||||
{value.toFixed(2)}
|
||||
<span className="text-sm font-normal text-slate-400 dark:text-zinc-500 ml-1">{"m³"}</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-2xl font-bold text-slate-400 dark:text-zinc-500">{"—"}</p>
|
||||
)}
|
||||
<p className="text-xs text-slate-400 dark:text-zinc-500">{sublabel}</p>
|
||||
</div>
|
||||
<div className={`w-12 h-12 rounded-xl bg-gradient-to-br ${gradient} flex items-center justify-center text-white shadow-lg group-hover:scale-110 transition-transform`}>
|
||||
<Droplets size={22} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={`absolute -right-8 -bottom-8 w-32 h-32 rounded-full bg-gradient-to-br ${gradient} opacity-5`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TypeBadge({ type }: { type: string | null }) {
|
||||
if (!type) return <span className="text-slate-400 dark:text-zinc-500">{"—"}</span>;
|
||||
|
||||
const styles: Record<string, { bg: string; text: string; dot: string }> = {
|
||||
AUTOMATIC: {
|
||||
bg: "bg-emerald-50 dark:bg-emerald-900/30",
|
||||
text: "text-emerald-700 dark:text-emerald-400",
|
||||
dot: "bg-emerald-500",
|
||||
},
|
||||
MANUAL: {
|
||||
bg: "bg-blue-50 dark:bg-blue-900/30",
|
||||
text: "text-blue-700 dark:text-blue-400",
|
||||
dot: "bg-blue-500",
|
||||
},
|
||||
SCHEDULED: {
|
||||
bg: "bg-violet-50 dark:bg-violet-900/30",
|
||||
text: "text-violet-700 dark:text-violet-400",
|
||||
dot: "bg-violet-500",
|
||||
},
|
||||
};
|
||||
|
||||
const style = styles[type] || {
|
||||
bg: "bg-slate-50 dark:bg-zinc-800",
|
||||
text: "text-slate-700 dark:text-zinc-300",
|
||||
dot: "bg-slate-500",
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-full ${style.bg} ${style.text}`}
|
||||
>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${style.dot}`} />
|
||||
{type}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function BatteryIndicator({ level }: { level: number }) {
|
||||
const getColor = () => {
|
||||
if (level > 50) return "bg-emerald-500";
|
||||
if (level > 20) return "bg-amber-500";
|
||||
return "bg-red-500";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="inline-flex items-center gap-1" title={`Batería: ${level}%`}>
|
||||
<div className="w-6 h-3 border border-slate-300 dark:border-zinc-600 rounded-sm relative overflow-hidden">
|
||||
<div
|
||||
className={`absolute left-0 top-0 bottom-0 ${getColor()} transition-all`}
|
||||
style={{ width: `${level}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 dark:text-zinc-400 font-medium">{level}%</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SignalIndicator({ strength }: { strength: number }) {
|
||||
const getBars = () => {
|
||||
if (strength >= -70) return 4;
|
||||
if (strength >= -85) return 3;
|
||||
if (strength >= -100) return 2;
|
||||
return 1;
|
||||
};
|
||||
|
||||
const bars = getBars();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="inline-flex items-end gap-0.5 h-3"
|
||||
title={`Señal: ${strength} dBm`}
|
||||
>
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`w-1 rounded-sm transition-colors ${
|
||||
i <= bars ? "bg-emerald-500" : "bg-slate-200 dark:bg-zinc-600"
|
||||
}`}
|
||||
style={{ height: `${i * 2 + 4}px` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user