Spaces:
Sleeping
Sleeping
| 'use client'; | |
| import Link from "next/link"; | |
| import { useRouter } from "next/navigation"; | |
| import { useState } from "react"; | |
| import { ChevronLeft, MapPin, Droplets, Wind, Thermometer, Hexagon } from "lucide-react"; | |
| import { useSensor } from "@/hooks/use-sensors"; | |
| import { useReadings } from "@/hooks/use-readings"; | |
| import { LoadingScreen } from "@/components/LoadingScreen"; | |
| import { TelemetryChart } from "@/components/TelemetryChart"; | |
| import { KpiCard, getPhStatus, getTurbidityStatus, getTempStatus, getHardnessStatus } from "@/components/KpiCard"; | |
| import { CreateReadingDialog } from "@/components/CreateReadingDialog"; | |
| export default function SensorDetailClient({ sensorId }: { sensorId: string }) { | |
| const [timeRange, setTimeRange] = useState<"1h" | "1d" | "1w" | "1m">("1d"); | |
| const { data: sensor, isLoading: sensorLoading } = useSensor(sensorId || ""); | |
| const { data: readings, isLoading: readingsLoading } = useReadings(sensorId, timeRange); | |
| if (sensorLoading) return <LoadingScreen message="ESTABLISHING NODE UPLINK..." />; | |
| if (!sensor) { | |
| return ( | |
| <div className="flex-1 flex flex-col items-center justify-center text-center p-8"> | |
| <div className="neon-border border-red-500 bg-red-950/20 p-8 rounded-2xl max-w-md"> | |
| <h2 className="text-red-400 font-display text-2xl tracking-[0.2em] mb-4">UPLINK FAILED</h2> | |
| <p className="text-slate-400 font-mono text-sm mb-6">Sensor Node {sensorId} could not be located in the grid database.</p> | |
| <Link href="/" className="inline-flex items-center gap-2 text-cyan-500 hover:text-cyan-400 font-display tracking-widest uppercase transition-colors"> | |
| <ChevronLeft className="h-4 w-4" /> Return to Grid | |
| </Link> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // Ensure chronological order for Recharts | |
| const sortedReadings = [...(readings || [])].sort( | |
| (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() | |
| ); | |
| const latestReading = sortedReadings[sortedReadings.length - 1]; | |
| return ( | |
| <div className="flex-1 overflow-y-auto p-4 md:p-8 custom-scrollbar"> | |
| <div className="max-w-7xl mx-auto space-y-8 pb-12"> | |
| {/* Header Section */} | |
| <div className="flex flex-col md:flex-row md:items-end justify-between gap-6 border-b border-slate-800 pb-8 relative"> | |
| <div className="absolute bottom-0 left-0 w-1/3 h-[1px] bg-gradient-to-r from-cyan-500 to-transparent"></div> | |
| <div> | |
| <Link href="/" className="inline-flex items-center gap-2 text-cyan-600 hover:text-cyan-400 transition-colors mb-6 font-display uppercase tracking-widest text-xs bg-cyan-950/20 px-3 py-1.5 rounded-full border border-cyan-900/50"> | |
| <ChevronLeft className="h-4 w-4" /> Return to Global Grid | |
| </Link> | |
| <h1 className="text-4xl md:text-5xl font-display font-bold text-white tracking-widest flex items-center gap-4 drop-shadow-[0_0_15px_rgba(255,255,255,0.1)]"> | |
| <MapPin className="text-cyan-400 h-10 w-10 drop-shadow-[0_0_10px_rgba(0,243,255,0.5)]" /> | |
| {sensor.locationName || "Unassigned Sector"} | |
| </h1> | |
| <div className="flex gap-4 mt-4 font-mono text-xs tracking-widest"> | |
| <span className="text-cyan-500 border border-cyan-500/30 bg-cyan-950/30 px-3 py-1 rounded">ID: {sensor.sensorId}</span> | |
| <span className="text-slate-400 border border-slate-800 bg-[#0A0A0F] px-3 py-1 rounded">LAT: {sensor.latitude}</span> | |
| <span className="text-slate-400 border border-slate-800 bg-[#0A0A0F] px-3 py-1 rounded">LNG: {sensor.longitude}</span> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-4"> | |
| <CreateReadingDialog sensorId={sensor.sensorId} /> | |
| </div> | |
| </div> | |
| {/* Real-time KPI Cards */} | |
| <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5"> | |
| <KpiCard | |
| title="pH Level" | |
| value={latestReading?.ph} | |
| unit="pH" | |
| icon={Droplets} | |
| status={getPhStatus(latestReading?.ph)} | |
| /> | |
| <KpiCard | |
| title="Turbidity" | |
| value={latestReading?.turbidity} | |
| unit="NTU" | |
| icon={Wind} | |
| status={getTurbidityStatus(latestReading?.turbidity)} | |
| /> | |
| <KpiCard | |
| title="Temperature" | |
| value={latestReading?.temperature} | |
| unit="°C" | |
| icon={Thermometer} | |
| status={getTempStatus(latestReading?.temperature)} | |
| /> | |
| <KpiCard | |
| title="Hardness" | |
| value={latestReading?.hardness} | |
| unit="mg/L" | |
| icon={Hexagon} | |
| status={getHardnessStatus(latestReading?.hardness)} | |
| /> | |
| </div> | |
| {/* Potability Status Banner */} | |
| {latestReading && ( | |
| <div className={`rounded-2xl p-5 border-2 flex items-center gap-4 ${ | |
| getPotabilityBannerStyle(latestReading.potability) | |
| }`}> | |
| <div className={`text-3xl ${ | |
| getPotabilityGlow(latestReading.potability) | |
| }`}> | |
| {getPotabilityIcon(latestReading.potability)} | |
| </div> | |
| <div> | |
| <p className={`font-display text-xl tracking-widest uppercase ${ | |
| getPotabilityTextColor(latestReading.potability) | |
| }`}> | |
| {getPotabilityStatusText(latestReading.potability)} | |
| </p> | |
| <p className="text-xs text-slate-500 font-mono mt-1"> | |
| ML INFERENCE {latestReading.potability !== null ? `— ${(latestReading.potability * 100).toFixed(2)}% CONFIDENCE` : '— AWAITING MODEL'} | |
| </p> | |
| </div> | |
| </div> | |
| )} | |
| {/* Time Range Selector & Charts */} | |
| <div className="pt-6"> | |
| <div className="flex justify-between items-end mb-6"> | |
| <h2 className="font-display text-2xl text-slate-200 tracking-[0.2em]">TELEMETRY HISTORY</h2> | |
| <div className="flex bg-[#0A0A0F]/80 backdrop-blur rounded-lg p-1 border border-slate-800 shadow-lg"> | |
| {(["1h", "1d", "1w", "1m"] as const).map((tr) => ( | |
| <button | |
| key={tr} | |
| onClick={() => setTimeRange(tr)} | |
| className={`px-5 py-2 text-xs font-display tracking-widest uppercase rounded transition-all duration-300 ${ | |
| timeRange === tr | |
| ? "bg-cyan-500/20 text-cyan-400 shadow-[inset_0_0_15px_rgba(0,243,255,0.15)] border border-cyan-500/30" | |
| : "text-slate-500 hover:text-slate-300 border border-transparent" | |
| }`} | |
| > | |
| {tr} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| {readingsLoading ? ( | |
| <div className="h-[600px] rounded-2xl glass-panel flex items-center justify-center"> | |
| <LoadingScreen message="DOWNLOADING TELEMETRY..." /> | |
| </div> | |
| ) : ( | |
| <div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> | |
| <TelemetryChart | |
| data={sortedReadings} | |
| dataKey="ph" | |
| color="#00f3ff" | |
| title="pH Analysis" | |
| unit="pH" | |
| /> | |
| <TelemetryChart | |
| data={sortedReadings} | |
| dataKey="turbidity" | |
| color="#00ff66" | |
| title="Turbidity Matrix" | |
| unit="NTU" | |
| /> | |
| <TelemetryChart | |
| data={sortedReadings} | |
| dataKey="temperature" | |
| color="#ff3366" | |
| title="Thermal Array" | |
| unit="°C" | |
| /> | |
| <TelemetryChart | |
| data={sortedReadings} | |
| dataKey="hardness" | |
| color="#bf00ff" | |
| title="Hardness Index" | |
| unit="mg/L" | |
| /> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function getPotabilityColor(potability: number | null | undefined): string { | |
| if (potability === null || potability === undefined) return 'text-gray-500'; | |
| const percentage = potability * 100; | |
| if (percentage > 80) return 'text-green-600'; | |
| if (percentage >= 30) return 'text-yellow-600'; | |
| return 'text-red-600'; | |
| } | |
| function getPotabilityIcon(potability: number | null | undefined): string { | |
| if (potability === null || potability === undefined) return '⏳'; | |
| const percentage = potability * 100; | |
| if (percentage > 80) return '✅'; | |
| if (percentage >= 30) return '⚠️'; | |
| return '⛔'; | |
| } | |
| function getPotabilityBannerStyle(potability: number | null | undefined): string { | |
| if (potability === null || potability === undefined) return 'bg-slate-900/50 border-slate-700'; | |
| const percentage = potability * 100; | |
| if (percentage > 80) return 'bg-green-950/30 border-green-500/50 shadow-[0_0_20px_rgba(74,222,128,0.1)]'; | |
| if (percentage >= 30) return 'bg-yellow-950/30 border-yellow-500/50 shadow-[0_0_20px_rgba(251,191,36,0.1)]'; | |
| return 'bg-red-950/30 border-red-500/50 shadow-[0_0_20px_rgba(248,113,113,0.1)]'; | |
| } | |
| function getPotabilityGlow(potability: number | null | undefined): string { | |
| if (potability === null || potability === undefined) return ''; | |
| const percentage = potability * 100; | |
| if (percentage > 80) return 'drop-shadow-[0_0_8px_rgba(74,222,128,0.8)]'; | |
| if (percentage >= 30) return 'drop-shadow-[0_0_8px_rgba(251,191,36,0.8)]'; | |
| return 'drop-shadow-[0_0_8px_rgba(248,113,113,0.8)]'; | |
| } | |
| function getPotabilityTextColor(potability: number | null | undefined): string { | |
| if (potability === null || potability === undefined) return 'text-slate-500'; | |
| const percentage = potability * 100; | |
| if (percentage > 80) return 'text-green-400'; | |
| if (percentage >= 30) return 'text-yellow-400'; | |
| return 'text-red-400'; | |
| } | |
| function getPotabilityStatusText(potability: number | null | undefined): string { | |
| if (potability === null || potability === undefined) return 'POTABILITY UNKNOWN'; | |
| const percentage = potability * 100; | |
| if (percentage > 80) return 'HIGH POTABILITY'; | |
| if (percentage >= 30) return 'MODERATE POTABILITY'; | |
| return 'LOW POTABILITY'; | |
| } | |