BMS/frontend/components/dashboard/generator-detail-sheet.tsx
2026-03-19 11:32:17 +00:00

489 lines
22 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useState } from "react";
import {
fetchGeneratorStatus, fetchGeneratorHistory,
type GeneratorStatus, type GeneratorHistoryPoint,
} from "@/lib/api";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { TimeRangePicker } from "@/components/ui/time-range-picker";
import {
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
ResponsiveContainer, ReferenceLine,
} from "recharts";
import {
Fuel, Zap, Gauge, Thermometer, Wind, Activity, Battery, Settings2,
} from "lucide-react";
import { cn } from "@/lib/utils";
interface Props {
siteId: string;
genId: string | null;
onClose: () => void;
}
function fmt(v: number | null | undefined, dec = 1, unit = "") {
if (v == null) return "—";
return `${v.toFixed(dec)}${unit}`;
}
function formatTime(iso: string) {
try { return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); }
catch { return iso; }
}
const STATE_BADGE: Record<string, string> = {
running: "bg-green-500/15 text-green-400 border-green-500/30",
standby: "bg-blue-500/15 text-blue-400 border-blue-500/30",
test: "bg-amber-500/15 text-amber-400 border-amber-500/30",
fault: "bg-destructive/15 text-destructive border-destructive/30",
unknown: "bg-muted/30 text-muted-foreground border-border",
};
function FillBar({
value, max, color, warn, crit, invert = false,
}: {
value: number | null; max: number; color: string; warn?: number; crit?: number; invert?: boolean;
}) {
const pct = value != null ? Math.min(100, (value / max) * 100) : 0;
const v = value ?? 0;
const barColor =
crit && (invert ? v <= crit : v >= crit) ? "#ef4444" :
warn && (invert ? v <= warn : v >= warn) ? "#f59e0b" :
color;
return (
<div className="h-1.5 rounded-full bg-muted overflow-hidden w-full">
<div className="h-full rounded-full transition-all duration-500"
style={{ width: `${pct}%`, backgroundColor: barColor }} />
</div>
);
}
function SectionLabel({ icon: Icon, title }: { icon: React.ElementType; title: string }) {
return (
<div className="flex items-center gap-1.5 mt-5 mb-2">
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">{title}</span>
</div>
);
}
function StatRow({
label, value, highlight, status,
}: {
label: string; value: string; highlight?: boolean; status?: "ok" | "warn" | "crit";
}) {
return (
<div className="flex justify-between items-center py-1.5 border-b border-border/40 last:border-0">
<span className="text-xs text-muted-foreground">{label}</span>
<div className="flex items-center gap-2">
<span className={cn(
"text-sm font-mono font-medium",
highlight && "text-amber-400",
status === "crit" && "text-destructive",
status === "warn" && "text-amber-400",
status === "ok" && "text-green-400",
)}>{value}</span>
{status && (
<span className={cn(
"text-[10px] font-semibold px-1.5 py-0.5 rounded-full uppercase tracking-wide",
status === "ok" ? "bg-green-500/10 text-green-400" :
status === "warn" ? "bg-amber-500/10 text-amber-400" :
"bg-destructive/10 text-destructive",
)}>
{status === "ok" ? "normal" : status}
</span>
)}
</div>
</div>
);
}
function MiniChart({
data, dataKey, color, label, unit, refLine,
}: {
data: GeneratorHistoryPoint[];
dataKey: keyof GeneratorHistoryPoint;
color: string;
label: string;
unit: string;
refLine?: number;
}) {
const vals = data.map(d => d[dataKey] as number | null).filter(v => v != null) as number[];
const last = vals[vals.length - 1];
return (
<div className="mb-5">
<div className="flex justify-between items-baseline mb-1">
<span className="text-xs text-muted-foreground">{label}</span>
<span className="text-sm font-mono font-medium" style={{ color }}>
{last != null ? `${last.toFixed(1)}${unit}` : "—"}
</span>
</div>
<ResponsiveContainer width="100%" height={60}>
<LineChart data={data} margin={{ top: 2, right: 4, left: -28, bottom: 0 }}>
<XAxis dataKey="bucket" tickFormatter={formatTime} tick={{ fontSize: 9 }} interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 9 }} domain={["auto", "auto"]} />
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" />
<Tooltip
formatter={(v: unknown) => [`${(v as number).toFixed(1)}${unit}`, label]}
labelFormatter={(l: unknown) => formatTime(String(l))}
contentStyle={{ fontSize: 11, background: "#1e1e2e", border: "1px solid #333" }}
/>
{refLine != null && <ReferenceLine y={refLine} stroke="rgba(251,191,36,0.4)" strokeDasharray="4 2" />}
<Line type="monotone" dataKey={dataKey} stroke={color} dot={false} strokeWidth={1.5} connectNulls />
</LineChart>
</ResponsiveContainer>
</div>
);
}
// ── Overview tab ──────────────────────────────────────────────────────────────
function OverviewTab({ gen }: { gen: GeneratorStatus }) {
const isRunning = gen.state === "running" || gen.state === "test";
const fuelLow = (gen.fuel_pct ?? 100) < 25;
const fuelCrit = (gen.fuel_pct ?? 100) < 10;
const loadWarn = (gen.load_pct ?? 0) > 75;
const loadCrit = (gen.load_pct ?? 0) > 90;
// Estimated runtime from fuel and consumption rate
const runtimeH = gen.fuel_rate_lph && gen.fuel_rate_lph > 0 && gen.fuel_litres
? gen.fuel_litres / gen.fuel_rate_lph
: gen.fuel_litres && gen.load_kw && gen.load_kw > 0
? gen.fuel_litres / (gen.load_kw * 0.27)
: null;
// Computed electrical values
const outputKva = gen.load_kw && gen.power_factor && gen.power_factor > 0
? gen.load_kw / gen.power_factor : null;
const outputCurrent = gen.load_kw && gen.voltage_v && gen.voltage_v > 0 && gen.power_factor
? (gen.load_kw * 1000) / (gen.voltage_v * 1.732 * gen.power_factor) : null;
return (
<div>
{/* Fuel */}
<SectionLabel icon={Fuel} title="Fuel" />
<div className="rounded-lg bg-muted/20 px-4 py-3 space-y-2">
<div className="flex justify-between items-baseline mb-1">
<span className="text-xs text-muted-foreground">Tank Level</span>
<span className={cn(
"text-2xl font-bold tabular-nums",
fuelCrit ? "text-destructive" : fuelLow ? "text-amber-400" : "text-green-400",
)}>
{fmt(gen.fuel_pct, 1)}%
</span>
</div>
<FillBar value={gen.fuel_pct} max={100} color="#22c55e" warn={25} crit={10} />
<div className="flex justify-between text-xs text-muted-foreground mt-1">
<span>{gen.fuel_litres != null ? `${gen.fuel_litres.toFixed(0)} L remaining` : "—"}</span>
{gen.fuel_rate_lph != null && gen.fuel_rate_lph > 0 && (
<span>{fmt(gen.fuel_rate_lph, 1)} L/hr consumption</span>
)}
</div>
{runtimeH != null && (
<div className={cn(
"text-xs font-semibold text-right",
runtimeH < 4 ? "text-destructive" : runtimeH < 12 ? "text-amber-400" : "text-green-400",
)}>
Est. runtime: {Math.floor(runtimeH)}h {Math.round((runtimeH % 1) * 60)}m
</div>
)}
</div>
{/* Load */}
<SectionLabel icon={Zap} title="Load" />
<div className="rounded-lg bg-muted/20 px-4 py-3 space-y-2">
<div className="flex justify-between items-baseline mb-1">
<span className="text-xs text-muted-foreground">Active Load</span>
<span className={cn(
"text-2xl font-bold tabular-nums",
loadCrit ? "text-destructive" : loadWarn ? "text-amber-400" : "text-foreground",
)}>
{fmt(gen.load_kw, 1)} kW
</span>
</div>
<FillBar value={gen.load_pct} max={100} color="#60a5fa" warn={75} crit={90} />
<div className="flex justify-between text-xs mt-1">
<span className={cn(
"font-medium tabular-nums",
loadCrit ? "text-destructive" : loadWarn ? "text-amber-400" : "text-muted-foreground",
)}>
{fmt(gen.load_pct, 1)}% of 500 kW rated
</span>
{outputKva != null && (
<span className="text-muted-foreground">{outputKva.toFixed(1)} kVA apparent</span>
)}
</div>
</div>
{/* Quick stats */}
<SectionLabel icon={Activity} title="Quick Stats" />
<div className="bg-muted/30 rounded-lg px-3 py-1">
<StatRow label="Output Current" value={outputCurrent != null ? `${outputCurrent.toFixed(1)} A` : "—"} />
<StatRow label="Power Factor" value={fmt(gen.power_factor, 3)} />
<StatRow label="Run Hours" value={gen.run_hours != null ? `${gen.run_hours.toLocaleString()} h` : "—"} />
</div>
</div>
);
}
// ── Engine tab ────────────────────────────────────────────────────────────────
function EngineTab({ gen }: { gen: GeneratorStatus }) {
const coolantWarn = (gen.coolant_temp_c ?? 0) > 85;
const coolantCrit = (gen.coolant_temp_c ?? 0) > 95;
const exhaustWarn = (gen.exhaust_temp_c ?? 0) > 420;
const exhaustCrit = (gen.exhaust_temp_c ?? 0) > 480;
const altTempWarn = (gen.alternator_temp_c ?? 0) > 70;
const altTempCrit = (gen.alternator_temp_c ?? 0) > 85;
const oilLow = (gen.oil_pressure_bar ?? 99) < 2.0;
const oilWarn = (gen.oil_pressure_bar ?? 99) < 3.0;
const battLow = (gen.battery_v ?? 99) < 23.0;
const battWarn = (gen.battery_v ?? 99) < 24.0;
const rpmWarn = gen.engine_rpm != null && gen.engine_rpm > 0 && Math.abs(gen.engine_rpm - 1500) > 20;
return (
<div>
<p className="text-xs text-muted-foreground mt-4 mb-4 leading-relaxed">
Mechanical health of the diesel engine. Normal operating range assumes a 50 Hz, 4-pole synchronous generator at 1500 RPM.
</p>
{/* Temperature gauges */}
<SectionLabel icon={Thermometer} title="Temperatures" />
<div className="space-y-3">
<div>
<div className="flex justify-between text-xs mb-1">
<span className="text-muted-foreground">Coolant</span>
<span className={cn("font-mono", coolantCrit ? "text-destructive" : coolantWarn ? "text-amber-400" : "text-foreground")}>
{fmt(gen.coolant_temp_c, 1)}°C
</span>
</div>
<FillBar value={gen.coolant_temp_c} max={120} color="#34d399" warn={85} crit={95} />
<p className="text-[10px] text-muted-foreground text-right mt-0.5">Normal: 7090°C</p>
</div>
<div>
<div className="flex justify-between text-xs mb-1">
<span className="text-muted-foreground">Exhaust Stack</span>
<span className={cn("font-mono", exhaustCrit ? "text-destructive" : exhaustWarn ? "text-amber-400" : "text-foreground")}>
{fmt(gen.exhaust_temp_c, 0)}°C
</span>
</div>
<FillBar value={gen.exhaust_temp_c} max={600} color="#f97316" warn={420} crit={480} />
<p className="text-[10px] text-muted-foreground text-right mt-0.5">Normal: 200420°C at load</p>
</div>
<div>
<div className="flex justify-between text-xs mb-1">
<span className="text-muted-foreground">Alternator Windings</span>
<span className={cn("font-mono", altTempCrit ? "text-destructive" : altTempWarn ? "text-amber-400" : "text-foreground")}>
{fmt(gen.alternator_temp_c, 1)}°C
</span>
</div>
<FillBar value={gen.alternator_temp_c} max={110} color="#a78bfa" warn={70} crit={85} />
<p className="text-[10px] text-muted-foreground text-right mt-0.5">Normal: 4070°C at load</p>
</div>
</div>
{/* Engine mechanical */}
<SectionLabel icon={Settings2} title="Mechanical" />
<div className="bg-muted/30 rounded-lg px-3 py-1">
<StatRow
label="Engine RPM"
value={gen.engine_rpm != null && gen.engine_rpm > 0 ? `${gen.engine_rpm.toFixed(0)} RPM` : "— (stopped)"}
status={rpmWarn ? "warn" : gen.engine_rpm && gen.engine_rpm > 0 ? "ok" : undefined}
/>
<StatRow
label="Oil Pressure"
value={gen.oil_pressure_bar != null && gen.oil_pressure_bar > 0 ? `${gen.oil_pressure_bar.toFixed(2)} bar` : "— (stopped)"}
status={oilLow ? "crit" : oilWarn ? "warn" : gen.oil_pressure_bar && gen.oil_pressure_bar > 0 ? "ok" : undefined}
/>
<StatRow
label="Run Hours"
value={gen.run_hours != null ? `${gen.run_hours.toLocaleString()} h` : "—"}
/>
</div>
{/* Starter battery */}
<SectionLabel icon={Battery} title="Starter Battery" />
<div className="bg-muted/30 rounded-lg px-3 py-1">
<StatRow
label="Battery Voltage (24 V system)"
value={fmt(gen.battery_v, 2, " V")}
status={battLow ? "crit" : battWarn ? "warn" : "ok"}
/>
<div className="py-1.5">
<FillBar value={gen.battery_v} max={29} color="#22c55e" warn={24} crit={23} invert />
</div>
<p className="text-[10px] text-muted-foreground pb-1.5">
Float charge: 27.2 V · Low threshold: 24 V · Critical: 23 V
</p>
</div>
</div>
);
}
// ── Electrical tab ────────────────────────────────────────────────────────────
function ElectricalTab({ gen }: { gen: GeneratorStatus }) {
const freqWarn = gen.frequency_hz != null && gen.frequency_hz > 0 && Math.abs(gen.frequency_hz - 50) > 0.3;
const freqCrit = gen.frequency_hz != null && gen.frequency_hz > 0 && Math.abs(gen.frequency_hz - 50) > 0.5;
const voltWarn = gen.voltage_v != null && gen.voltage_v > 0 && Math.abs(gen.voltage_v - 415) > 10;
const outputKva = gen.load_kw && gen.power_factor && gen.power_factor > 0
? gen.load_kw / gen.power_factor : null;
const outputKvar = outputKva && gen.load_kw
? Math.sqrt(Math.max(0, outputKva ** 2 - gen.load_kw ** 2)) : null;
const outputCurrent = gen.load_kw && gen.voltage_v && gen.voltage_v > 0 && gen.power_factor
? (gen.load_kw * 1000) / (gen.voltage_v * 1.732 * gen.power_factor) : null;
const phaseCurrentA = outputCurrent ? outputCurrent / 3 : null;
return (
<div>
<p className="text-xs text-muted-foreground mt-4 mb-4 leading-relaxed">
AC output electrical parameters. The generator feeds the ATS which transfers site load during utility failure. Rated 500 kW / 555 kVA at 0.90 PF, 415 V, 50 Hz.
</p>
{/* AC output hero */}
<div className="rounded-lg bg-muted/20 px-4 py-3 grid grid-cols-3 gap-3 mb-4 text-center">
{[
{ label: "Output Voltage", value: gen.voltage_v && gen.voltage_v > 0 ? `${gen.voltage_v.toFixed(0)} V` : "—", warn: voltWarn },
{ label: "Frequency", value: gen.frequency_hz && gen.frequency_hz > 0 ? `${gen.frequency_hz.toFixed(2)} Hz` : "—", warn: freqWarn || freqCrit },
{ label: "Power Factor", value: fmt(gen.power_factor, 3), warn: false },
].map(({ label, value, warn }) => (
<div key={label}>
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-0.5">{label}</p>
<p className={cn("text-lg font-bold tabular-nums", warn ? "text-amber-400" : "text-foreground")}>
{value}
</p>
</div>
))}
</div>
<SectionLabel icon={Zap} title="Power Output" />
<div className="bg-muted/30 rounded-lg px-3 py-1">
<StatRow label="Active Power (kW)" value={fmt(gen.load_kw, 1, " kW")} />
<StatRow label="Apparent Power (kVA)" value={outputKva != null ? `${outputKva.toFixed(1)} kVA` : "—"} />
<StatRow label="Reactive Power (kVAR)" value={outputKvar != null ? `${outputKvar.toFixed(1)} kVAR` : "—"} />
<StatRow label="Load %" value={fmt(gen.load_pct, 1, "%")} />
</div>
<SectionLabel icon={Activity} title="Current (3-Phase)" />
<div className="bg-muted/30 rounded-lg px-3 py-1">
<StatRow label="Total Output Current" value={outputCurrent != null ? `${outputCurrent.toFixed(1)} A` : "—"} />
<StatRow label="Per Phase (balanced)" value={phaseCurrentA != null ? `${phaseCurrentA.toFixed(1)} A` : "—"} />
<StatRow label="Rated Current (500 kW)" value="694 A" />
</div>
<SectionLabel icon={Wind} title="Frequency" />
<div className="space-y-1">
<div className="flex justify-between text-xs mb-1">
<span className="text-muted-foreground">Output Frequency</span>
<span className={cn("font-mono", freqCrit ? "text-destructive" : freqWarn ? "text-amber-400" : "text-green-400")}>
{gen.frequency_hz && gen.frequency_hz > 0 ? `${gen.frequency_hz.toFixed(2)} Hz` : "—"}
</span>
</div>
<FillBar value={gen.frequency_hz ?? 0} max={55} color="#34d399" warn={50.3} crit={50.5} />
<p className="text-[10px] text-muted-foreground text-right mt-0.5">
Nominal 50 Hz · Grid tolerance ±0.5 Hz
</p>
</div>
</div>
);
}
// ── Trends tab ────────────────────────────────────────────────────────────────
function TrendsTab({ history }: { history: GeneratorHistoryPoint[] }) {
if (history.length < 2) {
return (
<p className="text-sm text-muted-foreground mt-6 text-center">
Not enough history yet data accumulates every 5 minutes.
</p>
);
}
return (
<div className="mt-4">
<MiniChart data={history} dataKey="load_pct" color="#60a5fa" label="Load %" unit="%" refLine={90} />
<MiniChart data={history} dataKey="fuel_pct" color="#22c55e" label="Fuel Level" unit="%" refLine={25} />
<MiniChart data={history} dataKey="coolant_temp_c" color="#34d399" label="Coolant Temp" unit="°C" refLine={95} />
<MiniChart data={history} dataKey="exhaust_temp_c" color="#f97316" label="Exhaust Temp" unit="°C" refLine={420} />
<MiniChart data={history} dataKey="frequency_hz" color="#a78bfa" label="Frequency" unit=" Hz" refLine={50.5} />
<MiniChart data={history} dataKey="alternator_temp_c" color="#e879f9" label="Alternator Temp" unit="°C" refLine={85} />
</div>
);
}
// ── Sheet ─────────────────────────────────────────────────────────────────────
export function GeneratorDetailSheet({ siteId, genId, onClose }: Props) {
const [hours, setHours] = useState(6);
const [status, setStatus] = useState<GeneratorStatus | null>(null);
const [history, setHistory] = useState<GeneratorHistoryPoint[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!genId) return;
setLoading(true);
Promise.all([
fetchGeneratorStatus(siteId),
fetchGeneratorHistory(siteId, genId, hours),
]).then(([statuses, hist]) => {
setStatus(statuses.find(g => g.gen_id === genId) ?? null);
setHistory(hist);
}).finally(() => setLoading(false));
}, [siteId, genId, hours]);
return (
<Sheet open={genId != null} onOpenChange={open => { if (!open) onClose(); }}>
<SheetContent className="w-[500px] sm:w-[560px] overflow-y-auto" side="right">
<SheetHeader className="mb-2">
<SheetTitle className="flex items-center justify-between">
<span>{genId?.toUpperCase() ?? "Generator"}</span>
{status && (
<Badge
variant="outline"
className={cn("text-xs capitalize border", STATUS_BADGE_CLASS[status.state] ?? STATUS_BADGE_CLASS.unknown)}
>
{status.state}
</Badge>
)}
</SheetTitle>
{status && (
<p className="text-xs text-muted-foreground">
Diesel generator · 500 kW rated · 2,000 L tank
{status.run_hours != null ? ` · ${status.run_hours.toLocaleString()} run hours` : ""}
</p>
)}
</SheetHeader>
{loading && !status ? (
<div className="space-y-3 mt-4">
{Array.from({ length: 8 }).map((_, i) => <Skeleton key={i} className="h-8 w-full" />)}
</div>
) : status ? (
<Tabs defaultValue="overview">
<div className="flex items-center justify-between mt-3 mb-1">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="engine">Engine</TabsTrigger>
<TabsTrigger value="electrical">Electrical</TabsTrigger>
<TabsTrigger value="trends">Trends</TabsTrigger>
</TabsList>
<TimeRangePicker value={hours} onChange={setHours} />
</div>
<TabsContent value="overview"> <OverviewTab gen={status} /></TabsContent>
<TabsContent value="engine"> <EngineTab gen={status} /></TabsContent>
<TabsContent value="electrical"> <ElectricalTab gen={status} /></TabsContent>
<TabsContent value="trends"> <TrendsTab history={history} /></TabsContent>
</Tabs>
) : (
<p className="text-sm text-muted-foreground mt-4">No data available for {genId}.</p>
)}
</SheetContent>
</Sheet>
);
}
const STATUS_BADGE_CLASS: Record<string, string> = STATE_BADGE;