All checks were successful
PR Build Check / build (pull_request) Successful in 2m20s
476 lines
15 KiB
TypeScript
476 lines
15 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
||
import SiteSelector from '@/components/dashboards/SiteSelector';
|
||
import SiteStatus from '@/components/dashboards/SiteStatus';
|
||
import DashboardLayout from './dashlayout';
|
||
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';
|
||
import { formatAddress } from '@/app/utils/formatAddress';
|
||
import { formatCrmTimestamp } from '@/app/utils/datetime';
|
||
import LoggingControlCard from '@/components/dashboards/LoggingControl';
|
||
|
||
const EnergyLineChart = dynamic(() => import('@/components/dashboards/EnergyLineChart'), { ssr: false });
|
||
const MonthlyBarChart = dynamic(() => import('@/components/dashboards/MonthlyBarChart'), { 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;
|
||
};
|
||
|
||
type CrmProject = {
|
||
name: string; // e.g. PROJ-0008 <-- use as siteId
|
||
project_name: string;
|
||
status?: string;
|
||
percent_complete?: number | null;
|
||
owner?: string | null;
|
||
modified?: string | null;
|
||
customer?: string | null;
|
||
project_type?: string | null;
|
||
custom_address?: string | null;
|
||
custom_email?: string | null;
|
||
custom_mobile_phone_no?: string | null;
|
||
};
|
||
|
||
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000';
|
||
|
||
// Adjust this to your FastAPI route
|
||
const START_LOGGING_ENDPOINT = (siteId: string) =>
|
||
`${API}/logging/start?site=${encodeURIComponent(siteId)}`;
|
||
|
||
// helper to build ISO strings with +08:00
|
||
const withTZ = (d: Date) => {
|
||
const yyyyMMdd = d.toISOString().split('T')[0];
|
||
return {
|
||
start: `${yyyyMMdd}T00:00:00+08:00`,
|
||
end: `${yyyyMMdd}T23:59:59+08:00`,
|
||
};
|
||
};
|
||
|
||
const AdminDashboard = () => {
|
||
const router = useRouter();
|
||
const pathname = usePathname();
|
||
const searchParams = useSearchParams();
|
||
const [authChecked, setAuthChecked] = useState(false);
|
||
|
||
// --- load CRM projects dynamically ---
|
||
const [sites, setSites] = useState<CrmProject[]>([]);
|
||
const [sitesLoading, setSitesLoading] = useState(true);
|
||
const [sitesError, setSitesError] = useState<unknown>(null);
|
||
// near other refs
|
||
const loggingRef = useRef<HTMLDivElement | null>(null);
|
||
|
||
const API = process.env.NEXT_PUBLIC_FASTAPI_URL || 'http://127.0.0.1:8000';
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
|
||
const checkAuth = async () => {
|
||
try {
|
||
const res = await fetch(`${API}/auth/me`, {
|
||
credentials: 'include',
|
||
cache: 'no-store',
|
||
});
|
||
|
||
if (!res.ok) {
|
||
router.replace('/login');
|
||
return;
|
||
}
|
||
|
||
const user = await res.json().catch(() => null);
|
||
if (!user?.id) {
|
||
router.replace('/login');
|
||
return;
|
||
}
|
||
// authenticated
|
||
} catch {
|
||
router.replace('/login');
|
||
return;
|
||
} finally {
|
||
if (!cancelled) setAuthChecked(true);
|
||
}
|
||
};
|
||
|
||
checkAuth();
|
||
return () => { cancelled = true; };
|
||
}, [router, API]);
|
||
|
||
|
||
|
||
useEffect(() => {
|
||
setSitesLoading(true);
|
||
fetch(`${API}/crm/projects?limit=0`)
|
||
.then(r => r.json())
|
||
.then(json => setSites(json?.data ?? []))
|
||
.catch(setSitesError)
|
||
.finally(() => setSitesLoading(false));
|
||
}, []);
|
||
|
||
// The canonical siteId is the CRM Project "name" (e.g., PROJ-0008)
|
||
const siteParam = searchParams?.get('site') || null;
|
||
const [selectedSiteId, setSelectedSiteId] = useState<string | null>(siteParam);
|
||
|
||
// Keep query param <-> state in sync
|
||
useEffect(() => {
|
||
if ((siteParam || null) !== selectedSiteId) {
|
||
setSelectedSiteId(siteParam);
|
||
}
|
||
}, [siteParam]); // eslint-disable-line
|
||
|
||
// Default to the first site when loaded
|
||
useEffect(() => {
|
||
if (!selectedSiteId && sites.length) {
|
||
setSelectedSiteId(sites[0].name);
|
||
router.replace(`${pathname}?site=${encodeURIComponent(sites[0].name)}`);
|
||
}
|
||
}, [sites, selectedSiteId, pathname, router]);
|
||
|
||
// Current selected CRM project
|
||
const selectedProject: CrmProject | null = useMemo(
|
||
() => sites.find(s => s.name === selectedSiteId) ?? null,
|
||
[sites, selectedSiteId]
|
||
);
|
||
|
||
// declare currentMonth BEFORE it’s used
|
||
const currentMonth = useMemo(() => new Date().toISOString().slice(0, 7), []);
|
||
|
||
// --- Time-series state ---
|
||
const [timeSeriesData, setTimeSeriesData] = useState<{
|
||
consumption: { time: string; value: number }[];
|
||
generation: { time: string; value: number }[];
|
||
}>({ consumption: [], generation: [] });
|
||
|
||
// data-availability flags
|
||
const [hasAnyData, setHasAnyData] = useState(false); // historical window
|
||
const [hasTodayData, setHasTodayData] = useState(false);
|
||
const [isLogging, setIsLogging] = useState(false);
|
||
const [startError, setStartError] = useState<string | null>(null);
|
||
|
||
// Fetch today’s timeseries for selected siteId
|
||
useEffect(() => {
|
||
if (!selectedSiteId) return;
|
||
|
||
const fetchToday = async () => {
|
||
const { start, end } = withTZ(new Date());
|
||
|
||
try {
|
||
const raw = await fetchPowerTimeseries(selectedSiteId, start, end);
|
||
const consumption = raw.consumption.map((d: any) => ({ time: d.time, value: d.value }));
|
||
const generation = raw.generation.map((d: any) => ({ time: d.time, value: d.value }));
|
||
setTimeSeriesData({ consumption, generation });
|
||
|
||
const anyToday = (consumption?.length ?? 0) > 0 || (generation?.length ?? 0) > 0;
|
||
setHasTodayData(anyToday);
|
||
} catch (error) {
|
||
console.error('Failed to fetch power time series:', error);
|
||
setHasTodayData(false);
|
||
}
|
||
};
|
||
|
||
fetchToday();
|
||
}, [selectedSiteId]);
|
||
|
||
// Check historical data (last 30 days) → controls empty state
|
||
useEffect(() => {
|
||
if (!selectedSiteId) return;
|
||
|
||
const fetchHistorical = async () => {
|
||
try {
|
||
const endDate = new Date();
|
||
const startDate = new Date();
|
||
startDate.setDate(endDate.getDate() - 30);
|
||
|
||
const startISO = `${startDate.toISOString().split('T')[0]}T00:00:00+08:00`;
|
||
const endISO = `${endDate.toISOString().split('T')[0]}T23:59:59+08:00`;
|
||
|
||
const raw = await fetchPowerTimeseries(selectedSiteId, startISO, endISO);
|
||
const anyHistorical =
|
||
(raw?.consumption?.length ?? 0) > 0 ||
|
||
(raw?.generation?.length ?? 0) > 0;
|
||
|
||
setHasAnyData(anyHistorical);
|
||
} catch (e) {
|
||
console.error('Failed to check historical data:', e);
|
||
setHasAnyData(false);
|
||
}
|
||
};
|
||
|
||
fetchHistorical();
|
||
}, [selectedSiteId]);
|
||
|
||
// --- KPI monthly ---
|
||
const [kpi, setKpi] = useState<MonthlyKPI | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (!selectedSiteId) return;
|
||
const url = `${API}/kpi/monthly?site=${encodeURIComponent(selectedSiteId)}&month=${currentMonth}`;
|
||
fetch(url).then(r => r.json()).then(setKpi).catch(console.error);
|
||
}, [selectedSiteId, currentMonth]);
|
||
|
||
// 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);
|
||
|
||
// Update URL when site is changed manually (expects a siteId/Project.name)
|
||
const handleSiteChange = (newSiteId: string) => {
|
||
setSelectedSiteId(newSiteId);
|
||
const newUrl = `${pathname}?site=${encodeURIComponent(newSiteId)}`;
|
||
router.push(newUrl);
|
||
// reset flags when switching
|
||
setHasAnyData(false);
|
||
setHasTodayData(false);
|
||
setIsLogging(false);
|
||
setStartError(null);
|
||
};
|
||
|
||
const locationFormatted = useMemo(() => {
|
||
const raw = selectedProject?.custom_address ?? '';
|
||
if (!raw) return 'N/A';
|
||
return formatAddress(raw).multiLine;
|
||
}, [selectedProject?.custom_address]);
|
||
|
||
const lastSyncFormatted = useMemo(
|
||
() => formatCrmTimestamp(selectedProject?.modified, { includeSeconds: true }),
|
||
[selectedProject?.modified]
|
||
);
|
||
|
||
// Adapt CRM project -> SiteStatus props
|
||
const currentSiteDetails = {
|
||
location: locationFormatted,
|
||
inverterProvider: selectedProject?.project_type || 'N/A',
|
||
emergencyContact:
|
||
selectedProject?.custom_mobile_phone_no ||
|
||
selectedProject?.custom_email ||
|
||
selectedProject?.customer ||
|
||
'N/A',
|
||
lastSyncTimestamp: lastSyncFormatted || 'N/A',
|
||
};
|
||
|
||
const energyChartRef = useRef<HTMLDivElement | null>(null);
|
||
const monthlyChartRef = useRef<HTMLDivElement | null>(null);
|
||
|
||
const handlePDFExport = async () => {
|
||
const doc = new jsPDF('p', 'mm', 'a4');
|
||
const chartRefs = [
|
||
{ ref: energyChartRef, title: 'Energy Line Chart' },
|
||
{ ref: monthlyChartRef, title: 'Monthly Energy Yield' }
|
||
];
|
||
|
||
let yOffset = 10;
|
||
|
||
for (const chart of chartRefs) {
|
||
if (!chart.ref.current) continue;
|
||
const canvas = await html2canvas(chart.ref.current, { scale: 2 });
|
||
const imgData = canvas.toDataURL('image/png');
|
||
const imgProps = doc.getImageProperties(imgData);
|
||
const pdfWidth = doc.internal.pageSize.getWidth() - 20;
|
||
const imgHeight = (imgProps.height * pdfWidth) / imgProps.width;
|
||
|
||
doc.setFontSize(14);
|
||
doc.text(chart.title, 10, yOffset);
|
||
yOffset += 6;
|
||
|
||
if (yOffset + imgHeight > doc.internal.pageSize.getHeight()) {
|
||
doc.addPage();
|
||
yOffset = 10;
|
||
}
|
||
|
||
doc.addImage(imgData, 'PNG', 10, yOffset, pdfWidth, imgHeight);
|
||
yOffset += imgHeight + 10;
|
||
}
|
||
|
||
doc.save('dashboard_charts.pdf');
|
||
};
|
||
|
||
// Start logging then poll for data until it shows up
|
||
const startLogging = async () => {
|
||
if (!selectedSiteId) return;
|
||
setIsLogging(true);
|
||
setStartError(null);
|
||
|
||
try {
|
||
const resp = await fetch(START_LOGGING_ENDPOINT(selectedSiteId), {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
|
||
if (!resp.ok) {
|
||
const text = await resp.text();
|
||
throw new Error(text || `Failed with status ${resp.status}`);
|
||
}
|
||
|
||
// Poll for data for up to ~45s (15 tries x 3s)
|
||
for (let i = 0; i < 15; i++) {
|
||
const today = new Date();
|
||
const { start, end } = withTZ(today);
|
||
|
||
try {
|
||
const raw = await fetchPowerTimeseries(selectedSiteId, start, end);
|
||
const consumption = raw.consumption ?? [];
|
||
const generation = raw.generation ?? [];
|
||
if ((consumption.length ?? 0) > 0 || (generation.length ?? 0) > 0) {
|
||
setHasAnyData(true); // site now has data
|
||
setHasTodayData(true); // and today has data too
|
||
break;
|
||
}
|
||
} catch {
|
||
// ignore and keep polling
|
||
}
|
||
await new Promise(r => setTimeout(r, 3000));
|
||
}
|
||
} catch (e: any) {
|
||
setStartError(e?.message ?? 'Failed to start logging');
|
||
setIsLogging(false);
|
||
}
|
||
};
|
||
|
||
// ---------- RENDER ----------
|
||
if (!authChecked) {
|
||
return <div>Checking authentication…</div>;
|
||
}
|
||
|
||
if (sitesLoading) {
|
||
return (
|
||
<DashboardLayout>
|
||
<div className="px-6">Loading sites…</div>
|
||
</DashboardLayout>
|
||
);
|
||
}
|
||
if (sitesError) {
|
||
return (
|
||
<DashboardLayout>
|
||
<div className="px-6 text-red-600">Failed to load sites from CRM.</div>
|
||
</DashboardLayout>
|
||
);
|
||
}
|
||
if (!selectedProject) {
|
||
return (
|
||
<DashboardLayout>
|
||
<div className="px-6">No site selected.</div>
|
||
</DashboardLayout>
|
||
);
|
||
}
|
||
|
||
// Build selector options from CRM
|
||
const siteOptions = sites.map(s => ({
|
||
label: s.project_name || s.name,
|
||
value: s.name,
|
||
}));
|
||
|
||
return (
|
||
<DashboardLayout>
|
||
<div className="px-3 space-y-6 w-full max-w-screen-3xl mx-auto">
|
||
<h1 className="text-lg font-semibold dark:text-white">Admin Dashboard</h1>
|
||
|
||
{/* Selector + status */}
|
||
<div className="grid grid-cols-1 gap-6 w-full min-w-0">
|
||
<div className="space-y-4 w-full min-w-0">
|
||
<SiteSelector
|
||
options={siteOptions}
|
||
selectedValue={selectedSiteId!}
|
||
onChange={handleSiteChange}
|
||
/>
|
||
|
||
<SiteStatus
|
||
selectedSite={selectedProject.project_name || selectedProject.name}
|
||
siteId={selectedProject.name}
|
||
location={currentSiteDetails.location}
|
||
inverterProvider={currentSiteDetails.inverterProvider}
|
||
emergencyContact={currentSiteDetails.emergencyContact}
|
||
lastSyncTimestamp={currentSiteDetails.lastSyncTimestamp}
|
||
/>
|
||
|
||
|
||
</div>
|
||
</div>
|
||
|
||
{/* Small dark yellow banner when there is ZERO historical data */}
|
||
{!hasAnyData && (
|
||
<div className="rounded-lg border border-amber-400/40 bg-rtyellow-300/20 px-4 py-3 text-amber-600 dark:text-amber-100 flex flex-wrap items-center gap-3">
|
||
<span className="font-semibold text-black/85 dark:text-white/85">No data yet.</span>
|
||
<span className="opacity-95">Enter the meter number and click <span className="font-semibold text-black/85 dark:text-white/85">Start</span> to begin streaming.
|
||
</span>
|
||
|
||
{startError && <div className="basis-full text-sm text-red-300">{startError}</div>}
|
||
</div>
|
||
)}
|
||
|
||
<div ref={loggingRef}>
|
||
<LoggingControlCard
|
||
siteId={selectedProject.name}
|
||
projectLabel={selectedProject.project_name || selectedProject.name}
|
||
className="w-full"
|
||
/>
|
||
</div>
|
||
|
||
{/* Render the rest only if there is *any* data */}
|
||
{hasAnyData && (
|
||
<>
|
||
{/* Tiny banner if today is empty but historical exists */}
|
||
{!hasTodayData && (
|
||
<div className="rounded-lg border border-amber-300/50 bg-amber-50 dark:bg-amber-900/20 px-4 py-2 text-amber-800 dark:text-amber-200">
|
||
No data yet today — charts may be blank until new points arrive.
|
||
</div>
|
||
)}
|
||
|
||
|
||
{/* TOP 3 CARDS */}
|
||
<div className="space-y-4">
|
||
<KpiTop
|
||
yieldKwh={yieldKwh}
|
||
consumptionKwh={consumptionKwh}
|
||
gridDrawKwh={gridDrawKwh}
|
||
/>
|
||
</div>
|
||
|
||
<div ref={energyChartRef} className="pb-5">
|
||
<EnergyLineChart siteId={selectedProject.name} />
|
||
</div>
|
||
|
||
{/* BOTTOM 3 PANELS */}
|
||
<KpiBottom
|
||
efficiencyPct={efficiencyPct}
|
||
powerFactor={powerFactor}
|
||
loadFactor={loadFactor}
|
||
middle={
|
||
<div ref={monthlyChartRef} className="transform scale-90 origin-top">
|
||
<MonthlyBarChart siteId={selectedProject.name} />
|
||
</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
|
||
</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
|
||
</div>
|
||
</DashboardLayout>
|
||
);
|
||
};
|
||
|
||
export default AdminDashboard;
|