2025-08-13 12:30:11 +08:00

304 lines
10 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 { 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';
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';
const AdminDashboard = () => {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
// --- NEW: load CRM projects dynamically ---
const [sites, setSites] = useState<CrmProject[]>([]);
const [sitesLoading, setSitesLoading] = useState(true);
const [sitesError, setSitesError] = useState<unknown>(null);
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]
);
// --- FIX: declare currentMonth BEFORE its used ---
const currentMonth = useMemo(() => new Date().toISOString().slice(0, 7), []);
// --- Time-series state (unchanged) ---
const [timeSeriesData, setTimeSeriesData] = useState<{
consumption: { time: string; value: number }[];
generation: { time: string; value: number }[];
}>({ consumption: [], generation: [] });
// Fetch todays timeseries for selected siteId (from CRM)
useEffect(() => {
if (!selectedSiteId) return;
const fetchData = async () => {
const today = new Date();
const yyyyMMdd = today.toISOString().split('T')[0];
const start = `${yyyyMMdd}T00:00:00+08:00`;
const end = `${yyyyMMdd}T23:59:59+08:00`;
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 });
} catch (error) {
console.error('Failed to fetch power time series:', error);
}
};
fetchData();
}, [selectedSiteId]);
// --- KPI monthly (uses your FastAPI) ---
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 (now expects a siteId/Project.name)
const handleSiteChange = (newSiteId: string) => {
setSelectedSiteId(newSiteId);
const newUrl = `${pathname}?site=${encodeURIComponent(newSiteId)}`;
router.push(newUrl);
};
const locationFormatted = useMemo(() => {
const raw = selectedProject?.custom_address ?? '';
if (!raw) return 'N/A';
return formatAddress(raw).multiLine; // pretty, multi-line version
}, [selectedProject?.custom_address]);
const lastSyncFormatted = useMemo(
() => formatCrmTimestamp(selectedProject?.modified, { includeSeconds: true }),
[selectedProject?.modified]
);
// Adapt CRM project -> SiteStatus props
const currentSiteDetails = {
location: locationFormatted, // <- formatted!
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');
};
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, // nice display
value: s.name, // siteId used everywhere
}));
return (
<DashboardLayout>
<div className="px-6 space-y-6">
<h1 className="text-lg font-semibold dark:text-white">Admin Dashboard</h1>
<div className="grid gap-6">
<div className="space-y-4">
{/* UPDATE SiteSelector to accept these props */}
<SiteSelector
options={siteOptions}
selectedValue={selectedSiteId!}
onChange={handleSiteChange}
/>
{/* UPDATE SiteStatus to accept siteId & dynamic fields */}
<SiteStatus
selectedSite={selectedProject.project_name || selectedProject.name}
siteId={selectedProject.name} // <-- use for MQTT topics inside SiteStatus
location={currentSiteDetails.location}
inverterProvider={currentSiteDetails.inverterProvider}
emergencyContact={currentSiteDetails.emergencyContact}
lastSyncTimestamp={currentSiteDetails.lastSyncTimestamp}
/>
</div>
</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;