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;
 | ||
| 
 | ||
| 
 | ||
| 
 |