new layout test

This commit is contained in:
Syasya 2025-08-12 15:49:20 +08:00
parent 81a00d72e4
commit 9ab01d2655
7 changed files with 299 additions and 92 deletions

View File

@ -10,6 +10,8 @@ import html2canvas from 'html2canvas';
import jsPDF from 'jspdf';
import dynamic from 'next/dynamic';
import { fetchPowerTimeseries } from '@/app/utils/api';
import KpiTop from '@/components/dashboards/kpitop';
import KpiBottom from '@/components/dashboards/kpibottom';
const EnergyLineChart = dynamic(() => import('@/components/dashboards/EnergyLineChart'), {
ssr: false,
@ -19,6 +21,16 @@ const MonthlyBarChart = dynamic(() => import('@/components/dashboards/MonthlyBar
ssr: false,
});
type MonthlyKPI = {
site: string; month: string;
yield_kwh: number | null; consumption_kwh: number | null; grid_draw_kwh: number | null;
efficiency: number | null; peak_demand_kw: number | null;
avg_power_factor: number | null; load_factor: number | null;
error?: string;
};
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000';
import { SiteName, SiteDetails, mockSiteData } from '@/types/SiteData';
const AdminDashboard = () => {
@ -34,6 +46,8 @@ const AdminDashboard = () => {
const siteParam = searchParams?.get('site');
const validSiteNames: SiteName[] = ['Site A', 'Site B', 'Site C'];
const [kpi, setKpi] = useState<MonthlyKPI | null>(null);
const [selectedSite, setSelectedSite] = useState<SiteName>(() => {
if (siteParam && validSiteNames.includes(siteParam as SiteName)) {
return siteParam as SiteName;
@ -94,6 +108,23 @@ const AdminDashboard = () => {
fetchData();
}, [selectedSite]);
// fetch KPI monthly (uses your FastAPI)
useEffect(() => {
const siteId = siteIdMap[selectedSite];
const url = `${API}/kpi/monthly?site=${encodeURIComponent(siteId)}&month=${currentMonth}`;
fetch(url).then(r => r.json()).then(setKpi).catch(console.error);
}, [selectedSite]);
// derived values with safe fallbacks
const yieldKwh = kpi?.yield_kwh ?? 0;
const consumptionKwh = kpi?.consumption_kwh ?? 0;
const gridDrawKwh = kpi?.grid_draw_kwh ?? Math.max(0, consumptionKwh - yieldKwh);
const efficiencyPct = (kpi?.efficiency ?? 0) * 100;
const powerFactor = kpi?.avg_power_factor ?? 0;
const loadFactor = (kpi?.load_factor ?? 0);
// ...your existing code above return()
// Update query string when site is changed manually
const handleSiteChange = (newSite: SiteName) => {
setSelectedSite(newSite);
@ -169,7 +200,7 @@ const AdminDashboard = () => {
<div className="px-6 space-y-6">
<h1 className="text-lg font-semibold dark:text-white">Admin Dashboard</h1>
<div className="grid md:grid-cols-2 gap-6">
<div className="grid gap-6">
<div className="space-y-4">
<SiteSelector
selectedSite={selectedSite}
@ -183,22 +214,37 @@ const AdminDashboard = () => {
lastSyncTimestamp={currentSiteDetails.lastSyncTimestamp}
/>
</div>
<div>
<KPI_Table siteId={siteIdMap[selectedSite]} month={currentMonth} />
</div>
</div>
<div className="grid md:grid-cols-2 gap-6 lg:flex-col justify-center">
<div ref={energyChartRef} className="pb-5">
<EnergyLineChart siteId={siteIdMap[selectedSite]} />
{/* TOP 3 CARDS */}
<div className="space-y-4">
<KpiTop
yieldKwh={yieldKwh}
consumptionKwh={consumptionKwh}
gridDrawKwh={gridDrawKwh}
/>
</div>
<div ref={monthlyChartRef} className="pb-5">
<div ref={energyChartRef} className="pb-5">
<EnergyLineChart siteId={siteIdMap[selectedSite]} />
</div>
{/* BOTTOM 3 PANELS */}
<KpiBottom
efficiencyPct={efficiencyPct}
powerFactor={powerFactor}
loadFactor={loadFactor}
middle={
<div ref={monthlyChartRef} className="transform scale-90 origin-top">
<MonthlyBarChart siteId={siteIdMap[selectedSite]} />
</div>
</div>
}
right={
<div className="flex items-center justify-center w-full px-3 text-center">
<div className="text-3xl font-semibold">
{(kpi?.peak_demand_kw ?? 0).toFixed(2)} kW
</div>
</div>
}
/>
<div className="flex flex-col md:flex-row gap-4 justify-center">
<button onClick={handlePDFExport} className="text-sm lg:text-lg btn-primary">
Export Chart Images to PDF

View File

@ -48,3 +48,29 @@ export async function fetchForecast(
return res.json();
}
export type MonthlyKPI = {
site: string;
month: string; // "YYYY-MM"
yield_kwh: number | null;
consumption_kwh: number | null;
grid_draw_kwh: number | null;
efficiency: number | null; // 0..1 (fraction)
peak_demand_kw: number | null;
avg_power_factor: number | null; // 0..1
load_factor: number | null; // 0..1
error?: string;
};
const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000";
export async function fetchMonthlyKpi(params: {
site: string;
month: string; // "YYYY-MM"
consumption_topic?: string;
generation_topic?: string;
}): Promise<MonthlyKPI> {
const qs = new URLSearchParams(params as Record<string, string>);
const res = await fetch(`${API}/kpi/monthly?${qs.toString()}`, { cache: "no-store" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}

View File

@ -224,6 +224,20 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => {
const axisColor = isDark ? '#fff' : '#222';
function areaGradient(ctx: any, hex: string, alphaTop = 0.22, alphaBottom = 0.02) {
const { ctx: g, chartArea } = ctx.chart;
if (!chartArea) return hex; // initial render fallback
const gradient = g.createLinearGradient(0, chartArea.top, 0, chartArea.bottom);
// top more opaque → bottom fades out
gradient.addColorStop(0, hex + Math.floor(alphaTop * 255).toString(16).padStart(2, '0'));
gradient.addColorStop(1, hex + Math.floor(alphaBottom * 255).toString(16).padStart(2, '0'));
return gradient;
}
// Define colors for both light and dark modes
const consumptionColor = isDark ? '#B80F0A' : '#EF4444'; // Example: Brighter red for dark mode
const generationColor = isDark ? '#48A860' : '#22C55E'; // Example: Brighter green for dark mode
const forecastColor = '#fcd913'; // A golden yellow that works well in both modes
const data = {
labels: filteredLabels.map(formatLabel),
@ -231,26 +245,29 @@ const axisColor = isDark ? '#fff' : '#222';
{
label: 'Consumption',
data: filteredConsumption,
borderColor: '#8884d8',
borderColor: consumptionColor,
backgroundColor: (ctx: any) => areaGradient(ctx, consumptionColor),
fill: true, // <-- fill under line
tension: 0.4,
fill: false,
spanGaps: true,
},
{
label: 'Generation',
data: filteredGeneration,
borderColor: '#82ca9d',
borderColor: generationColor,
backgroundColor: (ctx: any) => areaGradient(ctx, generationColor),
fill: true, // <-- fill under line
tension: 0.4,
fill: false,
spanGaps: true,
},
{
label: 'Forecasted Solar',
data: filteredForecast,
borderColor: '#ffa500', // orange
borderColor: '#fcd913', // orange
backgroundColor: (ctx: any) => areaGradient(ctx, '#fcd913', 0.18, 0.03),
tension: 0.4,
borderDash: [5, 5], // dashed line to distinguish forecast
fill: false,
fill: true,
spanGaps: true,
}
],

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState } from "react";
interface KPI_TableProps {
siteId: string;
@ -12,8 +12,8 @@ interface MonthlyKPI {
consumption_kwh: number | null;
grid_draw_kwh: number | null;
efficiency: number | null;
peak_demand_kw: number | null; // ✅ new
avg_power_factor: number | null; // ✅ new
peak_demand_kw: number | null;
avg_power_factor: number | null;
load_factor: number | null;
}
@ -22,83 +22,66 @@ const KPI_Table: React.FC<KPI_TableProps> = ({ siteId, month }) => {
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!siteId || !month) return;
const fetchKPI = async () => {
setLoading(true);
try {
const res = await fetch(`http://localhost:8000/kpi/monthly?site=${siteId}&month=${month}`);
const data = await res.json();
setKpiData(data);
const res = await fetch(
`http://localhost:8000/kpi/monthly?site=${siteId}&month=${month}`
);
setKpiData(await res.json());
} catch (err) {
console.error('Failed to fetch KPI:', err);
setKpiData(null); // fallback
console.error("Failed to fetch KPI:", err);
setKpiData(null);
} finally {
setLoading(false);
}
};
if (siteId && month) fetchKPI();
fetchKPI();
}, [siteId, month]);
if (!siteId) {
return (
<div>
<h2 className="text-lg font-bold mb-2">Monthly KPI</h2>
<div className="min-h-[275px] w-full flex items-center justify-center border">
<p>No site selected</p>
</div>
</div>
);
}
const formatValue = (value: number | null, unit = "", decimals = 2) =>
value != null ? `${value.toFixed(decimals)}${unit}` : "—";
if (loading) {
return (
<div>
<h2 className="text-lg font-bold mb-2">Monthly KPI</h2>
<div className="min-h-[275px] w-full flex items-center justify-center border">
<p>Loading...</p>
</div>
</div>
);
}
// Use optional chaining and nullish coalescing to safely default values to 0
const yield_kwh = kpiData?.yield_kwh ?? 0;
const consumption_kwh = kpiData?.consumption_kwh ?? 0;
const grid_draw_kwh = kpiData?.grid_draw_kwh ?? 0;
const efficiency = kpiData?.efficiency ?? 0;
const peak_demand_kw = kpiData?.peak_demand_kw ?? 0;
const power_factor = kpiData?.avg_power_factor ?? 0;
const load_factor = kpiData?.load_factor ?? 0;
const data = [
{ kpi: 'Monthly Yield', value: `${yield_kwh.toFixed(0)} kWh` },
{ kpi: 'Monthly Consumption', value: `${consumption_kwh.toFixed(0)} kWh` },
{ kpi: 'Monthly Grid Draw', value: `${grid_draw_kwh.toFixed(0)} kWh` },
{ kpi: 'Efficiency', value: `${efficiency.toFixed(1)}%` },
{ kpi: 'Peak Demand', value: `${peak_demand_kw.toFixed(2)} kW` }, // ✅ added
{ kpi: 'Power Factor', value: `${power_factor.toFixed(2)} kW` }, // ✅ added
{ kpi: 'Load Factor', value: `${load_factor.toFixed(2)} kW` }, // ✅ added
];
const rows = [
{ label: "Monthly Yield", value: formatValue(kpiData?.yield_kwh ?? null, " kWh", 0) },
{ label: "Monthly Consumption", value: formatValue(kpiData?.consumption_kwh ?? null, " kWh", 0) },
{ label: "Monthly Grid Draw", value: formatValue(kpiData?.grid_draw_kwh ?? null, " kWh", 0) },
{ label: "Efficiency", value: formatValue(kpiData?.efficiency ?? null, "%", 1) },
{ label: "Peak Demand", value: formatValue(kpiData?.peak_demand_kw ?? null, " kW") },
{ label: "Power Factor", value: formatValue(kpiData?.avg_power_factor ?? null) },
{ label: "Load Factor", value: formatValue(kpiData?.load_factor ?? null) },
];
return (
<div>
<h2 className="text-lg font-bold mb-2 dark:text-white">Monthly KPI</h2>
<table className="min-h-[275px] w-full border-collapse border border-gray-300 dark:border-rtgray-700 text-black dark:text-white bg-white dark:bg-rtgray-700">
<thead>
<tr className="bg-rtgray-100 dark:bg-rtgray-800 text-black dark:text-white">
<th className="border border-rtgray-300 dark:border-rtgray-700 p-3 text-left dark:bg-rtgray-900">KPI</th>
<th className="border border-rtgray-300 dark:border-rtgray-700 p-3 text-left dark:bg-rtgray-900">Value</th>
</tr>
</thead>
<tbody>
{data.map((row) => (
<tr key={row.kpi} className="even:bg-rtgray-50 dark:even:bg-rtgray-800">
<td className="border border-rtgray-300 dark:border-rtgray-700 p-2.5">{row.kpi}</td>
<td className="border border-rtgray-300 dark:border-rtgray-700 p-2.5">{row.value}</td>
</tr>
))}
</tbody>
</table>
<div className="min-h-[275px] border rounded">
{!siteId ? (
<p className="text-center py-10">No site selected</p>
) : loading ? (
<p className="text-center py-10">Loading...</p>
) : (
<table className="w-full border-collapse">
<thead>
<tr className="bg-gray-100 dark:bg-rtgray-800">
<th className="border p-3 text-left dark:text-white">KPI</th>
<th className="border p-3 text-left dark:text-white">Value</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.label} className="even:bg-gray-50 dark:even:bg-rtgray-800">
<td className="border p-2.5 dark:text-white">{row.label}</td>
<td className="border p-2.5 dark:text-white">{row.value}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
};
@ -106,3 +89,4 @@ const data = [
export default KPI_Table;

View File

@ -126,9 +126,9 @@ const generationColor = isDark ? '#fcd913' : '#669bbc';
if (loading || !siteId || chartData.length === 0) {
return (
<div className="bg-white p-4 rounded-lg shadow-md dark:bg-rtgray-800 dark:text-white-light">
<div className="bg-white p-3 rounded-lg shadow-md dark:bg-rtgray-800 dark:text-white-light">
<div className="flex justify-between items-center mb-2">
<h2 className="text-lg font-bold pb-3">Monthly Energy Yield</h2>
<h2 className="text-lg font-bold">Monthly Energy Yield</h2>
</div>
<div className="h-96 w-full flex items-center justify-center">
<p className="text-white/70">
@ -140,14 +140,10 @@ const generationColor = isDark ? '#fcd913' : '#669bbc';
}
return (
<div className="bg-white p-4 rounded-lg shadow-md dark:bg-rtgray-800 dark:text-white-light">
<div className="flex justify-between items-center mb-2">
<h2 className="text-lg font-bold pb-3">Monthly Energy Yield</h2>
</div>
<div className="lg:h-[22.6vw] h-[290px] w-full pt-10">
<div className="bg-white p-3 rounded-lg dark:bg-rtgray-800 dark:text-white-light">
<div className="h-[200px] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData}>
<BarChart data={chartData} >
<XAxis
dataKey="month"
tick={{ fontSize: 10, fill: isDark ? '#fff' : '#222' }}
@ -158,6 +154,16 @@ const generationColor = isDark ? '#fcd913' : '#669bbc';
tick={{ fontSize: 10, fill: isDark ? '#fff' : '#222' }}
axisLine={{ stroke: isDark ? '#fff' : '#222' }}
tickLine={{ stroke: isDark ? '#fff' : '#222' }}
label={{
value: 'Power (kW)', // <-- Y-axis label
angle: -90, // Vertical text
position: 'insideLeft', // Position inside the chart area
style: {
textAnchor: 'middle',
fill: isDark ? '#fff' : '#222',
fontSize: 12,
},
}}
/>
<Tooltip
formatter={(value: number) => [`${value.toFixed(2)} kWh`]}

View File

@ -0,0 +1,54 @@
// components/dashboards/KpiBottom.tsx
'use client';
import React, { ReactNode } from 'react';
type Props = {
efficiencyPct: number; // % value (0..100)
powerFactor: number; // 0..1
loadFactor: number; // ratio, not %
middle?: ReactNode;
right?: ReactNode;
};
const Panel = ({ title, children }: { title: string; children: ReactNode }) => (
<div className="rounded-2xl p-5 shadow-md bg-white dark:bg-rtgray-800 text-white min-h-[260px] flex flex-col">
<div className="text-lg font-bold opacity-80 mb-3">{title}</div>
<div className="flex-1 grid place-items-center">{children}</div>
</div>
);
const Stat = ({ value, label, accent = false }: { value: ReactNode; label: string; accent?: boolean }) => (
<div className="flex flex-col items-center gap-1">
<div className={`text-3xl font-semibold ${accent ? 'text-[#fcd913]' : 'text-white'}`}>{value}</div>
<div className="text-xs text-[#9aa4b2]">{label}</div>
</div>
);
export default function KpiBottom({
efficiencyPct, powerFactor, loadFactor, middle, right,
}: Props) {
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<Panel title="Measurements">
<div className="grid grid-cols-3 gap-3 w-full">
<Stat value={`${efficiencyPct.toFixed(1)}%`} label="Efficiency" />
<Stat value={powerFactor.toFixed(2)} label="Power Factor" />
<Stat value={loadFactor.toFixed(2)} label="Load Factor" />
</div>
</Panel>
<Panel title="Monthly Yield">
<div className="w-full h-48">
{middle}
</div>
</Panel>
<Panel title="Peak Power Demand">
<div className="text-3xl font-semibold">
{right}
</div>
</Panel>
</div>
);
}

View File

@ -0,0 +1,74 @@
// components/KpiTop.tsx
import React from "react";
type Props = {
month?: string;
yieldKwh: number;
consumptionKwh: number;
gridDrawKwh: number;
};
const Card: React.FC<{ title: string; value: string; accent?: boolean; icon?: React.ReactNode }> = ({
title,
value,
accent,
icon,
}) => (
<div
className={`rounded-xl p-4 md:p-5 shadow-sm
${accent
? "bg-[#fcd913] text-black"
: "bg-white text-gray-900 dark:bg-rtgray-800 dark:text-white"}`}
>
<div className="flex items-center gap-3">
<div className="shrink-0 text-black dark:text-white">
{icon}
</div>
<div className="flex-1">
<div className="text-lg font-bold opacity-80">{title}</div>
<div className="text-2xl font-semibold">{value}</div>
</div>
</div>
</div>
);
export default function KpiTop({ month, yieldKwh, consumptionKwh, gridDrawKwh }: Props) {
return (
<section aria-label="Top KPIs" className="space-y-3">
{month && <div className="text-xs dark:text-[#9aa4b2]">{month}</div>}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<Card
title="Monthly Yield"
value={`${yieldKwh.toLocaleString()} kWh`}
icon={
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="4" stroke="#fcd913" strokeWidth="2" />
<path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1" stroke="#fcd913" strokeWidth="2" />
</svg>
}
/>
<Card
title="Monthly Consumption"
value={`${consumptionKwh.toLocaleString()} kWh`}
icon={
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
<rect x="8" y="3" width="8" height="12" rx="2" stroke="currentColor" strokeWidth="2" />
<path d="M12 15v6" stroke="#fcd913" strokeWidth="2" />
</svg>
}
/>
<Card
title="Monthly Grid Draw"
value={`${gridDrawKwh.toLocaleString()} kWh`}
icon={
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
<path d="M5 21h14M7 21l5-18 5 18" stroke="currentColor" strokeWidth="2" />
<path d="M14 8l2 2" stroke="#fcd913" strokeWidth="2" />
</svg>
}
/>
</div>
</section>
);
}