304 lines
10 KiB
TypeScript
304 lines
10 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';
|
||
|
||
|
||
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 it’s 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 today’s 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;
|
||
|
||
|
||
|