feature/syasya/testlayout #6
							
								
								
									
										37
									
								
								.gitea/workflows/pr-build-check.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								.gitea/workflows/pr-build-check.yml
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,37 @@
 | 
				
			|||||||
 | 
					name: PR Build Check
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					on:
 | 
				
			||||||
 | 
					  pull_request:
 | 
				
			||||||
 | 
					    branches:
 | 
				
			||||||
 | 
					      - '**'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					jobs:
 | 
				
			||||||
 | 
					  build:
 | 
				
			||||||
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    steps:
 | 
				
			||||||
 | 
					      - name: Checkout code
 | 
				
			||||||
 | 
					        uses: actions/checkout@v3
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      - name: Setup Node.js
 | 
				
			||||||
 | 
					        uses: actions/setup-node@v3
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          node-version: '18'
 | 
				
			||||||
 | 
					          
 | 
				
			||||||
 | 
					      - name: Install dependencies
 | 
				
			||||||
 | 
					        run: npm install --force
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      - name: Generate Prisma Client
 | 
				
			||||||
 | 
					        run: npx prisma generate
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					      - name: Build
 | 
				
			||||||
 | 
					        run: npm run build
 | 
				
			||||||
 | 
					        env:
 | 
				
			||||||
 | 
					          NEXT_PUBLIC_URL: 'http://localhost:3000'
 | 
				
			||||||
 | 
					          NEXT_PUBLIC_FORECAST_URL: 'http://localhost:3001'
 | 
				
			||||||
 | 
					          DATABASE_URL: 'postgresql://dummy:dummy@localhost:5432/dummy'
 | 
				
			||||||
 | 
					          SMTP_EMAIL: 'dummy@example.com'
 | 
				
			||||||
 | 
					          SMTP_EMAIL_PASSWORD: 'dummy'
 | 
				
			||||||
 | 
					          NEXT_PUBLIC_PLAUSIBLE_DOMAIN: 'localhost'
 | 
				
			||||||
 | 
					          JWT_SECRET: 'dummy_secret'
 | 
				
			||||||
 | 
					          JWT_REFRESH_SECRET: 'dummy_refresh_secret'
 | 
				
			||||||
@ -1,129 +1,231 @@
 | 
				
			|||||||
'use client';
 | 
					'use client';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { useState, useEffect, useRef } from 'react';
 | 
					import { useState, useEffect, useMemo, useRef } from 'react';
 | 
				
			||||||
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
 | 
					import { useRouter, usePathname, useSearchParams } from 'next/navigation';
 | 
				
			||||||
import SiteSelector from '@/components/dashboards/SiteSelector';
 | 
					import SiteSelector from '@/components/dashboards/SiteSelector';
 | 
				
			||||||
import SiteStatus from '@/components/dashboards/SiteStatus';
 | 
					import SiteStatus from '@/components/dashboards/SiteStatus';
 | 
				
			||||||
import KPI_Table from '@/components/dashboards/KPIStatus';
 | 
					 | 
				
			||||||
import DashboardLayout from './dashlayout';
 | 
					import DashboardLayout from './dashlayout';
 | 
				
			||||||
import html2canvas from 'html2canvas';
 | 
					import html2canvas from 'html2canvas';
 | 
				
			||||||
import jsPDF from 'jspdf';
 | 
					import jsPDF from 'jspdf';
 | 
				
			||||||
import dynamic from 'next/dynamic';
 | 
					import dynamic from 'next/dynamic';
 | 
				
			||||||
import { fetchPowerTimeseries } from '@/app/utils/api';
 | 
					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'), {
 | 
					const EnergyLineChart = dynamic(() => import('@/components/dashboards/EnergyLineChart'), { ssr: false });
 | 
				
			||||||
  ssr: false,
 | 
					const MonthlyBarChart  = dynamic(() => import('@/components/dashboards/MonthlyBarChart'), { ssr: false });
 | 
				
			||||||
});
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const MonthlyBarChart = dynamic(() => import('@/components/dashboards/MonthlyBarChart'), {
 | 
					type MonthlyKPI = {
 | 
				
			||||||
  ssr: false,
 | 
					  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;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { SiteName, SiteDetails, mockSiteData } from '@/types/SiteData';
 | 
					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 AdminDashboard = () => {
 | 
				
			||||||
  const router = useRouter();
 | 
					  const router = useRouter();
 | 
				
			||||||
  const pathname = usePathname();
 | 
					  const pathname = usePathname();
 | 
				
			||||||
  const searchParams = useSearchParams();
 | 
					  const searchParams = useSearchParams();
 | 
				
			||||||
  const siteIdMap: Record<SiteName, string> = {
 | 
					 | 
				
			||||||
  'Site A': 'site_01',
 | 
					 | 
				
			||||||
  'Site B': 'site_02',
 | 
					 | 
				
			||||||
  'Site C': 'site_03',
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const siteParam = searchParams?.get('site');
 | 
					  // --- load CRM projects dynamically ---
 | 
				
			||||||
  const validSiteNames: SiteName[] = ['Site A', 'Site B', 'Site C'];
 | 
					  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 [selectedSite, setSelectedSite] = useState<SiteName>(() => {
 | 
					 | 
				
			||||||
    if (siteParam && validSiteNames.includes(siteParam as SiteName)) {
 | 
					 | 
				
			||||||
      return siteParam as SiteName;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    return 'Site A';
 | 
					 | 
				
			||||||
  });
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // Keep siteParam and selectedSite in sync
 | 
					 | 
				
			||||||
  useEffect(() => {
 | 
					  useEffect(() => {
 | 
				
			||||||
    if (
 | 
					    setSitesLoading(true);
 | 
				
			||||||
      siteParam &&
 | 
					    fetch(`${API}/crm/projects?limit=0`)
 | 
				
			||||||
      validSiteNames.includes(siteParam as SiteName) &&
 | 
					      .then(r => r.json())
 | 
				
			||||||
      siteParam !== selectedSite
 | 
					      .then(json => setSites(json?.data ?? []))
 | 
				
			||||||
    ) {
 | 
					      .catch(setSitesError)
 | 
				
			||||||
      setSelectedSite(siteParam as SiteName);
 | 
					      .finally(() => setSitesLoading(false));
 | 
				
			||||||
    }
 | 
					  }, []);
 | 
				
			||||||
  }, [siteParam, selectedSite]);
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // 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<{
 | 
					  const [timeSeriesData, setTimeSeriesData] = useState<{
 | 
				
			||||||
    consumption: { time: string; value: number }[];
 | 
					    consumption: { time: string; value: number }[];
 | 
				
			||||||
    generation: { time: string; value: number }[];
 | 
					    generation: { time: string; value: number }[];
 | 
				
			||||||
  }>({ consumption: [], generation: [] });
 | 
					  }>({ 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(() => {
 | 
					  useEffect(() => {
 | 
				
			||||||
  const fetchData = async () => {
 | 
					    if (!selectedSiteId) return;
 | 
				
			||||||
 | 
					 | 
				
			||||||
  const siteId = siteIdMap[selectedSite];
 | 
					 | 
				
			||||||
  const today = new Date();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // Format to YYYY-MM-DD
 | 
					 | 
				
			||||||
  const yyyyMMdd = today.toISOString().split('T')[0];
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // Append Malaysia's +08:00 time zone manually
 | 
					 | 
				
			||||||
  const start = `${yyyyMMdd}T00:00:00+08:00`;
 | 
					 | 
				
			||||||
  const end = `${yyyyMMdd}T23:59:59+08:00`;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const fetchToday = async () => {
 | 
				
			||||||
 | 
					      const { start, end } = withTZ(new Date());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      try {
 | 
					      try {
 | 
				
			||||||
      const raw = await fetchPowerTimeseries(siteId, start, end);
 | 
					        const raw = await fetchPowerTimeseries(selectedSiteId, start, end);
 | 
				
			||||||
 | 
					        const consumption = raw.consumption.map((d: any) => ({ time: d.time, value: d.value }));
 | 
				
			||||||
    const consumption = raw.consumption.map(d => ({
 | 
					        const generation  = raw.generation.map((d: any) => ({ time: d.time, value: d.value }));
 | 
				
			||||||
      time: d.time,
 | 
					 | 
				
			||||||
      value: d.value,
 | 
					 | 
				
			||||||
    }));
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const generation = raw.generation.map(d => ({
 | 
					 | 
				
			||||||
      time: d.time,
 | 
					 | 
				
			||||||
      value: d.value,
 | 
					 | 
				
			||||||
    }));  
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        setTimeSeriesData({ consumption, generation });
 | 
					        setTimeSeriesData({ consumption, generation });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        const anyToday = (consumption?.length ?? 0) > 0 || (generation?.length ?? 0) > 0;
 | 
				
			||||||
 | 
					        setHasTodayData(anyToday);
 | 
				
			||||||
      } catch (error) {
 | 
					      } catch (error) {
 | 
				
			||||||
        console.error('Failed to fetch power time series:', error);
 | 
					        console.error('Failed to fetch power time series:', error);
 | 
				
			||||||
 | 
					        setHasTodayData(false);
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  fetchData();
 | 
					    fetchToday();
 | 
				
			||||||
}, [selectedSite]);
 | 
					  }, [selectedSiteId]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // Update query string when site is changed manually
 | 
					  // Check historical data (last 30 days) → controls empty state
 | 
				
			||||||
  const handleSiteChange = (newSite: SiteName) => {
 | 
					  useEffect(() => {
 | 
				
			||||||
    setSelectedSite(newSite);
 | 
					    if (!selectedSiteId) return;
 | 
				
			||||||
    const newUrl = `${pathname}?site=${encodeURIComponent(newSite)}`;
 | 
					
 | 
				
			||||||
 | 
					    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);
 | 
					    router.push(newUrl);
 | 
				
			||||||
 | 
					    // reset flags when switching
 | 
				
			||||||
 | 
					    setHasAnyData(false);
 | 
				
			||||||
 | 
					    setHasTodayData(false);
 | 
				
			||||||
 | 
					    setIsLogging(false);
 | 
				
			||||||
 | 
					    setStartError(null);
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const currentSiteDetails: SiteDetails = mockSiteData[selectedSite] || {
 | 
					  const locationFormatted = useMemo(() => {
 | 
				
			||||||
    location: 'N/A',
 | 
					    const raw = selectedProject?.custom_address ?? '';
 | 
				
			||||||
    inverterProvider: 'N/A',
 | 
					    if (!raw) return 'N/A';
 | 
				
			||||||
    emergencyContact: 'N/A',
 | 
					    return formatAddress(raw).multiLine;
 | 
				
			||||||
    lastSyncTimestamp: 'N/A',
 | 
					  }, [selectedProject?.custom_address]);
 | 
				
			||||||
    consumptionData: [],
 | 
					
 | 
				
			||||||
    generationData: [],
 | 
					  const lastSyncFormatted = useMemo(
 | 
				
			||||||
    systemStatus: 'N/A',
 | 
					    () => formatCrmTimestamp(selectedProject?.modified, { includeSeconds: true }),
 | 
				
			||||||
    temperature: 'N/A',
 | 
					    [selectedProject?.modified]
 | 
				
			||||||
    solarPower: 0,
 | 
					  );
 | 
				
			||||||
    realTimePower: 0,
 | 
					
 | 
				
			||||||
    installedPower: 0,
 | 
					  // 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 handleCSVExport = () => {
 | 
					  const energyChartRef = useRef<HTMLDivElement | null>(null);
 | 
				
			||||||
    alert('Exported raw data to CSV (mock)');
 | 
					  const monthlyChartRef = useRef<HTMLDivElement | null>(null);
 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const energyChartRef = useRef(null);
 | 
					 | 
				
			||||||
  const monthlyChartRef = useRef(null);
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const handlePDFExport = async () => {
 | 
					  const handlePDFExport = async () => {
 | 
				
			||||||
  const doc = new jsPDF('p', 'mm', 'a4'); // portrait, millimeters, A4
 | 
					    const doc = new jsPDF('p', 'mm', 'a4');
 | 
				
			||||||
    const chartRefs = [
 | 
					    const chartRefs = [
 | 
				
			||||||
      { ref: energyChartRef,  title: 'Energy Line Chart' },
 | 
					      { ref: energyChartRef,  title: 'Energy Line Chart' },
 | 
				
			||||||
      { ref: monthlyChartRef, title: 'Monthly Energy Yield' }
 | 
					      { ref: monthlyChartRef, title: 'Monthly Energy Yield' }
 | 
				
			||||||
@ -133,82 +235,200 @@ const AdminDashboard = () => {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    for (const chart of chartRefs) {
 | 
					    for (const chart of chartRefs) {
 | 
				
			||||||
      if (!chart.ref.current) continue;
 | 
					      if (!chart.ref.current) continue;
 | 
				
			||||||
 | 
					      const canvas = await html2canvas(chart.ref.current, { scale: 2 });
 | 
				
			||||||
    // Capture chart as image
 | 
					 | 
				
			||||||
    const canvas = await html2canvas(chart.ref.current, {
 | 
					 | 
				
			||||||
      scale: 2, // Higher scale for better resolution
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      const imgData = canvas.toDataURL('image/png');
 | 
					      const imgData = canvas.toDataURL('image/png');
 | 
				
			||||||
      const imgProps = doc.getImageProperties(imgData);
 | 
					      const imgProps = doc.getImageProperties(imgData);
 | 
				
			||||||
 | 
					      const pdfWidth = doc.internal.pageSize.getWidth() - 20;
 | 
				
			||||||
    const pdfWidth = doc.internal.pageSize.getWidth() - 20; // 10 margin each side
 | 
					 | 
				
			||||||
      const imgHeight = (imgProps.height * pdfWidth) / imgProps.width;
 | 
					      const imgHeight = (imgProps.height * pdfWidth) / imgProps.width;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Add title and image
 | 
					 | 
				
			||||||
      doc.setFontSize(14);
 | 
					      doc.setFontSize(14);
 | 
				
			||||||
      doc.text(chart.title, 10, yOffset);
 | 
					      doc.text(chart.title, 10, yOffset);
 | 
				
			||||||
    yOffset += 6; // Space between title and chart
 | 
					      yOffset += 6;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // If content will overflow page, add a new page
 | 
					 | 
				
			||||||
      if (yOffset + imgHeight > doc.internal.pageSize.getHeight()) {
 | 
					      if (yOffset + imgHeight > doc.internal.pageSize.getHeight()) {
 | 
				
			||||||
        doc.addPage();
 | 
					        doc.addPage();
 | 
				
			||||||
        yOffset = 10;
 | 
					        yOffset = 10;
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      doc.addImage(imgData, 'PNG', 10, yOffset, pdfWidth, imgHeight);
 | 
					      doc.addImage(imgData, 'PNG', 10, yOffset, pdfWidth, imgHeight);
 | 
				
			||||||
    yOffset += imgHeight + 10; // Update offset for next chart
 | 
					      yOffset += imgHeight + 10;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    doc.save('dashboard_charts.pdf');
 | 
					    doc.save('dashboard_charts.pdf');
 | 
				
			||||||
};
 | 
					  };
 | 
				
			||||||
  const currentMonth = new Date().toISOString().slice(0, 7); // "YYYY-MM"
 | 
					
 | 
				
			||||||
 | 
					  // 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 (
 | 
					  return (
 | 
				
			||||||
    <DashboardLayout>
 | 
					    <DashboardLayout>
 | 
				
			||||||
      <div className="px-6 space-y-6">
 | 
					      <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>
 | 
					        <h1 className="text-lg font-semibold dark:text-white">Admin Dashboard</h1>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        <div className="grid md:grid-cols-2 gap-6">
 | 
					        {/* Selector + status */}
 | 
				
			||||||
          <div className="space-y-4">
 | 
					        <div className="grid grid-cols-1 gap-6 w-full min-w-0">
 | 
				
			||||||
 | 
					          <div className="space-y-4 w-full min-w-0">
 | 
				
			||||||
            <SiteSelector
 | 
					            <SiteSelector
 | 
				
			||||||
              selectedSite={selectedSite}
 | 
					              options={siteOptions}
 | 
				
			||||||
              setSelectedSite={handleSiteChange}
 | 
					              selectedValue={selectedSiteId!}
 | 
				
			||||||
 | 
					              onChange={handleSiteChange}
 | 
				
			||||||
            />
 | 
					            />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            <SiteStatus
 | 
					            <SiteStatus
 | 
				
			||||||
              selectedSite={selectedSite}
 | 
					              selectedSite={selectedProject.project_name || selectedProject.name}
 | 
				
			||||||
 | 
					              siteId={selectedProject.name}
 | 
				
			||||||
              location={currentSiteDetails.location}
 | 
					              location={currentSiteDetails.location}
 | 
				
			||||||
              inverterProvider={currentSiteDetails.inverterProvider}
 | 
					              inverterProvider={currentSiteDetails.inverterProvider}
 | 
				
			||||||
              emergencyContact={currentSiteDetails.emergencyContact}
 | 
					              emergencyContact={currentSiteDetails.emergencyContact}
 | 
				
			||||||
              lastSyncTimestamp={currentSiteDetails.lastSyncTimestamp}
 | 
					              lastSyncTimestamp={currentSiteDetails.lastSyncTimestamp}
 | 
				
			||||||
            />
 | 
					            />
 | 
				
			||||||
          </div>
 | 
					 | 
				
			||||||
            
 | 
					            
 | 
				
			||||||
          <div>
 | 
					
 | 
				
			||||||
          <KPI_Table siteId={siteIdMap[selectedSite]} month={currentMonth} />
 | 
					 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        <div className="grid md:grid-cols-2 gap-6 lg:flex-col justify-center">
 | 
					        {/* 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">
 | 
					            <div ref={energyChartRef} className="pb-5">
 | 
				
			||||||
            <EnergyLineChart siteId={siteIdMap[selectedSite]} />
 | 
					              <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>
 | 
					                </div>
 | 
				
			||||||
          <div ref={monthlyChartRef} className="pb-5">
 | 
					              }
 | 
				
			||||||
            <MonthlyBarChart siteId={siteIdMap[selectedSite]} />
 | 
					              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>
 | 
					                </div>
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					            />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            <div className="flex flex-col md:flex-row gap-4 justify-center">
 | 
					            <div className="flex flex-col md:flex-row gap-4 justify-center">
 | 
				
			||||||
              <button onClick={handlePDFExport} className="text-sm lg:text-lg btn-primary">
 | 
					              <button onClick={handlePDFExport} className="text-sm lg:text-lg btn-primary">
 | 
				
			||||||
                Export Chart Images to PDF
 | 
					                Export Chart Images to PDF
 | 
				
			||||||
              </button>
 | 
					              </button>
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
 | 
					          </>
 | 
				
			||||||
 | 
					        )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    </DashboardLayout>
 | 
					    </DashboardLayout>
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default AdminDashboard;
 | 
					export default AdminDashboard;
 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
				
			|||||||
@ -1,46 +1,187 @@
 | 
				
			|||||||
'use client';
 | 
					'use client';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import React from 'react';
 | 
					import React, { useEffect, useMemo, useState } from 'react';
 | 
				
			||||||
import DashboardLayout from '../adminDashboard/dashlayout';
 | 
					import DashboardLayout from '../adminDashboard/dashlayout';
 | 
				
			||||||
import SiteCard from '@/components/dashboards/SiteCard'; // Import the new SiteCard component
 | 
					import SiteCard from '@/components/dashboards/SiteCard';
 | 
				
			||||||
import { mockSiteData, SiteName } from '@/types/SiteData'; // Import your mock data and SiteName type
 | 
					
 | 
				
			||||||
 | 
					type CrmProject = {
 | 
				
			||||||
 | 
					  name: string;                 // e.g. PROJ-0008 (siteId)
 | 
				
			||||||
 | 
					  project_name: string;
 | 
				
			||||||
 | 
					  status?: 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 SitesPage = () => {
 | 
					const SitesPage = () => {
 | 
				
			||||||
    // Helper function to determine status (can be externalized if used elsewhere)
 | 
					  const [projects, setProjects] = useState<CrmProject[]>([]);
 | 
				
			||||||
    const getSiteStatus = (siteName: SiteName): string => {
 | 
					  const [loading, setLoading] = useState(true);
 | 
				
			||||||
        const statusMap: Record<SiteName, string> = {
 | 
					  const [err, setErr] = useState<string | null>(null);
 | 
				
			||||||
            'Site A': 'Active',
 | 
					  const [q, setQ] = useState('');             // search filter
 | 
				
			||||||
            'Site B': 'Inactive',
 | 
					
 | 
				
			||||||
            'Site C': 'Faulty',
 | 
					  // pagination
 | 
				
			||||||
        };
 | 
					  const [page, setPage] = useState(1);
 | 
				
			||||||
        return statusMap[siteName];
 | 
					  const [pageSize, setPageSize] = useState(6); // tweak as you like
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  useEffect(() => {
 | 
				
			||||||
 | 
					    let cancelled = false;
 | 
				
			||||||
 | 
					    const run = async () => {
 | 
				
			||||||
 | 
					      setLoading(true);
 | 
				
			||||||
 | 
					      setErr(null);
 | 
				
			||||||
 | 
					      try {
 | 
				
			||||||
 | 
					        const res = await fetch(`${API}/crm/projects?limit=0`);
 | 
				
			||||||
 | 
					        if (!res.ok) throw new Error(await res.text());
 | 
				
			||||||
 | 
					        const json = await res.json();
 | 
				
			||||||
 | 
					        const data: CrmProject[] = json?.data ?? [];
 | 
				
			||||||
 | 
					        if (!cancelled) setProjects(data);
 | 
				
			||||||
 | 
					      } catch (e: any) {
 | 
				
			||||||
 | 
					        if (!cancelled) setErr(e?.message ?? 'Failed to load CRM projects');
 | 
				
			||||||
 | 
					      } finally {
 | 
				
			||||||
 | 
					        if (!cancelled) setLoading(false);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					    run();
 | 
				
			||||||
 | 
					    return () => { cancelled = true; };
 | 
				
			||||||
 | 
					  }, []);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Reset to first page whenever search or pageSize changes
 | 
				
			||||||
 | 
					  useEffect(() => {
 | 
				
			||||||
 | 
					    setPage(1);
 | 
				
			||||||
 | 
					  }, [q, pageSize]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const filtered = useMemo(() => {
 | 
				
			||||||
 | 
					    if (!q.trim()) return projects;
 | 
				
			||||||
 | 
					    const needle = q.toLowerCase();
 | 
				
			||||||
 | 
					    return projects.filter(p =>
 | 
				
			||||||
 | 
					      (p.project_name || '').toLowerCase().includes(needle) ||
 | 
				
			||||||
 | 
					      (p.name || '').toLowerCase().includes(needle) ||
 | 
				
			||||||
 | 
					      (p.customer || '').toLowerCase().includes(needle)
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }, [projects, q]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const total = filtered.length;
 | 
				
			||||||
 | 
					  const totalPages = Math.max(1, Math.ceil(total / pageSize));
 | 
				
			||||||
 | 
					  const safePage = Math.min(page, totalPages);
 | 
				
			||||||
 | 
					  const startIdx = (safePage - 1) * pageSize;
 | 
				
			||||||
 | 
					  const endIdx = Math.min(startIdx + pageSize, total);
 | 
				
			||||||
 | 
					  const pageItems = filtered.slice(startIdx, endIdx);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const goPrev = () => setPage(p => Math.max(1, p - 1));
 | 
				
			||||||
 | 
					  const goNext = () => setPage(p => Math.min(totalPages, p + 1));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <DashboardLayout>
 | 
					    <DashboardLayout>
 | 
				
			||||||
      <div className="p-6 space-y-6">
 | 
					      <div className="p-6 space-y-6">
 | 
				
			||||||
                <h1 className="text-2xl font-bold mb-6 dark:text-white-light">All Sites Overview</h1>
 | 
					        <div className="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
 | 
				
			||||||
 | 
					          <h1 className="text-2xl font-bold dark:text-white-light">All Sites Overview</h1>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
 | 
					          <div className="flex items-center gap-3">
 | 
				
			||||||
                    {/* Iterate over the keys of mockSiteData (which are your SiteNames) */}
 | 
					            <input
 | 
				
			||||||
                    {Object.keys(mockSiteData).map((siteNameKey) => {
 | 
					              value={q}
 | 
				
			||||||
                        const siteName = siteNameKey as SiteName; // Cast to SiteName type
 | 
					              onChange={e => setQ(e.target.value)}
 | 
				
			||||||
                        const siteDetails = mockSiteData[siteName];
 | 
					              placeholder="Search by name / ID / customer"
 | 
				
			||||||
                        const siteStatus = getSiteStatus(siteName);
 | 
					              className="w-64 max-w-full px-3 py-2 rounded-md border dark:border-gray-700 bg-white dark:bg-gray-900 dark:text-white"
 | 
				
			||||||
 | 
					 | 
				
			||||||
                        return (
 | 
					 | 
				
			||||||
                            <SiteCard
 | 
					 | 
				
			||||||
                                key={siteName} // Important for React list rendering
 | 
					 | 
				
			||||||
                                siteName={siteName}
 | 
					 | 
				
			||||||
                                details={siteDetails}
 | 
					 | 
				
			||||||
                                status={siteStatus}
 | 
					 | 
				
			||||||
            />
 | 
					            />
 | 
				
			||||||
                        );
 | 
					            <select
 | 
				
			||||||
                    })}
 | 
					              value={pageSize}
 | 
				
			||||||
 | 
					              onChange={e => setPageSize(Number(e.target.value))}
 | 
				
			||||||
 | 
					              className="px-3 py-2 rounded-md border dark:border-gray-700 bg-white dark:bg-gray-900 dark:text-white"
 | 
				
			||||||
 | 
					              aria-label="Items per page"
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              <option value={6}>6 / page</option>
 | 
				
			||||||
 | 
					              <option value={9}>9 / page</option>
 | 
				
			||||||
 | 
					              <option value={12}>12 / page</option>
 | 
				
			||||||
 | 
					            </select>
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        {loading && (
 | 
				
			||||||
 | 
					          <div className="text-gray-600 dark:text-gray-400">Loading CRM projects…</div>
 | 
				
			||||||
 | 
					        )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        {err && (
 | 
				
			||||||
 | 
					          <div className="text-red-600">Error: {err}</div>
 | 
				
			||||||
 | 
					        )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        {!loading && !err && total === 0 && (
 | 
				
			||||||
 | 
					          <div className="text-amber-600">No sites found.</div>
 | 
				
			||||||
 | 
					        )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        {!loading && !err && total > 0 && (
 | 
				
			||||||
 | 
					          <>
 | 
				
			||||||
 | 
					            {/* Pagination header */}
 | 
				
			||||||
 | 
					            <div className="flex items-center justify-between">
 | 
				
			||||||
 | 
					              <div className="text-sm text-gray-600 dark:text-gray-400">
 | 
				
			||||||
 | 
					                Showing <span className="font-semibold">{startIdx + 1}</span>–<span className="font-semibold">{endIdx}</span> of <span className="font-semibold">{total}</span>
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					              <div className="flex items-center gap-2 dark:text-white">
 | 
				
			||||||
 | 
					                <button
 | 
				
			||||||
 | 
					                  onClick={goPrev}
 | 
				
			||||||
 | 
					                  disabled={safePage <= 1}
 | 
				
			||||||
 | 
					                  className={`px-3 py-1.5 rounded-md border dark:border-gray-700 ${safePage <= 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-50 dark:hover:bg-gray-800'}`}
 | 
				
			||||||
 | 
					                >
 | 
				
			||||||
 | 
					                  Previous
 | 
				
			||||||
 | 
					                </button>
 | 
				
			||||||
 | 
					                <span className="text-sm text-gray-600 dark:text-gray-400">
 | 
				
			||||||
 | 
					                  Page <span className="font-semibold">{safePage}</span> / {totalPages}
 | 
				
			||||||
 | 
					                </span>
 | 
				
			||||||
 | 
					                <button
 | 
				
			||||||
 | 
					                  onClick={goNext}
 | 
				
			||||||
 | 
					                  disabled={safePage >= totalPages}
 | 
				
			||||||
 | 
					                  className={`px-3 py-1.5 rounded-md border dark:border-gray-700 ${safePage >= totalPages ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-50 dark:hover:bg-gray-800 '}`}
 | 
				
			||||||
 | 
					                >
 | 
				
			||||||
 | 
					                  Next
 | 
				
			||||||
 | 
					                </button>
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            {/* Cards */}
 | 
				
			||||||
 | 
					            <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
 | 
				
			||||||
 | 
					              {pageItems.map(p => (
 | 
				
			||||||
 | 
					                <SiteCard
 | 
				
			||||||
 | 
					                  key={p.name}
 | 
				
			||||||
 | 
					                  siteId={p.name}          // SiteCard self-fetches details
 | 
				
			||||||
 | 
					                  fallbackStatus={p.status ?? undefined}
 | 
				
			||||||
 | 
					                />
 | 
				
			||||||
 | 
					              ))}
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            {/* Pagination footer mirrors header for convenience */}
 | 
				
			||||||
 | 
					            <div className="flex items-center justify-between">
 | 
				
			||||||
 | 
					              <div className="text-sm text-gray-600 dark:text-gray-400">
 | 
				
			||||||
 | 
					                Showing <span className="font-semibold">{startIdx + 1}</span>–<span className="font-semibold">{endIdx}</span> of <span className="font-semibold">{total}</span>
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					              <div className="flex items-center gap-2 dark:text-white">
 | 
				
			||||||
 | 
					                <button
 | 
				
			||||||
 | 
					                  onClick={goPrev}
 | 
				
			||||||
 | 
					                  disabled={safePage <= 1}
 | 
				
			||||||
 | 
					                  className={`px-3 py-1.5 rounded-md border dark:border-gray-700 ${safePage <= 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-50 dark:hover:bg-gray-800'}`}
 | 
				
			||||||
 | 
					                >
 | 
				
			||||||
 | 
					                  Previous
 | 
				
			||||||
 | 
					                </button>
 | 
				
			||||||
 | 
					                <span className="text-sm text-gray-600 dark:text-gray-400">
 | 
				
			||||||
 | 
					                  Page <span className="font-semibold">{safePage}</span> / {totalPages}
 | 
				
			||||||
 | 
					                </span>
 | 
				
			||||||
 | 
					                <button
 | 
				
			||||||
 | 
					                  onClick={goNext}
 | 
				
			||||||
 | 
					                  disabled={safePage >= totalPages}
 | 
				
			||||||
 | 
					                  className={`px-3 py-1.5 rounded-md border dark:border-gray-700 ${safePage >= totalPages ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-50 dark:hover:bg-gray-800'}`}
 | 
				
			||||||
 | 
					                >
 | 
				
			||||||
 | 
					                  Next
 | 
				
			||||||
 | 
					                </button>
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					          </>
 | 
				
			||||||
 | 
					        )}
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
    </DashboardLayout>
 | 
					    </DashboardLayout>
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default SitesPage;
 | 
					export default SitesPage;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -1,20 +0,0 @@
 | 
				
			|||||||
// app/api/sites/route.ts
 | 
					 | 
				
			||||||
import { NextResponse } from 'next/server';
 | 
					 | 
				
			||||||
import prisma from '@/lib/prisma';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export async function GET() {
 | 
					 | 
				
			||||||
  try {
 | 
					 | 
				
			||||||
    const sites = await prisma.site.findMany({
 | 
					 | 
				
			||||||
      include: {
 | 
					 | 
				
			||||||
        consumptionData: true,
 | 
					 | 
				
			||||||
        generationData: true,
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
    console.log('✅ Sites:', sites);
 | 
					 | 
				
			||||||
    return NextResponse.json(sites);
 | 
					 | 
				
			||||||
  } catch (error) {
 | 
					 | 
				
			||||||
    console.error('❌ Error fetching sites:', error);
 | 
					 | 
				
			||||||
    return new NextResponse('Failed to fetch sites', { status: 500 });
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
							
								
								
									
										20
									
								
								app/hooks/useCrmProjects.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								app/hooks/useCrmProjects.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,20 @@
 | 
				
			|||||||
 | 
					// src/hooks/useCrmProjects.ts
 | 
				
			||||||
 | 
					import { useEffect, useState } from "react";
 | 
				
			||||||
 | 
					import { crmapi } from "../utils/api";
 | 
				
			||||||
 | 
					import { CrmProject } from "@/types/crm";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function useCrmProjects() {
 | 
				
			||||||
 | 
					  const [data, setData] = useState<CrmProject[]>([]);
 | 
				
			||||||
 | 
					  const [loading, setLoading] = useState(true);
 | 
				
			||||||
 | 
					  const [error, setError] = useState<unknown>(null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  useEffect(() => {
 | 
				
			||||||
 | 
					    setLoading(true);
 | 
				
			||||||
 | 
					    crmapi.getProjects()
 | 
				
			||||||
 | 
					      .then(res => setData(res.data?.data ?? []))
 | 
				
			||||||
 | 
					      .catch(setError)
 | 
				
			||||||
 | 
					      .finally(() => setLoading(false));
 | 
				
			||||||
 | 
					  }, []);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return { data, loading, error };
 | 
				
			||||||
 | 
					} 
 | 
				
			||||||
@ -9,6 +9,18 @@ export interface TimeSeriesResponse {
 | 
				
			|||||||
  generation: TimeSeriesEntry[];
 | 
					  generation: TimeSeriesEntry[];
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const API_BASE_URL =
 | 
				
			||||||
 | 
					  process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://127.0.0.1:8000";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const crmapi = {
 | 
				
			||||||
 | 
					  getProjects: async () => {
 | 
				
			||||||
 | 
					    const res = await fetch(`${API_BASE_URL}/crm/projects`, {
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					    if (!res.ok) throw new Error(`HTTP ${res.status}`);
 | 
				
			||||||
 | 
					    return res.json();
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export async function fetchPowerTimeseries(
 | 
					export async function fetchPowerTimeseries(
 | 
				
			||||||
  site: string,
 | 
					  site: string,
 | 
				
			||||||
  start: string,
 | 
					  start: string,
 | 
				
			||||||
@ -48,3 +60,29 @@ export async function fetchForecast(
 | 
				
			|||||||
  return res.json();
 | 
					  return res.json();
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export type MonthlyKPI = {
 | 
				
			||||||
 | 
					  site: string;
 | 
				
			||||||
 | 
					  month: string;        // "YYYY-MM"
 | 
				
			||||||
 | 
					  yield_kwh: number | null;
 | 
				
			||||||
 | 
					  consumption_kwh: number | null;
 | 
				
			||||||
 | 
					  grid_draw_kwh: number | null;
 | 
				
			||||||
 | 
					  efficiency: number | null;        // 0..1 (fraction)
 | 
				
			||||||
 | 
					  peak_demand_kw: number | null;
 | 
				
			||||||
 | 
					  avg_power_factor: number | null;  // 0..1
 | 
				
			||||||
 | 
					  load_factor: number | null;       // 0..1
 | 
				
			||||||
 | 
					  error?: string;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export async function fetchMonthlyKpi(params: {
 | 
				
			||||||
 | 
					  site: string;
 | 
				
			||||||
 | 
					  month: string; // "YYYY-MM"
 | 
				
			||||||
 | 
					  consumption_topic?: string;
 | 
				
			||||||
 | 
					  generation_topic?: string;
 | 
				
			||||||
 | 
					}): Promise<MonthlyKPI> {
 | 
				
			||||||
 | 
					  const qs = new URLSearchParams(params as Record<string, string>);
 | 
				
			||||||
 | 
					  const res = await fetch(`${API}/kpi/monthly?${qs.toString()}`, { cache: "no-store" });
 | 
				
			||||||
 | 
					  if (!res.ok) throw new Error(`HTTP ${res.status}`);
 | 
				
			||||||
 | 
					  return res.json();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										37
									
								
								app/utils/datetime.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								app/utils/datetime.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,37 @@
 | 
				
			|||||||
 | 
					// app/utils/datetime.ts
 | 
				
			||||||
 | 
					export function formatCrmTimestamp(
 | 
				
			||||||
 | 
					  input: string | null | undefined,
 | 
				
			||||||
 | 
					  opts?: { locale?: string; timeZone?: string; includeSeconds?: boolean }
 | 
				
			||||||
 | 
					): string {
 | 
				
			||||||
 | 
					  if (!input) return 'N/A';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Accept: 2025-06-30 10:04:58.387651 (also with 'T', with/without fraction)
 | 
				
			||||||
 | 
					  const m = String(input).trim().match(
 | 
				
			||||||
 | 
					    /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,6}))?$/
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					  if (!m) return input; // fallback: show as-is
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const [, y, mo, d, hh, mm, ss, frac = ''] = m;
 | 
				
			||||||
 | 
					  const ms = Number((frac + '000').slice(0, 3)); // micro→millis
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const dt = new Date(
 | 
				
			||||||
 | 
					    Number(y),
 | 
				
			||||||
 | 
					    Number(mo) - 1,
 | 
				
			||||||
 | 
					    Number(d),
 | 
				
			||||||
 | 
					    Number(hh),
 | 
				
			||||||
 | 
					    Number(mm),
 | 
				
			||||||
 | 
					    Number(ss),
 | 
				
			||||||
 | 
					    ms
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const locale = opts?.locale ?? 'en-MY';
 | 
				
			||||||
 | 
					  const timeZone = opts?.timeZone ?? 'Asia/Kuala_Lumpur';
 | 
				
			||||||
 | 
					  const timeStyle = opts?.includeSeconds ? 'medium' : 'short';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return new Intl.DateTimeFormat(locale, {
 | 
				
			||||||
 | 
					    dateStyle: 'medium',
 | 
				
			||||||
 | 
					    timeStyle,         // 'short'=no seconds, 'medium'=with seconds
 | 
				
			||||||
 | 
					    timeZone,
 | 
				
			||||||
 | 
					    hour12: true,
 | 
				
			||||||
 | 
					  }).format(dt);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										35
									
								
								app/utils/formatAddress.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								app/utils/formatAddress.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,35 @@
 | 
				
			|||||||
 | 
					// utils/formatAddress.ts
 | 
				
			||||||
 | 
					// npm i he  (for robust HTML entity decoding)
 | 
				
			||||||
 | 
					import { decode } from "he";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function formatAddress(raw: string) {
 | 
				
			||||||
 | 
					  // 1) decode entities (& → &), 2) <br> → \n, 3) tidy whitespace
 | 
				
			||||||
 | 
					  const text = decode(raw)
 | 
				
			||||||
 | 
					    .replace(/<br\s*\/?>/gi, "\n")
 | 
				
			||||||
 | 
					    .replace(/\u00A0/g, " ")      //  
 | 
				
			||||||
 | 
					    .replace(/[ \t]{2,}/g, " ")   // collapse spaces
 | 
				
			||||||
 | 
					    .replace(/\n{2,}/g, "\n")     // collapse blank lines
 | 
				
			||||||
 | 
					    .trim();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // split to lines, strip empties
 | 
				
			||||||
 | 
					  const lines = text.split("\n").map(s => s.trim()).filter(Boolean);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // If postcode is alone (e.g., "40150") before the city line, merge: "40150 Shah Alam"
 | 
				
			||||||
 | 
					  const merged: string[] = [];
 | 
				
			||||||
 | 
					  for (let i = 0; i < lines.length; i++) {
 | 
				
			||||||
 | 
					    const cur = lines[i];
 | 
				
			||||||
 | 
					    const next = lines[i + 1];
 | 
				
			||||||
 | 
					    if (/^\d{5}$/.test(cur) && next) {
 | 
				
			||||||
 | 
					      merged.push(`${cur} ${next}`);
 | 
				
			||||||
 | 
					      i++; // skip the city line, already merged
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      merged.push(cur);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return {
 | 
				
			||||||
 | 
					    parts: merged,                 // array of lines
 | 
				
			||||||
 | 
					    multiLine: merged.join("\n"),  // lines with \n
 | 
				
			||||||
 | 
					    singleLine: merged.join(", "), // one-liner
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -2,53 +2,69 @@
 | 
				
			|||||||
import IconLockDots from '@/components/icon/icon-lock-dots';
 | 
					import IconLockDots from '@/components/icon/icon-lock-dots';
 | 
				
			||||||
import IconMail from '@/components/icon/icon-mail';
 | 
					import IconMail from '@/components/icon/icon-mail';
 | 
				
			||||||
import { useRouter } from 'next/navigation';
 | 
					import { useRouter } from 'next/navigation';
 | 
				
			||||||
import { useState } from "react";
 | 
					import { useState } from 'react';
 | 
				
			||||||
import axios from "axios";
 | 
					import axios from 'axios';
 | 
				
			||||||
import toast from 'react-hot-toast';
 | 
					import toast from 'react-hot-toast';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const ComponentsAuthLoginForm = () => {
 | 
					const ComponentsAuthLoginForm = () => {
 | 
				
			||||||
    const [email, setEmail] = useState("")
 | 
					  const [email, setEmail] = useState('');
 | 
				
			||||||
    const [password, setPassword] = useState("")
 | 
					  const [password, setPassword] = useState('');
 | 
				
			||||||
    const [loading, setLoading] = useState(false)
 | 
					  const [loading, setLoading] = useState(false);
 | 
				
			||||||
    const router = useRouter()
 | 
					  const router = useRouter();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const submitForm = async (e: React.FormEvent) => {
 | 
					  const submitForm = async (e: React.FormEvent) => {
 | 
				
			||||||
        e.preventDefault()
 | 
					    e.preventDefault();
 | 
				
			||||||
 | 
					    setLoading(true);
 | 
				
			||||||
        setLoading(true)
 | 
					 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
            const res = await axios.post(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/login`, {
 | 
					      const res = await axios.post('/api/login', { email, password });
 | 
				
			||||||
                email,
 | 
					      toast.success(res.data?.message || 'Login successful!');
 | 
				
			||||||
                password,
 | 
					      router.push('/adminDashboard');
 | 
				
			||||||
            })
 | 
					      router.refresh();
 | 
				
			||||||
 | 
					      // token cookie is already set by the server:
 | 
				
			||||||
            localStorage.setItem("token", res.data.token)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            toast.success("Login successful!")
 | 
					 | 
				
			||||||
            router.push("/")
 | 
					 | 
				
			||||||
    } catch (err: any) {
 | 
					    } catch (err: any) {
 | 
				
			||||||
            console.error("Login error:", err)
 | 
					      console.error('Login error:', err);
 | 
				
			||||||
            toast.error(err.response?.data?.error || "Invalid credentials")
 | 
					      const msg =
 | 
				
			||||||
 | 
					        err?.response?.data?.message ||
 | 
				
			||||||
 | 
					        err?.message ||
 | 
				
			||||||
 | 
					        'Invalid credentials';
 | 
				
			||||||
 | 
					      toast.error(msg);
 | 
				
			||||||
    } finally {
 | 
					    } finally {
 | 
				
			||||||
            setLoading(false)
 | 
					      setLoading(false);
 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <form className="space-y-3 dark:text-white" onSubmit={submitForm}>
 | 
					    <form className="space-y-3 dark:text-white" onSubmit={submitForm}>
 | 
				
			||||||
      <div>
 | 
					      <div>
 | 
				
			||||||
                <label htmlFor="Email" className='text-yellow-400 text-left'>Email</label>
 | 
					        <label htmlFor="Email" className="text-yellow-400 text-left">Email</label>
 | 
				
			||||||
        <div className="relative text-white-dark">
 | 
					        <div className="relative text-white-dark">
 | 
				
			||||||
                    <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Enter Email" className="form-input ps-10 placeholder:text-white-dark" />
 | 
					          <input
 | 
				
			||||||
 | 
					            id="Email"
 | 
				
			||||||
 | 
					            type="email"
 | 
				
			||||||
 | 
					            value={email}
 | 
				
			||||||
 | 
					            onChange={(e) => setEmail(e.target.value)}
 | 
				
			||||||
 | 
					            placeholder="Enter Email"
 | 
				
			||||||
 | 
					            className="form-input ps-10 placeholder:text-white-dark"
 | 
				
			||||||
 | 
					            required
 | 
				
			||||||
 | 
					          />
 | 
				
			||||||
          <span className="absolute start-4 top-1/2 -translate-y-1/2">
 | 
					          <span className="absolute start-4 top-1/2 -translate-y-1/2">
 | 
				
			||||||
            <IconMail fill={true} />
 | 
					            <IconMail fill={true} />
 | 
				
			||||||
          </span>
 | 
					          </span>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
            <div className= "pb-2">
 | 
					      <div className="pb-2">
 | 
				
			||||||
                <label htmlFor="Password" className='text-yellow-400 text-left'>Password</label>
 | 
					        <label htmlFor="Password" className="text-yellow-400 text-left">Password</label>
 | 
				
			||||||
        <div className="relative text-white-dark">
 | 
					        <div className="relative text-white-dark">
 | 
				
			||||||
                    <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required placeholder="Enter Password" className="form-input ps-10 placeholder:text-white-dark" />
 | 
					          <input
 | 
				
			||||||
 | 
					            id="Password"
 | 
				
			||||||
 | 
					            type="password"
 | 
				
			||||||
 | 
					            value={password}
 | 
				
			||||||
 | 
					            onChange={(e) => setPassword(e.target.value)}
 | 
				
			||||||
 | 
					            required
 | 
				
			||||||
 | 
					            placeholder="Enter Password"
 | 
				
			||||||
 | 
					            className="form-input ps-10 placeholder:text-white-dark"
 | 
				
			||||||
 | 
					            minLength={8}
 | 
				
			||||||
 | 
					          />
 | 
				
			||||||
          <span className="absolute start-4 top-1/2 -translate-y-1/2">
 | 
					          <span className="absolute start-4 top-1/2 -translate-y-1/2">
 | 
				
			||||||
            <IconLockDots fill={true} />
 | 
					            <IconLockDots fill={true} />
 | 
				
			||||||
          </span>
 | 
					          </span>
 | 
				
			||||||
@ -57,12 +73,13 @@ const ComponentsAuthLoginForm = () => {
 | 
				
			|||||||
      <button
 | 
					      <button
 | 
				
			||||||
        type="submit"
 | 
					        type="submit"
 | 
				
			||||||
        disabled={loading}
 | 
					        disabled={loading}
 | 
				
			||||||
                className=" w-full uppercase border-0 rounded-md bg-[#fcd913] text-black font-semibold py-2 hover:bg-[#E6C812] transition disabled:opacity-70"
 | 
					        className="w-full uppercase border-0 rounded-md bg-[#fcd913] text-black font-semibold py-2 hover:bg-[#E6C812] transition disabled:opacity-70"
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
                {loading ? "Logging in..." : "Sign In"}
 | 
					        {loading ? 'Logging in...' : 'Sign In'}
 | 
				
			||||||
      </button>
 | 
					      </button>
 | 
				
			||||||
    </form>
 | 
					    </form>
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default ComponentsAuthLoginForm;
 | 
					export default ComponentsAuthLoginForm;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -1,74 +1,131 @@
 | 
				
			|||||||
'use client';
 | 
					// components/auth/components-auth-register-form.tsx
 | 
				
			||||||
import IconLockDots from '@/components/icon/icon-lock-dots';
 | 
					"use client";
 | 
				
			||||||
import IconMail from '@/components/icon/icon-mail';
 | 
					 | 
				
			||||||
import IconUser from '@/components/icon/icon-user';
 | 
					 | 
				
			||||||
import axios from 'axios';
 | 
					 | 
				
			||||||
import { useRouter } from 'next/navigation';
 | 
					 | 
				
			||||||
import { useState } from "react";
 | 
					 | 
				
			||||||
import React from 'react';
 | 
					 | 
				
			||||||
import toast from 'react-hot-toast';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const ComponentsAuthRegisterForm = () => {
 | 
					import * as React from "react";
 | 
				
			||||||
    const [email, setEmail] = useState("")
 | 
					import { useRouter } from "next/navigation";
 | 
				
			||||||
    const [password, setPassword] = useState("")
 | 
					 | 
				
			||||||
    const [loading, setLoading] = useState(false)
 | 
					 | 
				
			||||||
    const router = useRouter()
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const submitForm = async(e: any) => {
 | 
					type Props = {
 | 
				
			||||||
        e.preventDefault()
 | 
					  redirectTo?: string; // optional override
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        setLoading(true)
 | 
					export default function ComponentsAuthRegisterForm({ redirectTo = "/dashboard" }: Props) {
 | 
				
			||||||
        try {
 | 
					  const router = useRouter();
 | 
				
			||||||
            const res = await axios.post(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/register`, {
 | 
					  const [email, setEmail] = React.useState("");
 | 
				
			||||||
                email,
 | 
					  const [password, setPassword] = React.useState("");
 | 
				
			||||||
                password,
 | 
					  const [confirm, setConfirm] = React.useState("");
 | 
				
			||||||
            })
 | 
					  const [loading, setLoading] = React.useState(false);
 | 
				
			||||||
 | 
					  const [error, setError] = React.useState<string | null>(null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            localStorage.setItem("token", res.data.token)
 | 
					  async function onSubmit(e: React.FormEvent) {
 | 
				
			||||||
 | 
					    e.preventDefault();
 | 
				
			||||||
 | 
					    setError(null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            toast.success("Register successful!")
 | 
					    if (!email.trim() || !password) {
 | 
				
			||||||
            router.push("/")
 | 
					      setError("Please fill in all fields.");
 | 
				
			||||||
        } catch (err: any) {
 | 
					      return;
 | 
				
			||||||
            console.error("Register error:", err)
 | 
					 | 
				
			||||||
            toast.error(err.response?.data?.error || "Something went wrong")
 | 
					 | 
				
			||||||
        } finally {
 | 
					 | 
				
			||||||
            setLoading(false)
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    };
 | 
					    if (password.length < 8) {
 | 
				
			||||||
 | 
					      setError("Password must be at least 8 characters.");
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    if (password !== confirm) {
 | 
				
			||||||
 | 
					      setError("Passwords do not match.");
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      setLoading(true);
 | 
				
			||||||
 | 
					      const res = await fetch("/api/register", {
 | 
				
			||||||
 | 
					        method: "POST",
 | 
				
			||||||
 | 
					        headers: { "Content-Type": "application/json" },
 | 
				
			||||||
 | 
					        body: JSON.stringify({ email, password }),
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const data = await res.json();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (!res.ok) {
 | 
				
			||||||
 | 
					        setError(data?.message || "Registration failed.");
 | 
				
			||||||
 | 
					        return;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Cookie is set by API; just route away
 | 
				
			||||||
 | 
					      router.replace(redirectTo);
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      setError("Network error. Please try again.");
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setLoading(false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
        <form className="space-y-5 dark:text-white" onSubmit={submitForm}>
 | 
					    <form onSubmit={onSubmit} className="space-y-4 text-left">
 | 
				
			||||||
            {/* <div>
 | 
					 | 
				
			||||||
                <label htmlFor="Name">Name</label>
 | 
					 | 
				
			||||||
                <div className="relative text-white-dark">
 | 
					 | 
				
			||||||
                    <input id="Name" type="text" placeholder="Enter Name" className="form-input ps-10 placeholder:text-white-dark" />
 | 
					 | 
				
			||||||
                    <span className="absolute start-4 top-1/2 -translate-y-1/2">
 | 
					 | 
				
			||||||
                        <IconUser fill={true} />
 | 
					 | 
				
			||||||
                    </span>
 | 
					 | 
				
			||||||
                </div>
 | 
					 | 
				
			||||||
            </div> */}
 | 
					 | 
				
			||||||
      <div>
 | 
					      <div>
 | 
				
			||||||
                <label htmlFor="Email" className='text-yellow-400 text-left'>Email</label>
 | 
					        <label htmlFor="email" className="mb-1 block text-sm text-gray-300">
 | 
				
			||||||
                <div className="relative text-white-dark">
 | 
					          Email
 | 
				
			||||||
                    <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Enter Email" className="form-input ps-10 placeholder:text-white-dark" />
 | 
					        </label>
 | 
				
			||||||
                    <span className="absolute start-4 top-1/2 -translate-y-1/2">
 | 
					        <input
 | 
				
			||||||
                        <IconMail fill={true} />
 | 
					          id="email"
 | 
				
			||||||
                    </span>
 | 
					          type="email"
 | 
				
			||||||
 | 
					          autoComplete="email"
 | 
				
			||||||
 | 
					          className="w-full rounded-xl border border-white/10 bg-white/10 px-4 py-3 text-white placeholder-gray-400 outline-none focus:border-yellow-400"
 | 
				
			||||||
 | 
					          placeholder="you@example.com"
 | 
				
			||||||
 | 
					          value={email}
 | 
				
			||||||
 | 
					          onChange={(e) => setEmail(e.target.value)}
 | 
				
			||||||
 | 
					          disabled={loading}
 | 
				
			||||||
 | 
					          required
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <div>
 | 
				
			||||||
 | 
					        <label htmlFor="password" className="mb-1 block text-sm text-gray-300">
 | 
				
			||||||
 | 
					          Password
 | 
				
			||||||
 | 
					        </label>
 | 
				
			||||||
 | 
					        <input
 | 
				
			||||||
 | 
					          id="password"
 | 
				
			||||||
 | 
					          type="password"
 | 
				
			||||||
 | 
					          autoComplete="new-password"
 | 
				
			||||||
 | 
					          className="w-full rounded-xl border border-white/10 bg-white/10 px-4 py-3 text-white placeholder-gray-400 outline-none focus:border-yellow-400"
 | 
				
			||||||
 | 
					          placeholder="••••••••"
 | 
				
			||||||
 | 
					          value={password}
 | 
				
			||||||
 | 
					          onChange={(e) => setPassword(e.target.value)}
 | 
				
			||||||
 | 
					          disabled={loading}
 | 
				
			||||||
 | 
					          required
 | 
				
			||||||
 | 
					          minLength={8}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
            <div className= "pb-2">
 | 
					
 | 
				
			||||||
                <label htmlFor="Password" className='text-yellow-400 text-left'>Password</label>
 | 
					      <div>
 | 
				
			||||||
                <div className="relative text-white-dark">
 | 
					        <label htmlFor="confirm" className="mb-1 block text-sm text-gray-300">
 | 
				
			||||||
                    <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required placeholder="Enter Password" className="form-input ps-10 placeholder:text-white-dark" />
 | 
					          Confirm Password
 | 
				
			||||||
                    <span className="absolute start-4 top-1/2 -translate-y-1/2">
 | 
					        </label>
 | 
				
			||||||
                        <IconLockDots fill={true} />
 | 
					        <input
 | 
				
			||||||
                    </span>
 | 
					          id="confirm"
 | 
				
			||||||
 | 
					          type="password"
 | 
				
			||||||
 | 
					          autoComplete="new-password"
 | 
				
			||||||
 | 
					          className="w-full rounded-xl border border-white/10 bg-white/10 px-4 py-3 text-white placeholder-gray-400 outline-none focus:border-yellow-400"
 | 
				
			||||||
 | 
					          placeholder="••••••••"
 | 
				
			||||||
 | 
					          value={confirm}
 | 
				
			||||||
 | 
					          onChange={(e) => setConfirm(e.target.value)}
 | 
				
			||||||
 | 
					          disabled={loading}
 | 
				
			||||||
 | 
					          required
 | 
				
			||||||
 | 
					          minLength={8}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
            </div>
 | 
					
 | 
				
			||||||
            <button type="submit" disabled={loading} className=" w-full uppercase border-0 rounded-md bg-[#fcd913] text-black font-semibold py-2 hover:bg-[#E6C812] transition disabled:opacity-70">
 | 
					      {error && (
 | 
				
			||||||
            {loading ? "Creating account..." : "Register"}
 | 
					        <p className="rounded-lg bg-red-500/10 px-3 py-2 text-sm text-red-300">
 | 
				
			||||||
 | 
					          {error}
 | 
				
			||||||
 | 
					        </p>
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <button
 | 
				
			||||||
 | 
					        type="submit"
 | 
				
			||||||
 | 
					        disabled={loading}
 | 
				
			||||||
 | 
					        className="inline-flex w-full items-center justify-center rounded-xl bg-yellow-400 px-4 py-3 font-semibold text-black hover:brightness-90 disabled:opacity-60"
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        {loading ? "Creating account…" : "Create account"}
 | 
				
			||||||
      </button>
 | 
					      </button>
 | 
				
			||||||
    </form>
 | 
					    </form>
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
};
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default ComponentsAuthRegisterForm;
 | 
					 | 
				
			||||||
 | 
				
			|||||||
@ -19,6 +19,7 @@ import DatePicker from 'react-datepicker';
 | 
				
			|||||||
import 'react-datepicker/dist/react-datepicker.css';
 | 
					import 'react-datepicker/dist/react-datepicker.css';
 | 
				
			||||||
import './datepicker-dark.css'; // custom dark mode styles
 | 
					import './datepicker-dark.css'; // custom dark mode styles
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
ChartJS.register(zoomPlugin);
 | 
					ChartJS.register(zoomPlugin);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
interface TimeSeriesEntry {
 | 
					interface TimeSeriesEntry {
 | 
				
			||||||
@ -30,9 +31,48 @@ interface EnergyLineChartProps {
 | 
				
			|||||||
  siteId: string;
 | 
					  siteId: string;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function powerSeriesToEnergySeries(
 | 
				
			||||||
 | 
					  data: TimeSeriesEntry[],
 | 
				
			||||||
 | 
					  guessMinutes = 30
 | 
				
			||||||
 | 
					): TimeSeriesEntry[] {
 | 
				
			||||||
 | 
					  if (!data?.length) return [];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Ensure ascending by time
 | 
				
			||||||
 | 
					  const sorted = [...data].sort(
 | 
				
			||||||
 | 
					    (a, b) => new Date(a.time).getTime() - new Date(b.time).getTime()
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const out: TimeSeriesEntry[] = [];
 | 
				
			||||||
 | 
					  let lastDeltaMs: number | null = null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  for (let i = 0; i < sorted.length; i++) {
 | 
				
			||||||
 | 
					    const t0 = new Date(sorted[i].time).getTime();
 | 
				
			||||||
 | 
					    const p0 = sorted[i].value; // kW
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let deltaMs: number;
 | 
				
			||||||
 | 
					    if (i < sorted.length - 1) {
 | 
				
			||||||
 | 
					      const t1 = new Date(sorted[i + 1].time).getTime();
 | 
				
			||||||
 | 
					      deltaMs = Math.max(0, t1 - t0);
 | 
				
			||||||
 | 
					      if (deltaMs > 0) lastDeltaMs = deltaMs;
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      // For the last point, assume previous cadence or a guess
 | 
				
			||||||
 | 
					      deltaMs = lastDeltaMs ?? guessMinutes * 60 * 1000;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const hours = deltaMs / (1000 * 60 * 60);
 | 
				
			||||||
 | 
					    const kwh = p0 * hours; // kW * h = kWh
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    out.push({ time: sorted[i].time, value: kwh });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return out;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function groupTimeSeries(
 | 
					function groupTimeSeries(
 | 
				
			||||||
  data: TimeSeriesEntry[],
 | 
					  data: TimeSeriesEntry[],
 | 
				
			||||||
  mode: 'day' | 'daily' | 'weekly' | 'monthly' | 'yearly'
 | 
					  mode: 'day' | 'daily' | 'weekly' | 'monthly' | 'yearly',
 | 
				
			||||||
 | 
					  agg: 'mean' | 'max' | 'sum' = 'mean'
 | 
				
			||||||
): TimeSeriesEntry[] {
 | 
					): TimeSeriesEntry[] {
 | 
				
			||||||
  const groupMap = new Map<string, number[]>();
 | 
					  const groupMap = new Map<string, number[]>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -41,19 +81,22 @@ function groupTimeSeries(
 | 
				
			|||||||
    let key = '';
 | 
					    let key = '';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    switch (mode) {
 | 
					    switch (mode) {
 | 
				
			||||||
      case 'day':
 | 
					      case 'day': {
 | 
				
			||||||
        const local = new Date(date.toLocaleString('en-US', { timeZone: 'Asia/Kuala_Lumpur' }));
 | 
					        const local = new Date(
 | 
				
			||||||
        const hour = local.getHours();
 | 
					          date.toLocaleString('en-US', { timeZone: 'Asia/Kuala_Lumpur' })
 | 
				
			||||||
        const minute = local.getMinutes() < 30 ? '00' : '30';
 | 
					        );
 | 
				
			||||||
        const adjusted = new Date(local.setMinutes(minute === '00' ? 0 : 30, 0)); // zero seconds
 | 
					        const minute = local.getMinutes() < 30 ? 0 : 30;
 | 
				
			||||||
        key = adjusted.toISOString();  // ✅ full timestamp key
 | 
					        local.setMinutes(minute, 0, 0);
 | 
				
			||||||
 | 
					        key = local.toISOString();
 | 
				
			||||||
        break;
 | 
					        break;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
      case 'daily':
 | 
					      case 'daily':
 | 
				
			||||||
        key = date.toLocaleDateString('en-MY', {
 | 
					        key = date.toLocaleDateString('en-MY', {
 | 
				
			||||||
          timeZone: 'Asia/Kuala_Lumpur',
 | 
					          timeZone: 'Asia/Kuala_Lumpur',
 | 
				
			||||||
          weekday: 'short',
 | 
					          weekday: 'short',
 | 
				
			||||||
          day: '2-digit',
 | 
					          day: '2-digit',
 | 
				
			||||||
          month: 'short',
 | 
					          month: 'short',
 | 
				
			||||||
 | 
					          year: 'numeric',
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
        break;
 | 
					        break;
 | 
				
			||||||
      case 'weekly':
 | 
					      case 'weekly':
 | 
				
			||||||
@ -71,12 +114,19 @@ function groupTimeSeries(
 | 
				
			|||||||
    groupMap.get(key)!.push(entry.value);
 | 
					    groupMap.get(key)!.push(entry.value);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return Array.from(groupMap.entries()).map(([time, values]) => ({
 | 
					  return Array.from(groupMap.entries()).map(([time, values]) => {
 | 
				
			||||||
    time,
 | 
					    if (agg === 'sum') {
 | 
				
			||||||
    value: values.reduce((sum, v) => sum + v, 0),
 | 
					      const sum = values.reduce((a, b) => a + b, 0);
 | 
				
			||||||
  }));
 | 
					      return { time, value: sum };
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    const mean = values.reduce((a, b) => a + b, 0) / values.length;
 | 
				
			||||||
 | 
					    const max = values.reduce((a, b) => (b > a ? b : a), -Infinity);
 | 
				
			||||||
 | 
					    return { time, value: agg === 'max' ? max : mean };
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => {
 | 
					const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => {
 | 
				
			||||||
  const chartRef = useRef<any>(null);
 | 
					  const chartRef = useRef<any>(null);
 | 
				
			||||||
  const [viewMode, setViewMode] = useState<'day' | 'daily' | 'weekly' | 'monthly' | 'yearly'>('day');
 | 
					  const [viewMode, setViewMode] = useState<'day' | 'daily' | 'weekly' | 'monthly' | 'yearly'>('day');
 | 
				
			||||||
@ -85,6 +135,94 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => {
 | 
				
			|||||||
  const [selectedDate, setSelectedDate] = useState(new Date());
 | 
					  const [selectedDate, setSelectedDate] = useState(new Date());
 | 
				
			||||||
  const [forecast, setForecast] = useState<TimeSeriesEntry[]>([]);
 | 
					  const [forecast, setForecast] = useState<TimeSeriesEntry[]>([]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const LIVE_REFRESH_MS = 300000;       // 5min when viewing a single day
 | 
				
			||||||
 | 
					  const SLOW_REFRESH_MS = 600000;      // 10min for weekly/monthly/yearly
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const fetchAndSet = React.useCallback(async () => {
 | 
				
			||||||
 | 
					    const now = new Date();
 | 
				
			||||||
 | 
					    let start: Date;
 | 
				
			||||||
 | 
					    let end: Date;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    switch (viewMode) {
 | 
				
			||||||
 | 
					      case 'day':
 | 
				
			||||||
 | 
					        start = startOfDay(selectedDate);
 | 
				
			||||||
 | 
					        end = endOfDay(selectedDate);
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					      case 'daily':
 | 
				
			||||||
 | 
					        start = startOfWeek(now, { weekStartsOn: 1 });
 | 
				
			||||||
 | 
					        end = endOfWeek(now, { weekStartsOn: 1 });
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					      case 'weekly':
 | 
				
			||||||
 | 
					        start = startOfMonth(now);
 | 
				
			||||||
 | 
					        end = endOfMonth(now);
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					      case 'monthly':
 | 
				
			||||||
 | 
					        start = startOfYear(now);
 | 
				
			||||||
 | 
					        end = endOfYear(now);
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					      case 'yearly':
 | 
				
			||||||
 | 
					        start = new Date('2020-01-01');
 | 
				
			||||||
 | 
					        end = now;
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const isoStart = start.toISOString();
 | 
				
			||||||
 | 
					    const isoEnd = end.toISOString();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const res = await fetchPowerTimeseries(siteId, isoStart, isoEnd);
 | 
				
			||||||
 | 
					      setConsumption(res.consumption);
 | 
				
			||||||
 | 
					      setGeneration(res.generation);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Forecast only needs updating for the selected day
 | 
				
			||||||
 | 
					      const forecastData = await fetchForecast(3.15, 101.7, 37, 0, 25.67);
 | 
				
			||||||
 | 
					      const selectedDateStr = selectedDate.toISOString().split('T')[0];
 | 
				
			||||||
 | 
					      setForecast(
 | 
				
			||||||
 | 
					        forecastData
 | 
				
			||||||
 | 
					          .filter(({ time }: any) => time.startsWith(selectedDateStr))
 | 
				
			||||||
 | 
					          .map(({ time, forecast }: any) => ({ time, value: forecast }))
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    } catch (error) {
 | 
				
			||||||
 | 
					      console.error('Failed to fetch energy timeseries:', error);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }, [siteId, viewMode, selectedDate]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // 3) Auto-refresh effect: initial load + interval (pauses when tab hidden)
 | 
				
			||||||
 | 
					  useEffect(() => {
 | 
				
			||||||
 | 
					    let timer: number | undefined;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const tick = async () => {
 | 
				
			||||||
 | 
					      // Avoid wasted calls when the tab is in the background
 | 
				
			||||||
 | 
					      if (!document.hidden) {
 | 
				
			||||||
 | 
					        await fetchAndSet();
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      const ms = viewMode === 'day' ? LIVE_REFRESH_MS : SLOW_REFRESH_MS;
 | 
				
			||||||
 | 
					      timer = window.setTimeout(tick, ms);
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // initial load
 | 
				
			||||||
 | 
					    fetchAndSet();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // schedule next cycles
 | 
				
			||||||
 | 
					    timer = window.setTimeout(tick, viewMode === 'day' ? LIVE_REFRESH_MS : SLOW_REFRESH_MS);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const onVis = () => {
 | 
				
			||||||
 | 
					      if (!document.hidden) {
 | 
				
			||||||
 | 
					        // kick immediately when user returns
 | 
				
			||||||
 | 
					        clearTimeout(timer);
 | 
				
			||||||
 | 
					        tick();
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					    document.addEventListener('visibilitychange', onVis);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return () => {
 | 
				
			||||||
 | 
					      clearTimeout(timer);
 | 
				
			||||||
 | 
					      document.removeEventListener('visibilitychange', onVis);
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					  }, [fetchAndSet, viewMode]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  function useIsDarkMode() {
 | 
					  function useIsDarkMode() {
 | 
				
			||||||
  const [isDark, setIsDark] = useState(() =>
 | 
					  const [isDark, setIsDark] = useState(() =>
 | 
				
			||||||
    typeof document !== 'undefined'
 | 
					    typeof document !== 'undefined'
 | 
				
			||||||
@ -140,7 +278,7 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => {
 | 
				
			|||||||
        setGeneration(res.generation);
 | 
					        setGeneration(res.generation);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // ⬇️ ADD THIS here — fetch forecast
 | 
					        // ⬇️ ADD THIS here — fetch forecast
 | 
				
			||||||
      const forecastData = await fetchForecast(3.15, 101.7, 37, 0, 5.67);
 | 
					      const forecastData = await fetchForecast(3.15, 101.7, 37, 0, 30.67);
 | 
				
			||||||
      const selectedDateStr = selectedDate.toISOString().split('T')[0];
 | 
					      const selectedDateStr = selectedDate.toISOString().split('T')[0];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      setForecast(
 | 
					      setForecast(
 | 
				
			||||||
@ -160,9 +298,40 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => {
 | 
				
			|||||||
    fetchData();
 | 
					    fetchData();
 | 
				
			||||||
  }, [siteId, viewMode, selectedDate]);
 | 
					  }, [siteId, viewMode, selectedDate]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const groupedConsumption = groupTimeSeries(consumption, viewMode);
 | 
					  const isEnergyView = viewMode !== 'day';
 | 
				
			||||||
  const groupedGeneration = groupTimeSeries(generation, viewMode);
 | 
					
 | 
				
			||||||
  const groupedForecast = groupTimeSeries(forecast, viewMode);
 | 
					// Convert to energy series for aggregated views
 | 
				
			||||||
 | 
					const consumptionForGrouping = isEnergyView
 | 
				
			||||||
 | 
					  ? powerSeriesToEnergySeries(consumption, 30)
 | 
				
			||||||
 | 
					  : consumption;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const generationForGrouping = isEnergyView
 | 
				
			||||||
 | 
					  ? powerSeriesToEnergySeries(generation, 30)
 | 
				
			||||||
 | 
					  : generation;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const forecastForGrouping = isEnergyView
 | 
				
			||||||
 | 
					  ? powerSeriesToEnergySeries(forecast, 60) // if forecast is hourly, guess 60
 | 
				
			||||||
 | 
					  : forecast;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Group: sum for energy views, mean for day view
 | 
				
			||||||
 | 
					const groupedConsumption = groupTimeSeries(
 | 
				
			||||||
 | 
					  consumptionForGrouping,
 | 
				
			||||||
 | 
					  viewMode,
 | 
				
			||||||
 | 
					  isEnergyView ? 'sum' : 'mean'
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const groupedGeneration = groupTimeSeries(
 | 
				
			||||||
 | 
					  generationForGrouping,
 | 
				
			||||||
 | 
					  viewMode,
 | 
				
			||||||
 | 
					  isEnergyView ? 'sum' : 'mean'
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const groupedForecast = groupTimeSeries(
 | 
				
			||||||
 | 
					  forecastForGrouping,
 | 
				
			||||||
 | 
					  viewMode,
 | 
				
			||||||
 | 
					  isEnergyView ? 'sum' : 'mean'
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const forecastMap = Object.fromEntries(groupedForecast.map(d => [d.time, d.value]));
 | 
					  const forecastMap = Object.fromEntries(groupedForecast.map(d => [d.time, d.value]));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -224,6 +393,22 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
const axisColor = isDark ? '#fff' : '#222';
 | 
					const axisColor = isDark ? '#fff' : '#222';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function areaGradient(ctx: any, hex: string, alphaTop = 0.22, alphaBottom = 0.02) {
 | 
				
			||||||
 | 
					  const { ctx: g, chartArea } = ctx.chart;
 | 
				
			||||||
 | 
					  if (!chartArea) return hex; // initial render fallback
 | 
				
			||||||
 | 
					  const gradient = g.createLinearGradient(0, chartArea.top, 0, chartArea.bottom);
 | 
				
			||||||
 | 
					  // top more opaque → bottom fades out
 | 
				
			||||||
 | 
					  gradient.addColorStop(0, hex + Math.floor(alphaTop * 255).toString(16).padStart(2, '0'));
 | 
				
			||||||
 | 
					  gradient.addColorStop(1, hex + Math.floor(alphaBottom * 255).toString(16).padStart(2, '0'));
 | 
				
			||||||
 | 
					  return gradient;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Define colors for both light and dark modes
 | 
				
			||||||
 | 
					const consumptionColor = isDark ? '#B80F0A' : '#EF4444'; // Example: Brighter red for dark mode
 | 
				
			||||||
 | 
					const generationColor = isDark ? '#48A860' : '#22C55E'; // Example: Brighter green for dark mode
 | 
				
			||||||
 | 
					const forecastColor = '#fcd913'; // A golden yellow that works well in both modes
 | 
				
			||||||
 | 
					const yUnit = isEnergyView ? 'kWh' : 'kW';
 | 
				
			||||||
 | 
					const yTitle = isEnergyView ? 'Energy (kWh)' : 'Power (kW)';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const data = {
 | 
					  const data = {
 | 
				
			||||||
    labels: filteredLabels.map(formatLabel),
 | 
					    labels: filteredLabels.map(formatLabel),
 | 
				
			||||||
@ -231,26 +416,29 @@ const axisColor = isDark ? '#fff' : '#222';
 | 
				
			|||||||
      {
 | 
					      {
 | 
				
			||||||
        label: 'Consumption',
 | 
					        label: 'Consumption',
 | 
				
			||||||
        data: filteredConsumption,
 | 
					        data: filteredConsumption,
 | 
				
			||||||
        borderColor: '#8884d8',
 | 
					        borderColor: consumptionColor,
 | 
				
			||||||
 | 
					        backgroundColor: (ctx: any) => areaGradient(ctx, consumptionColor),
 | 
				
			||||||
 | 
					        fill: true,                                   // <-- fill under line
 | 
				
			||||||
        tension: 0.4,
 | 
					        tension: 0.4,
 | 
				
			||||||
        fill: false,
 | 
					 | 
				
			||||||
        spanGaps: true,
 | 
					        spanGaps: true,
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
      {
 | 
					      {
 | 
				
			||||||
        label: 'Generation',
 | 
					        label: 'Generation',
 | 
				
			||||||
        data: filteredGeneration,
 | 
					        data: filteredGeneration,
 | 
				
			||||||
        borderColor: '#82ca9d',
 | 
					        borderColor: generationColor,
 | 
				
			||||||
 | 
					        backgroundColor: (ctx: any) => areaGradient(ctx, generationColor),
 | 
				
			||||||
 | 
					        fill: true,                                   // <-- fill under line
 | 
				
			||||||
        tension: 0.4,
 | 
					        tension: 0.4,
 | 
				
			||||||
        fill: false,
 | 
					 | 
				
			||||||
        spanGaps: true,
 | 
					        spanGaps: true,
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
      {
 | 
					      {
 | 
				
			||||||
      label: 'Forecasted Solar',
 | 
					      label: 'Forecasted Solar',
 | 
				
			||||||
      data: filteredForecast,
 | 
					      data: filteredForecast,
 | 
				
			||||||
      borderColor: '#ffa500', // orange
 | 
					      borderColor: '#fcd913', // orange
 | 
				
			||||||
 | 
					      backgroundColor: (ctx: any) => areaGradient(ctx, '#fcd913', 0.18, 0.03),
 | 
				
			||||||
      tension: 0.4,
 | 
					      tension: 0.4,
 | 
				
			||||||
      borderDash: [5, 5], // dashed line to distinguish forecast
 | 
					      borderDash: [5, 5], // dashed line to distinguish forecast
 | 
				
			||||||
      fill: false,
 | 
					      fill: true,
 | 
				
			||||||
      spanGaps: true,
 | 
					      spanGaps: true,
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    ],
 | 
					    ],
 | 
				
			||||||
@ -283,6 +471,13 @@ const axisColor = isDark ? '#fff' : '#222';
 | 
				
			|||||||
      bodyColor: axisColor,
 | 
					      bodyColor: axisColor,
 | 
				
			||||||
      borderColor: isDark ? '#444' : '#ccc',
 | 
					      borderColor: isDark ? '#444' : '#ccc',
 | 
				
			||||||
      borderWidth: 1,
 | 
					      borderWidth: 1,
 | 
				
			||||||
 | 
					      callbacks: {
 | 
				
			||||||
 | 
					      label: (ctx: any) => {
 | 
				
			||||||
 | 
					        const dsLabel = ctx.dataset.label || '';
 | 
				
			||||||
 | 
					        const val = ctx.parsed.y;
 | 
				
			||||||
 | 
					        return `${dsLabel}: ${val?.toFixed(2)} ${yUnit}`;
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    scales: {
 | 
					    scales: {
 | 
				
			||||||
@ -309,12 +504,7 @@ const axisColor = isDark ? '#fff' : '#222';
 | 
				
			|||||||
      y: {
 | 
					      y: {
 | 
				
			||||||
        beginAtZero: true,
 | 
					        beginAtZero: true,
 | 
				
			||||||
        suggestedMax: yAxisSuggestedMax,
 | 
					        suggestedMax: yAxisSuggestedMax,
 | 
				
			||||||
        title: {
 | 
					        title: { display: true, text: yTitle, color: axisColor, font: { weight: 'normal' as const } },
 | 
				
			||||||
          display: true,
 | 
					 | 
				
			||||||
          text: 'Power (kW)',
 | 
					 | 
				
			||||||
          color: axisColor,
 | 
					 | 
				
			||||||
          font: { weight: 'normal' as const },
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        ticks: {
 | 
					        ticks: {
 | 
				
			||||||
        color: axisColor,
 | 
					        color: axisColor,
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
 | 
				
			|||||||
@ -1,4 +1,4 @@
 | 
				
			|||||||
import React, { useEffect, useState } from 'react';
 | 
					import React, { useEffect, useState } from "react";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
interface KPI_TableProps {
 | 
					interface KPI_TableProps {
 | 
				
			||||||
  siteId: string;
 | 
					  siteId: string;
 | 
				
			||||||
@ -12,8 +12,8 @@ interface MonthlyKPI {
 | 
				
			|||||||
  consumption_kwh: number | null;
 | 
					  consumption_kwh: number | null;
 | 
				
			||||||
  grid_draw_kwh: number | null;
 | 
					  grid_draw_kwh: number | null;
 | 
				
			||||||
  efficiency: number | null;
 | 
					  efficiency: number | null;
 | 
				
			||||||
  peak_demand_kw: number | null; // ✅ new
 | 
					  peak_demand_kw: number | null;
 | 
				
			||||||
  avg_power_factor: number | null; // ✅ new
 | 
					  avg_power_factor: number | null;
 | 
				
			||||||
  load_factor: number | null;
 | 
					  load_factor: number | null;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -22,83 +22,66 @@ const KPI_Table: React.FC<KPI_TableProps> = ({ siteId, month }) => {
 | 
				
			|||||||
  const [loading, setLoading] = useState(false);
 | 
					  const [loading, setLoading] = useState(false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  useEffect(() => {
 | 
					  useEffect(() => {
 | 
				
			||||||
 | 
					    if (!siteId || !month) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const fetchKPI = async () => {
 | 
					    const fetchKPI = async () => {
 | 
				
			||||||
      setLoading(true);
 | 
					      setLoading(true);
 | 
				
			||||||
      try {
 | 
					      try {
 | 
				
			||||||
        const res = await fetch(`http://localhost:8000/kpi/monthly?site=${siteId}&month=${month}`);
 | 
					        const res = await fetch(
 | 
				
			||||||
        const data = await res.json();
 | 
					          `http://localhost:8000/kpi/monthly?site=${siteId}&month=${month}`
 | 
				
			||||||
        setKpiData(data);
 | 
					        );
 | 
				
			||||||
 | 
					        setKpiData(await res.json());
 | 
				
			||||||
      } catch (err) {
 | 
					      } catch (err) {
 | 
				
			||||||
        console.error('Failed to fetch KPI:', err);
 | 
					        console.error("Failed to fetch KPI:", err);
 | 
				
			||||||
        setKpiData(null); // fallback
 | 
					        setKpiData(null);
 | 
				
			||||||
      } finally {
 | 
					      } finally {
 | 
				
			||||||
        setLoading(false);
 | 
					        setLoading(false);
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (siteId && month) fetchKPI();
 | 
					    fetchKPI();
 | 
				
			||||||
  }, [siteId, month]);
 | 
					  }, [siteId, month]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (!siteId) {
 | 
					  const formatValue = (value: number | null, unit = "", decimals = 2) =>
 | 
				
			||||||
    return (
 | 
					    value != null ? `${value.toFixed(decimals)}${unit}` : "—";
 | 
				
			||||||
      <div>
 | 
					 | 
				
			||||||
        <h2 className="text-lg font-bold mb-2">Monthly KPI</h2>
 | 
					 | 
				
			||||||
        <div className="min-h-[275px] w-full flex items-center justify-center border">
 | 
					 | 
				
			||||||
          <p>No site selected</p>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (loading) {
 | 
					  const rows = [
 | 
				
			||||||
    return (
 | 
					    { label: "Monthly Yield", value: formatValue(kpiData?.yield_kwh ?? null, " kWh", 0) },
 | 
				
			||||||
      <div>
 | 
					    { label: "Monthly Consumption", value: formatValue(kpiData?.consumption_kwh ?? null, " kWh", 0) },
 | 
				
			||||||
        <h2 className="text-lg font-bold mb-2">Monthly KPI</h2>
 | 
					    { label: "Monthly Grid Draw", value: formatValue(kpiData?.grid_draw_kwh ?? null, " kWh", 0) },
 | 
				
			||||||
        <div className="min-h-[275px] w-full flex items-center justify-center border">
 | 
					    { label: "Efficiency", value: formatValue(kpiData?.efficiency ?? null, "%", 1) },
 | 
				
			||||||
          <p>Loading...</p>
 | 
					    { label: "Peak Demand", value: formatValue(kpiData?.peak_demand_kw ?? null, " kW") },
 | 
				
			||||||
        </div>
 | 
					    { label: "Power Factor", value: formatValue(kpiData?.avg_power_factor ?? null) },
 | 
				
			||||||
      </div>
 | 
					    { label: "Load Factor", value: formatValue(kpiData?.load_factor ?? null) },
 | 
				
			||||||
    );
 | 
					  ];
 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // Use optional chaining and nullish coalescing to safely default values to 0
 | 
					 | 
				
			||||||
  const yield_kwh = kpiData?.yield_kwh ?? 0;
 | 
					 | 
				
			||||||
  const consumption_kwh = kpiData?.consumption_kwh ?? 0;
 | 
					 | 
				
			||||||
  const grid_draw_kwh = kpiData?.grid_draw_kwh ?? 0;
 | 
					 | 
				
			||||||
  const efficiency = kpiData?.efficiency ?? 0;
 | 
					 | 
				
			||||||
  const peak_demand_kw = kpiData?.peak_demand_kw ?? 0;
 | 
					 | 
				
			||||||
  const power_factor = kpiData?.avg_power_factor ?? 0;
 | 
					 | 
				
			||||||
  const load_factor = kpiData?.load_factor ?? 0;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const data = [
 | 
					 | 
				
			||||||
  { kpi: 'Monthly Yield', value: `${yield_kwh.toFixed(0)} kWh` },
 | 
					 | 
				
			||||||
  { kpi: 'Monthly Consumption', value: `${consumption_kwh.toFixed(0)} kWh` },
 | 
					 | 
				
			||||||
  { kpi: 'Monthly Grid Draw', value: `${grid_draw_kwh.toFixed(0)} kWh` },
 | 
					 | 
				
			||||||
  { kpi: 'Efficiency', value: `${efficiency.toFixed(1)}%` },
 | 
					 | 
				
			||||||
  { kpi: 'Peak Demand', value: `${peak_demand_kw.toFixed(2)} kW` }, // ✅ added
 | 
					 | 
				
			||||||
  { kpi: 'Power Factor', value: `${power_factor.toFixed(2)} kW` }, // ✅ added
 | 
					 | 
				
			||||||
  { kpi: 'Load Factor', value: `${load_factor.toFixed(2)} kW` }, // ✅ added
 | 
					 | 
				
			||||||
];
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <div>
 | 
					    <div>
 | 
				
			||||||
      <h2 className="text-lg font-bold mb-2 dark:text-white">Monthly KPI</h2>
 | 
					      <h2 className="text-lg font-bold mb-2 dark:text-white">Monthly KPI</h2>
 | 
				
			||||||
      <table className="min-h-[275px] w-full border-collapse border border-gray-300 dark:border-rtgray-700 text-black dark:text-white bg-white dark:bg-rtgray-700">
 | 
					      <div className="min-h-[275px] border rounded">
 | 
				
			||||||
 | 
					        {!siteId ? (
 | 
				
			||||||
 | 
					          <p className="text-center py-10">No site selected</p>
 | 
				
			||||||
 | 
					        ) : loading ? (
 | 
				
			||||||
 | 
					          <p className="text-center py-10">Loading...</p>
 | 
				
			||||||
 | 
					        ) : (
 | 
				
			||||||
 | 
					          <table className="w-full border-collapse">
 | 
				
			||||||
            <thead>
 | 
					            <thead>
 | 
				
			||||||
          <tr className="bg-rtgray-100 dark:bg-rtgray-800 text-black dark:text-white">
 | 
					              <tr className="bg-gray-100 dark:bg-rtgray-800">
 | 
				
			||||||
            <th className="border border-rtgray-300 dark:border-rtgray-700 p-3 text-left dark:bg-rtgray-900">KPI</th>
 | 
					                <th className="border p-3 text-left dark:text-white">KPI</th>
 | 
				
			||||||
            <th className="border border-rtgray-300 dark:border-rtgray-700 p-3 text-left dark:bg-rtgray-900">Value</th>
 | 
					                <th className="border p-3 text-left dark:text-white">Value</th>
 | 
				
			||||||
              </tr>
 | 
					              </tr>
 | 
				
			||||||
            </thead>
 | 
					            </thead>
 | 
				
			||||||
            <tbody>
 | 
					            <tbody>
 | 
				
			||||||
          {data.map((row) => (
 | 
					              {rows.map((row) => (
 | 
				
			||||||
            <tr key={row.kpi} className="even:bg-rtgray-50 dark:even:bg-rtgray-800">
 | 
					                <tr key={row.label} className="even:bg-gray-50 dark:even:bg-rtgray-800">
 | 
				
			||||||
              <td className="border border-rtgray-300 dark:border-rtgray-700 p-2.5">{row.kpi}</td>
 | 
					                  <td className="border p-2.5 dark:text-white">{row.label}</td>
 | 
				
			||||||
              <td className="border border-rtgray-300 dark:border-rtgray-700 p-2.5">{row.value}</td>
 | 
					                  <td className="border p-2.5 dark:text-white">{row.value}</td>
 | 
				
			||||||
                </tr>
 | 
					                </tr>
 | 
				
			||||||
              ))}
 | 
					              ))}
 | 
				
			||||||
            </tbody>
 | 
					            </tbody>
 | 
				
			||||||
          </table>
 | 
					          </table>
 | 
				
			||||||
 | 
					        )}
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
@ -106,3 +89,4 @@ const data = [
 | 
				
			|||||||
export default KPI_Table;
 | 
					export default KPI_Table;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										223
									
								
								components/dashboards/LoggingControl.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										223
									
								
								components/dashboards/LoggingControl.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,223 @@
 | 
				
			|||||||
 | 
					'use client';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import React, { useEffect, useMemo, useState } from 'react';
 | 
				
			||||||
 | 
					import axios from 'axios';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type FnType = 'grid' | 'solar';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface LoggingControlCardProps {
 | 
				
			||||||
 | 
					  siteId: string;
 | 
				
			||||||
 | 
					  projectLabel?: string; // nice display (e.g., CRM project_name)
 | 
				
			||||||
 | 
					  className?: string;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type FnState = {
 | 
				
			||||||
 | 
					  serial: string;
 | 
				
			||||||
 | 
					  isLogging: boolean;
 | 
				
			||||||
 | 
					  isBusy: boolean; // to block double clicks while calling API
 | 
				
			||||||
 | 
					  error?: string | null;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const emptyFnState: FnState = { serial: '', isLogging: false, isBusy: false, error: null };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const storageKey = (siteId: string) => `logging_control_${siteId}`;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default function LoggingControlCard({
 | 
				
			||||||
 | 
					  siteId,
 | 
				
			||||||
 | 
					  projectLabel,
 | 
				
			||||||
 | 
					  className = '',
 | 
				
			||||||
 | 
					}: LoggingControlCardProps) {
 | 
				
			||||||
 | 
					  const [grid, setGrid] = useState<FnState>(emptyFnState);
 | 
				
			||||||
 | 
					  const [solar, setSolar] = useState<FnState>(emptyFnState);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Load persisted state (if any)
 | 
				
			||||||
 | 
					  useEffect(() => {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const raw = localStorage.getItem(storageKey(siteId));
 | 
				
			||||||
 | 
					      if (raw) {
 | 
				
			||||||
 | 
					        const parsed = JSON.parse(raw);
 | 
				
			||||||
 | 
					        setGrid({ ...emptyFnState, ...(parsed.grid ?? {}) });
 | 
				
			||||||
 | 
					        setSolar({ ...emptyFnState, ...(parsed.solar ?? {}) });
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        setGrid(emptyFnState);
 | 
				
			||||||
 | 
					        setSolar(emptyFnState);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    } catch {
 | 
				
			||||||
 | 
					      setGrid(emptyFnState);
 | 
				
			||||||
 | 
					      setSolar(emptyFnState);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }, [siteId]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Persist on any change
 | 
				
			||||||
 | 
					  useEffect(() => {
 | 
				
			||||||
 | 
					    const data = { grid, solar };
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      localStorage.setItem(storageKey(siteId), JSON.stringify(data));
 | 
				
			||||||
 | 
					    } catch {
 | 
				
			||||||
 | 
					      // ignore storage errors
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }, [siteId, grid, solar]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const title = useMemo(
 | 
				
			||||||
 | 
					    () => `Logging Control${projectLabel ? ` — ${projectLabel}` : ''}`,
 | 
				
			||||||
 | 
					    [projectLabel]
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const topicsFor = (fn: FnType, serial: string) => {
 | 
				
			||||||
 | 
					    return [`ADW300/${siteId}/${serial}/${fn}`];
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const start = async (fn: FnType) => {
 | 
				
			||||||
 | 
					    const state = fn === 'grid' ? grid : solar;
 | 
				
			||||||
 | 
					    const setState = fn === 'grid' ? setGrid : setSolar;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!state.serial.trim()) {
 | 
				
			||||||
 | 
					      setState((s) => ({ ...s, error: 'Please enter a meter serial number.' }));
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setState((s) => ({ ...s, isBusy: true, error: null }));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const topics = topicsFor(fn, state.serial.trim());
 | 
				
			||||||
 | 
					      const res = await axios.post(`${API_URL}/start-logging`, { topics });
 | 
				
			||||||
 | 
					      console.log('Start logging:', res.data);
 | 
				
			||||||
 | 
					      setState((s) => ({ ...s, isLogging: true, isBusy: false }));
 | 
				
			||||||
 | 
					    } catch (e: any) {
 | 
				
			||||||
 | 
					      console.error('Failed to start logging', e);
 | 
				
			||||||
 | 
					      setState((s) => ({
 | 
				
			||||||
 | 
					        ...s,
 | 
				
			||||||
 | 
					        isBusy: false,
 | 
				
			||||||
 | 
					        error: e?.response?.data?.detail || e?.message || 'Failed to start logging',
 | 
				
			||||||
 | 
					      }));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const stop = async (fn: FnType) => {
 | 
				
			||||||
 | 
					    const state = fn === 'grid' ? grid : solar;
 | 
				
			||||||
 | 
					    const setState = fn === 'grid' ? setGrid : setSolar;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!state.isLogging) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const confirmed = window.confirm(
 | 
				
			||||||
 | 
					      `Stop logging for ${fn.toUpperCase()} meter "${state.serial}" at site ${siteId}?`
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    if (!confirmed) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setState((s) => ({ ...s, isBusy: true, error: null }));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const topics = topicsFor(fn, state.serial.trim());
 | 
				
			||||||
 | 
					      const res = await axios.post(`${API_URL}/stop-logging`, { topics });
 | 
				
			||||||
 | 
					      console.log('Stop logging:', res.data);
 | 
				
			||||||
 | 
					      setState((s) => ({ ...s, isLogging: false, isBusy: false }));
 | 
				
			||||||
 | 
					    } catch (e: any) {
 | 
				
			||||||
 | 
					      console.error('Failed to stop logging', e);
 | 
				
			||||||
 | 
					      setState((s) => ({
 | 
				
			||||||
 | 
					        ...s,
 | 
				
			||||||
 | 
					        isBusy: false,
 | 
				
			||||||
 | 
					        error: e?.response?.data?.detail || e?.message || 'Failed to stop logging',
 | 
				
			||||||
 | 
					      }));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Responsive utility classes
 | 
				
			||||||
 | 
					  const field =
 | 
				
			||||||
 | 
					    'w-full px-3 py-2 sm:py-2.5 border rounded-md text-sm sm:text-base placeholder:text-gray-400 dark:border-rtgray-700 dark:bg-rtgray-700 dark:text-white';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const label =
 | 
				
			||||||
 | 
					    'text-gray-600 dark:text-white/85 font-medium text-sm sm:text-base mb-1 flex items-center justify-between mr-2.5';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const section = (
 | 
				
			||||||
 | 
					    fn: FnType,
 | 
				
			||||||
 | 
					    labelText: string,
 | 
				
			||||||
 | 
					    state: FnState,
 | 
				
			||||||
 | 
					    setState: React.Dispatch<React.SetStateAction<FnState>>
 | 
				
			||||||
 | 
					  ) => (
 | 
				
			||||||
 | 
					    <div className="space-y-2">
 | 
				
			||||||
 | 
					      <div className={label}>
 | 
				
			||||||
 | 
					        <span>{labelText}</span>
 | 
				
			||||||
 | 
					        {state.isLogging && (
 | 
				
			||||||
 | 
					          <span
 | 
				
			||||||
 | 
					            className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] sm:text-xs font-semibold
 | 
				
			||||||
 | 
					                       bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-300"
 | 
				
			||||||
 | 
					            aria-live="polite"
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            Logging
 | 
				
			||||||
 | 
					          </span>
 | 
				
			||||||
 | 
					        )}
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      {/* Input + Button: stack on mobile, row on ≥sm */}
 | 
				
			||||||
 | 
					      <div className="flex flex-col sm:flex-row gap-2 sm:gap-3">
 | 
				
			||||||
 | 
					        <input
 | 
				
			||||||
 | 
					          type="text"
 | 
				
			||||||
 | 
					          autoComplete="off"
 | 
				
			||||||
 | 
					          inputMode="text"
 | 
				
			||||||
 | 
					          placeholder="Meter serial number"
 | 
				
			||||||
 | 
					          className={`${field} flex-1`}
 | 
				
			||||||
 | 
					          value={state.serial}
 | 
				
			||||||
 | 
					          onChange={(e) => setState((s) => ({ ...s, serial: e.target.value }))}
 | 
				
			||||||
 | 
					          disabled={state.isLogging || state.isBusy}
 | 
				
			||||||
 | 
					          aria-label={`${labelText} serial number`}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        {!state.isLogging ? (
 | 
				
			||||||
 | 
					          <button
 | 
				
			||||||
 | 
					            onClick={() => start(fn)}
 | 
				
			||||||
 | 
					            disabled={state.isBusy || !state.serial.trim()}
 | 
				
			||||||
 | 
					            className={`h-10 sm:h-11 rounded-full font-medium transition
 | 
				
			||||||
 | 
					              w-full sm:w-auto sm:min-w-28 mt-2 lg:mt-0
 | 
				
			||||||
 | 
					              ${state.isBusy || !state.serial.trim()
 | 
				
			||||||
 | 
					                ? 'bg-gray-400 cursor-not-allowed text-black/70'
 | 
				
			||||||
 | 
					                : 'bg-rtyellow-200 hover:bg-rtyellow-300 text-black'}`}
 | 
				
			||||||
 | 
					            aria-disabled={state.isBusy || !state.serial.trim()}
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            {state.isBusy ? 'Starting…' : 'Start'}
 | 
				
			||||||
 | 
					          </button>
 | 
				
			||||||
 | 
					        ) : (
 | 
				
			||||||
 | 
					          <button
 | 
				
			||||||
 | 
					            onClick={() => stop(fn)}
 | 
				
			||||||
 | 
					            disabled={state.isBusy}
 | 
				
			||||||
 | 
					            className={`h-10 sm:h-11 rounded-full font-medium transition
 | 
				
			||||||
 | 
					              w-full sm:w-auto sm:min-w-28 mt-2 lg:mt-0
 | 
				
			||||||
 | 
					              ${state.isBusy ? 'bg-gray-400 cursor-not-allowed' : 'bg-red-500 hover:bg-red-600'} text-white`}
 | 
				
			||||||
 | 
					            aria-disabled={state.isBusy}
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            {state.isBusy ? 'Stopping…' : 'Stop'}
 | 
				
			||||||
 | 
					          </button>
 | 
				
			||||||
 | 
					        )}
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      {!!state.error && <div className="text-sm sm:text-[15px] text-red-600">{state.error}</div>}
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <div
 | 
				
			||||||
 | 
					      className={`bg-white p-4 sm:p-5 md:p-6 rounded-xl md:rounded-2xl shadow-md space-y-4 md:space-y-5
 | 
				
			||||||
 | 
					                  dark:bg-rtgray-800 dark:text-white-light w-full  ${className}`}
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <h2 className="text-lg sm:text-xl md:text-2xl font-semibold truncate" title={title}>
 | 
				
			||||||
 | 
					        {title}
 | 
				
			||||||
 | 
					      </h2>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      {section('grid', 'Grid Meter', grid, setGrid)}
 | 
				
			||||||
 | 
					      <div className="border-t dark:border-rtgray-700" />
 | 
				
			||||||
 | 
					      {section('solar', 'Solar Meter', solar, setSolar)}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <div className="text-[11px] sm:text-xs text-gray-500 dark:text-gray-400 pt-2 leading-relaxed break-words">
 | 
				
			||||||
 | 
					        • Inputs lock while logging is active. Stop to edit the serial.
 | 
				
			||||||
 | 
					        <br />
 | 
				
			||||||
 | 
					        • Topics follow{' '}
 | 
				
			||||||
 | 
					        <code className="break-all">
 | 
				
			||||||
 | 
					          ADW300/{'{'}siteId{'}'}/{'{'}serial{'}'}/(grid|solar)
 | 
				
			||||||
 | 
					        </code>
 | 
				
			||||||
 | 
					        .
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -1,4 +1,4 @@
 | 
				
			|||||||
import React, { useEffect, useState } from 'react';
 | 
					import React, { useEffect, useMemo, useState } from 'react';
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  BarChart,
 | 
					  BarChart,
 | 
				
			||||||
  Bar,
 | 
					  Bar,
 | 
				
			||||||
@ -9,46 +9,25 @@ import {
 | 
				
			|||||||
  Legend,
 | 
					  Legend,
 | 
				
			||||||
} from 'recharts';
 | 
					} from 'recharts';
 | 
				
			||||||
import { format } from 'date-fns';
 | 
					import { format } from 'date-fns';
 | 
				
			||||||
import { fetchPowerTimeseries } from '@/app/utils/api';
 | 
					import { fetchMonthlyKpi, type MonthlyKPI } from '@/app/utils/api';
 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
interface MonthlyBarChartProps {
 | 
					interface MonthlyBarChartProps {
 | 
				
			||||||
  siteId: string;
 | 
					  siteId: string;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
interface TimeSeriesEntry {
 | 
					const getLastNMonthKeys = (n: number): string[] => {
 | 
				
			||||||
  time: string;
 | 
					  const out: string[] = [];
 | 
				
			||||||
  value: number;
 | 
					  const now = new Date();
 | 
				
			||||||
}
 | 
					  // include current month, go back n-1 months
 | 
				
			||||||
 | 
					  for (let i = 0; i < n; i++) {
 | 
				
			||||||
const groupTimeSeries = (
 | 
					    const d = new Date(now.getFullYear(), now.getMonth() - (n - 1 - i), 1);
 | 
				
			||||||
  data: TimeSeriesEntry[],
 | 
					    const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; // YYYY-MM
 | 
				
			||||||
  mode: 'monthly'
 | 
					    out.push(key);
 | 
				
			||||||
): TimeSeriesEntry[] => {
 | 
					 | 
				
			||||||
  const groupMap = new Map<string, number[]>();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  for (const entry of data) {
 | 
					 | 
				
			||||||
    const date = new Date(entry.time);
 | 
					 | 
				
			||||||
    const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
 | 
					 | 
				
			||||||
    if (!groupMap.has(key)) groupMap.set(key, []);
 | 
					 | 
				
			||||||
    groupMap.get(key)!.push(entry.value);
 | 
					 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					  return out;
 | 
				
			||||||
  return Array.from(groupMap.entries()).map(([time, values]) => ({
 | 
					 | 
				
			||||||
    time,
 | 
					 | 
				
			||||||
    value: values.reduce((sum, v) => sum + v, 0),
 | 
					 | 
				
			||||||
  }));
 | 
					 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function useIsDarkMode() {
 | 
				
			||||||
 | 
					 | 
				
			||||||
const MonthlyBarChart: React.FC<MonthlyBarChartProps> = ({ siteId }) => {
 | 
					 | 
				
			||||||
  const [chartData, setChartData] = useState<
 | 
					 | 
				
			||||||
    { month: string; consumption: number; generation: number }[]
 | 
					 | 
				
			||||||
  >([]);
 | 
					 | 
				
			||||||
  const [loading, setLoading] = useState(true);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  function useIsDarkMode() {
 | 
					 | 
				
			||||||
  const [isDark, setIsDark] = useState(() =>
 | 
					  const [isDark, setIsDark] = useState(() =>
 | 
				
			||||||
    typeof document !== 'undefined'
 | 
					    typeof document !== 'undefined'
 | 
				
			||||||
      ? document.body.classList.contains('dark')
 | 
					      ? document.body.classList.contains('dark')
 | 
				
			||||||
@ -58,79 +37,82 @@ const MonthlyBarChart: React.FC<MonthlyBarChartProps> = ({ siteId }) => {
 | 
				
			|||||||
  useEffect(() => {
 | 
					  useEffect(() => {
 | 
				
			||||||
    const check = () => setIsDark(document.body.classList.contains('dark'));
 | 
					    const check = () => setIsDark(document.body.classList.contains('dark'));
 | 
				
			||||||
    check();
 | 
					    check();
 | 
				
			||||||
 | 
					 | 
				
			||||||
    // Listen for class changes on <body>
 | 
					 | 
				
			||||||
    const observer = new MutationObserver(check);
 | 
					    const observer = new MutationObserver(check);
 | 
				
			||||||
    observer.observe(document.body, { attributes: true, attributeFilter: ['class'] });
 | 
					    observer.observe(document.body, { attributes: true, attributeFilter: ['class'] });
 | 
				
			||||||
 | 
					 | 
				
			||||||
    return () => observer.disconnect();
 | 
					    return () => observer.disconnect();
 | 
				
			||||||
  }, []);
 | 
					  }, []);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return isDark;
 | 
					  return isDark;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const isDark = useIsDarkMode();
 | 
					const MonthlyBarChart: React.FC<MonthlyBarChartProps> = ({ siteId }) => {
 | 
				
			||||||
 | 
					  const [chartData, setChartData] = useState<
 | 
				
			||||||
 | 
					    { month: string; consumption: number; generation: number }[]
 | 
				
			||||||
 | 
					  >([]);
 | 
				
			||||||
 | 
					  const [loading, setLoading] = useState(true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const consumptionColor = isDark ? '#ba8e23' : '#003049'; 
 | 
					  const isDark = useIsDarkMode();
 | 
				
			||||||
const generationColor = isDark ? '#fcd913' : '#669bbc';  
 | 
					  const consumptionColor = isDark ? '#ba8e23' : '#003049';
 | 
				
			||||||
 | 
					  const generationColor = isDark ? '#fcd913' : '#669bbc';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const monthKeys = useMemo(() => getLastNMonthKeys(6), []);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  useEffect(() => {
 | 
					  useEffect(() => {
 | 
				
			||||||
    if (!siteId) return;
 | 
					    if (!siteId) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const fetchMonthlyData = async () => {
 | 
					    const load = async () => {
 | 
				
			||||||
      setLoading(true);
 | 
					      setLoading(true);
 | 
				
			||||||
      const start = '2025-01-01T00:00:00+08:00';
 | 
					 | 
				
			||||||
      const end = '2025-12-31T23:59:59+08:00';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      try {
 | 
					      try {
 | 
				
			||||||
        const res = await fetchPowerTimeseries(siteId, start, end);
 | 
					        // Fetch all 6 months in parallel
 | 
				
			||||||
 | 
					        const results: MonthlyKPI[] = await Promise.all(
 | 
				
			||||||
 | 
					          monthKeys.map((month) =>
 | 
				
			||||||
 | 
					            fetchMonthlyKpi({
 | 
				
			||||||
 | 
					              site: siteId,
 | 
				
			||||||
 | 
					              month,
 | 
				
			||||||
 | 
					              // consumption_topic: '...', // optional if your API needs it
 | 
				
			||||||
 | 
					              // generation_topic: '...',  // optional if your API needs it
 | 
				
			||||||
 | 
					            }).catch((e) => {
 | 
				
			||||||
 | 
					              // normalize failures to an error-shaped record so the chart can still render other months
 | 
				
			||||||
 | 
					              return {
 | 
				
			||||||
 | 
					                site: siteId,
 | 
				
			||||||
 | 
					                month,
 | 
				
			||||||
 | 
					                yield_kwh: null,
 | 
				
			||||||
 | 
					                consumption_kwh: null,
 | 
				
			||||||
 | 
					                grid_draw_kwh: null,
 | 
				
			||||||
 | 
					                efficiency: null,
 | 
				
			||||||
 | 
					                peak_demand_kw: null,
 | 
				
			||||||
 | 
					                avg_power_factor: null,
 | 
				
			||||||
 | 
					                load_factor: null,
 | 
				
			||||||
 | 
					                error: String(e),
 | 
				
			||||||
 | 
					              } as MonthlyKPI;
 | 
				
			||||||
 | 
					            })
 | 
				
			||||||
 | 
					          )
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        const groupedConsumption = groupTimeSeries(res.consumption, 'monthly');
 | 
					        // Map to chart rows; default nulls to 0 for stacking/tooltip friendliness
 | 
				
			||||||
        const groupedGeneration = groupTimeSeries(res.generation, 'monthly');
 | 
					        const rows = results.map((kpi) => {
 | 
				
			||||||
 | 
					          const monthLabel = format(new Date(`${kpi.month}-01`), 'MMM');
 | 
				
			||||||
 | 
					          return {
 | 
				
			||||||
 | 
					            month: monthLabel,
 | 
				
			||||||
 | 
					            consumption: kpi.consumption_kwh ?? 0,
 | 
				
			||||||
 | 
					            generation: kpi.yield_kwh ?? 0,
 | 
				
			||||||
 | 
					          };
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        const monthMap = new Map<string, { consumption: number; generation: number }>();
 | 
					        setChartData(rows);
 | 
				
			||||||
 | 
					 | 
				
			||||||
        for (const entry of groupedConsumption) {
 | 
					 | 
				
			||||||
          if (!monthMap.has(entry.time)) {
 | 
					 | 
				
			||||||
            monthMap.set(entry.time, { consumption: 0, generation: 0 });
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
          monthMap.get(entry.time)!.consumption = entry.value;
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        for (const entry of groupedGeneration) {
 | 
					 | 
				
			||||||
          if (!monthMap.has(entry.time)) {
 | 
					 | 
				
			||||||
            monthMap.set(entry.time, { consumption: 0, generation: 0 });
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
          monthMap.get(entry.time)!.generation = entry.value;
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        const formatted = Array.from(monthMap.entries())
 | 
					 | 
				
			||||||
          .sort(([a], [b]) => a.localeCompare(b))
 | 
					 | 
				
			||||||
          .map(([key, val]) => ({
 | 
					 | 
				
			||||||
            month: format(new Date(`${key}-01`), 'MMM'),
 | 
					 | 
				
			||||||
            consumption: val.consumption,
 | 
					 | 
				
			||||||
            generation: val.generation,
 | 
					 | 
				
			||||||
          }));
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        setChartData(formatted.slice(-6)); // last 6 months
 | 
					 | 
				
			||||||
      } catch (error) {
 | 
					 | 
				
			||||||
        console.error('Failed to fetch monthly power data:', error);
 | 
					 | 
				
			||||||
        setChartData([]);
 | 
					 | 
				
			||||||
      } finally {
 | 
					      } finally {
 | 
				
			||||||
        setLoading(false);
 | 
					        setLoading(false);
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    fetchMonthlyData();
 | 
					    load();
 | 
				
			||||||
  }, [siteId]);
 | 
					    // eslint-disable-next-line react-hooks/exhaustive-deps
 | 
				
			||||||
 | 
					  }, [siteId]); // monthKeys are stable via useMemo
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (loading || !siteId || chartData.length === 0) {
 | 
					  if (loading || !siteId || chartData.length === 0) {
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
      <div className="bg-white p-4 rounded-lg shadow-md dark:bg-rtgray-800 dark:text-white-light">
 | 
					      <div className="bg-white p-3 rounded-lg shadow-md dark:bg-rtgray-800 dark:text-white-light">
 | 
				
			||||||
        <div className="flex justify-between items-center mb-2">
 | 
					        <div className="h-[200px] w-full flex items-center justify-center">
 | 
				
			||||||
          <h2 className="text-lg font-bold pb-3">Monthly Energy Yield</h2>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
        <div className="h-96 w-full flex items-center justify-center">
 | 
					 | 
				
			||||||
          <p className="text-white/70">
 | 
					          <p className="text-white/70">
 | 
				
			||||||
            {loading ? 'Loading data...' : 'No data available for chart.'}
 | 
					            {loading ? 'Loading data...' : 'No data available for chart.'}
 | 
				
			||||||
          </p>
 | 
					          </p>
 | 
				
			||||||
@ -140,12 +122,8 @@ const generationColor = isDark ? '#fcd913' : '#669bbc';
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <div className="bg-white p-4 rounded-lg shadow-md dark:bg-rtgray-800 dark:text-white-light">
 | 
					    <div className="bg-white p-3 rounded-lg dark:bg-rtgray-800 dark:text-white-light">
 | 
				
			||||||
      <div className="flex justify-between items-center mb-2">
 | 
					      <div className="h-[200px] w-full">
 | 
				
			||||||
        <h2 className="text-lg font-bold pb-3">Monthly Energy Yield</h2>
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      <div className="lg:h-[22.6vw] h-[290px] w-full pt-10">
 | 
					 | 
				
			||||||
        <ResponsiveContainer width="100%" height="100%">
 | 
					        <ResponsiveContainer width="100%" height="100%">
 | 
				
			||||||
          <BarChart data={chartData}>
 | 
					          <BarChart data={chartData}>
 | 
				
			||||||
            <XAxis
 | 
					            <XAxis
 | 
				
			||||||
@ -158,6 +136,16 @@ const generationColor = isDark ? '#fcd913' : '#669bbc';
 | 
				
			|||||||
              tick={{ fontSize: 10, fill: isDark ? '#fff' : '#222' }}
 | 
					              tick={{ fontSize: 10, fill: isDark ? '#fff' : '#222' }}
 | 
				
			||||||
              axisLine={{ stroke: isDark ? '#fff' : '#222' }}
 | 
					              axisLine={{ stroke: isDark ? '#fff' : '#222' }}
 | 
				
			||||||
              tickLine={{ stroke: isDark ? '#fff' : '#222' }}
 | 
					              tickLine={{ stroke: isDark ? '#fff' : '#222' }}
 | 
				
			||||||
 | 
					              label={{
 | 
				
			||||||
 | 
					                value: 'Energy (kWh)', // fixed: units are kWh
 | 
				
			||||||
 | 
					                angle: -90,
 | 
				
			||||||
 | 
					                position: 'insideLeft',
 | 
				
			||||||
 | 
					                style: {
 | 
				
			||||||
 | 
					                  textAnchor: 'middle',
 | 
				
			||||||
 | 
					                  fill: isDark ? '#fff' : '#222',
 | 
				
			||||||
 | 
					                  fontSize: 12,
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					              }}
 | 
				
			||||||
            />
 | 
					            />
 | 
				
			||||||
            <Tooltip
 | 
					            <Tooltip
 | 
				
			||||||
              formatter={(value: number) => [`${value.toFixed(2)} kWh`]}
 | 
					              formatter={(value: number) => [`${value.toFixed(2)} kWh`]}
 | 
				
			||||||
@ -171,15 +159,11 @@ const generationColor = isDark ? '#fcd913' : '#669bbc';
 | 
				
			|||||||
                color: isDark ? '#fff' : '#222',
 | 
					                color: isDark ? '#fff' : '#222',
 | 
				
			||||||
              }}
 | 
					              }}
 | 
				
			||||||
              cursor={{
 | 
					              cursor={{
 | 
				
			||||||
                fill: isDark ? '#808080' : '#e0e7ef', // dark mode bg, light mode bg
 | 
					                fill: isDark ? '#808080' : '#e0e7ef',
 | 
				
			||||||
                fillOpacity: isDark ? 0.6 : 0.3,      // adjust opacity as you like
 | 
					                fillOpacity: isDark ? 0.6 : 0.3,
 | 
				
			||||||
              }}
 | 
					 | 
				
			||||||
            />
 | 
					 | 
				
			||||||
            <Legend
 | 
					 | 
				
			||||||
              wrapperStyle={{
 | 
					 | 
				
			||||||
                color: isDark ? '#fff' : '#222',
 | 
					 | 
				
			||||||
              }}
 | 
					              }}
 | 
				
			||||||
            />
 | 
					            />
 | 
				
			||||||
 | 
					            <Legend wrapperStyle={{ color: isDark ? '#fff' : '#222' }} />
 | 
				
			||||||
            <Bar dataKey="consumption" fill={consumptionColor} name="Consumption (kWh)" />
 | 
					            <Bar dataKey="consumption" fill={consumptionColor} name="Consumption (kWh)" />
 | 
				
			||||||
            <Bar dataKey="generation" fill={generationColor} name="Generation (kWh)" />
 | 
					            <Bar dataKey="generation" fill={generationColor} name="Generation (kWh)" />
 | 
				
			||||||
          </BarChart>
 | 
					          </BarChart>
 | 
				
			||||||
@ -191,3 +175,4 @@ const generationColor = isDark ? '#fcd913' : '#669bbc';
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export default MonthlyBarChart;
 | 
					export default MonthlyBarChart;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -1,26 +1,111 @@
 | 
				
			|||||||
// components/dashboards/SiteCard.tsx
 | 
					// components/dashboards/SiteCard.tsx
 | 
				
			||||||
import React from 'react';
 | 
					'use client';
 | 
				
			||||||
import Link from 'next/link'; // Import Link from Next.js
 | 
					
 | 
				
			||||||
import { SiteName, SiteDetails } from '@/types/SiteData'; // Adjust path as necessary
 | 
					import React, { useEffect, useMemo, useState } from 'react';
 | 
				
			||||||
 | 
					import Link from 'next/link';
 | 
				
			||||||
 | 
					import { formatAddress } from '@/app/utils/formatAddress';
 | 
				
			||||||
 | 
					import { formatCrmTimestamp } from '@/app/utils/datetime';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type CrmProject = {
 | 
				
			||||||
 | 
					  name: string;                  // e.g. PROJ-0008 (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;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
interface SiteCardProps {
 | 
					interface SiteCardProps {
 | 
				
			||||||
    siteName: SiteName;
 | 
					  siteId: string;                // CRM Project "name" (canonical id)
 | 
				
			||||||
    details: SiteDetails;
 | 
					  className?: string;            // optional styling hook
 | 
				
			||||||
    status: string;
 | 
					  fallbackStatus?: string;       // optional backup status if CRM is missing it
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const SiteCard: React.FC<SiteCardProps> = ({ siteName, details, status }) => {
 | 
					const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const SiteCard: React.FC<SiteCardProps> = ({ siteId, className = '', fallbackStatus }) => {
 | 
				
			||||||
 | 
					  const [project, setProject] = useState<CrmProject | null>(null);
 | 
				
			||||||
 | 
					  const [loading, setLoading] = useState(true);
 | 
				
			||||||
 | 
					  const [err, setErr] = useState<string | null>(null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  useEffect(() => {
 | 
				
			||||||
 | 
					    let cancelled = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const fetchProject = async () => {
 | 
				
			||||||
 | 
					      setLoading(true);
 | 
				
			||||||
 | 
					      setErr(null);
 | 
				
			||||||
 | 
					      try {
 | 
				
			||||||
 | 
					        // ---- Try a single-project endpoint first (best) ----
 | 
				
			||||||
 | 
					        // e.g. GET /crm/projects/PROJ-0008
 | 
				
			||||||
 | 
					        const single = await fetch(`${API}/crm/projects/${encodeURIComponent(siteId)}`);
 | 
				
			||||||
 | 
					        if (single.ok) {
 | 
				
			||||||
 | 
					          const pj = await single.json();
 | 
				
			||||||
 | 
					          if (!cancelled) setProject(pj?.data ?? pj ?? null);
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					          // ---- Fallback: fetch all and find by name (works with your existing API) ----
 | 
				
			||||||
 | 
					          const list = await fetch(`${API}/crm/projects?limit=0`);
 | 
				
			||||||
 | 
					          if (!list.ok) throw new Error(await list.text());
 | 
				
			||||||
 | 
					          const json = await list.json();
 | 
				
			||||||
 | 
					          const found = (json?.data ?? []).find((p: CrmProject) => p.name === siteId) ?? null;
 | 
				
			||||||
 | 
					          if (!cancelled) setProject(found);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      } catch (e: any) {
 | 
				
			||||||
 | 
					        if (!cancelled) setErr(e?.message ?? 'Failed to load CRM project');
 | 
				
			||||||
 | 
					      } finally {
 | 
				
			||||||
 | 
					        if (!cancelled) setLoading(false);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    fetchProject();
 | 
				
			||||||
 | 
					    return () => { cancelled = true; };
 | 
				
			||||||
 | 
					  }, [siteId]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const status = project?.status || fallbackStatus || 'Unknown';
 | 
				
			||||||
  const statusColorClass =
 | 
					  const statusColorClass =
 | 
				
			||||||
    status === 'Active' ? 'text-green-500' :
 | 
					    status === 'Active' ? 'text-green-500' :
 | 
				
			||||||
    status === 'Inactive' ? 'text-orange-500' :
 | 
					    status === 'Inactive' ? 'text-orange-500' :
 | 
				
			||||||
    'text-red-500';
 | 
					    'text-red-500';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const niceAddress = useMemo(() => {
 | 
				
			||||||
 | 
					    if (!project?.custom_address) return 'N/A';
 | 
				
			||||||
 | 
					    return formatAddress(project.custom_address).multiLine;
 | 
				
			||||||
 | 
					  }, [project?.custom_address]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const lastSync = useMemo(() => {
 | 
				
			||||||
 | 
					    return formatCrmTimestamp(project?.modified, { includeSeconds: true }) || 'N/A';
 | 
				
			||||||
 | 
					  }, [project?.modified]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const inverterProvider = project?.project_type || 'N/A';
 | 
				
			||||||
 | 
					  const emergencyContact =
 | 
				
			||||||
 | 
					    project?.custom_mobile_phone_no ||
 | 
				
			||||||
 | 
					    project?.custom_email ||
 | 
				
			||||||
 | 
					    project?.customer ||
 | 
				
			||||||
 | 
					    'N/A';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
        <div className="bg-white p-4 rounded-lg shadow-md dark:bg-gray-800 dark:text-white-light flex flex-col space-y-2">
 | 
					    <div className={`bg-white p-4 rounded-lg shadow-md dark:bg-rtgray-800 dark:text-white-light flex flex-col space-y-2 ${className}`}>
 | 
				
			||||||
      <h3 className="text-xl font-bold text-primary-600 dark:text-primary-400 border-b pb-2 mb-2">
 | 
					      <h3 className="text-xl font-bold text-primary-600 dark:text-primary-400 border-b pb-2 mb-2">
 | 
				
			||||||
                {siteName}
 | 
					        {project?.project_name || siteId}
 | 
				
			||||||
      </h3>
 | 
					      </h3>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      {loading ? (
 | 
				
			||||||
 | 
					        <div className="animate-pulse space-y-2">
 | 
				
			||||||
 | 
					          <div className="h-4 w-24 bg-rtgray-200 dark:bg-rtgray-700 rounded" />
 | 
				
			||||||
 | 
					          <div className="h-4 w-48 bg-rtgray-200 dark:bg-rtgray-700 rounded" />
 | 
				
			||||||
 | 
					          <div className="h-4 w-40 bg-rtgray-200 dark:bg-rtgray-700 rounded" />
 | 
				
			||||||
 | 
					          <div className="h-4 w-36 bg-rtgray-200 dark:bg-rtgray-700 rounded" />
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      ) : err ? (
 | 
				
			||||||
 | 
					        <div className="text-red-500 text-sm">Failed to load CRM: {err}</div>
 | 
				
			||||||
 | 
					      ) : !project ? (
 | 
				
			||||||
 | 
					        <div className="text-amber-500 text-sm">No CRM project found for <span className="font-semibold">{siteId}</span>.</div>
 | 
				
			||||||
 | 
					      ) : (
 | 
				
			||||||
 | 
					        <>
 | 
				
			||||||
          <div className="flex justify-between items-center">
 | 
					          <div className="flex justify-between items-center">
 | 
				
			||||||
            <p className="text-gray-600 dark:text-gray-400 font-medium">Status:</p>
 | 
					            <p className="text-gray-600 dark:text-gray-400 font-medium">Status:</p>
 | 
				
			||||||
            <p className={`font-semibold ${statusColorClass}`}>{status}</p>
 | 
					            <p className={`font-semibold ${statusColorClass}`}>{status}</p>
 | 
				
			||||||
@ -28,31 +113,29 @@ const SiteCard: React.FC<SiteCardProps> = ({ siteName, details, status }) => {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
          <div className="flex justify-between items-center">
 | 
					          <div className="flex justify-between items-center">
 | 
				
			||||||
            <p className="text-gray-600 dark:text-gray-400 font-medium">Location:</p>
 | 
					            <p className="text-gray-600 dark:text-gray-400 font-medium">Location:</p>
 | 
				
			||||||
                <p className="font-semibold">{details.location}</p>
 | 
					            <p className="font-medium whitespace-pre-line text-right">{niceAddress}</p>
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          <div className="flex justify-between items-center">
 | 
					          <div className="flex justify-between items-center">
 | 
				
			||||||
            <p className="text-gray-600 dark:text-gray-400 font-medium">Inverter Provider:</p>
 | 
					            <p className="text-gray-600 dark:text-gray-400 font-medium">Inverter Provider:</p>
 | 
				
			||||||
                <p className="font-semibold">{details.inverterProvider}</p>
 | 
					            <p className="font-medium">{inverterProvider}</p>
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          <div className="flex justify-between items-center">
 | 
					          <div className="flex justify-between items-center">
 | 
				
			||||||
            <p className="text-gray-600 dark:text-gray-400 font-medium">Emergency Contact:</p>
 | 
					            <p className="text-gray-600 dark:text-gray-400 font-medium">Emergency Contact:</p>
 | 
				
			||||||
                <p className="font-semibold">{details.emergencyContact}</p>
 | 
					            <p className="font-medium text-right">{emergencyContact}</p>
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          <div className="flex justify-between items-center">
 | 
					          <div className="flex justify-between items-center">
 | 
				
			||||||
            <p className="text-gray-600 dark:text-gray-400 font-medium">Last Sync:</p>
 | 
					            <p className="text-gray-600 dark:text-gray-400 font-medium">Last Sync:</p>
 | 
				
			||||||
                <p className="font-semibold">{details.lastSyncTimestamp}</p>
 | 
					            <p className="font-medium">{lastSync}</p>
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
 | 
					        </>
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            {/* New: View Dashboard Button */}
 | 
					 | 
				
			||||||
      <Link
 | 
					      <Link
 | 
				
			||||||
                href={{
 | 
					        href={{ pathname: '/adminDashboard', query: { site: siteId } }}
 | 
				
			||||||
                    pathname: '/adminDashboard', // Path to your AdminDashboard page
 | 
					        className="mt-4 w-full text-center text-sm btn-primary"
 | 
				
			||||||
                    query: { site: siteName }, // Pass the siteName as a query parameter
 | 
					 | 
				
			||||||
                }}
 | 
					 | 
				
			||||||
                className="mt-4 w-full text-center text-sm btn-primary" // Tailwind classes for basic button styling
 | 
					 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
        View Dashboard
 | 
					        View Dashboard
 | 
				
			||||||
      </Link>
 | 
					      </Link>
 | 
				
			||||||
 | 
				
			|||||||
@ -1,26 +1,51 @@
 | 
				
			|||||||
 | 
					'use client';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import type { SiteName } from '@/components/dashboards/SiteStatus';
 | 
					type Option = { label: string; value: string };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type SiteSelectorProps = {
 | 
					type SiteSelectorProps = {
 | 
				
			||||||
  selectedSite: SiteName;
 | 
					  options: Option[];                 // e.g. [{label: 'Timo… (Installation)', value: 'PROJ-0008'}, …]
 | 
				
			||||||
  setSelectedSite: (site: SiteName) => void;
 | 
					  selectedValue: string | null;      // the selected project "name" (siteId) or null
 | 
				
			||||||
 | 
					  onChange: (value: string) => void; // called with the selected value
 | 
				
			||||||
 | 
					  label?: string;
 | 
				
			||||||
 | 
					  disabled?: boolean;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
const SiteSelector = ({ selectedSite, setSelectedSite }: SiteSelectorProps) => {
 | 
					
 | 
				
			||||||
 | 
					const SiteSelector = ({
 | 
				
			||||||
 | 
					  options,
 | 
				
			||||||
 | 
					  selectedValue,
 | 
				
			||||||
 | 
					  onChange,
 | 
				
			||||||
 | 
					  label = 'Select Site:',
 | 
				
			||||||
 | 
					  disabled = false,
 | 
				
			||||||
 | 
					}: SiteSelectorProps) => {
 | 
				
			||||||
 | 
					  const isEmpty = !options || options.length === 0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <div className="flex flex-col ">
 | 
					    <div className="flex flex-col">
 | 
				
			||||||
      <label htmlFor="site" className="font-semibold text-lg dark:text-white">Select Site:</label>
 | 
					      <label htmlFor="site" className="font-semibold text-lg dark:text-white">
 | 
				
			||||||
 | 
					        {label}
 | 
				
			||||||
 | 
					      </label>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <select
 | 
					      <select
 | 
				
			||||||
        id="site"
 | 
					        id="site"
 | 
				
			||||||
        className="border p-2 rounded dark:text-white dark:bg-rtgray-800 dark:border-rtgray-700"
 | 
					        className="border p-2 rounded dark:text-white dark:bg-rtgray-800 dark:border-rtgray-700"
 | 
				
			||||||
        value={selectedSite}
 | 
					        value={selectedValue ?? ''}                 // keep controlled even when null
 | 
				
			||||||
        onChange={(e) => setSelectedSite(e.target.value as SiteName)}
 | 
					        onChange={(e) => onChange(e.target.value)}
 | 
				
			||||||
 | 
					        disabled={disabled || isEmpty}
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
        <option>Site A</option>
 | 
					        {/* Placeholder when nothing selected */}
 | 
				
			||||||
        <option>Site B</option>
 | 
					        <option value="" disabled>
 | 
				
			||||||
        <option>Site C</option>
 | 
					          {isEmpty ? 'No sites available' : 'Choose a site…'}
 | 
				
			||||||
 | 
					        </option>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        {options.map((opt) => (
 | 
				
			||||||
 | 
					          <option key={opt.value} value={opt.value}>
 | 
				
			||||||
 | 
					            {opt.label}
 | 
				
			||||||
 | 
					          </option>
 | 
				
			||||||
 | 
					        ))}
 | 
				
			||||||
      </select>
 | 
					      </select>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default SiteSelector;
 | 
					export default SiteSelector;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -1,82 +1,80 @@
 | 
				
			|||||||
import axios from "axios";
 | 
					'use client';
 | 
				
			||||||
import React, { useState, useEffect } from "react";
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type SiteName = 'Site A' | 'Site B' | 'Site C';
 | 
					import axios from "axios";
 | 
				
			||||||
 | 
					import React, { useState, useEffect, useMemo } from "react";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export type SiteName = string;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
interface SiteStatusProps {
 | 
					interface SiteStatusProps {
 | 
				
			||||||
    selectedSite: SiteName;
 | 
					  selectedSite: string;   // display label (e.g., CRM project_name)
 | 
				
			||||||
 | 
					  siteId: string;         // canonical id (e.g., CRM Project.name like PROJ-0008)
 | 
				
			||||||
 | 
					  status?: string;        // CRM status (Open/Completed/On Hold/…)
 | 
				
			||||||
  location: string;
 | 
					  location: string;
 | 
				
			||||||
  inverterProvider: string;
 | 
					  inverterProvider: string;
 | 
				
			||||||
  emergencyContact: string;
 | 
					  emergencyContact: string;
 | 
				
			||||||
  lastSyncTimestamp: string;
 | 
					  lastSyncTimestamp: string;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000";
 | 
				
			||||||
 | 
					const WS_URL  = process.env.NEXT_PUBLIC_WS_URL  ?? "ws://localhost:8000/ws";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const SiteStatus = ({
 | 
					const SiteStatus = ({
 | 
				
			||||||
  selectedSite,
 | 
					  selectedSite,
 | 
				
			||||||
 | 
					  siteId,
 | 
				
			||||||
 | 
					  status,
 | 
				
			||||||
  location,
 | 
					  location,
 | 
				
			||||||
  inverterProvider,
 | 
					  inverterProvider,
 | 
				
			||||||
  emergencyContact,
 | 
					  emergencyContact,
 | 
				
			||||||
  lastSyncTimestamp,
 | 
					  lastSyncTimestamp,
 | 
				
			||||||
}: SiteStatusProps) => {
 | 
					}: SiteStatusProps) => {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // --- WebSocket to receive MQTT-forwarded messages ---
 | 
				
			||||||
  useEffect(() => {
 | 
					  useEffect(() => {
 | 
				
			||||||
    const ws = new WebSocket("ws://localhost:8000/ws");
 | 
					    const ws = new WebSocket(WS_URL);
 | 
				
			||||||
 | 
					 | 
				
			||||||
    ws.onmessage = (event) => {
 | 
					 | 
				
			||||||
        const data = event.data;
 | 
					 | 
				
			||||||
        alert(`MQTT: ${data}`);
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    ws.onopen = () => console.log("WebSocket connected");
 | 
					    ws.onopen = () => console.log("WebSocket connected");
 | 
				
			||||||
    ws.onclose = () => console.log("WebSocket disconnected");
 | 
					    ws.onclose = () => console.log("WebSocket disconnected");
 | 
				
			||||||
 | 
					    ws.onerror = (e) => console.error("WebSocket error:", e);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ws.onmessage = (event) => {
 | 
				
			||||||
 | 
					      // Tip: avoid alert storms; log or toast instead
 | 
				
			||||||
 | 
					      try {
 | 
				
			||||||
 | 
					        const data = JSON.parse(event.data);
 | 
				
			||||||
 | 
					        console.log("WS:", data);
 | 
				
			||||||
 | 
					      } catch {
 | 
				
			||||||
 | 
					        console.log("WS raw:", event.data);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return () => ws.close();
 | 
					    return () => ws.close();
 | 
				
			||||||
}, []);
 | 
					  }, []);
 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const [showModal, setShowModal] = useState(false);
 | 
					  const [showModal, setShowModal] = useState(false);
 | 
				
			||||||
  const [deviceId, setDeviceId] = useState("");
 | 
					  const [deviceId, setDeviceId] = useState("");
 | 
				
			||||||
    const [functionType, setFunctionType] = useState("Grid");
 | 
					  const [functionType, setFunctionType] = useState<"Grid" | "Solar">("Grid");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Map site names to site IDs
 | 
					  // Track devices connected per siteId (dynamic)
 | 
				
			||||||
    const siteIdMap: Record<SiteName, string> = {
 | 
					  const [loggedDevices, setLoggedDevices] = useState<Record<string, string[]>>({});
 | 
				
			||||||
        "Site A": "site_01",
 | 
					  const devicesAtSite = loggedDevices[siteId] ?? [];
 | 
				
			||||||
        "Site B": "site_02",
 | 
					 | 
				
			||||||
        "Site C": "site_03",
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Track devices connected per site
 | 
					  const handleStartLogging = () => setShowModal(true);
 | 
				
			||||||
    const [loggedDevices, setLoggedDevices] = useState<Record<string, string[]>>({
 | 
					 | 
				
			||||||
        site_01: [],
 | 
					 | 
				
			||||||
        site_02: [],
 | 
					 | 
				
			||||||
        site_03: [],
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const siteId = siteIdMap[selectedSite];
 | 
					 | 
				
			||||||
    const devicesAtSite = loggedDevices[siteId] || [];
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const handleStartLogging = () => {
 | 
					 | 
				
			||||||
        setShowModal(true);
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const handleConfirm = async () => {
 | 
					  const handleConfirm = async () => {
 | 
				
			||||||
        const siteId = siteIdMap[selectedSite];
 | 
					    const id = deviceId.trim();
 | 
				
			||||||
        const topic = `ADW300/${siteId}/${deviceId}/${functionType.toLowerCase()}`;
 | 
					    if (!id) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const topic = `ADW300/${siteId}/${id}/${functionType.toLowerCase()}`;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
            const response = await axios.post("http://localhost:8000/start-logging", {
 | 
					      const response = await axios.post(`${API_URL}/start-logging`, { topics: [topic] });
 | 
				
			||||||
                topics: [topic],
 | 
					 | 
				
			||||||
            });
 | 
					 | 
				
			||||||
      console.log("Started logging:", response.data);
 | 
					      console.log("Started logging:", response.data);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            // Add device to list
 | 
					      setLoggedDevices(prev => ({
 | 
				
			||||||
            setLoggedDevices((prev) => ({
 | 
					 | 
				
			||||||
        ...prev,
 | 
					        ...prev,
 | 
				
			||||||
                [siteId]: [...(prev[siteId] || []), deviceId],
 | 
					        [siteId]: [...(prev[siteId] ?? []), id],
 | 
				
			||||||
      }));
 | 
					      }));
 | 
				
			||||||
      setShowModal(false);
 | 
					      setShowModal(false);
 | 
				
			||||||
 | 
					      setDeviceId("");
 | 
				
			||||||
    } catch (error) {
 | 
					    } catch (error) {
 | 
				
			||||||
      console.error("Failed to start logging:", error);
 | 
					      console.error("Failed to start logging:", error);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@ -84,40 +82,37 @@ const SiteStatus = ({
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  const handleStopLogging = async () => {
 | 
					  const handleStopLogging = async () => {
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
            await axios.post("http://localhost:8000/stop-logging");
 | 
					      // Stop only this site's topics (both function types for each device)
 | 
				
			||||||
 | 
					      const topics = (loggedDevices[siteId] ?? []).flatMap(did => [
 | 
				
			||||||
            // Clear all devices for the site (or modify to remove only specific one)
 | 
					        `ADW300/${siteId}/${did}/grid`,
 | 
				
			||||||
            setLoggedDevices((prev) => ({
 | 
					        `ADW300/${siteId}/${did}/solar`,
 | 
				
			||||||
                ...prev,
 | 
					      ]);
 | 
				
			||||||
                [siteId]: [],
 | 
					      await axios.post(`${API_URL}/stop-logging`, topics.length ? { topics } : {});
 | 
				
			||||||
            }));
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      setLoggedDevices(prev => ({ ...prev, [siteId]: [] }));
 | 
				
			||||||
      console.log("Stopped logging for", siteId);
 | 
					      console.log("Stopped logging for", siteId);
 | 
				
			||||||
    } catch (error) {
 | 
					    } catch (error) {
 | 
				
			||||||
      console.error("Failed to stop logging:", error);
 | 
					      console.error("Failed to stop logging:", error);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const statusMap: Record<SiteName, string> = {
 | 
					  const statusClass = useMemo(() => {
 | 
				
			||||||
        'Site A': 'Active',
 | 
					    const s = (status ?? "").toLowerCase();
 | 
				
			||||||
        'Site B': 'Inactive',
 | 
					    if (s === "open" || s === "active") return "text-green-500";
 | 
				
			||||||
        'Site C': 'Faulty',
 | 
					    if (s === "completed" || s === "closed") return "text-blue-500";
 | 
				
			||||||
    };
 | 
					    if (s === "inactive" || s === "on hold") return "text-orange-500";
 | 
				
			||||||
 | 
					    if (s === "faulty" || s === "cancelled") return "text-red-500";
 | 
				
			||||||
 | 
					    return "text-gray-500";
 | 
				
			||||||
 | 
					  }, [status]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <div className="bg-white p-4 rounded-lg shadow-md space-y-2 dark:bg-rtgray-800 dark:text-white-light">
 | 
					    <div className="bg-white p-4 rounded-lg shadow-md space-y-2 dark:bg-rtgray-800 dark:text-white-light">
 | 
				
			||||||
      <h2 className="text-xl font-semibold mb-3">Site Details</h2>
 | 
					      <h2 className="text-xl font-semibold mb-3">Site Details</h2>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            {/* Status */}
 | 
					      {/* Status (from CRM) */}
 | 
				
			||||||
      <div className="flex justify-between items-center text-base">
 | 
					      <div className="flex justify-between items-center text-base">
 | 
				
			||||||
        <p className="text-gray-600 dark:text-white/85 font-medium">Status:</p>
 | 
					        <p className="text-gray-600 dark:text-white/85 font-medium">Status:</p>
 | 
				
			||||||
                <p className={`font-semibold ${
 | 
					        <p className={`font-semibold ${statusClass}`}>{status ?? "—"}</p>
 | 
				
			||||||
                    statusMap[selectedSite] === 'Active' ? 'text-green-500' :
 | 
					 | 
				
			||||||
                    statusMap[selectedSite] === 'Inactive' ? 'text-orange-500' :
 | 
					 | 
				
			||||||
                    'text-red-500'
 | 
					 | 
				
			||||||
                }`}>
 | 
					 | 
				
			||||||
                    {statusMap[selectedSite]}
 | 
					 | 
				
			||||||
                </p>
 | 
					 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      {/* Site ID */}
 | 
					      {/* Site ID */}
 | 
				
			||||||
@ -149,69 +144,9 @@ const SiteStatus = ({
 | 
				
			|||||||
        <p className="text-gray-600 dark:text-white/85 font-medium">Last Sync:</p>
 | 
					        <p className="text-gray-600 dark:text-white/85 font-medium">Last Sync:</p>
 | 
				
			||||||
        <p className="font-medium">{lastSyncTimestamp}</p>
 | 
					        <p className="font-medium">{lastSyncTimestamp}</p>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
 | 
					 | 
				
			||||||
            {/* Start Logging Button */}
 | 
					 | 
				
			||||||
            <div className="flex justify-between items-center text-base space-x-2">
 | 
					 | 
				
			||||||
                {devicesAtSite.length > 0 ? (
 | 
					 | 
				
			||||||
                    <button
 | 
					 | 
				
			||||||
                        onClick={handleStopLogging}
 | 
					 | 
				
			||||||
                        className="text-sm lg:text-md bg-red-500 hover:bg-red-600 text-white font-medium px-3 py-2 rounded"
 | 
					 | 
				
			||||||
                    >
 | 
					 | 
				
			||||||
                        Stop Logging
 | 
					 | 
				
			||||||
                    </button>
 | 
					 | 
				
			||||||
                ) : (
 | 
					 | 
				
			||||||
                    <button
 | 
					 | 
				
			||||||
                        onClick={handleStartLogging}
 | 
					 | 
				
			||||||
                        className="text-sm lg:text-md btn-primary px-3 py-2"
 | 
					 | 
				
			||||||
                    >
 | 
					 | 
				
			||||||
                        Start Logging
 | 
					 | 
				
			||||||
                    </button>
 | 
					 | 
				
			||||||
                )}
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            {/* Modal */}
 | 
					 | 
				
			||||||
            {showModal && (
 | 
					 | 
				
			||||||
                <div className="fixed inset-0 z-50 bg-black bg-opacity-50 flex items-center justify-center">
 | 
					 | 
				
			||||||
                    <div className="bg-white rounded-lg p-6 w-[90%] max-w-md shadow-lg">
 | 
					 | 
				
			||||||
                        <h2 className="text-lg font-semibold mb-4">Enter Device Info</h2>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                        <input
 | 
					 | 
				
			||||||
                            type="text"
 | 
					 | 
				
			||||||
                            placeholder="Device ID (e.g. device_01)"
 | 
					 | 
				
			||||||
                            className="w-full p-2 mb-4 border rounded"
 | 
					 | 
				
			||||||
                            value={deviceId}
 | 
					 | 
				
			||||||
                            onChange={(e) => setDeviceId(e.target.value)}
 | 
					 | 
				
			||||||
                        />
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                        <select
 | 
					 | 
				
			||||||
                            className="w-full p-2 mb-4 border rounded"
 | 
					 | 
				
			||||||
                            value={functionType}
 | 
					 | 
				
			||||||
                            onChange={(e) => setFunctionType(e.target.value)}
 | 
					 | 
				
			||||||
                        >
 | 
					 | 
				
			||||||
                            <option value="Grid">Grid</option>
 | 
					 | 
				
			||||||
                            <option value="Solar">Solar</option>
 | 
					 | 
				
			||||||
                        </select>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                        <div className="flex justify-end space-x-2">
 | 
					 | 
				
			||||||
                            <button
 | 
					 | 
				
			||||||
                                onClick={() => setShowModal(false)}
 | 
					 | 
				
			||||||
                                className="btn-primary bg-white border-2 border-black hover:bg-rtgray-200 px-4 py-2"
 | 
					 | 
				
			||||||
                            >
 | 
					 | 
				
			||||||
                                Cancel
 | 
					 | 
				
			||||||
                            </button>
 | 
					 | 
				
			||||||
                            <button
 | 
					 | 
				
			||||||
                                onClick={handleConfirm}
 | 
					 | 
				
			||||||
                                className="btn-primary px-4 py-2"
 | 
					 | 
				
			||||||
                            >
 | 
					 | 
				
			||||||
                                Confirm
 | 
					 | 
				
			||||||
                            </button>
 | 
					 | 
				
			||||||
                        </div>
 | 
					 | 
				
			||||||
                    </div>
 | 
					 | 
				
			||||||
                </div>
 | 
					 | 
				
			||||||
            )}
 | 
					 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default SiteStatus;
 | 
					export default SiteStatus;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										54
									
								
								components/dashboards/kpibottom.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								components/dashboards/kpibottom.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,54 @@
 | 
				
			|||||||
 | 
					// components/dashboards/KpiBottom.tsx
 | 
				
			||||||
 | 
					'use client';
 | 
				
			||||||
 | 
					import React, { ReactNode } from 'react';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type Props = {
 | 
				
			||||||
 | 
					  efficiencyPct: number;     // % value (0..100)
 | 
				
			||||||
 | 
					  powerFactor: number;       // 0..1
 | 
				
			||||||
 | 
					  loadFactor: number;        // ratio, not %
 | 
				
			||||||
 | 
					  middle?: ReactNode;
 | 
				
			||||||
 | 
					  right?: ReactNode;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const Panel = ({ title, children }: { title: string; children: ReactNode }) => (
 | 
				
			||||||
 | 
					  <div className="rounded-2xl p-5 shadow-md bg-white dark:bg-rtgray-800 text-white min-h-[260px] flex flex-col">
 | 
				
			||||||
 | 
					    <div className="text-lg font-bold opacity-80 mb-3">{title}</div>
 | 
				
			||||||
 | 
					    <div className="flex-1 grid place-items-center">{children}</div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const Stat = ({ value, label, accent = false }: { value: ReactNode; label: string; accent?: boolean }) => (
 | 
				
			||||||
 | 
					  <div className="flex flex-col items-center gap-1">
 | 
				
			||||||
 | 
					    <div className={`text-3xl font-semibold ${accent ? 'text-[#fcd913]' : 'text-white'}`}>{value}</div>
 | 
				
			||||||
 | 
					    <div className="text-xs text-[#9aa4b2]">{label}</div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default function KpiBottom({
 | 
				
			||||||
 | 
					  efficiencyPct, powerFactor, loadFactor, middle, right,
 | 
				
			||||||
 | 
					}: Props) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
 | 
				
			||||||
 | 
					      <Panel title="Measurements">
 | 
				
			||||||
 | 
					        <div className="grid grid-cols-3 gap-3 w-full">
 | 
				
			||||||
 | 
					          <Stat value={`${efficiencyPct.toFixed(1)}%`} label="Efficiency" />
 | 
				
			||||||
 | 
					          <Stat value={powerFactor.toFixed(2)} label="Power Factor" />
 | 
				
			||||||
 | 
					          <Stat value={loadFactor.toFixed(2)} label="Load Factor" />
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </Panel>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <Panel title="Yield Bar Chart">
 | 
				
			||||||
 | 
					        <div className="w-full h-48">
 | 
				
			||||||
 | 
					          {middle}
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </Panel>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <Panel title="Peak Power Demand">
 | 
				
			||||||
 | 
					        <div className="text-3xl font-semibold">
 | 
				
			||||||
 | 
					          {right}
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </Panel>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										74
									
								
								components/dashboards/kpitop.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										74
									
								
								components/dashboards/kpitop.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,74 @@
 | 
				
			|||||||
 | 
					// components/KpiTop.tsx
 | 
				
			||||||
 | 
					import React from "react";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type Props = {
 | 
				
			||||||
 | 
					  month?: string;
 | 
				
			||||||
 | 
					  yieldKwh: number;
 | 
				
			||||||
 | 
					  consumptionKwh: number;
 | 
				
			||||||
 | 
					  gridDrawKwh: number;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const Card: React.FC<{ title: string; value: string; accent?: boolean; icon?: React.ReactNode }> = ({
 | 
				
			||||||
 | 
					  title,
 | 
				
			||||||
 | 
					  value,
 | 
				
			||||||
 | 
					  accent,
 | 
				
			||||||
 | 
					  icon,
 | 
				
			||||||
 | 
					}) => (
 | 
				
			||||||
 | 
					  <div
 | 
				
			||||||
 | 
					    className={`rounded-xl p-4 md:p-5 shadow-sm
 | 
				
			||||||
 | 
					      ${accent 
 | 
				
			||||||
 | 
					        ? "bg-[#fcd913] text-black" 
 | 
				
			||||||
 | 
					        : "bg-white text-gray-900 dark:bg-rtgray-800 dark:text-white"}`}
 | 
				
			||||||
 | 
					  >
 | 
				
			||||||
 | 
					    <div className="flex items-center gap-3">
 | 
				
			||||||
 | 
					      <div className="shrink-0 text-black dark:text-white">
 | 
				
			||||||
 | 
					        {icon}
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					      <div className="flex-1">
 | 
				
			||||||
 | 
					        <div className="text-lg font-bold opacity-80">{title}</div>
 | 
				
			||||||
 | 
					        <div className="text-2xl font-semibold">{value}</div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default function KpiTop({ month, yieldKwh, consumptionKwh, gridDrawKwh }: Props) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <section aria-label="Top KPIs" className="space-y-3">
 | 
				
			||||||
 | 
					      {month && <div className="text-xs dark:text-[#9aa4b2]">{month}</div>}
 | 
				
			||||||
 | 
					      <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
 | 
				
			||||||
 | 
					        <Card
 | 
				
			||||||
 | 
					          title="Monthly Yield"
 | 
				
			||||||
 | 
					          value={`${yieldKwh.toLocaleString()} kWh`}
 | 
				
			||||||
 | 
					          icon={
 | 
				
			||||||
 | 
					            <svg width="28" height="28" viewBox="0 0 24 24" fill="none">
 | 
				
			||||||
 | 
					              <circle cx="12" cy="12" r="4" stroke="#fcd913" strokeWidth="2" />
 | 
				
			||||||
 | 
					              <path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1" stroke="#fcd913" strokeWidth="2" />
 | 
				
			||||||
 | 
					            </svg>
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					        <Card
 | 
				
			||||||
 | 
					          title="Monthly Consumption"
 | 
				
			||||||
 | 
					          value={`${consumptionKwh.toLocaleString()} kWh`}
 | 
				
			||||||
 | 
					          icon={
 | 
				
			||||||
 | 
					            <svg width="28" height="28" viewBox="0 0 24 24" fill="none">
 | 
				
			||||||
 | 
					              <rect x="8" y="3" width="8" height="12" rx="2" stroke="currentColor" strokeWidth="2" />
 | 
				
			||||||
 | 
					              <path d="M12 15v6" stroke="#fcd913" strokeWidth="2" />
 | 
				
			||||||
 | 
					            </svg>
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					        <Card
 | 
				
			||||||
 | 
					          title="Monthly Grid Draw"
 | 
				
			||||||
 | 
					          value={`${gridDrawKwh.toLocaleString()} kWh`}
 | 
				
			||||||
 | 
					          icon={
 | 
				
			||||||
 | 
					            <svg width="28" height="28" viewBox="0 0 24 24" fill="none">
 | 
				
			||||||
 | 
					              <path d="M5 21h14M7 21l5-18 5 18" stroke="currentColor" strokeWidth="2" />
 | 
				
			||||||
 | 
					              <path d="M14 8l2 2" stroke="#fcd913" strokeWidth="2" />
 | 
				
			||||||
 | 
					            </svg>
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </section>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -1,57 +1,73 @@
 | 
				
			|||||||
 | 
					// Dropdown.tsx
 | 
				
			||||||
'use client';
 | 
					'use client';
 | 
				
			||||||
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
 | 
					import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
 | 
				
			||||||
import { usePopper } from 'react-popper';
 | 
					import { usePopper } from 'react-popper';
 | 
				
			||||||
 | 
					import type { ReactNode } from 'react';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const Dropdown = (props: any, forwardedRef: any) => {
 | 
					type DropdownProps = {
 | 
				
			||||||
    const [visibility, setVisibility] = useState<any>(false);
 | 
					  button?: ReactNode;             // 👈 make optional
 | 
				
			||||||
 | 
					  children: ReactNode;
 | 
				
			||||||
 | 
					  btnClassName?: string;
 | 
				
			||||||
 | 
					  placement?: any;
 | 
				
			||||||
 | 
					  offset?: [number, number];
 | 
				
			||||||
 | 
					  panelClassName?: string;
 | 
				
			||||||
 | 
					  closeOnItemClick?: boolean;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const referenceRef = useRef<any>();
 | 
					const Dropdown = (props: DropdownProps, forwardedRef: any) => {
 | 
				
			||||||
    const popperRef = useRef<any>();
 | 
					  const [visible, setVisible] = useState(false);
 | 
				
			||||||
 | 
					  const referenceRef = useRef<HTMLButtonElement | null>(null);
 | 
				
			||||||
 | 
					  const popperRef = useRef<HTMLDivElement | null>(null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const { styles, attributes } = usePopper(referenceRef.current, popperRef.current, {
 | 
					  const { styles, attributes } = usePopper(referenceRef.current, popperRef.current, {
 | 
				
			||||||
    placement: props.placement || 'bottom-end',
 | 
					    placement: props.placement || 'bottom-end',
 | 
				
			||||||
        modifiers: [
 | 
					    modifiers: [{ name: 'offset', options: { offset: props.offset ?? [0, 8] } }],
 | 
				
			||||||
            {
 | 
					 | 
				
			||||||
                name: 'offset',
 | 
					 | 
				
			||||||
                options: {
 | 
					 | 
				
			||||||
                    offset: props.offset || [0],
 | 
					 | 
				
			||||||
                },
 | 
					 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
        ],
 | 
					 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const handleDocumentClick = (event: any) => {
 | 
					 | 
				
			||||||
        if (referenceRef.current.contains(event.target) || popperRef.current.contains(event.target)) {
 | 
					 | 
				
			||||||
            return;
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        setVisibility(false);
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  useEffect(() => {
 | 
					  useEffect(() => {
 | 
				
			||||||
        document.addEventListener('mousedown', handleDocumentClick);
 | 
					    const onDoc = (e: MouseEvent) => {
 | 
				
			||||||
        return () => {
 | 
					      if (!referenceRef.current || !popperRef.current) return;
 | 
				
			||||||
            document.removeEventListener('mousedown', handleDocumentClick);
 | 
					      if (referenceRef.current.contains(e.target as Node)) return;
 | 
				
			||||||
 | 
					      if (popperRef.current.contains(e.target as Node)) return;
 | 
				
			||||||
 | 
					      setVisible(false);
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					    document.addEventListener('mousedown', onDoc);
 | 
				
			||||||
 | 
					    return () => document.removeEventListener('mousedown', onDoc);
 | 
				
			||||||
  }, []);
 | 
					  }, []);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    useImperativeHandle(forwardedRef, () => ({
 | 
					  useImperativeHandle(forwardedRef, () => ({ close: () => setVisible(false) }));
 | 
				
			||||||
        close() {
 | 
					
 | 
				
			||||||
            setVisibility(false);
 | 
					  const defaultButton = (
 | 
				
			||||||
        },
 | 
					    <span className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-gray-200 dark:bg-rtgray-700" />
 | 
				
			||||||
    }));
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <>
 | 
					    <>
 | 
				
			||||||
            <button ref={referenceRef} type="button" className={props.btnClassName} onClick={() => setVisibility(!visibility)}>
 | 
					      <button
 | 
				
			||||||
                {props.button}
 | 
					        ref={referenceRef}
 | 
				
			||||||
 | 
					        type="button"
 | 
				
			||||||
 | 
					        className={props.btnClassName}
 | 
				
			||||||
 | 
					        onClick={() => setVisible((v) => !v)}
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        {props.button ?? defaultButton} {/* 👈 fallback */}
 | 
				
			||||||
      </button>
 | 
					      </button>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            <div ref={popperRef} style={styles.popper} {...attributes.popper} className="z-50" onClick={() => setVisibility(!visibility)}>
 | 
					      <div
 | 
				
			||||||
                {visibility && props.children}
 | 
					        ref={popperRef}
 | 
				
			||||||
 | 
					        style={styles.popper}
 | 
				
			||||||
 | 
					        {...attributes.popper}
 | 
				
			||||||
 | 
					        className="z-50"
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        {visible && (
 | 
				
			||||||
 | 
					          <div className={props.panelClassName ?? 'rounded-lg bg-white dark:bg-rtgray-700 shadow-lg ring-1 ring-black/5'}>
 | 
				
			||||||
 | 
					            {props.children}
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        )}
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    </>
 | 
					    </>
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default forwardRef(Dropdown);
 | 
					export default forwardRef(Dropdown);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -4,253 +4,175 @@ import { useDispatch, useSelector } from 'react-redux';
 | 
				
			|||||||
import Link from 'next/link';
 | 
					import Link from 'next/link';
 | 
				
			||||||
import { IRootState } from '@/store';
 | 
					import { IRootState } from '@/store';
 | 
				
			||||||
import { toggleTheme, toggleSidebar, toggleRTL } from '@/store/themeConfigSlice';
 | 
					import { toggleTheme, toggleSidebar, toggleRTL } from '@/store/themeConfigSlice';
 | 
				
			||||||
 | 
					import Image from 'next/image';
 | 
				
			||||||
import Dropdown from '@/components/dropdown';
 | 
					import Dropdown from '@/components/dropdown';
 | 
				
			||||||
import IconMenu from '@/components/icon/icon-menu';
 | 
					import IconMenu from '@/components/icon/icon-menu';
 | 
				
			||||||
import IconCalendar from '@/components/icon/icon-calendar';
 | 
					 | 
				
			||||||
import IconEdit from '@/components/icon/icon-edit';
 | 
					 | 
				
			||||||
import IconChatNotification from '@/components/icon/icon-chat-notification';
 | 
					 | 
				
			||||||
import IconSearch from '@/components/icon/icon-search';
 | 
					 | 
				
			||||||
import IconXCircle from '@/components/icon/icon-x-circle';
 | 
					 | 
				
			||||||
import IconSun from '@/components/icon/icon-sun';
 | 
					import IconSun from '@/components/icon/icon-sun';
 | 
				
			||||||
import IconMoon from '@/components/icon/icon-moon';
 | 
					import IconMoon from '@/components/icon/icon-moon';
 | 
				
			||||||
import IconLaptop from '@/components/icon/icon-laptop';
 | 
					 | 
				
			||||||
import IconMailDot from '@/components/icon/icon-mail-dot';
 | 
					 | 
				
			||||||
import IconArrowLeft from '@/components/icon/icon-arrow-left';
 | 
					 | 
				
			||||||
import IconInfoCircle from '@/components/icon/icon-info-circle';
 | 
					 | 
				
			||||||
import IconBellBing from '@/components/icon/icon-bell-bing';
 | 
					 | 
				
			||||||
import IconUser from '@/components/icon/icon-user';
 | 
					import IconUser from '@/components/icon/icon-user';
 | 
				
			||||||
import IconMail from '@/components/icon/icon-mail';
 | 
					import IconMail from '@/components/icon/icon-mail';
 | 
				
			||||||
import IconLockDots from '@/components/icon/icon-lock-dots';
 | 
					import IconLockDots from '@/components/icon/icon-lock-dots';
 | 
				
			||||||
import IconLogout from '@/components/icon/icon-logout';
 | 
					import IconLogout from '@/components/icon/icon-logout';
 | 
				
			||||||
import IconMenuDashboard from '@/components/icon/menu/icon-menu-dashboard';
 | 
					 | 
				
			||||||
import IconCaretDown from '@/components/icon/icon-caret-down';
 | 
					 | 
				
			||||||
import IconMenuApps from '@/components/icon/menu/icon-menu-apps';
 | 
					 | 
				
			||||||
import IconMenuComponents from '@/components/icon/menu/icon-menu-components';
 | 
					 | 
				
			||||||
import IconMenuElements from '@/components/icon/menu/icon-menu-elements';
 | 
					 | 
				
			||||||
import IconMenuDatatables from '@/components/icon/menu/icon-menu-datatables';
 | 
					 | 
				
			||||||
import IconMenuForms from '@/components/icon/menu/icon-menu-forms';
 | 
					 | 
				
			||||||
import IconMenuPages from '@/components/icon/menu/icon-menu-pages';
 | 
					 | 
				
			||||||
import IconMenuMore from '@/components/icon/menu/icon-menu-more';
 | 
					 | 
				
			||||||
import { usePathname, useRouter } from 'next/navigation';
 | 
					import { usePathname, useRouter } from 'next/navigation';
 | 
				
			||||||
import { getTranslation } from '@/i18n';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const Header = () => {
 | 
					type UserData = { id: string; email: string; createdAt: string };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default function Header() {
 | 
				
			||||||
  const pathname = usePathname();
 | 
					  const pathname = usePathname();
 | 
				
			||||||
  const dispatch = useDispatch();
 | 
					  const dispatch = useDispatch();
 | 
				
			||||||
  const router = useRouter();
 | 
					  const router = useRouter();
 | 
				
			||||||
    const { t, i18n } = getTranslation();
 | 
					  const themeConfig = useSelector((state: IRootState) => state.themeConfig);
 | 
				
			||||||
 | 
					  const isRtl = themeConfig.rtlClass === 'rtl';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const [user, setUser] = useState<UserData | null>(null);
 | 
				
			||||||
 | 
					  const [loadingUser, setLoadingUser] = useState(true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Highlight active menu (your original effect)
 | 
				
			||||||
  useEffect(() => {
 | 
					  useEffect(() => {
 | 
				
			||||||
        const selector = document.querySelector('ul.horizontal-menu a[href="' + window.location.pathname + '"]');
 | 
					    const selector = document.querySelector(
 | 
				
			||||||
 | 
					      'ul.horizontal-menu a[href="' + window.location.pathname + '"]'
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
    if (selector) {
 | 
					    if (selector) {
 | 
				
			||||||
            const all: any = document.querySelectorAll('ul.horizontal-menu .nav-link.active');
 | 
					      document
 | 
				
			||||||
            for (let i = 0; i < all.length; i++) {
 | 
					        .querySelectorAll('ul.horizontal-menu .nav-link.active')
 | 
				
			||||||
                all[0]?.classList.remove('active');
 | 
					        .forEach((el) => el.classList.remove('active'));
 | 
				
			||||||
            }
 | 
					      document
 | 
				
			||||||
 | 
					        .querySelectorAll('ul.horizontal-menu a.active')
 | 
				
			||||||
            let allLinks = document.querySelectorAll('ul.horizontal-menu a.active');
 | 
					        .forEach((el) => el.classList.remove('active'));
 | 
				
			||||||
            for (let i = 0; i < allLinks.length; i++) {
 | 
					      selector.classList.add('active');
 | 
				
			||||||
                const element = allLinks[i];
 | 
					 | 
				
			||||||
                element?.classList.remove('active');
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
            selector?.classList.add('active');
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      const ul: any = selector.closest('ul.sub-menu');
 | 
					      const ul: any = selector.closest('ul.sub-menu');
 | 
				
			||||||
      if (ul) {
 | 
					      if (ul) {
 | 
				
			||||||
                let ele: any = ul.closest('li.menu').querySelectorAll('.nav-link');
 | 
					        const ele: any = ul.closest('li.menu')?.querySelector('.nav-link');
 | 
				
			||||||
                if (ele) {
 | 
					        setTimeout(() => ele?.classList.add('active'));
 | 
				
			||||||
                    ele = ele[0];
 | 
					 | 
				
			||||||
                    setTimeout(() => {
 | 
					 | 
				
			||||||
                        ele?.classList.add('active');
 | 
					 | 
				
			||||||
                    });
 | 
					 | 
				
			||||||
                }
 | 
					 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }, [pathname]);
 | 
					  }, [pathname]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const isRtl = useSelector((state: IRootState) => state.themeConfig.rtlClass) === 'rtl';
 | 
					  async function loadUser() {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
    const themeConfig = useSelector((state: IRootState) => state.themeConfig);
 | 
					    const res = await fetch('/api/auth/me', {
 | 
				
			||||||
    const setLocale = (flag: string) => {
 | 
					      method: 'GET',
 | 
				
			||||||
        if (flag.toLowerCase() === 'ae') {
 | 
					      credentials: 'include', // send cookie
 | 
				
			||||||
            dispatch(toggleRTL('rtl'));
 | 
					      cache: 'no-store',      // avoid stale cached responses
 | 
				
			||||||
        } else {
 | 
					    });
 | 
				
			||||||
            dispatch(toggleRTL('ltr'));
 | 
					    if (!res.ok) throw new Error();
 | 
				
			||||||
 | 
					    const data = await res.json();
 | 
				
			||||||
 | 
					    setUser(data.user);
 | 
				
			||||||
 | 
					  } catch {
 | 
				
			||||||
 | 
					    setUser(null);
 | 
				
			||||||
 | 
					  } finally {
 | 
				
			||||||
 | 
					    setLoadingUser(false);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
        router.refresh();
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					useEffect(() => {
 | 
				
			||||||
 | 
					  setLoadingUser(true);
 | 
				
			||||||
 | 
					  loadUser();
 | 
				
			||||||
 | 
					  // eslint-disable-next-line react-hooks/exhaustive-deps
 | 
				
			||||||
 | 
					}, [pathname]); // re-fetch on route change (after login redirect)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const handleLogout = async () => {
 | 
				
			||||||
 | 
					    await fetch('/api/auth/logout', { method: 'POST' });
 | 
				
			||||||
 | 
					    setUser(null);
 | 
				
			||||||
 | 
					    router.push('/login'); // go to login
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    function createMarkup(messages: any) {
 | 
					 | 
				
			||||||
        return { __html: messages };
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    const [messages, setMessages] = useState([
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            id: 1,
 | 
					 | 
				
			||||||
            image: '<span class="grid place-content-center w-9 h-9 rounded-full bg-success-light dark:bg-success text-success dark:text-success-light"><svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path></svg></span>',
 | 
					 | 
				
			||||||
            title: 'Congratulations!',
 | 
					 | 
				
			||||||
            message: 'Your OS has been updated.',
 | 
					 | 
				
			||||||
            time: '1hr',
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            id: 2,
 | 
					 | 
				
			||||||
            image: '<span class="grid place-content-center w-9 h-9 rounded-full bg-info-light dark:bg-info text-info dark:text-info-light"><svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg></span>',
 | 
					 | 
				
			||||||
            title: 'Did you know?',
 | 
					 | 
				
			||||||
            message: 'You can switch between artboards.',
 | 
					 | 
				
			||||||
            time: '2hr',
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            id: 3,
 | 
					 | 
				
			||||||
            image: '<span class="grid place-content-center w-9 h-9 rounded-full bg-danger-light dark:bg-danger text-danger dark:text-danger-light"> <svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></span>',
 | 
					 | 
				
			||||||
            title: 'Something went wrong!',
 | 
					 | 
				
			||||||
            message: 'Send Reposrt',
 | 
					 | 
				
			||||||
            time: '2days',
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            id: 4,
 | 
					 | 
				
			||||||
            image: '<span class="grid place-content-center w-9 h-9 rounded-full bg-warning-light dark:bg-warning text-warning dark:text-warning-light"><svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">    <circle cx="12" cy="12" r="10"></circle>    <line x1="12" y1="8" x2="12" y2="12"></line>    <line x1="12" y1="16" x2="12.01" y2="16"></line></svg></span>',
 | 
					 | 
				
			||||||
            title: 'Warning',
 | 
					 | 
				
			||||||
            message: 'Your password strength is low.',
 | 
					 | 
				
			||||||
            time: '5days',
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
    ]);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const removeMessage = (value: number) => {
 | 
					 | 
				
			||||||
        setMessages(messages.filter((user) => user.id !== value));
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const [notifications, setNotifications] = useState([
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            id: 1,
 | 
					 | 
				
			||||||
            profile: 'user-profile.jpeg',
 | 
					 | 
				
			||||||
            message: '<strong class="text-sm mr-1">John Doe</strong>invite you to <strong>Prototyping</strong>',
 | 
					 | 
				
			||||||
            time: '45 min ago',
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            id: 2,
 | 
					 | 
				
			||||||
            profile: 'profile-34.jpeg',
 | 
					 | 
				
			||||||
            message: '<strong class="text-sm mr-1">Adam Nolan</strong>mentioned you to <strong>UX Basics</strong>',
 | 
					 | 
				
			||||||
            time: '9h Ago',
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            id: 3,
 | 
					 | 
				
			||||||
            profile: 'profile-16.jpeg',
 | 
					 | 
				
			||||||
            message: '<strong class="text-sm mr-1">Anna Morgan</strong>Upload a file',
 | 
					 | 
				
			||||||
            time: '9h Ago',
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
    ]);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const removeNotification = (value: number) => {
 | 
					 | 
				
			||||||
        setNotifications(notifications.filter((user) => user.id !== value));
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const [search, setSearch] = useState(false);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <header className={`z-40 ${themeConfig.semidark && themeConfig.menu === 'horizontal' ? 'dark' : ''}`}>
 | 
					    <header className={`z-40 ${themeConfig.semidark && themeConfig.menu === 'horizontal' ? 'dark' : ''}`}>
 | 
				
			||||||
      <div className="shadow-sm">
 | 
					      <div className="shadow-sm">
 | 
				
			||||||
                <div className="relative flex w-full items-center bg-white px-5 py-2.5 dark:bg-black">
 | 
					        <div className="relative flex w-full items-center bg-white px-5 py-2.5 dark:bg-rtgray-900">
 | 
				
			||||||
 | 
					          {/* Logo */}
 | 
				
			||||||
          <div className="horizontal-logo flex items-center justify-between ltr:mr-2 rtl:ml-2 lg:hidden">
 | 
					          <div className="horizontal-logo flex items-center justify-between ltr:mr-2 rtl:ml-2 lg:hidden">
 | 
				
			||||||
                        <Link href="/" className="main-logo flex shrink-0 items-center">
 | 
					            <div className="relative h-10 w-32 sm:h-11 sm:w-36 md:h-12 md:w-27 shrink-0 max-h-12">
 | 
				
			||||||
                            <img className="inline w-8 ltr:-ml-1 rtl:-mr-1" src="/assets/images/newfulllogo.png" alt="logo" />
 | 
					                <Image
 | 
				
			||||||
                            <span className="hidden align-middle text-2xl  font-semibold  transition-all duration-300 ltr:ml-1.5 rtl:mr-1.5 dark:text-white-light md:inline">Rooftop Energy</span>
 | 
					                src="/assets/images/newfulllogo.png"
 | 
				
			||||||
                        </Link>
 | 
					                alt="logo"
 | 
				
			||||||
 | 
					                fill
 | 
				
			||||||
 | 
					                className="object-cover"
 | 
				
			||||||
 | 
					                priority
 | 
				
			||||||
 | 
					                sizes="(max-width: 640px) 8rem, (max-width: 768px) 9rem, (max-width: 1024px) 10rem, 10rem"
 | 
				
			||||||
 | 
					                />
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            <button
 | 
					            <button
 | 
				
			||||||
                type="button"
 | 
					                type="button"
 | 
				
			||||||
                            className="collapse-icon flex flex-none rounded-full bg-white-light/40 p-2 hover:bg-white-light/90 hover:text-primary ltr:ml-2 rtl:mr-2 dark:bg-dark/40 dark:text-[#d0d2d6] dark:hover:bg-dark/60 dark:hover:text-primary lg:hidden"
 | 
					 | 
				
			||||||
                onClick={() => dispatch(toggleSidebar())}
 | 
					                onClick={() => dispatch(toggleSidebar())}
 | 
				
			||||||
 | 
					                className="collapse-icon flex p-2 rounded-full hover:bg-rtgray-200 dark:text-white dark:hover:bg-rtgray-700"
 | 
				
			||||||
            >
 | 
					            >
 | 
				
			||||||
                            <IconMenu className="h-5 w-5" />
 | 
					                <IconMenu className="h-6 w-6" />
 | 
				
			||||||
            </button>
 | 
					            </button>
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                    <div className="flex items-center justify-end space-x-1.5 ltr:ml-auto rtl:mr-auto rtl:space-x-reverse dark:text-[#d0d2d6] sm:flex-1 ltr:sm:ml-0 sm:rtl:mr-0 lg:space-x-2">
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
                        {/* ------------------- Start Theme Switch ------------------- */}
 | 
					          {/* Right-side actions */}
 | 
				
			||||||
                        <div>
 | 
					          <div className="flex items-center justify-end space-x-1.5 ltr:ml-auto rtl:mr-auto rtl:space-x-reverse dark:text-[#d0d2d6] lg:space-x-2">
 | 
				
			||||||
 | 
					            {/* Theme toggle */}
 | 
				
			||||||
            {themeConfig.theme === 'light' ? (
 | 
					            {themeConfig.theme === 'light' ? (
 | 
				
			||||||
              <button
 | 
					              <button
 | 
				
			||||||
                                    className={`${
 | 
					 | 
				
			||||||
                                        themeConfig.theme === 'light' &&
 | 
					 | 
				
			||||||
                                        'flex items-center rounded-full bg-white-light/40 p-2 hover:bg-white-light/90 hover:text-primary dark:bg-dark/40 dark:hover:bg-dark/60'
 | 
					 | 
				
			||||||
                                    }`}
 | 
					 | 
				
			||||||
                onClick={() => dispatch(toggleTheme('dark'))}
 | 
					                onClick={() => dispatch(toggleTheme('dark'))}
 | 
				
			||||||
 | 
					                className="flex items-center p-2 rounded-full bg-white-light/40 hover:bg-white-light/90 dark:bg-rtgray-800 dark:hover:bg-rtgray-700"
 | 
				
			||||||
              >
 | 
					              >
 | 
				
			||||||
                <IconSun />
 | 
					                <IconSun />
 | 
				
			||||||
              </button>
 | 
					              </button>
 | 
				
			||||||
            ) : (
 | 
					            ) : (
 | 
				
			||||||
                                ''
 | 
					 | 
				
			||||||
                            )}
 | 
					 | 
				
			||||||
                            {themeConfig.theme === 'dark' && (
 | 
					 | 
				
			||||||
              <button
 | 
					              <button
 | 
				
			||||||
                                    className={`${
 | 
					 | 
				
			||||||
                                        themeConfig.theme === 'dark' &&
 | 
					 | 
				
			||||||
                                        'flex items-center rounded-full bg-white-light/40 p-2 hover:bg-white-light/90 hover:text-primary dark:bg-dark/40 dark:hover:bg-dark/60'
 | 
					 | 
				
			||||||
                                    }`}
 | 
					 | 
				
			||||||
                onClick={() => dispatch(toggleTheme('light'))}
 | 
					                onClick={() => dispatch(toggleTheme('light'))}
 | 
				
			||||||
 | 
					                className="flex items-center p-2 rounded-full bg-white-light/40 hover:bg-white-light/90 dark:bg-rtgray-800 dark:hover:bg-rtgray-700"
 | 
				
			||||||
              >
 | 
					              >
 | 
				
			||||||
                <IconMoon />
 | 
					                <IconMoon />
 | 
				
			||||||
              </button>
 | 
					              </button>
 | 
				
			||||||
            )}
 | 
					            )}
 | 
				
			||||||
                        </div>
 | 
					 | 
				
			||||||
                        {/* ------------------- End Theme Switch ------------------- */}
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            {/* User dropdown */}
 | 
				
			||||||
                        <div className="dropdown flex shrink-0">
 | 
					            <div className="dropdown flex shrink-0 ">
 | 
				
			||||||
 | 
					              {loadingUser ? (
 | 
				
			||||||
 | 
					                <div className="h-9 w-9 rounded-full animate-pulse bg-gray-300 dark:bg-rtgray-800" />
 | 
				
			||||||
 | 
					              ) : user ? (
 | 
				
			||||||
            <Dropdown
 | 
					            <Dropdown
 | 
				
			||||||
                                offset={[0, 8]}
 | 
					                placement={isRtl ? 'bottom-start' : 'bottom-end'}
 | 
				
			||||||
                                placement={`${isRtl ? 'bottom-start' : 'bottom-end'}`}
 | 
					 | 
				
			||||||
                btnClassName="relative group block"
 | 
					                btnClassName="relative group block"
 | 
				
			||||||
                                button={<img className="h-9 w-9 rounded-full object-cover saturate-50 group-hover:saturate-100" src="/assets/images/user-profile.jpeg" alt="userProfile" />}
 | 
					                panelClassName="rounded-lg shadow-lg border border-white/10 bg-rtgray-100 dark:bg-rtgray-800 p-2" // ✅
 | 
				
			||||||
                            >
 | 
					                button={
 | 
				
			||||||
                                <ul className="w-[230px] !py-0 font-semibold text-dark dark:text-white-dark dark:text-white-light/90">
 | 
					                        <div className="h-9 w-9 rounded-full bg-rtgray-200 dark:bg-rtgray-800 flex items-center justify-center group-hover:bg-rtgray-300 dark:group-hover:bg-rtgray-700">
 | 
				
			||||||
                                    <li>
 | 
					                        <IconUser className="h-5 w-5 text-gray-600 dark:text-gray-300" />
 | 
				
			||||||
                                        <div className="flex items-center px-4 py-4">
 | 
					 | 
				
			||||||
                                            <img className="h-10 w-10 rounded-md object-cover" src="/assets/images/user-profile.jpeg" alt="userProfile" />
 | 
					 | 
				
			||||||
                                            <div className="truncate ltr:pl-4 rtl:pr-4">
 | 
					 | 
				
			||||||
                                                <h4 className="text-base">
 | 
					 | 
				
			||||||
                                                    John Doe
 | 
					 | 
				
			||||||
                                                    <span className="rounded bg-success-light px-1 text-xs text-success ltr:ml-2 rtl:ml-2">Pro</span>
 | 
					 | 
				
			||||||
                                                </h4>
 | 
					 | 
				
			||||||
                                                <button type="button" className="text-black/60 hover:text-primary dark:text-dark-light/60 dark:hover:text-white">
 | 
					 | 
				
			||||||
                                                    johndoe@gmail.com
 | 
					 | 
				
			||||||
                                                </button>
 | 
					 | 
				
			||||||
                        </div>
 | 
					                        </div>
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                >
 | 
				
			||||||
 | 
					                <ul className="w-[230px] font-semibold text-dark"> {/* make sure this stays transparent */}
 | 
				
			||||||
 | 
					                    <li className="px-4 py-4 flex items-center">
 | 
				
			||||||
 | 
					                      <div className="truncate ltr:pl-1.5 rtl:pr-4">
 | 
				
			||||||
 | 
					                        <h4 className="text-sm text-left">{user.email}</h4>
 | 
				
			||||||
                      </div>
 | 
					                      </div>
 | 
				
			||||||
                    </li>
 | 
					                    </li>
 | 
				
			||||||
                    <li>
 | 
					                    <li>
 | 
				
			||||||
                      <Link href="/users/profile" className="dark:hover:text-white">
 | 
					                      <Link href="/users/profile" className="dark:hover:text-white">
 | 
				
			||||||
                                            <IconUser className="h-4.5 w-4.5 shrink-0 ltr:mr-2 rtl:ml-2" />
 | 
					                        <IconUser className="h-4.5 w-4.5 mr-2" /> Profile
 | 
				
			||||||
                                            Profile
 | 
					 | 
				
			||||||
                                        </Link>
 | 
					 | 
				
			||||||
                                    </li>
 | 
					 | 
				
			||||||
                                    <li>
 | 
					 | 
				
			||||||
                                        <Link href="/apps/mailbox" className="dark:hover:text-white">
 | 
					 | 
				
			||||||
                                            <IconMail className="h-4.5 w-4.5 shrink-0 ltr:mr-2 rtl:ml-2" />
 | 
					 | 
				
			||||||
                                            Inbox
 | 
					 | 
				
			||||||
                      </Link>
 | 
					                      </Link>
 | 
				
			||||||
                    </li>
 | 
					                    </li>
 | 
				
			||||||
                    <li>
 | 
					                    <li>
 | 
				
			||||||
                      <Link href="/auth/boxed-lockscreen" className="dark:hover:text-white">
 | 
					                      <Link href="/auth/boxed-lockscreen" className="dark:hover:text-white">
 | 
				
			||||||
                                            <IconLockDots className="h-4.5 w-4.5 shrink-0 ltr:mr-2 rtl:ml-2" />
 | 
					                        <IconLockDots className="h-4.5 w-4.5 mr-2" /> Lock Screen
 | 
				
			||||||
                                            Lock Screen
 | 
					 | 
				
			||||||
                      </Link>
 | 
					                      </Link>
 | 
				
			||||||
                    </li>
 | 
					                    </li>
 | 
				
			||||||
                    <li className="border-t border-white-light dark:border-white-light/10">
 | 
					                    <li className="border-t border-white-light dark:border-white-light/10">
 | 
				
			||||||
                                        <Link href="/auth/boxed-signin" className="!py-3 text-danger">
 | 
					                      <button onClick={handleLogout} className="flex w-full items-center py-3 text-danger">
 | 
				
			||||||
                                            <IconLogout className="h-4.5 w-4.5 shrink-0 rotate-90 ltr:mr-2 rtl:ml-2" />
 | 
					                        <IconLogout className="h-4.5 w-4.5 mr-2 rotate-90" /> Sign Out
 | 
				
			||||||
                                            Sign Out
 | 
					                      </button>
 | 
				
			||||||
                                        </Link>
 | 
					 | 
				
			||||||
                    </li>
 | 
					                    </li>
 | 
				
			||||||
                  </ul>
 | 
					                  </ul>
 | 
				
			||||||
                </Dropdown>
 | 
					                </Dropdown>
 | 
				
			||||||
 | 
					              ) : (
 | 
				
			||||||
 | 
					                <Link
 | 
				
			||||||
 | 
					                  href="/login"
 | 
				
			||||||
 | 
					                  className="rounded-md bg-yellow-400 px-3 py-1.5 text-black font-semibold hover:brightness-95"
 | 
				
			||||||
 | 
					                >
 | 
				
			||||||
 | 
					                  Sign In
 | 
				
			||||||
 | 
					                </Link>
 | 
				
			||||||
 | 
					              )}
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    </header>
 | 
					    </header>
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
};
 | 
					}
 | 
				
			||||||
 | 
					 | 
				
			||||||
export default Header;
 | 
					 | 
				
			||||||
 | 
				
			|||||||
@ -67,16 +67,15 @@ const Sidebar = () => {
 | 
				
			|||||||
            <nav
 | 
					            <nav
 | 
				
			||||||
                className={`sidebar fixed bottom-0 top-0 z-50 h-full min-h-screen w-[260px] shadow-[5px_0_25px_0_rgba(94,92,154,0.1)] transition-all duration-300 ${semidark ? 'text-white-dark' : ''}`}
 | 
					                className={`sidebar fixed bottom-0 top-0 z-50 h-full min-h-screen w-[260px] shadow-[5px_0_25px_0_rgba(94,92,154,0.1)] transition-all duration-300 ${semidark ? 'text-white-dark' : ''}`}
 | 
				
			||||||
            >
 | 
					            >
 | 
				
			||||||
                <div className="h-full bg-[white] dark:bg-black">
 | 
					                <div className="h-full bg-[white] dark:bg-rtgray-900">
 | 
				
			||||||
                    <div className="flex items-center justify-between px-4 py-3">
 | 
					                    <div className="flex items-center justify-between px-4 pt-4">
 | 
				
			||||||
                        <Link href="/" className="main-logo flex shrink-0 items-center">
 | 
					                        <Link href="/" className="main-logo flex shrink-0 items-center">
 | 
				
			||||||
                            <img className="ml-[5px] w-8 flex-none" src="/assets/images/newlogo.png" alt="logo" />
 | 
					                            <img className="max-w-[180px] h-auto flex" src="/assets/images/newfulllogo.png" alt="logo" />
 | 
				
			||||||
                            <span className="align-middle text-2xl font-semibold ltr:ml-1.5 rtl:mr-1.5 dark:text-white-light lg:inline">Rooftop Energy</span>
 | 
					 | 
				
			||||||
                        </Link>
 | 
					                        </Link>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                        <button
 | 
					                        <button
 | 
				
			||||||
                            type="button"
 | 
					                            type="button"
 | 
				
			||||||
                            className="collapse-icon flex h-8 w-8 items-center rounded-full transition duration-300 hover:bg-gray-500/10 rtl:rotate-180 dark:text-white-light dark:hover:bg-dark-light/10"
 | 
					                            className="collapse-icon flex h-8 w-8 items-center rounded-full transition duration-300 hover:bg-rtgray-500/10 rtl:rotate-180 dark:text-white-light dark:hover:bg-rtgray-900/10"
 | 
				
			||||||
                            onClick={() => dispatch(toggleSidebar())}
 | 
					                            onClick={() => dispatch(toggleSidebar())}
 | 
				
			||||||
                        >
 | 
					                        >
 | 
				
			||||||
                            <IconCaretsDown className="m-auto rotate-90" />
 | 
					                            <IconCaretsDown className="m-auto rotate-90" />
 | 
				
			||||||
@ -84,7 +83,7 @@ const Sidebar = () => {
 | 
				
			|||||||
                    </div>
 | 
					                    </div>
 | 
				
			||||||
                    <PerfectScrollbar className="relative h-[calc(100vh-80px)] ">
 | 
					                    <PerfectScrollbar className="relative h-[calc(100vh-80px)] ">
 | 
				
			||||||
                        <ul className="relative space-y-0.5 p-4 py-0 font-md">
 | 
					                        <ul className="relative space-y-0.5 p-4 py-0 font-md">
 | 
				
			||||||
                            <h2 className="-mx-4 mb-1 flex items-center bg-white-light/30 px-7 py-3 font-extrabold uppercase dark:bg-dark dark:bg-opacity-[0.08] dark:text-white dark:active:text-white">
 | 
					                            <h2 className="-mx-4 mb-1 flex items-center bg-white-light/30 px-7 pb-3 pt-2 font-extrabold uppercase dark:bg-dark dark:bg-opacity-[0.08] dark:text-white dark:active:text-white">
 | 
				
			||||||
                                <IconMinus className="hidden h-5 w-4 flex-none" />
 | 
					                                <IconMinus className="hidden h-5 w-4 flex-none" />
 | 
				
			||||||
                                <span>Customer</span>
 | 
					                                <span>Customer</span>
 | 
				
			||||||
                            </h2>
 | 
					                            </h2>
 | 
				
			||||||
@ -156,6 +155,7 @@ const Sidebar = () => {
 | 
				
			|||||||
                                    </div>
 | 
					                                    </div>
 | 
				
			||||||
                                </Link>
 | 
					                                </Link>
 | 
				
			||||||
                            </li>
 | 
					                            </li>
 | 
				
			||||||
 | 
					                             {/*}
 | 
				
			||||||
                            <h2 className="-mx-4 mb-1 flex items-center px-7 py-3 font-extrabold uppercase dark:bg-opacity-[0.08] dark:group-hover:text-white dark:text-white">
 | 
					                            <h2 className="-mx-4 mb-1 flex items-center px-7 py-3 font-extrabold uppercase dark:bg-opacity-[0.08] dark:group-hover:text-white dark:text-white">
 | 
				
			||||||
                                <IconMinus className="hidden h-5 w-4 flex-none" />
 | 
					                                <IconMinus className="hidden h-5 w-4 flex-none" />
 | 
				
			||||||
                                <span>Providers</span>
 | 
					                                <span>Providers</span>
 | 
				
			||||||
@ -239,7 +239,7 @@ const Sidebar = () => {
 | 
				
			|||||||
                                    </ul>
 | 
					                                    </ul>
 | 
				
			||||||
                                </AnimateHeight>
 | 
					                                </AnimateHeight>
 | 
				
			||||||
                            </li>
 | 
					                            </li>
 | 
				
			||||||
 | 
					                            */}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                        </ul>
 | 
					                        </ul>
 | 
				
			||||||
                    </PerfectScrollbar>
 | 
					                    </PerfectScrollbar>
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										5920
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										5920
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@ -11,6 +11,7 @@
 | 
				
			|||||||
    "dependencies": {
 | 
					    "dependencies": {
 | 
				
			||||||
        "@emotion/react": "^11.10.6",
 | 
					        "@emotion/react": "^11.10.6",
 | 
				
			||||||
        "@headlessui/react": "^1.7.8",
 | 
					        "@headlessui/react": "^1.7.8",
 | 
				
			||||||
 | 
					        "@heroui/react": "^2.8.2",
 | 
				
			||||||
        "@prisma/client": "^6.8.2",
 | 
					        "@prisma/client": "^6.8.2",
 | 
				
			||||||
        "@reduxjs/toolkit": "^1.9.1",
 | 
					        "@reduxjs/toolkit": "^1.9.1",
 | 
				
			||||||
        "@tippyjs/react": "^4.2.6",
 | 
					        "@tippyjs/react": "^4.2.6",
 | 
				
			||||||
@ -27,6 +28,8 @@
 | 
				
			|||||||
        "date-fns": "^4.1.0",
 | 
					        "date-fns": "^4.1.0",
 | 
				
			||||||
        "eslint": "8.32.0",
 | 
					        "eslint": "8.32.0",
 | 
				
			||||||
        "eslint-config-next": "13.1.2",
 | 
					        "eslint-config-next": "13.1.2",
 | 
				
			||||||
 | 
					        "framer-motion": "^12.23.12",
 | 
				
			||||||
 | 
					        "he": "^1.2.0",
 | 
				
			||||||
        "html2canvas": "^1.4.1",
 | 
					        "html2canvas": "^1.4.1",
 | 
				
			||||||
        "i18next": "^22.4.10",
 | 
					        "i18next": "^22.4.10",
 | 
				
			||||||
        "jsonwebtoken": "^9.0.2",
 | 
					        "jsonwebtoken": "^9.0.2",
 | 
				
			||||||
@ -51,6 +54,7 @@
 | 
				
			|||||||
    "devDependencies": {
 | 
					    "devDependencies": {
 | 
				
			||||||
        "@tailwindcss/forms": "^0.5.3",
 | 
					        "@tailwindcss/forms": "^0.5.3",
 | 
				
			||||||
        "@tailwindcss/typography": "^0.5.8",
 | 
					        "@tailwindcss/typography": "^0.5.8",
 | 
				
			||||||
 | 
					        "@types/he": "^1.2.3",
 | 
				
			||||||
        "@types/jsonwebtoken": "^9.0.9",
 | 
					        "@types/jsonwebtoken": "^9.0.9",
 | 
				
			||||||
        "@types/lodash": "^4.14.191",
 | 
					        "@types/lodash": "^4.14.191",
 | 
				
			||||||
        "@types/react-redux": "^7.1.32",
 | 
					        "@types/react-redux": "^7.1.32",
 | 
				
			||||||
 | 
				
			|||||||
@ -1,27 +1,46 @@
 | 
				
			|||||||
import { NextApiRequest, NextApiResponse } from "next";
 | 
					// pages/api/auth/me.ts
 | 
				
			||||||
import jwt from "jsonwebtoken";
 | 
					import type { NextApiRequest, NextApiResponse } from "next";
 | 
				
			||||||
 | 
					import jwt, { JwtPayload } from "jsonwebtoken";
 | 
				
			||||||
import { PrismaClient } from "@prisma/client";
 | 
					import { PrismaClient } from "@prisma/client";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const prisma = new PrismaClient();
 | 
					const prisma = new PrismaClient();
 | 
				
			||||||
const SECRET_KEY = process.env.JWT_SECRET as string;
 | 
					const SECRET_KEY = process.env.JWT_SECRET as string;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function readCookieToken(req: NextApiRequest) {
 | 
				
			||||||
 | 
					  const cookie = req.headers.cookie || "";
 | 
				
			||||||
 | 
					  const match = cookie.split("; ").find((c) => c.startsWith("token="));
 | 
				
			||||||
 | 
					  return match?.split("=")[1];
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function readAuthBearer(req: NextApiRequest) {
 | 
				
			||||||
 | 
					  const auth = req.headers.authorization;
 | 
				
			||||||
 | 
					  if (!auth?.startsWith("Bearer ")) return undefined;
 | 
				
			||||||
 | 
					  return auth.slice("Bearer ".length);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function hasEmail(payload: string | JwtPayload): payload is JwtPayload & { email: string } {
 | 
				
			||||||
 | 
					  return typeof payload === "object" && payload !== null && typeof (payload as any).email === "string";
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
 | 
					export default async function handler(req: NextApiRequest, res: NextApiResponse) {
 | 
				
			||||||
  const authHeader = req.headers.authorization;
 | 
					  if (req.method !== "GET") return res.status(405).json({ message: "Method not allowed" });
 | 
				
			||||||
 | 
					 | 
				
			||||||
  if (!authHeader || !authHeader.startsWith("Bearer ")) {
 | 
					 | 
				
			||||||
    return res.status(401).json({ message: "Unauthorized" });
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const token = authHeader.split(" ")[1]; // Extract token
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  try {
 | 
					  try {
 | 
				
			||||||
    const decoded: any = jwt.verify(token, SECRET_KEY);
 | 
					    const token = readAuthBearer(req) ?? readCookieToken(req);
 | 
				
			||||||
    const user = await prisma.user.findUnique({ where: { id: decoded.userId } });
 | 
					    if (!token) return res.status(401).json({ message: "Unauthorized" });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const decoded = jwt.verify(token, SECRET_KEY);
 | 
				
			||||||
 | 
					    if (!hasEmail(decoded)) return res.status(401).json({ message: "Invalid token" });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const user = await prisma.user.findUnique({
 | 
				
			||||||
 | 
					      where: { email: decoded.email },
 | 
				
			||||||
 | 
					      select: { id: true, email: true, createdAt: true },
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (!user) return res.status(401).json({ message: "User not found" });
 | 
					    if (!user) return res.status(401).json({ message: "User not found" });
 | 
				
			||||||
 | 
					    return res.status(200).json({ user });
 | 
				
			||||||
    res.json({ user });
 | 
					  } catch {
 | 
				
			||||||
  } catch (error) {
 | 
					    return res.status(401).json({ message: "Invalid token" });
 | 
				
			||||||
    res.status(401).json({ message: "Invalid token" });
 | 
					 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -1,15 +1,18 @@
 | 
				
			|||||||
import { NextApiRequest, NextApiResponse } from "next";
 | 
					// pages/api/login.ts
 | 
				
			||||||
 | 
					import type { NextApiRequest, NextApiResponse } from "next";
 | 
				
			||||||
import { PrismaClient } from "@prisma/client";
 | 
					import { PrismaClient } from "@prisma/client";
 | 
				
			||||||
import bcrypt from "bcrypt";
 | 
					import bcrypt from "bcrypt";
 | 
				
			||||||
import jwt from "jsonwebtoken";
 | 
					import jwt from "jsonwebtoken";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const prisma = new PrismaClient()
 | 
					const prisma = new PrismaClient();
 | 
				
			||||||
const SECRET_KEY = process.env.JWT_SECRET as string;
 | 
					const SECRET_KEY = process.env.JWT_SECRET as string;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
 | 
					export default async function handler(req: NextApiRequest, res: NextApiResponse) {
 | 
				
			||||||
  if (req.method !== "POST") return res.status(405).json({ message: "Method not allowed" });
 | 
					  if (req.method !== "POST") return res.status(405).json({ message: "Method not allowed" });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const { email, password } = req.body;
 | 
					  try {
 | 
				
			||||||
 | 
					    const { email, password } = req.body as { email?: string; password?: string };
 | 
				
			||||||
 | 
					    if (!email || !password) return res.status(400).json({ message: "Email and password are required" });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const user = await prisma.user.findUnique({ where: { email } });
 | 
					    const user = await prisma.user.findUnique({ where: { email } });
 | 
				
			||||||
    if (!user) return res.status(401).json({ message: "Invalid credentials" });
 | 
					    if (!user) return res.status(401).json({ message: "Invalid credentials" });
 | 
				
			||||||
@ -17,8 +20,23 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
 | 
				
			|||||||
    const isMatch = await bcrypt.compare(password, user.password);
 | 
					    const isMatch = await bcrypt.compare(password, user.password);
 | 
				
			||||||
    if (!isMatch) return res.status(401).json({ message: "Invalid credentials" });
 | 
					    if (!isMatch) return res.status(401).json({ message: "Invalid credentials" });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const token = jwt.sign({ email: user.email }, SECRET_KEY, { expiresIn: "1d" });
 | 
					    const token = jwt.sign({ sub: String(user.id), email: user.email }, SECRET_KEY, { expiresIn: "1d" });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    res.setHeader("Set-Cookie", `token=${token}; HttpOnly; Path=/; Secure`);
 | 
					    const isProd = process.env.NODE_ENV === "production";
 | 
				
			||||||
    res.json({ token });
 | 
					    const cookie = [
 | 
				
			||||||
 | 
					      `token=${token}`,
 | 
				
			||||||
 | 
					      "HttpOnly",
 | 
				
			||||||
 | 
					      "Path=/",
 | 
				
			||||||
 | 
					      "SameSite=Strict",
 | 
				
			||||||
 | 
					      `Max-Age=${60 * 60 * 24}`, // 1 day
 | 
				
			||||||
 | 
					      isProd ? "Secure" : "",    // only secure in prod
 | 
				
			||||||
 | 
					    ].filter(Boolean).join("; ");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    res.setHeader("Set-Cookie", cookie);
 | 
				
			||||||
 | 
					    return res.status(200).json({ message: "Login successful" });
 | 
				
			||||||
 | 
					  } catch (e) {
 | 
				
			||||||
 | 
					    console.error(e);
 | 
				
			||||||
 | 
					    return res.status(500).json({ message: "Something went wrong" });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										20
									
								
								pages/api/logout.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								pages/api/logout.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,20 @@
 | 
				
			|||||||
 | 
					// pages/api/auth/logout.ts
 | 
				
			||||||
 | 
					import type { NextApiRequest, NextApiResponse } from "next";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default function handler(req: NextApiRequest, res: NextApiResponse) {
 | 
				
			||||||
 | 
					  const isProd = process.env.NODE_ENV === "production";
 | 
				
			||||||
 | 
					  res.setHeader(
 | 
				
			||||||
 | 
					    "Set-Cookie",
 | 
				
			||||||
 | 
					    [
 | 
				
			||||||
 | 
					      "token=", // empty token
 | 
				
			||||||
 | 
					      "HttpOnly",
 | 
				
			||||||
 | 
					      "Path=/",
 | 
				
			||||||
 | 
					      "SameSite=Strict",
 | 
				
			||||||
 | 
					      "Max-Age=0", // expire immediately
 | 
				
			||||||
 | 
					      isProd ? "Secure" : "",
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					      .filter(Boolean)
 | 
				
			||||||
 | 
					      .join("; ")
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					  return res.status(200).json({ message: "Logged out" });
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -1,16 +1,19 @@
 | 
				
			|||||||
import { NextApiRequest, NextApiResponse } from "next";
 | 
					// pages/api/register.ts
 | 
				
			||||||
 | 
					import type { NextApiRequest, NextApiResponse } from "next";
 | 
				
			||||||
import { PrismaClient } from "@prisma/client";
 | 
					import { PrismaClient } from "@prisma/client";
 | 
				
			||||||
import bcrypt from "bcrypt";
 | 
					import bcrypt from "bcrypt";
 | 
				
			||||||
import jwt from "jsonwebtoken";
 | 
					import jwt from "jsonwebtoken";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const prisma = new PrismaClient()
 | 
					const prisma = new PrismaClient();
 | 
				
			||||||
const SECRET_KEY = process.env.JWT_SECRET as string;
 | 
					const SECRET_KEY = process.env.JWT_SECRET as string;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 | 
				
			||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
 | 
					export default async function handler(req: NextApiRequest, res: NextApiResponse) {
 | 
				
			||||||
  if (req.method !== "POST") return res.status(405).json({ message: "Method not allowed" });
 | 
					  if (req.method !== "POST") return res.status(405).json({ message: "Method not allowed" });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const { email, password } = req.body;
 | 
					  try {
 | 
				
			||||||
 | 
					    const { email, password } = req.body as { email?: string; password?: string };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!email || !password) return res.status(400).json({ message: "Email and password are required" });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const existingUser = await prisma.user.findUnique({ where: { email } });
 | 
					    const existingUser = await prisma.user.findUnique({ where: { email } });
 | 
				
			||||||
    if (existingUser) return res.status(400).json({ message: "User already exists" });
 | 
					    if (existingUser) return res.status(400).json({ message: "User already exists" });
 | 
				
			||||||
@ -18,10 +21,22 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
 | 
				
			|||||||
    const hashedPassword = await bcrypt.hash(password, 10);
 | 
					    const hashedPassword = await bcrypt.hash(password, 10);
 | 
				
			||||||
    const user = await prisma.user.create({
 | 
					    const user = await prisma.user.create({
 | 
				
			||||||
      data: { email, password: hashedPassword },
 | 
					      data: { email, password: hashedPassword },
 | 
				
			||||||
 | 
					      select: { id: true, email: true, createdAt: true }, // do NOT expose password
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const token = jwt.sign({ email: user.email }, SECRET_KEY, { expiresIn: "1d" });
 | 
					    const token = jwt.sign({ sub: String(user.id), email: user.email }, SECRET_KEY, { expiresIn: "1d" });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    res.setHeader("Set-Cookie", `token=${token}; HttpOnly; Path=/; Secure`);
 | 
					    // Set a secure, httpOnly cookie
 | 
				
			||||||
    res.status(201).json({ message: "User registered", user, token });
 | 
					    const maxAge = 60 * 60 * 24; // 1 day
 | 
				
			||||||
 | 
					    res.setHeader(
 | 
				
			||||||
 | 
					      "Set-Cookie",
 | 
				
			||||||
 | 
					      `token=${token}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Strict; Secure`
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return res.status(201).json({ message: "User registered", user });
 | 
				
			||||||
 | 
					  } catch (err) {
 | 
				
			||||||
 | 
					    console.error(err);
 | 
				
			||||||
 | 
					    return res.status(500).json({ message: "Something went wrong" });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
										
											Binary file not shown.
										
									
								
							| 
		 Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 91 KiB  | 
| 
		 Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 84 KiB  | 
@ -449,6 +449,7 @@ hover:text-primary hover:before:!bg-primary ltr:before:mr-2 rtl:before:ml-2 dark
 | 
				
			|||||||
    /* dropdown */
 | 
					    /* dropdown */
 | 
				
			||||||
    .dropdown {
 | 
					    .dropdown {
 | 
				
			||||||
        @apply relative;
 | 
					        @apply relative;
 | 
				
			||||||
 | 
					        @apply z-50;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    .dropdown > button {
 | 
					    .dropdown > button {
 | 
				
			||||||
        @apply flex;
 | 
					        @apply flex;
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										14
									
								
								types/crm.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								types/crm.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,14 @@
 | 
				
			|||||||
 | 
					// src/types/crm.ts
 | 
				
			||||||
 | 
					export interface CrmProject {
 | 
				
			||||||
 | 
					  name: string;                 // e.g. PROJ-0008
 | 
				
			||||||
 | 
					  project_name: string;         // display title
 | 
				
			||||||
 | 
					  status?: string;              // "Open" | ...
 | 
				
			||||||
 | 
					  percent_complete?: number;
 | 
				
			||||||
 | 
					  owner?: string;
 | 
				
			||||||
 | 
					  modified?: string;            // ISO or "YYYY-MM-DD HH:mm:ss"
 | 
				
			||||||
 | 
					  customer?: string;
 | 
				
			||||||
 | 
					  project_type?: string;
 | 
				
			||||||
 | 
					  custom_address?: string | null;
 | 
				
			||||||
 | 
					  custom_email?: string | null;
 | 
				
			||||||
 | 
					  custom_mobile_phone_no?: string | null;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user