435 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			435 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();
 | 
						||
 | 
						||
  // --- 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);
 | 
						||
 | 
						||
 | 
						||
  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 (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;
 |