BMS/frontend/app/(dashboard)/energy/page.tsx
2026-03-19 11:32:17 +00:00

330 lines
14 KiB
TypeScript
Raw 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, useCallback } from "react";
import { toast } from "sonner";
import {
fetchEnergyReport, fetchUtilityPower,
type EnergyReport, type UtilityPower,
} from "@/lib/api";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import {
AreaChart, Area, LineChart, Line,
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine,
} from "recharts";
import { Zap, Leaf, RefreshCw, TrendingDown, DollarSign, Activity } from "lucide-react";
import { cn } from "@/lib/utils";
const SITE_ID = "sg-01";
// Singapore grid emission factor (kgCO2e/kWh) — Energy Market Authority 2023
const GRID_EF_KG_CO2_KWH = 0.4168;
// Approximate WUE for air-cooled DC in Singapore climate
const WUE_EST = 1.4;
function KpiTile({
label, value, sub, icon: Icon, iconClass, warn,
}: {
label: string; value: string; sub?: string;
icon?: React.ElementType; iconClass?: string; warn?: boolean;
}) {
return (
<div className="rounded-xl border border-border bg-muted/10 px-4 py-4 space-y-1">
<div className="flex items-center gap-2">
{Icon && <Icon className={cn("w-4 h-4 shrink-0", iconClass ?? "text-primary")} />}
<p className="text-[10px] text-muted-foreground uppercase tracking-wider">{label}</p>
</div>
<p className={cn("text-2xl font-bold tabular-nums leading-none", warn && "text-amber-400")}>{value}</p>
{sub && <p className="text-[10px] text-muted-foreground">{sub}</p>}
</div>
);
}
function SectionHeader({ children }: { children: React.ReactNode }) {
return (
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">{children}</h2>
);
}
export default function EnergyPage() {
const [energy, setEnergy] = useState<EnergyReport | null>(null);
const [utility, setUtility] = useState<UtilityPower | null>(null);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
const [e, u] = await Promise.all([
fetchEnergyReport(SITE_ID, 30),
fetchUtilityPower(SITE_ID).catch(() => null),
]);
setEnergy(e);
setUtility(u);
} catch { toast.error("Failed to load energy data"); }
finally { setLoading(false); }
}, []);
useEffect(() => {
load();
const id = setInterval(load, 60_000);
return () => clearInterval(id);
}, [load]);
const co2e_kg = energy ? Math.round(energy.kwh_total * GRID_EF_KG_CO2_KWH) : null;
const co2e_t = co2e_kg ? (co2e_kg / 1000).toFixed(2) : null;
const wue_water = energy ? (energy.kwh_total * (WUE_EST - 1)).toFixed(0) : null;
const itKwChart = (energy?.pue_trend ?? []).map((d) => ({
day: new Date(d.day).toLocaleDateString("en-GB", { month: "short", day: "numeric" }),
kw: d.avg_it_kw,
pue: d.pue_est,
}));
const avgPue30 = energy?.pue_estimated ?? null;
const pueWarn = avgPue30 != null && avgPue30 > 1.5;
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h1 className="text-xl font-semibold">Energy &amp; Sustainability</h1>
<p className="text-sm text-muted-foreground">Singapore DC01 30-day energy analysis · refreshes every 60s</p>
</div>
<div className="flex items-center gap-3">
{!loading && (
<div className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-full bg-green-500/10 text-green-400 font-semibold">
<Leaf className="w-3.5 h-3.5" /> {co2e_t ? `${co2e_t} tCO₂e this month` : "—"}
</div>
)}
<button
onClick={load}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<RefreshCw className="w-3.5 h-3.5" /> Refresh
</button>
</div>
</div>
{/* Site energy banner */}
{!loading && utility && (
<div className="rounded-xl border border-border bg-muted/10 px-5 py-3 flex items-center gap-8 text-sm flex-wrap">
<div>
<span className="text-muted-foreground">Current IT load: </span>
<strong>{utility.total_kw.toFixed(1)} kW</strong>
</div>
<div>
<span className="text-muted-foreground">Tariff: </span>
<strong>SGD {utility.tariff_sgd_kwh.toFixed(3)}/kWh</strong>
</div>
<div>
<span className="text-muted-foreground">Month-to-date: </span>
<strong>{utility.kwh_month_to_date.toFixed(0)} kWh</strong>
<span className="text-muted-foreground ml-1">
(SGD {utility.cost_sgd_mtd.toFixed(0)})
</span>
</div>
<div className="ml-auto text-xs text-muted-foreground">
Singapore · SP Group grid
</div>
</div>
)}
{/* 30-day KPIs */}
<SectionHeader>30-Day Energy Summary</SectionHeader>
{loading ? (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-24" />)}
</div>
) : (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<KpiTile
label="Total Consumption"
value={energy ? `${energy.kwh_total.toFixed(0)} kWh` : "—"}
sub="last 30 days"
icon={Zap}
iconClass="text-amber-400"
/>
<KpiTile
label="Energy Cost"
value={energy ? `SGD ${energy.cost_sgd.toFixed(0)}` : "—"}
sub={`@ SGD ${energy?.tariff_sgd_kwh.toFixed(3) ?? "—"}/kWh`}
icon={DollarSign}
iconClass="text-green-400"
/>
<KpiTile
label="Avg PUE"
value={avgPue30 != null ? avgPue30.toFixed(3) : "—"}
sub={avgPue30 != null && avgPue30 < 1.4 ? "Excellent" : avgPue30 != null && avgPue30 < 1.6 ? "Good" : "Room to improve"}
icon={Activity}
iconClass={pueWarn ? "text-amber-400" : "text-primary"}
warn={pueWarn}
/>
<KpiTile
label="Annual Estimate"
value={utility ? `SGD ${(utility.cost_sgd_annual_est).toFixed(0)}` : "—"}
sub={utility ? `${utility.kwh_annual_est.toFixed(0)} kWh/yr` : undefined}
icon={TrendingDown}
iconClass="text-muted-foreground"
/>
</div>
)}
{/* IT Load trend */}
{(loading || itKwChart.length > 0) && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Zap className="w-4 h-4 text-amber-400" />
Daily IT Load 30 Days
</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-48" />
) : (
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={itKwChart} margin={{ top: 4, right: 16, left: -10, bottom: 0 }}>
<defs>
<linearGradient id="itKwGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="oklch(0.78 0.17 84)" stopOpacity={0.3} />
<stop offset="95%" stopColor="oklch(0.78 0.17 84)" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="oklch(1 0 0 / 8%)" vertical={false} />
<XAxis
dataKey="day"
tick={{ fontSize: 9, fill: "oklch(0.65 0.04 257)" }}
tickLine={false} axisLine={false}
interval={4}
/>
<YAxis
tick={{ fontSize: 9, fill: "oklch(0.65 0.04 257)" }}
tickLine={false} axisLine={false}
tickFormatter={(v) => `${v} kW`}
domain={["auto", "auto"]}
/>
<Tooltip
contentStyle={{ backgroundColor: "oklch(0.16 0.04 265)", border: "1px solid oklch(1 0 0/9%)", borderRadius: "6px", fontSize: "12px" }}
formatter={(v) => [`${Number(v).toFixed(1)} kW`, "IT Load"]}
/>
<Area type="monotone" dataKey="kw" stroke="oklch(0.78 0.17 84)" fill="url(#itKwGrad)" strokeWidth={2} dot={false} />
</AreaChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
)}
{/* PUE trend */}
{(loading || itKwChart.length > 0) && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Activity className="w-4 h-4 text-primary" />
PUE Trend 30 Days
<span className="text-[10px] font-normal text-muted-foreground ml-1">(target: &lt; 1.4)</span>
</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-48" />
) : (
<ResponsiveContainer width="100%" height={200}>
<LineChart data={itKwChart} margin={{ top: 4, right: 16, left: -10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="oklch(1 0 0 / 8%)" vertical={false} />
<XAxis
dataKey="day"
tick={{ fontSize: 9, fill: "oklch(0.65 0.04 257)" }}
tickLine={false} axisLine={false}
interval={4}
/>
<YAxis
tick={{ fontSize: 9, fill: "oklch(0.65 0.04 257)" }}
tickLine={false} axisLine={false}
domain={[1.0, "auto"]}
/>
<Tooltip
contentStyle={{ backgroundColor: "oklch(0.16 0.04 265)", border: "1px solid oklch(1 0 0/9%)", borderRadius: "6px", fontSize: "12px" }}
formatter={(v) => [Number(v).toFixed(3), "PUE"]}
/>
<ReferenceLine y={1.4} stroke="oklch(0.68 0.14 162)" strokeDasharray="4 4" strokeWidth={1}
label={{ value: "Target 1.4", fontSize: 9, fill: "oklch(0.68 0.14 162)", position: "insideTopRight" }} />
<ReferenceLine y={1.6} stroke="oklch(0.65 0.20 45)" strokeDasharray="4 4" strokeWidth={1}
label={{ value: "Warn 1.6", fontSize: 9, fill: "oklch(0.65 0.20 45)", position: "insideTopRight" }} />
<Line type="monotone" dataKey="pue" stroke="oklch(0.62 0.17 212)" strokeWidth={2} dot={false} activeDot={{ r: 3 }} />
</LineChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
)}
{/* Sustainability */}
<SectionHeader>Sustainability Metrics</SectionHeader>
{loading ? (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-24" />)}
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="rounded-xl border border-green-500/20 bg-green-500/5 px-4 py-4 space-y-2">
<div className="flex items-center gap-2">
<Leaf className="w-4 h-4 text-green-400" />
<p className="text-xs font-semibold text-green-400 uppercase tracking-wider">Carbon Footprint</p>
</div>
<p className="text-2xl font-bold tabular-nums">{co2e_t ?? "—"} tCOe</p>
<p className="text-[10px] text-muted-foreground">
30-day estimate · {energy?.kwh_total.toFixed(0) ?? "—"} kWh × {GRID_EF_KG_CO2_KWH} kgCOe/kWh
</p>
<p className="text-[10px] text-muted-foreground">
Singapore grid emission factor (EMA 2023)
</p>
</div>
<div className="rounded-xl border border-blue-500/20 bg-blue-500/5 px-4 py-4 space-y-2">
<div className="flex items-center gap-2">
<Activity className="w-4 h-4 text-blue-400" />
<p className="text-xs font-semibold text-blue-400 uppercase tracking-wider">Water Usage (WUE)</p>
</div>
<p className="text-2xl font-bold tabular-nums">{WUE_EST.toFixed(1)}</p>
<p className="text-[10px] text-muted-foreground">
Estimated WUE (L/kWh) · air-cooled DC
</p>
<p className="text-[10px] text-muted-foreground">
Est. {wue_water ? `${Number(wue_water).toLocaleString()} L` : "—"} consumed (30d)
</p>
</div>
<div className="rounded-xl border border-primary/20 bg-primary/5 px-4 py-4 space-y-2">
<div className="flex items-center gap-2">
<TrendingDown className="w-4 h-4 text-primary" />
<p className="text-xs font-semibold text-primary uppercase tracking-wider">Efficiency</p>
</div>
<p className={cn("text-2xl font-bold tabular-nums", pueWarn ? "text-amber-400" : "text-green-400")}>
{avgPue30?.toFixed(3) ?? "—"}
</p>
<p className="text-[10px] text-muted-foreground">
Avg PUE · {avgPue30 != null && avgPue30 < 1.4 ? "Excellent — Tier IV class" :
avgPue30 != null && avgPue30 < 1.6 ? "Good — industry average" :
"Above average — optimise cooling"}
</p>
<p className="text-[10px] text-muted-foreground">
IT energy efficiency: {avgPue30 != null ? `${(1 / avgPue30 * 100).toFixed(1)}%` : "—"} of total power to IT
</p>
</div>
</div>
)}
{/* Reference info */}
<div className="rounded-xl border border-border bg-muted/5 px-5 py-4 text-xs text-muted-foreground space-y-1.5">
<p className="font-semibold text-foreground/80">Singapore Energy Context</p>
<p>Grid emission factor: {GRID_EF_KG_CO2_KWH} kgCOe/kWh (EMA 2023, predominantly natural gas + growing solar)</p>
<p>Electricity tariff: SGD {utility?.tariff_sgd_kwh.toFixed(3) ?? "0.298"}/kWh (SP Group commercial rate)</p>
<p>BCA Green Mark: Targeting GoldPLUS certification · PUE target &lt; 1.4</p>
<p className="text-muted-foreground/50 text-[10px] pt-1">
COe and WUE estimates are indicative. Actual values depend on metered chilled water and cooling tower data.
</p>
</div>
</div>
);
}