489 lines
22 KiB
TypeScript
489 lines
22 KiB
TypeScript
"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: 70–90°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: 200–420°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: 40–70°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;
|