All checks were successful
		
		
	
	Build and Deploy / build-and-deploy (push) Successful in 2m50s
				
			
		
			
				
	
	
		
			707 lines
		
	
	
		
			23 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			707 lines
		
	
	
		
			23 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';
 | ||
| import { buildExportUrl, getFilenameFromCD } from "@/utils/export";
 | ||
| 
 | ||
| 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_FASTAPI_URL;
 | ||
| 
 | ||
| // 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);
 | ||
|     }
 | ||
|   };
 | ||
| 
 | ||
|   // helpers
 | ||
| // helpers
 | ||
| const ymd = (d: Date) => d.toISOString().slice(0, 10);
 | ||
| const excelUrl = (site: string, device: string, fn: 'grid' | 'solar', dateYMD: string) =>
 | ||
|   `${API}/excel-fs/${encodeURIComponent(site)}/${encodeURIComponent(device)}/${fn}/${dateYMD}.xlsx`;
 | ||
| 
 | ||
| // popup state
 | ||
| const [isDownloadOpen, setIsDownloadOpen] = useState(false);
 | ||
| const [meter, setMeter] = useState('01');                         // ADW300 device id
 | ||
| const [fn, setFn] = useState<'grid' | 'solar'>('grid');           // which function
 | ||
| const [downloadDate, setDownloadDate] = useState(ymd(new Date())); // YYYY-MM-DD
 | ||
| const [downloading, setDownloading] = useState(false);
 | ||
| 
 | ||
| // action
 | ||
| // util: parse filename from Content-Disposition
 | ||
| function getFilenameFromCD(h: string | null): string | null {
 | ||
|   if (!h) return null;
 | ||
|   // filename*=UTF-8''name.ext  (RFC 5987)
 | ||
|   const star = /filename\*\s*=\s*([^']*)''([^;]+)/i.exec(h);
 | ||
|   if (star && star[2]) return decodeURIComponent(star[2]);
 | ||
| 
 | ||
|   // filename="name.ext" or filename=name.ext
 | ||
|   const plain = /filename\s*=\s*("?)([^";]+)\1/i.exec(h);
 | ||
|   if (plain && plain[2]) return plain[2];
 | ||
| 
 | ||
|   return null;
 | ||
| }
 | ||
| 
 | ||
| const downloadExcel = async () => {
 | ||
|   if (!selectedProject) return;
 | ||
|   try {
 | ||
|     setDownloading(true);
 | ||
| 
 | ||
|     // Prefer the simple day-based export
 | ||
|     const url = buildExportUrl({
 | ||
|       baseUrl: process.env.NEXT_PUBLIC_FASTAPI_URL,
 | ||
|       site: selectedProject.name,
 | ||
|       suffix: fn,
 | ||
|       serial: meter?.trim() || undefined,
 | ||
|       day: downloadDate, // "YYYY-MM-DD"
 | ||
|     });
 | ||
| 
 | ||
| 
 | ||
|     const resp = await fetch(url, { credentials: "include" });
 | ||
| 
 | ||
|     if (!resp.ok) {
 | ||
|       // server might return JSON error; try to surface it nicely
 | ||
|       const ctype = resp.headers.get("Content-Type") || "";
 | ||
|       let msg = `HTTP ${resp.status}`;
 | ||
|       if (ctype.includes("application/json")) {
 | ||
|         const j = await resp.json().catch(() => null);
 | ||
|         if (j?.detail) msg = String(j.detail);
 | ||
|       } else {
 | ||
|         const t = await resp.text().catch(() => "");
 | ||
|         if (t) msg = t;
 | ||
|       }
 | ||
|       throw new Error(msg);
 | ||
|     }
 | ||
| 
 | ||
|     const blob = await resp.blob();
 | ||
| 
 | ||
|     // 1) use server-provided filename if present
 | ||
|     const cd = resp.headers.get("Content-Disposition");
 | ||
|     let downloadName = getFilenameFromCD(cd);
 | ||
| 
 | ||
|     // 2) client-side fallback (date-only as requested)
 | ||
|     if (!downloadName) {
 | ||
|       const serialPart = meter?.trim() ? meter.trim() : "ALL";
 | ||
|       downloadName = `${selectedProject.name}_${serialPart}_${fn}_${downloadDate}.xlsx`;
 | ||
|     }
 | ||
| 
 | ||
|     const a = document.createElement("a");
 | ||
|     const href = URL.createObjectURL(blob);
 | ||
|     a.href = href;
 | ||
|     a.download = downloadName;
 | ||
|     document.body.appendChild(a);
 | ||
|     a.click();
 | ||
|     a.remove();
 | ||
|     URL.revokeObjectURL(href);
 | ||
|     setIsDownloadOpen(false);
 | ||
|   } catch (e: any) {
 | ||
|     alert(`Download failed: ${e?.message ?? e}`);
 | ||
|   } finally {
 | ||
|     setDownloading(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>
 | ||
| 
 | ||
|               <button
 | ||
|                 onClick={() => setIsDownloadOpen(true)}
 | ||
|                 className="text-sm lg:text-lg btn-primary"
 | ||
|               >
 | ||
|                 Download Excel Log
 | ||
|               </button>
 | ||
|             </div>
 | ||
| 
 | ||
|           </>
 | ||
|         )}
 | ||
| 
 | ||
|         {isDownloadOpen && (
 | ||
|   <div
 | ||
|     className="fixed inset-0 z-50 flex items-center justify-center"
 | ||
|     aria-modal="true"
 | ||
|     role="dialog"
 | ||
|     onKeyDown={(e) => e.key === 'Escape' && setIsDownloadOpen(false)}
 | ||
|   >
 | ||
|     {/* Backdrop */}
 | ||
|     <div
 | ||
|       className="absolute inset-0 bg-black/50"
 | ||
|       onClick={() => setIsDownloadOpen(false)}
 | ||
|     />
 | ||
| 
 | ||
|     {/* Modal */}
 | ||
|     <div className="relative w-full max-w-lg mx-4 rounded-2xl bg-white dark:bg-rtgray-800 shadow-2xl">
 | ||
|       <div className="p-5 sm:p-6 border-b border-black/5 dark:border-white/10">
 | ||
|         <h3 className="text-lg font-semibold text-black/90 dark:text-white">
 | ||
|           Download Excel Log
 | ||
|         </h3>
 | ||
|         <p className="mt-1 text-sm text-black/60 dark:text-white/60">
 | ||
|           Choose device, function, and date to export the .xlsx generated by the logger.
 | ||
|         </p>
 | ||
|       </div>
 | ||
| 
 | ||
|       <div className="p-5 sm:p-6 space-y-5">
 | ||
|         {/* Site (read-only preview) */}
 | ||
|         <div>
 | ||
|           <label className="block text-sm opacity-80 mb-1 dark:text-white">Site</label>
 | ||
|           <div className="px-3 py-2 rounded-lg bg-black/5 dark:bg-white/5 text-sm truncate dark:text-white">
 | ||
|             {selectedProject?.project_name || selectedProject?.name}
 | ||
|           </div>
 | ||
|         </div>
 | ||
| 
 | ||
|         {/* Device + Function */}
 | ||
|         <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
 | ||
|           <div>
 | ||
|             <label className="block text-sm opacity-80 mb-1 dark:text-white">Meter (Device)</label>
 | ||
|             <input
 | ||
|               value={meter}
 | ||
|               onChange={(e) => setMeter(e.target.value)}
 | ||
|               placeholder="01"
 | ||
|               className="input input-bordered w-full pl-2 rounded-lg"
 | ||
|             />
 | ||
|             <p className="mt-1 text-xs opacity-70 dark:text-white">
 | ||
|               Matches topic: <code>ADW300/<site>/<b>{meter || '01'}</b>/…</code>
 | ||
|             </p>
 | ||
|           </div>
 | ||
| 
 | ||
|           <div>
 | ||
|             <label className="block text-sm opacity-80 mb-1 dark:text-white">Function</label>
 | ||
|             <div className="flex rounded-xl overflow-hidden border border-black/10 dark:border-white/10">
 | ||
|               <button
 | ||
|                 type="button"
 | ||
|                 onClick={() => setFn('grid')}
 | ||
|                 className={`flex-1 px-3 py-2 text-sm ${
 | ||
|                   fn === 'grid'
 | ||
|                     ? 'bg-rtyellow-200 text-black'
 | ||
|                     : 'bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 dark:text-white'
 | ||
|                 }`}
 | ||
|               >
 | ||
|                 Grid
 | ||
|               </button>
 | ||
|               <button
 | ||
|                 type="button"
 | ||
|                 onClick={() => setFn('solar')}
 | ||
|                 className={`flex-1 px-3 py-2 text-sm ${
 | ||
|                   fn === 'solar'
 | ||
|                     ? 'bg-rtyellow-200 text-black'
 | ||
|                     : 'bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 dark:text-white'
 | ||
|                 }`}
 | ||
|               >
 | ||
|                 Solar
 | ||
|               </button>
 | ||
|             </div>
 | ||
|           </div>
 | ||
|         </div>
 | ||
| 
 | ||
|         {/* Date + quick picks */}
 | ||
|         <div>
 | ||
|           <label className="block text-sm opacity-80 mb-1 dark:text-white">Date</label>
 | ||
|           <div className="flex items-center gap-3">
 | ||
|             <input
 | ||
|               type="date"
 | ||
|               value={downloadDate}
 | ||
|               onChange={(e) => setDownloadDate(e.target.value)}
 | ||
|               className="input input-bordered w-48 pl-2 rounded-lg"
 | ||
|             />
 | ||
|             <div className="flex gap-2">
 | ||
|               <button
 | ||
|                 type="button"
 | ||
|                 className="px-3 py-1 rounded-full text-xs border border-black/10 dark:border-white/15 hover:bg-black/5 dark:hover:bg-white/10 dark:text-white"
 | ||
|                 onClick={() => setDownloadDate(ymd(new Date()))}
 | ||
|               >
 | ||
|                 Today
 | ||
|               </button>
 | ||
|               <button
 | ||
|                 type="button"
 | ||
|                 className="px-3 py-1 rounded-full text-xs border border-black/10 dark:border-white/15 hover:bg-black/5 dark:hover:bg:white/10 dark:text-white"
 | ||
|                 onClick={() => {
 | ||
|                   const d = new Date();
 | ||
|                   d.setDate(d.getDate() - 1);
 | ||
|                   setDownloadDate(ymd(d));
 | ||
|                 }}
 | ||
|               >
 | ||
|                 Yesterday
 | ||
|               </button>
 | ||
|             </div>
 | ||
|           </div>
 | ||
|         </div>
 | ||
|       </div>
 | ||
| 
 | ||
|       {/* Footer */}
 | ||
|       <div className="p-5 sm:p-6 flex justify-end gap-3 border-t border-black/5 dark:border-white/10">
 | ||
|         <button
 | ||
|           type="button"
 | ||
|           className="btn btn-primary bg-red-500 hover:bg-red-600 border-transparent"
 | ||
|           onClick={() => setIsDownloadOpen(false)}
 | ||
|           disabled={downloading}
 | ||
|         >
 | ||
|           Cancel
 | ||
|         </button>
 | ||
|         <button
 | ||
|           type="button"
 | ||
|           className="btn btn-primary border-transparent"
 | ||
|           onClick={downloadExcel}
 | ||
|           disabled={downloading || !meter || !downloadDate}
 | ||
|         >
 | ||
|           {downloading ? 'Preparing…' : 'Download'}
 | ||
|         </button>
 | ||
|       </div>
 | ||
|     </div>
 | ||
|   </div>
 | ||
| )}
 | ||
| 
 | ||
|       </div>
 | ||
|     </DashboardLayout>
 | ||
|   );
 | ||
| };
 | ||
| 
 | ||
| export default AdminDashboard;
 |