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

426 lines
21 KiB
TypeScript

"use client";
import { useEffect, useState, useCallback } from "react";
import { toast } from "sonner";
import { fetchReportSummary, fetchKpis, fetchAlarmStats, fetchEnergyReport, reportExportUrl, type ReportSummary, type KpiData, type AlarmStats, type EnergyReport } from "@/lib/api";
import { PageShell } from "@/components/layout/page-shell";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import {
Zap, Thermometer, Bell, AlertTriangle, CheckCircle2, Clock,
Download, Wind, Battery, RefreshCw, Activity, DollarSign,
} from "lucide-react";
import {
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
} from "recharts";
import { cn } from "@/lib/utils";
const SITE_ID = "sg-01";
function UptimeBar({ pct }: { pct: number }) {
const color = pct >= 99 ? "bg-green-500" : pct >= 95 ? "bg-amber-500" : "bg-destructive";
return (
<div className="flex items-center gap-3">
<div className="flex-1 h-2 rounded-full bg-muted overflow-hidden">
<div className={cn("h-full rounded-full transition-all", color)} style={{ width: `${pct}%` }} />
</div>
<span className={cn(
"text-sm font-bold tabular-nums w-16 text-right",
pct >= 99 ? "text-green-400" : pct >= 95 ? "text-amber-400" : "text-destructive"
)}>
{pct.toFixed(1)}%
</span>
</div>
);
}
function ExportCard({ hours, setHours }: { hours: number; setHours: (h: number) => void }) {
const exports: { label: string; type: "power" | "temperature" | "alarms"; icon: React.ElementType }[] = [
{ label: "Power History", type: "power", icon: Zap },
{ label: "Temperature History", type: "temperature", icon: Thermometer },
{ label: "Alarm Log", type: "alarms", icon: Bell },
];
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Download className="w-4 h-4 text-primary" /> Export Data
</CardTitle>
<div className="flex items-center gap-1">
{([24, 48, 168] as const).map((h) => (
<button
key={h}
onClick={() => setHours(h)}
className={cn(
"px-2 py-0.5 rounded text-xs font-medium transition-colors",
hours === h ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
>
{h === 168 ? "7d" : `${h}h`}
</button>
))}
</div>
</div>
</CardHeader>
<CardContent className="space-y-2">
{exports.map(({ label, type, icon: Icon }) => (
<a
key={type}
href={reportExportUrl(type, SITE_ID, hours)}
download
className="flex items-center justify-between rounded-lg border border-border px-3 py-2.5 hover:bg-muted/40 transition-colors group"
>
<div className="flex items-center gap-2 text-sm">
<Icon className="w-4 h-4 text-muted-foreground group-hover:text-primary transition-colors" />
<span>{label}</span>
{type !== "alarms" && (
<span className="text-xs text-muted-foreground">
(last {hours === 168 ? "7 days" : `${hours}h`})
</span>
)}
</div>
<Download className="w-3.5 h-3.5 text-muted-foreground group-hover:text-primary transition-colors" />
</a>
))}
<p className="text-[10px] text-muted-foreground pt-1">CSV format 5-minute bucketed averages</p>
</CardContent>
</Card>
);
}
const RANGE_HOURS: Record<"24h" | "7d" | "30d", number> = { "24h": 24, "7d": 168, "30d": 720 };
const RANGE_DAYS: Record<"24h" | "7d" | "30d", number> = { "24h": 1, "7d": 7, "30d": 30 };
export default function ReportsPage() {
const [summary, setSummary] = useState<ReportSummary | null>(null);
const [kpis, setKpis] = useState<KpiData | null>(null);
const [alarmStats, setAlarmStats] = useState<AlarmStats | null>(null);
const [energy, setEnergy] = useState<EnergyReport | null>(null);
const [loading, setLoading] = useState(true);
const [exportHours, setExportHours] = useState(720);
const [dateRange, setDateRange] = useState<"24h" | "7d" | "30d">("30d");
const load = useCallback(async () => {
try {
const [s, k, a, e] = await Promise.all([
fetchReportSummary(SITE_ID),
fetchKpis(SITE_ID),
fetchAlarmStats(SITE_ID),
fetchEnergyReport(SITE_ID, RANGE_DAYS[dateRange]).catch(() => null),
]);
setSummary(s);
setKpis(k);
setAlarmStats(a);
setEnergy(e);
} catch { toast.error("Failed to load report data"); }
finally { setLoading(false); }
}, [dateRange]);
useEffect(() => { load(); }, [load]);
function handleRangeChange(r: "24h" | "7d" | "30d") {
setDateRange(r);
setExportHours(RANGE_HOURS[r]);
}
return (
<PageShell className="p-6">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h1 className="text-xl font-semibold">Reports</h1>
<p className="text-sm text-muted-foreground">Singapore DC01 site summary &amp; data exports</p>
</div>
<div className="flex items-center gap-2">
{/* Date range picker */}
<div className="flex items-center gap-0.5 rounded-lg border border-border p-0.5 text-xs">
{(["24h", "7d", "30d"] as const).map((r) => (
<button
key={r}
onClick={() => handleRangeChange(r)}
className={cn(
"px-2.5 py-1 rounded-md font-medium transition-colors",
dateRange === r ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
>
{r}
</button>
))}
</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>
{/* Export always visible at top */}
<ExportCard hours={exportHours} setHours={setExportHours} />
{loading ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-48" />)}
</div>
) : !summary ? (
<div className="flex items-center justify-center h-48 text-sm text-muted-foreground">
Unable to load report data.
</div>
) : (
<>
<p className="text-xs text-muted-foreground">
Generated {new Date(summary.generated_at).toLocaleString()} · Showing last {dateRange}
</p>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* KPI snapshot — expanded */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Zap className="w-4 h-4 text-primary" /> Site KPI Snapshot
</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-4 sm:grid-cols-3">
<div className="space-y-1">
<p className="text-xs text-muted-foreground flex items-center gap-1">
<Zap className="w-3 h-3" /> Total Power
</p>
<p className="text-2xl font-bold">
{summary.kpis.total_power_kw} <span className="text-sm font-normal text-muted-foreground">kW</span>
</p>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground flex items-center gap-1">
<Activity className="w-3 h-3" /> PUE
</p>
<p className={cn(
"text-2xl font-bold",
kpis && kpis.pue > 1.5 ? "text-amber-400" : ""
)}>
{kpis?.pue.toFixed(2) ?? "—"}
<span className="text-xs font-normal text-muted-foreground ml-1">target &lt;1.4</span>
</p>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground flex items-center gap-1">
<Thermometer className="w-3 h-3" /> Avg Temp
</p>
<p className={cn(
"text-2xl font-bold",
summary.kpis.avg_temperature >= 28 ? "text-destructive" :
summary.kpis.avg_temperature >= 25 ? "text-amber-400" : ""
)}>
{summary.kpis.avg_temperature}°<span className="text-sm font-normal text-muted-foreground">C</span>
</p>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground flex items-center gap-1">
<Bell className="w-3 h-3" /> Active Alarms
</p>
<p className={cn("text-2xl font-bold", (alarmStats?.active ?? 0) > 0 ? "text-destructive" : "")}>
{alarmStats?.active ?? "—"}
</p>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground flex items-center gap-1">
<AlertTriangle className="w-3 h-3 text-destructive" /> Critical
</p>
<p className={cn("text-2xl font-bold", (alarmStats?.critical ?? 0) > 0 ? "text-destructive" : "")}>
{alarmStats?.critical ?? "—"}
</p>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground flex items-center gap-1">
<CheckCircle2 className="w-3 h-3 text-green-400" /> Resolved
</p>
<p className="text-2xl font-bold text-green-400">
{alarmStats?.resolved ?? "—"}
</p>
</div>
</CardContent>
</Card>
{/* Alarm breakdown */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Bell className="w-4 h-4 text-primary" /> Alarm Breakdown
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-3">
{[
{ label: "Active", value: summary.alarm_stats.active, icon: AlertTriangle, color: "text-destructive" },
{ label: "Acknowledged", value: summary.alarm_stats.acknowledged, icon: Clock, color: "text-amber-400" },
{ label: "Resolved", value: summary.alarm_stats.resolved, icon: CheckCircle2, color: "text-green-400" },
].map(({ label, value, icon: Icon, color }) => (
<div key={label} className="text-center space-y-1 rounded-lg bg-muted/30 p-3">
<Icon className={cn("w-4 h-4 mx-auto", color)} />
<p className={cn("text-xl font-bold", value > 0 && label === "Active" ? "text-destructive" : "")}>{value}</p>
<p className="text-[10px] text-muted-foreground">{label}</p>
</div>
))}
</div>
<div className="flex items-center gap-4 mt-4 pt-3 border-t border-border text-xs">
<span className="text-muted-foreground">By severity:</span>
<span className="flex items-center gap-1 text-destructive font-medium">
<span className="w-1.5 h-1.5 rounded-full bg-destructive" />
{summary.alarm_stats.critical} critical
</span>
<span className="flex items-center gap-1 text-amber-400 font-medium">
<span className="w-1.5 h-1.5 rounded-full bg-amber-500" />
{summary.alarm_stats.warning} warning
</span>
</div>
</CardContent>
</Card>
{/* CRAC uptime */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Wind className="w-4 h-4 text-primary" /> CRAC Uptime (last 24h)
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{summary.crac_uptime.map((crac) => (
<div key={crac.crac_id} className="space-y-1.5">
<div className="flex justify-between text-xs">
<span className="font-medium">{crac.crac_id.toUpperCase()}</span>
<span className="text-muted-foreground">{crac.room_id}</span>
</div>
<UptimeBar pct={crac.uptime_pct} />
</div>
))}
{summary.crac_uptime.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">No CRAC data available</p>
)}
</CardContent>
</Card>
{/* UPS uptime */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Battery className="w-4 h-4 text-primary" /> UPS Uptime (last 24h)
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{summary.ups_uptime.map((ups) => (
<div key={ups.ups_id} className="space-y-1.5">
<div className="text-xs font-medium">{ups.ups_id.toUpperCase()}</div>
<UptimeBar pct={ups.uptime_pct} />
</div>
))}
{summary.ups_uptime.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">No UPS data available</p>
)}
</CardContent>
</Card>
</div>
{/* Energy cost section */}
{energy && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<DollarSign className="w-4 h-4 text-primary" /> Energy Cost Last {energy.period_days} Days
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-6">
<div className="space-y-1">
<p className="text-xs text-muted-foreground">Total kWh</p>
<p className="text-2xl font-bold">{energy.kwh_total.toFixed(0)}</p>
<p className="text-[10px] text-muted-foreground">{energy.from_date} {energy.to_date}</p>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground">Cost ({energy.currency})</p>
<p className="text-2xl font-bold">${energy.cost_sgd.toLocaleString()}</p>
<p className="text-[10px] text-muted-foreground">@ ${energy.tariff_sgd_kwh}/kWh</p>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground">Est. Annual kWh</p>
<p className="text-2xl font-bold">{(energy.pue_trend.length > 0 ? energy.kwh_total / energy.period_days * 365 : 0).toFixed(0)}</p>
<p className="text-[10px] text-muted-foreground">at current pace</p>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground">PUE (estimated)</p>
<p className={cn("text-2xl font-bold", energy.pue_estimated > 1.5 ? "text-amber-400" : "")}>{energy.pue_estimated.toFixed(2)}</p>
<p className="text-[10px] text-muted-foreground">target &lt; 1.4</p>
</div>
</div>
{(() => {
const trend = energy.pue_trend ?? [];
const thisWeek = trend.slice(-7);
const lastWeek = trend.slice(-14, -7);
const thisKwh = thisWeek.reduce((s, d) => s + d.avg_it_kw * 24, 0);
const lastKwh = lastWeek.reduce((s, d) => s + d.avg_it_kw * 24, 0);
const kwhDelta = lastKwh > 0 ? ((thisKwh - lastKwh) / lastKwh * 100) : 0;
const thisAvgKw = thisWeek.length > 0 ? thisWeek.reduce((s, d) => s + d.avg_it_kw, 0) / thisWeek.length : 0;
const lastAvgKw = lastWeek.length > 0 ? lastWeek.reduce((s, d) => s + d.avg_it_kw, 0) / lastWeek.length : 0;
const kwDelta = lastAvgKw > 0 ? ((thisAvgKw - lastAvgKw) / lastAvgKw * 100) : 0;
if (thisWeek.length === 0 || lastWeek.length === 0) return null;
return (
<div className="rounded-lg border border-border bg-muted/10 px-4 py-3 flex items-center gap-6 text-xs flex-wrap mb-6">
<span className="text-muted-foreground font-medium">This week vs last week:</span>
<div className="flex items-center gap-1.5">
<span className="text-muted-foreground">kWh:</span>
<span className="font-bold">{thisKwh.toFixed(0)}</span>
<span className={cn(
"font-semibold",
kwhDelta > 5 ? "text-destructive" : kwhDelta < -5 ? "text-green-400" : "text-muted-foreground"
)}>
({kwhDelta > 0 ? "+" : ""}{kwhDelta.toFixed(1)}%)
</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-muted-foreground">Avg IT load:</span>
<span className="font-bold">{thisAvgKw.toFixed(1)} kW</span>
<span className={cn(
"font-semibold",
kwDelta > 5 ? "text-amber-400" : kwDelta < -5 ? "text-green-400" : "text-muted-foreground"
)}>
({kwDelta > 0 ? "+" : ""}{kwDelta.toFixed(1)}%)
</span>
</div>
<span className="text-muted-foreground text-[10px] ml-auto">based on last {thisWeek.length + lastWeek.length} days of data</span>
</div>
);
})()}
{energy.pue_trend.length > 0 && (
<>
<p className="text-xs text-muted-foreground mb-2 uppercase font-medium tracking-wider">Daily IT Load (kW)</p>
<ResponsiveContainer width="100%" height={140}>
<AreaChart data={energy.pue_trend} margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
<defs>
<linearGradient id="energy-grad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="oklch(0.62 0.17 212)" stopOpacity={0.3} />
<stop offset="95%" stopColor="oklch(0.62 0.17 212)" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="oklch(1 0 0 / 8%)" />
<XAxis dataKey="day" tick={{ fontSize: 9, fill: "oklch(0.65 0.04 257)" }} tickLine={false} axisLine={false}
tickFormatter={(v) => new Date(v).toLocaleDateString([], { month: "short", day: "numeric" })} />
<YAxis tick={{ fontSize: 9, fill: "oklch(0.65 0.04 257)" }} tickLine={false} axisLine={false} />
<Tooltip
contentStyle={{ backgroundColor: "oklch(0.16 0.04 265)", border: "1px solid oklch(1 0 0 / 9%)", borderRadius: "6px", fontSize: "11px" }}
formatter={(v) => [`${Number(v).toFixed(1)} kW`, "Avg IT Load"]}
labelFormatter={(l) => new Date(l).toLocaleDateString()}
/>
<Area type="monotone" dataKey="avg_it_kw" stroke="oklch(0.62 0.17 212)" fill="url(#energy-grad)" strokeWidth={2} dot={false} />
</AreaChart>
</ResponsiveContainer>
</>
)}
</CardContent>
</Card>
)}
</>
)}
</PageShell>
);
}