Compare commits
	
		
			No commits in common. "f1836c4247d4f76ef49cbd4c4223587313f0d5c9" and "81a00d72e443ae28c26de3e1dc40c3fb0452cc9c" have entirely different histories.
		
	
	
		
			f1836c4247
			...
			81a00d72e4
		
	
		
| @ -1,37 +0,0 @@ | |||||||
| 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,434 +1,214 @@ | |||||||
| 'use client'; | 'use client'; | ||||||
| 
 | 
 | ||||||
| import { useState, useEffect, useMemo, useRef } from 'react'; | import { useState, useEffect, 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'), { ssr: false }); | const EnergyLineChart = dynamic(() => import('@/components/dashboards/EnergyLineChart'), { | ||||||
| const MonthlyBarChart  = dynamic(() => import('@/components/dashboards/MonthlyBarChart'), { ssr: false }); |   ssr: false, | ||||||
|  | }); | ||||||
| 
 | 
 | ||||||
| type MonthlyKPI = { | const MonthlyBarChart = dynamic(() => import('@/components/dashboards/MonthlyBarChart'), { | ||||||
|   site: string; month: string; |   ssr: false, | ||||||
|   yield_kwh: number | null; consumption_kwh: number | null; grid_draw_kwh: number | null; | }); | ||||||
|   efficiency: number | null; peak_demand_kw: number | null; |  | ||||||
|   avg_power_factor: number | null; load_factor: number | null; |  | ||||||
|   error?: string; |  | ||||||
| }; |  | ||||||
| 
 | 
 | ||||||
| type CrmProject = { | import { SiteName, SiteDetails, mockSiteData } from '@/types/SiteData'; | ||||||
|   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', | ||||||
|  | }; | ||||||
| 
 | 
 | ||||||
|   // --- load CRM projects dynamically ---
 |   const siteParam = searchParams?.get('site'); | ||||||
|   const [sites, setSites] = useState<CrmProject[]>([]); |   const validSiteNames: SiteName[] = ['Site A', 'Site B', 'Site C']; | ||||||
|   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>(() => { | ||||||
|   useEffect(() => { |     if (siteParam && validSiteNames.includes(siteParam as SiteName)) { | ||||||
|     setSitesLoading(true); |       return siteParam as SiteName; | ||||||
|     fetch(`${API}/crm/projects?limit=0`) |  | ||||||
|       .then(r => r.json()) |  | ||||||
|       .then(json => setSites(json?.data ?? [])) |  | ||||||
|       .catch(setSitesError) |  | ||||||
|       .finally(() => setSitesLoading(false)); |  | ||||||
|   }, []); |  | ||||||
| 
 |  | ||||||
|   // The canonical siteId is the CRM Project "name" (e.g., PROJ-0008)
 |  | ||||||
|   const siteParam = searchParams?.get('site') || null; |  | ||||||
|   const [selectedSiteId, setSelectedSiteId] = useState<string | null>(siteParam); |  | ||||||
| 
 |  | ||||||
|   // Keep query param <-> state in sync
 |  | ||||||
|   useEffect(() => { |  | ||||||
|     if ((siteParam || null) !== selectedSiteId) { |  | ||||||
|       setSelectedSiteId(siteParam); |  | ||||||
|     } |     } | ||||||
|   }, [siteParam]); // eslint-disable-line
 |     return 'Site A'; | ||||||
|  |   }); | ||||||
| 
 | 
 | ||||||
|   // Default to the first site when loaded
 |   // Keep siteParam and selectedSite in sync
 | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     if (!selectedSiteId && sites.length) { |     if ( | ||||||
|       setSelectedSiteId(sites[0].name); |       siteParam && | ||||||
|       router.replace(`${pathname}?site=${encodeURIComponent(sites[0].name)}`); |       validSiteNames.includes(siteParam as SiteName) && | ||||||
|  |       siteParam !== selectedSite | ||||||
|  |     ) { | ||||||
|  |       setSelectedSite(siteParam as SiteName); | ||||||
|     } |     } | ||||||
|   }, [sites, selectedSiteId, pathname, router]); |   }, [siteParam, selectedSite]); | ||||||
| 
 | 
 | ||||||
|   // 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(() => { | ||||||
|     if (!selectedSiteId) return; |   const fetchData = async () => { | ||||||
| 
 | 
 | ||||||
|     const fetchToday = async () => { |   const siteId = siteIdMap[selectedSite]; | ||||||
|       const { start, end } = withTZ(new Date()); |   const today = new Date(); | ||||||
| 
 | 
 | ||||||
|       try { |   // Format to YYYY-MM-DD
 | ||||||
|         const raw = await fetchPowerTimeseries(selectedSiteId, start, end); |   const yyyyMMdd = today.toISOString().split('T')[0]; | ||||||
|         const consumption = raw.consumption.map((d: any) => ({ time: d.time, value: d.value })); |  | ||||||
|         const generation  = raw.generation.map((d: any) => ({ time: d.time, value: d.value })); |  | ||||||
|         setTimeSeriesData({ consumption, generation }); |  | ||||||
| 
 | 
 | ||||||
|         const anyToday = (consumption?.length ?? 0) > 0 || (generation?.length ?? 0) > 0; |   // Append Malaysia's +08:00 time zone manually
 | ||||||
|         setHasTodayData(anyToday); |   const start = `${yyyyMMdd}T00:00:00+08:00`; | ||||||
|       } catch (error) { |   const end = `${yyyyMMdd}T23:59:59+08:00`; | ||||||
|         console.error('Failed to fetch power time series:', error); |    | ||||||
|         setHasTodayData(false); |  | ||||||
|       } |  | ||||||
|     }; |  | ||||||
| 
 |  | ||||||
|     fetchToday(); |  | ||||||
|   }, [selectedSiteId]); |  | ||||||
| 
 |  | ||||||
|   // Check historical data (last 30 days) → controls empty state
 |  | ||||||
|   useEffect(() => { |  | ||||||
|     if (!selectedSiteId) return; |  | ||||||
| 
 |  | ||||||
|     const fetchHistorical = async () => { |  | ||||||
|       try { |  | ||||||
|         const endDate = new Date(); |  | ||||||
|         const startDate = new Date(); |  | ||||||
|         startDate.setDate(endDate.getDate() - 30); |  | ||||||
| 
 |  | ||||||
|         const startISO = `${startDate.toISOString().split('T')[0]}T00:00:00+08:00`; |  | ||||||
|         const endISO   = `${endDate.toISOString().split('T')[0]}T23:59:59+08:00`; |  | ||||||
| 
 |  | ||||||
|         const raw = await fetchPowerTimeseries(selectedSiteId, startISO, endISO); |  | ||||||
|         const anyHistorical = |  | ||||||
|           (raw?.consumption?.length ?? 0) > 0 || |  | ||||||
|           (raw?.generation?.length ?? 0) > 0; |  | ||||||
| 
 |  | ||||||
|         setHasAnyData(anyHistorical); |  | ||||||
|       } catch (e) { |  | ||||||
|         console.error('Failed to check historical data:', e); |  | ||||||
|         setHasAnyData(false); |  | ||||||
|       } |  | ||||||
|     }; |  | ||||||
| 
 |  | ||||||
|     fetchHistorical(); |  | ||||||
|   }, [selectedSiteId]); |  | ||||||
| 
 |  | ||||||
|   // --- KPI monthly ---
 |  | ||||||
|   const [kpi, setKpi] = useState<MonthlyKPI | null>(null); |  | ||||||
| 
 |  | ||||||
|   useEffect(() => { |  | ||||||
|     if (!selectedSiteId) return; |  | ||||||
|     const url = `${API}/kpi/monthly?site=${encodeURIComponent(selectedSiteId)}&month=${currentMonth}`; |  | ||||||
|     fetch(url).then(r => r.json()).then(setKpi).catch(console.error); |  | ||||||
|   }, [selectedSiteId, currentMonth]); |  | ||||||
| 
 |  | ||||||
|   // derived values with safe fallbacks
 |  | ||||||
|   const yieldKwh       = kpi?.yield_kwh ?? 0; |  | ||||||
|   const consumptionKwh = kpi?.consumption_kwh ?? 0; |  | ||||||
|   const gridDrawKwh    = kpi?.grid_draw_kwh ?? Math.max(0, consumptionKwh - yieldKwh); |  | ||||||
|   const efficiencyPct  = (kpi?.efficiency ?? 0) * 100; |  | ||||||
|   const powerFactor    = kpi?.avg_power_factor ?? 0; |  | ||||||
|   const loadFactor     = (kpi?.load_factor ?? 0); |  | ||||||
| 
 |  | ||||||
|   // Update URL when site is changed manually (expects a siteId/Project.name)
 |  | ||||||
|   const handleSiteChange = (newSiteId: string) => { |  | ||||||
|     setSelectedSiteId(newSiteId); |  | ||||||
|     const newUrl = `${pathname}?site=${encodeURIComponent(newSiteId)}`; |  | ||||||
|     router.push(newUrl); |  | ||||||
|     // reset flags when switching
 |  | ||||||
|     setHasAnyData(false); |  | ||||||
|     setHasTodayData(false); |  | ||||||
|     setIsLogging(false); |  | ||||||
|     setStartError(null); |  | ||||||
|   }; |  | ||||||
| 
 |  | ||||||
|   const locationFormatted = useMemo(() => { |  | ||||||
|     const raw = selectedProject?.custom_address ?? ''; |  | ||||||
|     if (!raw) return 'N/A'; |  | ||||||
|     return formatAddress(raw).multiLine; |  | ||||||
|   }, [selectedProject?.custom_address]); |  | ||||||
| 
 |  | ||||||
|   const lastSyncFormatted = useMemo( |  | ||||||
|     () => formatCrmTimestamp(selectedProject?.modified, { includeSeconds: true }), |  | ||||||
|     [selectedProject?.modified] |  | ||||||
|   ); |  | ||||||
| 
 |  | ||||||
|   // Adapt CRM project -> SiteStatus props
 |  | ||||||
|   const currentSiteDetails = { |  | ||||||
|     location: locationFormatted, |  | ||||||
|     inverterProvider: selectedProject?.project_type || 'N/A', |  | ||||||
|     emergencyContact: |  | ||||||
|       selectedProject?.custom_mobile_phone_no || |  | ||||||
|       selectedProject?.custom_email || |  | ||||||
|       selectedProject?.customer || |  | ||||||
|       'N/A', |  | ||||||
|     lastSyncTimestamp: lastSyncFormatted || 'N/A', |  | ||||||
|   }; |  | ||||||
| 
 |  | ||||||
|   const energyChartRef = useRef<HTMLDivElement | null>(null); |  | ||||||
|   const monthlyChartRef = useRef<HTMLDivElement | null>(null); |  | ||||||
| 
 |  | ||||||
|   const handlePDFExport = async () => { |  | ||||||
|     const doc = new jsPDF('p', 'mm', 'a4'); |  | ||||||
|     const chartRefs = [ |  | ||||||
|       { ref: energyChartRef,  title: 'Energy Line Chart' }, |  | ||||||
|       { ref: monthlyChartRef, title: 'Monthly Energy Yield' } |  | ||||||
|     ]; |  | ||||||
| 
 |  | ||||||
|     let yOffset = 10; |  | ||||||
| 
 |  | ||||||
|     for (const chart of chartRefs) { |  | ||||||
|       if (!chart.ref.current) continue; |  | ||||||
|       const canvas = await html2canvas(chart.ref.current, { scale: 2 }); |  | ||||||
|       const imgData = canvas.toDataURL('image/png'); |  | ||||||
|       const imgProps = doc.getImageProperties(imgData); |  | ||||||
|       const pdfWidth = doc.internal.pageSize.getWidth() - 20; |  | ||||||
|       const imgHeight = (imgProps.height * pdfWidth) / imgProps.width; |  | ||||||
| 
 |  | ||||||
|       doc.setFontSize(14); |  | ||||||
|       doc.text(chart.title, 10, yOffset); |  | ||||||
|       yOffset += 6; |  | ||||||
| 
 |  | ||||||
|       if (yOffset + imgHeight > doc.internal.pageSize.getHeight()) { |  | ||||||
|         doc.addPage(); |  | ||||||
|         yOffset = 10; |  | ||||||
|       } |  | ||||||
| 
 |  | ||||||
|       doc.addImage(imgData, 'PNG', 10, yOffset, pdfWidth, imgHeight); |  | ||||||
|       yOffset += imgHeight + 10; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     doc.save('dashboard_charts.pdf'); |  | ||||||
|   }; |  | ||||||
| 
 |  | ||||||
|   // Start logging then poll for data until it shows up
 |  | ||||||
|   const startLogging = async () => { |  | ||||||
|     if (!selectedSiteId) return; |  | ||||||
|     setIsLogging(true); |  | ||||||
|     setStartError(null); |  | ||||||
| 
 | 
 | ||||||
|     try { |     try { | ||||||
|       const resp = await fetch(START_LOGGING_ENDPOINT(selectedSiteId), { |       const raw = await fetchPowerTimeseries(siteId, start, end); | ||||||
|         method: 'POST', |  | ||||||
|         headers: { 'Content-Type': 'application/json' }, |  | ||||||
|       }); |  | ||||||
| 
 | 
 | ||||||
|       if (!resp.ok) { |     const consumption = raw.consumption.map(d => ({ | ||||||
|         const text = await resp.text(); |       time: d.time, | ||||||
|         throw new Error(text || `Failed with status ${resp.status}`); |       value: d.value, | ||||||
|       } |     })); | ||||||
| 
 | 
 | ||||||
|       // Poll for data for up to ~45s (15 tries x 3s)
 |     const generation = raw.generation.map(d => ({ | ||||||
|       for (let i = 0; i < 15; i++) { |       time: d.time, | ||||||
|         const today = new Date(); |       value: d.value, | ||||||
|         const { start, end } = withTZ(today); |     }));   | ||||||
| 
 | 
 | ||||||
|         try { |     setTimeSeriesData({ consumption, generation }); | ||||||
|           const raw = await fetchPowerTimeseries(selectedSiteId, start, end); | 
 | ||||||
|           const consumption = raw.consumption ?? []; |     } catch (error) { | ||||||
|           const generation  = raw.generation ?? []; |       console.error('Failed to fetch power time series:', error); | ||||||
|           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 ----------
 |   fetchData(); | ||||||
|   if (sitesLoading) { | }, [selectedSite]); | ||||||
|     return ( | 
 | ||||||
|       <DashboardLayout> |   // Update query string when site is changed manually
 | ||||||
|         <div className="px-6">Loading sites…</div> |   const handleSiteChange = (newSite: SiteName) => { | ||||||
|       </DashboardLayout> |     setSelectedSite(newSite); | ||||||
|     ); |     const newUrl = `${pathname}?site=${encodeURIComponent(newSite)}`; | ||||||
|   } |     router.push(newUrl); | ||||||
|   if (sitesError) { |   }; | ||||||
|     return ( | 
 | ||||||
|       <DashboardLayout> |   const currentSiteDetails: SiteDetails = mockSiteData[selectedSite] || { | ||||||
|         <div className="px-6 text-red-600">Failed to load sites from CRM.</div> |     location: 'N/A', | ||||||
|       </DashboardLayout> |     inverterProvider: 'N/A', | ||||||
|     ); |     emergencyContact: 'N/A', | ||||||
|   } |     lastSyncTimestamp: 'N/A', | ||||||
|   if (!selectedProject) { |     consumptionData: [], | ||||||
|     return ( |     generationData: [], | ||||||
|       <DashboardLayout> |     systemStatus: 'N/A', | ||||||
|         <div className="px-6">No site selected.</div> |     temperature: 'N/A', | ||||||
|       </DashboardLayout> |     solarPower: 0, | ||||||
|     ); |     realTimePower: 0, | ||||||
|  |     installedPower: 0, | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   const handleCSVExport = () => { | ||||||
|  |     alert('Exported raw data to CSV (mock)'); | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   const energyChartRef = useRef(null); | ||||||
|  |   const monthlyChartRef = useRef(null); | ||||||
|  | 
 | ||||||
|  |   const handlePDFExport = async () => { | ||||||
|  |   const doc = new jsPDF('p', 'mm', 'a4'); // portrait, millimeters, A4
 | ||||||
|  |   const chartRefs = [ | ||||||
|  |     { ref: energyChartRef, title: 'Energy Line Chart' }, | ||||||
|  |     { ref: monthlyChartRef, title: 'Monthly Energy Yield' } | ||||||
|  |   ]; | ||||||
|  | 
 | ||||||
|  |   let yOffset = 10; | ||||||
|  | 
 | ||||||
|  |   for (const chart of chartRefs) { | ||||||
|  |     if (!chart.ref.current) continue; | ||||||
|  | 
 | ||||||
|  |     // 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 imgProps = doc.getImageProperties(imgData); | ||||||
|  | 
 | ||||||
|  |     const pdfWidth = doc.internal.pageSize.getWidth() - 20; // 10 margin each side
 | ||||||
|  |     const imgHeight = (imgProps.height * pdfWidth) / imgProps.width; | ||||||
|  | 
 | ||||||
|  |     // Add title and image
 | ||||||
|  |     doc.setFontSize(14); | ||||||
|  |     doc.text(chart.title, 10, yOffset); | ||||||
|  |     yOffset += 6; // Space between title and chart
 | ||||||
|  | 
 | ||||||
|  |     // If content will overflow page, add a new page
 | ||||||
|  |     if (yOffset + imgHeight > doc.internal.pageSize.getHeight()) { | ||||||
|  |       doc.addPage(); | ||||||
|  |       yOffset = 10; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     doc.addImage(imgData, 'PNG', 10, yOffset, pdfWidth, imgHeight); | ||||||
|  |     yOffset += imgHeight + 10; // Update offset for next chart
 | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   // Build selector options from CRM
 |   doc.save('dashboard_charts.pdf'); | ||||||
|   const siteOptions = sites.map(s => ({ | }; | ||||||
|     label: s.project_name || s.name, |   const currentMonth = new Date().toISOString().slice(0, 7); // "YYYY-MM"
 | ||||||
|     value: s.name, |  | ||||||
|   })); |  | ||||||
| 
 | 
 | ||||||
|   return ( |   return ( | ||||||
|     <DashboardLayout> |     <DashboardLayout> | ||||||
|       <div className="px-3 space-y-6 w-full max-w-screen-3xl mx-auto"> |       <div className="px-6 space-y-6"> | ||||||
|         <h1 className="text-lg font-semibold dark:text-white">Admin Dashboard</h1> |         <h1 className="text-lg font-semibold dark:text-white">Admin Dashboard</h1> | ||||||
| 
 | 
 | ||||||
|         {/* Selector + status */} |         <div className="grid md:grid-cols-2 gap-6"> | ||||||
|         <div className="grid grid-cols-1 gap-6 w-full min-w-0"> |           <div className="space-y-4"> | ||||||
|           <div className="space-y-4 w-full min-w-0"> |  | ||||||
|             <SiteSelector |             <SiteSelector | ||||||
|               options={siteOptions} |               selectedSite={selectedSite} | ||||||
|               selectedValue={selectedSiteId!} |               setSelectedSite={handleSiteChange} | ||||||
|               onChange={handleSiteChange} |  | ||||||
|             /> |             /> | ||||||
| 
 |  | ||||||
|             <SiteStatus |             <SiteStatus | ||||||
|               selectedSite={selectedProject.project_name || selectedProject.name} |               selectedSite={selectedSite} | ||||||
|               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> | ||||||
| 
 | 
 | ||||||
|         {/* Small dark yellow banner when there is ZERO historical data */} |         <div className="grid md:grid-cols-2 gap-6 lg:flex-col justify-center"> | ||||||
|         {!hasAnyData && ( |           <div ref={energyChartRef} className="pb-5"> | ||||||
|           <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"> |             <EnergyLineChart siteId={siteIdMap[selectedSite]} /> | ||||||
|             <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> | ||||||
|         )} |           <div ref={monthlyChartRef} className="pb-5"> | ||||||
| 
 |             <MonthlyBarChart siteId={siteIdMap[selectedSite]} /> | ||||||
|         <div ref={loggingRef}> |           </div> | ||||||
|           <LoggingControlCard |  | ||||||
|             siteId={selectedProject.name} |  | ||||||
|             projectLabel={selectedProject.project_name || selectedProject.name} |  | ||||||
|             className="w-full" |  | ||||||
|           /> |  | ||||||
|         </div> |         </div> | ||||||
| 
 | 
 | ||||||
|         {/* Render the rest only if there is *any* data */} |         <div className="flex flex-col md:flex-row gap-4 justify-center"> | ||||||
|         {hasAnyData && ( |           <button onClick={handlePDFExport} className="text-sm lg:text-lg btn-primary"> | ||||||
|           <> |             Export Chart Images to PDF | ||||||
|             {/* Tiny banner if today is empty but historical exists */} |           </button> | ||||||
|             {!hasTodayData && ( |         </div> | ||||||
|               <div className="rounded-lg border border-amber-300/50 bg-amber-50 dark:bg-amber-900/20 px-4 py-2 text-amber-800 dark:text-amber-200"> |  | ||||||
|                 No data yet today — charts may be blank until new points arrive. |  | ||||||
|               </div> |  | ||||||
|             )} |  | ||||||
|              |  | ||||||
| 
 |  | ||||||
|             {/* TOP 3 CARDS */} |  | ||||||
|             <div className="space-y-4"> |  | ||||||
|               <KpiTop |  | ||||||
|                 yieldKwh={yieldKwh} |  | ||||||
|                 consumptionKwh={consumptionKwh} |  | ||||||
|                 gridDrawKwh={gridDrawKwh} |  | ||||||
|               /> |  | ||||||
|             </div> |  | ||||||
| 
 |  | ||||||
|             <div ref={energyChartRef} className="pb-5"> |  | ||||||
|               <EnergyLineChart siteId={selectedProject.name} /> |  | ||||||
|             </div> |  | ||||||
| 
 |  | ||||||
|             {/* BOTTOM 3 PANELS */} |  | ||||||
|             <KpiBottom |  | ||||||
|               efficiencyPct={efficiencyPct} |  | ||||||
|               powerFactor={powerFactor} |  | ||||||
|               loadFactor={loadFactor} |  | ||||||
|               middle={ |  | ||||||
|                 <div ref={monthlyChartRef} className="transform scale-90 origin-top"> |  | ||||||
|                   <MonthlyBarChart siteId={selectedProject.name} /> |  | ||||||
|                 </div> |  | ||||||
|               } |  | ||||||
|               right={ |  | ||||||
|                 <div className="flex items-center justify-center w-full px-3 text-center"> |  | ||||||
|                   <div className="text-3xl font-semibold"> |  | ||||||
|                     {(kpi?.peak_demand_kw ?? 0).toFixed(2)} kW |  | ||||||
|                   </div> |  | ||||||
|                 </div> |  | ||||||
|               } |  | ||||||
|             /> |  | ||||||
| 
 |  | ||||||
|             <div className="flex flex-col md:flex-row gap-4 justify-center"> |  | ||||||
|               <button onClick={handlePDFExport} className="text-sm lg:text-lg btn-primary"> |  | ||||||
|                 Export Chart Images to PDF |  | ||||||
|               </button> |  | ||||||
|             </div> |  | ||||||
|           </> |  | ||||||
|         )} |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
|       </div> |       </div> | ||||||
|     </DashboardLayout> |     </DashboardLayout> | ||||||
|   ); |   ); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export default AdminDashboard; | export default AdminDashboard; | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | |||||||
| @ -1,187 +1,46 @@ | |||||||
| 'use client'; | 'use client'; | ||||||
| 
 | 
 | ||||||
| import React, { useEffect, useMemo, useState } from 'react'; | import React from 'react'; | ||||||
| import DashboardLayout from '../adminDashboard/dashlayout'; | import DashboardLayout from '../adminDashboard/dashlayout'; | ||||||
| import SiteCard from '@/components/dashboards/SiteCard'; | import SiteCard from '@/components/dashboards/SiteCard'; // Import the new SiteCard component
 | ||||||
| 
 | 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 = () => { | ||||||
|   const [projects, setProjects] = useState<CrmProject[]>([]); |     // Helper function to determine status (can be externalized if used elsewhere)
 | ||||||
|   const [loading, setLoading] = useState(true); |     const getSiteStatus = (siteName: SiteName): string => { | ||||||
|   const [err, setErr] = useState<string | null>(null); |         const statusMap: Record<SiteName, string> = { | ||||||
|   const [q, setQ] = useState('');             // search filter
 |             'Site A': 'Active', | ||||||
| 
 |             'Site B': 'Inactive', | ||||||
|   // pagination
 |             'Site C': 'Faulty', | ||||||
|   const [page, setPage] = useState(1); |         }; | ||||||
|   const [pageSize, setPageSize] = useState(6); // tweak as you like
 |         return statusMap[siteName]; | ||||||
| 
 |  | ||||||
|   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
 |     return ( | ||||||
|   useEffect(() => { |         <DashboardLayout> | ||||||
|     setPage(1); |             <div className="p-6 space-y-6"> | ||||||
|   }, [q, pageSize]); |                 <h1 className="text-2xl font-bold mb-6 dark:text-white-light">All Sites Overview</h1> | ||||||
| 
 | 
 | ||||||
|   const filtered = useMemo(() => { |                 <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> | ||||||
|     if (!q.trim()) return projects; |                     {/* Iterate over the keys of mockSiteData (which are your SiteNames) */} | ||||||
|     const needle = q.toLowerCase(); |                     {Object.keys(mockSiteData).map((siteNameKey) => { | ||||||
|     return projects.filter(p => |                         const siteName = siteNameKey as SiteName; // Cast to SiteName type
 | ||||||
|       (p.project_name || '').toLowerCase().includes(needle) || |                         const siteDetails = mockSiteData[siteName]; | ||||||
|       (p.name || '').toLowerCase().includes(needle) || |                         const siteStatus = getSiteStatus(siteName); | ||||||
|       (p.customer || '').toLowerCase().includes(needle) | 
 | ||||||
|  |                         return ( | ||||||
|  |                             <SiteCard | ||||||
|  |                                 key={siteName} // Important for React list rendering
 | ||||||
|  |                                 siteName={siteName} | ||||||
|  |                                 details={siteDetails} | ||||||
|  |                                 status={siteStatus} | ||||||
|  |                             /> | ||||||
|  |                         ); | ||||||
|  |                     })} | ||||||
|  |                 </div> | ||||||
|  |             </div> | ||||||
|  |         </DashboardLayout> | ||||||
|     ); |     ); | ||||||
|   }, [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 ( |  | ||||||
|     <DashboardLayout> |  | ||||||
|       <div className="p-6 space-y-6"> |  | ||||||
|         <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="flex items-center gap-3"> |  | ||||||
|             <input |  | ||||||
|               value={q} |  | ||||||
|               onChange={e => setQ(e.target.value)} |  | ||||||
|               placeholder="Search by name / ID / customer" |  | ||||||
|               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" |  | ||||||
|             /> |  | ||||||
|             <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> |  | ||||||
| 
 |  | ||||||
|         {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> |  | ||||||
|   ); |  | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export default SitesPage; | export default SitesPage; | ||||||
| 
 |  | ||||||
							
								
								
									
										20
									
								
								app/api/sites/route.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								app/api/sites/route.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,20 @@ | |||||||
|  | // 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 }); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
| @ -1,20 +0,0 @@ | |||||||
| // 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 }; |  | ||||||
| }  |  | ||||||
| Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 84 KiB | 
| @ -9,18 +9,6 @@ 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, | ||||||
| @ -60,29 +48,3 @@ 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(); |  | ||||||
| } |  | ||||||
|  | |||||||
| @ -1,37 +0,0 @@ | |||||||
| // 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); |  | ||||||
| } |  | ||||||
| @ -1,35 +0,0 @@ | |||||||
| // 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,84 +2,67 @@ | |||||||
| 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); | 
 | ||||||
|     try { |         setLoading(true) | ||||||
|       const res = await axios.post('/api/login', { email, password }); |         try { | ||||||
|       toast.success(res.data?.message || 'Login successful!'); |             const res = await axios.post(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/login`, { | ||||||
|       router.push('/adminDashboard'); |                 email, | ||||||
|       router.refresh(); |                 password, | ||||||
|       // token cookie is already set by the server:
 |             }) | ||||||
|     } catch (err: any) { | 
 | ||||||
|       console.error('Login error:', err); |             localStorage.setItem("token", res.data.token) | ||||||
|       const msg = | 
 | ||||||
|         err?.response?.data?.message || |             toast.success("Login successful!") | ||||||
|         err?.message || |             router.push("/") | ||||||
|         'Invalid credentials'; |         } catch (err: any) { | ||||||
|       toast.error(msg); |             console.error("Login error:", err) | ||||||
|     } finally { |             toast.error(err.response?.data?.error || "Invalid credentials") | ||||||
|       setLoading(false); |         } finally { | ||||||
|  |             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 |                     <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Enter Email" className="form-input ps-10 placeholder:text-white-dark" /> | ||||||
|             id="Email" |                     <span className="absolute start-4 top-1/2 -translate-y-1/2"> | ||||||
|             type="email" |                         <IconMail fill={true} /> | ||||||
|             value={email} |                     </span> | ||||||
|             onChange={(e) => setEmail(e.target.value)} |                 </div> | ||||||
|             placeholder="Enter Email" |             </div> | ||||||
|             className="form-input ps-10 placeholder:text-white-dark" |             <div className= "pb-2"> | ||||||
|             required |                 <label htmlFor="Password" className='text-yellow-400 text-left'>Password</label> | ||||||
|           /> |                 <div className="relative text-white-dark"> | ||||||
|           <span className="absolute start-4 top-1/2 -translate-y-1/2"> |                     <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required placeholder="Enter Password" className="form-input ps-10 placeholder:text-white-dark" /> | ||||||
|             <IconMail fill={true} /> |                     <span className="absolute start-4 top-1/2 -translate-y-1/2"> | ||||||
|           </span> |                         <IconLockDots fill={true} /> | ||||||
|         </div> |                     </span> | ||||||
|       </div> |                 </div> | ||||||
|       <div className="pb-2"> |             </div> | ||||||
|         <label htmlFor="Password" className="text-yellow-400 text-left">Password</label> |             <button | ||||||
|         <div className="relative text-white-dark"> |                 type="submit" | ||||||
|           <input |                 disabled={loading} | ||||||
|             id="Password" |                 className=" w-full uppercase border-0 rounded-md bg-[#fcd913] text-black font-semibold py-2 hover:bg-[#E6C812] transition disabled:opacity-70" | ||||||
|             type="password" |                 > | ||||||
|             value={password} |                 {loading ? "Logging in..." : "Sign In"} | ||||||
|             onChange={(e) => setPassword(e.target.value)} |             </button> | ||||||
|             required |         </form> | ||||||
|             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"> |  | ||||||
|             <IconLockDots fill={true} /> |  | ||||||
|           </span> |  | ||||||
|         </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" |  | ||||||
|       > |  | ||||||
|         {loading ? 'Logging in...' : 'Sign In'} |  | ||||||
|       </button> |  | ||||||
|     </form> |  | ||||||
|   ); |  | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export default ComponentsAuthLoginForm; | export default ComponentsAuthLoginForm; | ||||||
| 
 |  | ||||||
|  | |||||||
| @ -1,131 +1,74 @@ | |||||||
| // components/auth/components-auth-register-form.tsx
 | 'use client'; | ||||||
| "use client"; | import IconLockDots from '@/components/icon/icon-lock-dots'; | ||||||
|  | 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'; | ||||||
| 
 | 
 | ||||||
| import * as React from "react"; | const ComponentsAuthRegisterForm = () => { | ||||||
| import { useRouter } from "next/navigation"; |     const [email, setEmail] = useState("") | ||||||
|  |     const [password, setPassword] = useState("") | ||||||
|  |     const [loading, setLoading] = useState(false) | ||||||
|  |     const router = useRouter() | ||||||
| 
 | 
 | ||||||
| type Props = { |     const submitForm = async(e: any) => { | ||||||
|   redirectTo?: string; // optional override
 |         e.preventDefault() | ||||||
|  | 
 | ||||||
|  |         setLoading(true) | ||||||
|  |         try { | ||||||
|  |             const res = await axios.post(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/register`, { | ||||||
|  |                 email, | ||||||
|  |                 password, | ||||||
|  |             }) | ||||||
|  | 
 | ||||||
|  |             localStorage.setItem("token", res.data.token) | ||||||
|  | 
 | ||||||
|  |             toast.success("Register successful!") | ||||||
|  |             router.push("/") | ||||||
|  |         } catch (err: any) { | ||||||
|  |             console.error("Register error:", err) | ||||||
|  |             toast.error(err.response?.data?.error || "Something went wrong") | ||||||
|  |         } finally { | ||||||
|  |             setLoading(false) | ||||||
|  |         } | ||||||
|  |     }; | ||||||
|  |     return ( | ||||||
|  |         <form className="space-y-5 dark:text-white" onSubmit={submitForm}> | ||||||
|  |             {/* <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> | ||||||
|  |                 <label htmlFor="Email" className='text-yellow-400 text-left'>Email</label> | ||||||
|  |                 <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" /> | ||||||
|  |                     <span className="absolute start-4 top-1/2 -translate-y-1/2"> | ||||||
|  |                         <IconMail fill={true} /> | ||||||
|  |                     </span> | ||||||
|  |                 </div> | ||||||
|  |             </div> | ||||||
|  |             <div className= "pb-2"> | ||||||
|  |                 <label htmlFor="Password" className='text-yellow-400 text-left'>Password</label> | ||||||
|  |                 <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" /> | ||||||
|  |                     <span className="absolute start-4 top-1/2 -translate-y-1/2"> | ||||||
|  |                         <IconLockDots fill={true} /> | ||||||
|  |                     </span> | ||||||
|  |                 </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"> | ||||||
|  |             {loading ? "Creating account..." : "Register"} | ||||||
|  |             </button> | ||||||
|  |         </form> | ||||||
|  |     ); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export default function ComponentsAuthRegisterForm({ redirectTo = "/dashboard" }: Props) { | export default ComponentsAuthRegisterForm; | ||||||
|   const router = useRouter(); |  | ||||||
|   const [email, setEmail] = React.useState(""); |  | ||||||
|   const [password, setPassword] = React.useState(""); |  | ||||||
|   const [confirm, setConfirm] = React.useState(""); |  | ||||||
|   const [loading, setLoading] = React.useState(false); |  | ||||||
|   const [error, setError] = React.useState<string | null>(null); |  | ||||||
| 
 |  | ||||||
|   async function onSubmit(e: React.FormEvent) { |  | ||||||
|     e.preventDefault(); |  | ||||||
|     setError(null); |  | ||||||
| 
 |  | ||||||
|     if (!email.trim() || !password) { |  | ||||||
|       setError("Please fill in all fields."); |  | ||||||
|       return; |  | ||||||
|     } |  | ||||||
|     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 ( |  | ||||||
|     <form onSubmit={onSubmit} className="space-y-4 text-left"> |  | ||||||
|       <div> |  | ||||||
|         <label htmlFor="email" className="mb-1 block text-sm text-gray-300"> |  | ||||||
|           Email |  | ||||||
|         </label> |  | ||||||
|         <input |  | ||||||
|           id="email" |  | ||||||
|           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> |  | ||||||
|         <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> |  | ||||||
|         <label htmlFor="confirm" className="mb-1 block text-sm text-gray-300"> |  | ||||||
|           Confirm Password |  | ||||||
|         </label> |  | ||||||
|         <input |  | ||||||
|           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> |  | ||||||
| 
 |  | ||||||
|       {error && ( |  | ||||||
|         <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> |  | ||||||
|     </form> |  | ||||||
|   ); |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
|  | |||||||
| @ -19,7 +19,6 @@ 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 { | ||||||
| @ -31,48 +30,9 @@ 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[]>(); | ||||||
| 
 | 
 | ||||||
| @ -81,22 +41,19 @@ function groupTimeSeries( | |||||||
|     let key = ''; |     let key = ''; | ||||||
| 
 | 
 | ||||||
|     switch (mode) { |     switch (mode) { | ||||||
|       case 'day': { |       case 'day': | ||||||
|         const local = new Date( |         const local = new Date(date.toLocaleString('en-US', { timeZone: 'Asia/Kuala_Lumpur' })); | ||||||
|           date.toLocaleString('en-US', { timeZone: 'Asia/Kuala_Lumpur' }) |         const hour = local.getHours(); | ||||||
|         ); |         const minute = local.getMinutes() < 30 ? '00' : '30'; | ||||||
|         const minute = local.getMinutes() < 30 ? 0 : 30; |         const adjusted = new Date(local.setMinutes(minute === '00' ? 0 : 30, 0)); // zero seconds
 | ||||||
|         local.setMinutes(minute, 0, 0); |         key = adjusted.toISOString();  // ✅ full timestamp key
 | ||||||
|         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': | ||||||
| @ -114,19 +71,12 @@ 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]) => ({ | ||||||
|     if (agg === 'sum') { |     time, | ||||||
|       const sum = values.reduce((a, b) => a + b, 0); |     value: values.reduce((sum, v) => sum + v, 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'); | ||||||
| @ -135,94 +85,6 @@ 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' | ||||||
| @ -278,7 +140,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, 30.67); |       const forecastData = await fetchForecast(3.15, 101.7, 37, 0, 5.67); | ||||||
|       const selectedDateStr = selectedDate.toISOString().split('T')[0]; |       const selectedDateStr = selectedDate.toISOString().split('T')[0]; | ||||||
| 
 | 
 | ||||||
|       setForecast( |       setForecast( | ||||||
| @ -298,40 +160,9 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => { | |||||||
|     fetchData(); |     fetchData(); | ||||||
|   }, [siteId, viewMode, selectedDate]); |   }, [siteId, viewMode, selectedDate]); | ||||||
| 
 | 
 | ||||||
|   const isEnergyView = viewMode !== 'day'; |   const groupedConsumption = groupTimeSeries(consumption, viewMode); | ||||||
| 
 |   const groupedGeneration = groupTimeSeries(generation, viewMode); | ||||||
| // Convert to energy series for aggregated views
 |   const groupedForecast = groupTimeSeries(forecast, viewMode); | ||||||
| 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])); | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @ -393,22 +224,6 @@ const groupedForecast = groupTimeSeries( | |||||||
| 
 | 
 | ||||||
| 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), | ||||||
| @ -416,29 +231,26 @@ const yTitle = isEnergyView ? 'Energy (kWh)' : 'Power (kW)'; | |||||||
|       { |       { | ||||||
|         label: 'Consumption', |         label: 'Consumption', | ||||||
|         data: filteredConsumption, |         data: filteredConsumption, | ||||||
|         borderColor: consumptionColor, |         borderColor: '#8884d8', | ||||||
|         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: generationColor, |         borderColor: '#82ca9d', | ||||||
|         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: '#fcd913', // orange
 |       borderColor: '#ffa500', // 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: true, |       fill: false, | ||||||
|       spanGaps: true, |       spanGaps: true, | ||||||
|     } |     } | ||||||
|     ], |     ], | ||||||
| @ -471,13 +283,6 @@ const yTitle = isEnergyView ? 'Energy (kWh)' : 'Power (kW)'; | |||||||
|       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: { | ||||||
| @ -504,7 +309,12 @@ const yTitle = isEnergyView ? 'Energy (kWh)' : 'Power (kW)'; | |||||||
|       y: { |       y: { | ||||||
|         beginAtZero: true, |         beginAtZero: true, | ||||||
|         suggestedMax: yAxisSuggestedMax, |         suggestedMax: yAxisSuggestedMax, | ||||||
|         title: { display: true, text: yTitle, color: axisColor, font: { weight: 'normal' as const } }, |         title: { | ||||||
|  |           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; |   peak_demand_kw: number | null; // ✅ new
 | ||||||
|   avg_power_factor: number | null; |   avg_power_factor: number | null; // ✅ new
 | ||||||
|   load_factor: number | null; |   load_factor: number | null; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| @ -22,66 +22,83 @@ 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( |         const res = await fetch(`http://localhost:8000/kpi/monthly?site=${siteId}&month=${month}`); | ||||||
|           `http://localhost:8000/kpi/monthly?site=${siteId}&month=${month}` |         const data = await res.json(); | ||||||
|         ); |         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); |         setKpiData(null); // fallback
 | ||||||
|       } finally { |       } finally { | ||||||
|         setLoading(false); |         setLoading(false); | ||||||
|       } |       } | ||||||
|     }; |     }; | ||||||
| 
 | 
 | ||||||
|     fetchKPI(); |     if (siteId && month) fetchKPI(); | ||||||
|   }, [siteId, month]); |   }, [siteId, month]); | ||||||
| 
 | 
 | ||||||
|   const formatValue = (value: number | null, unit = "", decimals = 2) => |   if (!siteId) { | ||||||
|     value != null ? `${value.toFixed(decimals)}${unit}` : "—"; |     return ( | ||||||
|  |       <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> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
| 
 | 
 | ||||||
|   const rows = [ |   if (loading) { | ||||||
|     { label: "Monthly Yield", value: formatValue(kpiData?.yield_kwh ?? null, " kWh", 0) }, |     return ( | ||||||
|     { label: "Monthly Consumption", value: formatValue(kpiData?.consumption_kwh ?? null, " kWh", 0) }, |       <div> | ||||||
|     { label: "Monthly Grid Draw", value: formatValue(kpiData?.grid_draw_kwh ?? null, " kWh", 0) }, |         <h2 className="text-lg font-bold mb-2">Monthly KPI</h2> | ||||||
|     { label: "Efficiency", value: formatValue(kpiData?.efficiency ?? null, "%", 1) }, |         <div className="min-h-[275px] w-full flex items-center justify-center border"> | ||||||
|     { label: "Peak Demand", value: formatValue(kpiData?.peak_demand_kw ?? null, " kW") }, |           <p>Loading...</p> | ||||||
|     { label: "Power Factor", value: formatValue(kpiData?.avg_power_factor ?? null) }, |         </div> | ||||||
|     { label: "Load Factor", value: formatValue(kpiData?.load_factor ?? null) }, |       </div> | ||||||
|   ]; |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   // 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> | ||||||
|       <div className="min-h-[275px] border rounded"> |       <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"> | ||||||
|         {!siteId ? ( |         <thead> | ||||||
|           <p className="text-center py-10">No site selected</p> |           <tr className="bg-rtgray-100 dark:bg-rtgray-800 text-black dark:text-white"> | ||||||
|         ) : loading ? ( |             <th className="border border-rtgray-300 dark:border-rtgray-700 p-3 text-left dark:bg-rtgray-900">KPI</th> | ||||||
|           <p className="text-center py-10">Loading...</p> |             <th className="border border-rtgray-300 dark:border-rtgray-700 p-3 text-left dark:bg-rtgray-900">Value</th> | ||||||
|         ) : ( |           </tr> | ||||||
|           <table className="w-full border-collapse"> |         </thead> | ||||||
|             <thead> |         <tbody> | ||||||
|               <tr className="bg-gray-100 dark:bg-rtgray-800"> |           {data.map((row) => ( | ||||||
|                 <th className="border p-3 text-left dark:text-white">KPI</th> |             <tr key={row.kpi} className="even:bg-rtgray-50 dark:even:bg-rtgray-800"> | ||||||
|                 <th className="border p-3 text-left dark:text-white">Value</th> |               <td className="border border-rtgray-300 dark:border-rtgray-700 p-2.5">{row.kpi}</td> | ||||||
|               </tr> |               <td className="border border-rtgray-300 dark:border-rtgray-700 p-2.5">{row.value}</td> | ||||||
|             </thead> |             </tr> | ||||||
|             <tbody> |           ))} | ||||||
|               {rows.map((row) => ( |         </tbody> | ||||||
|                 <tr key={row.label} className="even:bg-gray-50 dark:even:bg-rtgray-800"> |       </table> | ||||||
|                   <td className="border p-2.5 dark:text-white">{row.label}</td> |  | ||||||
|                   <td className="border p-2.5 dark:text-white">{row.value}</td> |  | ||||||
|                 </tr> |  | ||||||
|               ))} |  | ||||||
|             </tbody> |  | ||||||
|           </table> |  | ||||||
|         )} |  | ||||||
|       </div> |  | ||||||
|     </div> |     </div> | ||||||
|   ); |   ); | ||||||
| }; | }; | ||||||
| @ -89,4 +106,3 @@ const KPI_Table: React.FC<KPI_TableProps> = ({ siteId, month }) => { | |||||||
| export default KPI_Table; | export default KPI_Table; | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| 
 |  | ||||||
|  | |||||||
| @ -1,223 +0,0 @@ | |||||||
| '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, useMemo, useState } from 'react'; | import React, { useEffect, useState } from 'react'; | ||||||
| import { | import { | ||||||
|   BarChart, |   BarChart, | ||||||
|   Bar, |   Bar, | ||||||
| @ -9,25 +9,46 @@ import { | |||||||
|   Legend, |   Legend, | ||||||
| } from 'recharts'; | } from 'recharts'; | ||||||
| import { format } from 'date-fns'; | import { format } from 'date-fns'; | ||||||
| import { fetchMonthlyKpi, type MonthlyKPI } from '@/app/utils/api'; | import { fetchPowerTimeseries } from '@/app/utils/api'; | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| interface MonthlyBarChartProps { | interface MonthlyBarChartProps { | ||||||
|   siteId: string; |   siteId: string; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| const getLastNMonthKeys = (n: number): string[] => { | interface TimeSeriesEntry { | ||||||
|   const out: string[] = []; |   time: string; | ||||||
|   const now = new Date(); |   value: number; | ||||||
|   // include current month, go back n-1 months
 | } | ||||||
|   for (let i = 0; i < n; i++) { | 
 | ||||||
|     const d = new Date(now.getFullYear(), now.getMonth() - (n - 1 - i), 1); | const groupTimeSeries = ( | ||||||
|     const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; // YYYY-MM
 |   data: TimeSeriesEntry[], | ||||||
|     out.push(key); |   mode: 'monthly' | ||||||
|  | ): 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') | ||||||
| @ -37,82 +58,79 @@ function useIsDarkMode() { | |||||||
|   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 MonthlyBarChart: React.FC<MonthlyBarChartProps> = ({ siteId }) => { | const isDark = useIsDarkMode(); | ||||||
|   const [chartData, setChartData] = useState< |  | ||||||
|     { month: string; consumption: number; generation: number }[] |  | ||||||
|   >([]); |  | ||||||
|   const [loading, setLoading] = useState(true); |  | ||||||
| 
 | 
 | ||||||
|   const isDark = useIsDarkMode(); | const consumptionColor = isDark ? '#ba8e23' : '#003049';  | ||||||
|   const consumptionColor = isDark ? '#ba8e23' : '#003049'; | const generationColor = isDark ? '#fcd913' : '#669bbc';   | ||||||
|   const generationColor = isDark ? '#fcd913' : '#669bbc'; |  | ||||||
| 
 |  | ||||||
|   const monthKeys = useMemo(() => getLastNMonthKeys(6), []); |  | ||||||
| 
 | 
 | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     if (!siteId) return; |     if (!siteId) return; | ||||||
| 
 | 
 | ||||||
|     const load = async () => { |     const fetchMonthlyData = 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 { | ||||||
|         // Fetch all 6 months in parallel
 |         const res = await fetchPowerTimeseries(siteId, start, end); | ||||||
|         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; |  | ||||||
|             }) |  | ||||||
|           ) |  | ||||||
|         ); |  | ||||||
| 
 | 
 | ||||||
|         // Map to chart rows; default nulls to 0 for stacking/tooltip friendliness
 |         const groupedConsumption = groupTimeSeries(res.consumption, 'monthly'); | ||||||
|         const rows = results.map((kpi) => { |         const groupedGeneration = groupTimeSeries(res.generation, 'monthly'); | ||||||
|           const monthLabel = format(new Date(`${kpi.month}-01`), 'MMM'); |  | ||||||
|           return { |  | ||||||
|             month: monthLabel, |  | ||||||
|             consumption: kpi.consumption_kwh ?? 0, |  | ||||||
|             generation: kpi.yield_kwh ?? 0, |  | ||||||
|           }; |  | ||||||
|         }); |  | ||||||
| 
 | 
 | ||||||
|         setChartData(rows); |         const monthMap = new Map<string, { consumption: number; generation: number }>(); | ||||||
|  | 
 | ||||||
|  |         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); | ||||||
|       } |       } | ||||||
|     }; |     }; | ||||||
| 
 | 
 | ||||||
|     load(); |     fetchMonthlyData(); | ||||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 |   }, [siteId]); | ||||||
|   }, [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-3 rounded-lg shadow-md dark:bg-rtgray-800 dark:text-white-light"> |       <div className="bg-white p-4 rounded-lg shadow-md dark:bg-rtgray-800 dark:text-white-light"> | ||||||
|         <div className="h-[200px] w-full flex items-center justify-center"> |         <div className="flex justify-between items-center mb-2"> | ||||||
|  |           <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> | ||||||
| @ -122,8 +140,12 @@ const MonthlyBarChart: React.FC<MonthlyBarChartProps> = ({ siteId }) => { | |||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   return ( |   return ( | ||||||
|     <div className="bg-white p-3 rounded-lg dark:bg-rtgray-800 dark:text-white-light"> |     <div className="bg-white p-4 rounded-lg shadow-md dark:bg-rtgray-800 dark:text-white-light"> | ||||||
|       <div className="h-[200px] w-full"> |       <div className="flex justify-between items-center mb-2"> | ||||||
|  |         <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 | ||||||
| @ -136,16 +158,6 @@ const MonthlyBarChart: React.FC<MonthlyBarChartProps> = ({ siteId }) => { | |||||||
|               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`]} | ||||||
| @ -159,11 +171,15 @@ const MonthlyBarChart: React.FC<MonthlyBarChartProps> = ({ siteId }) => { | |||||||
|                 color: isDark ? '#fff' : '#222', |                 color: isDark ? '#fff' : '#222', | ||||||
|               }} |               }} | ||||||
|               cursor={{ |               cursor={{ | ||||||
|                 fill: isDark ? '#808080' : '#e0e7ef', |                 fill: isDark ? '#808080' : '#e0e7ef', // dark mode bg, light mode bg
 | ||||||
|                 fillOpacity: isDark ? 0.6 : 0.3, |                 fillOpacity: isDark ? 0.6 : 0.3,      // adjust opacity as you like
 | ||||||
|  |               }} | ||||||
|  |             /> | ||||||
|  |             <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> | ||||||
| @ -175,4 +191,3 @@ const MonthlyBarChart: React.FC<MonthlyBarChartProps> = ({ siteId }) => { | |||||||
| 
 | 
 | ||||||
| export default MonthlyBarChart; | export default MonthlyBarChart; | ||||||
| 
 | 
 | ||||||
| 
 |  | ||||||
|  | |||||||
| @ -1,146 +1,63 @@ | |||||||
| // components/dashboards/SiteCard.tsx
 | // components/dashboards/SiteCard.tsx
 | ||||||
| 'use client'; | import React from 'react'; | ||||||
| 
 | import Link from 'next/link'; // Import Link from Next.js
 | ||||||
| import React, { useEffect, useMemo, useState } from 'react'; | import { SiteName, SiteDetails } from '@/types/SiteData'; // Adjust path as necessary
 | ||||||
| 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 { | ||||||
|   siteId: string;                // CRM Project "name" (canonical id)
 |     siteName: SiteName; | ||||||
|   className?: string;            // optional styling hook
 |     details: SiteDetails; | ||||||
|   fallbackStatus?: string;       // optional backup status if CRM is missing it
 |     status: string; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000'; | const SiteCard: React.FC<SiteCardProps> = ({ siteName, details, status }) => { | ||||||
|  |     const statusColorClass = | ||||||
|  |         status === 'Active' ? 'text-green-500' : | ||||||
|  |         status === 'Inactive' ? 'text-orange-500' : | ||||||
|  |         'text-red-500'; | ||||||
| 
 | 
 | ||||||
| const SiteCard: React.FC<SiteCardProps> = ({ siteId, className = '', fallbackStatus }) => { |     return ( | ||||||
|   const [project, setProject] = useState<CrmProject | null>(null); |         <div className="bg-white p-4 rounded-lg shadow-md dark:bg-gray-800 dark:text-white-light flex flex-col space-y-2"> | ||||||
|   const [loading, setLoading] = useState(true); |             <h3 className="text-xl font-bold text-primary-600 dark:text-primary-400 border-b pb-2 mb-2"> | ||||||
|   const [err, setErr] = useState<string | null>(null); |                 {siteName} | ||||||
|  |             </h3> | ||||||
| 
 | 
 | ||||||
|   useEffect(() => { |             <div className="flex justify-between items-center"> | ||||||
|     let cancelled = false; |                 <p className="text-gray-600 dark:text-gray-400 font-medium">Status:</p> | ||||||
|  |                 <p className={`font-semibold ${statusColorClass}`}>{status}</p> | ||||||
|  |             </div> | ||||||
| 
 | 
 | ||||||
|     const fetchProject = async () => { |             <div className="flex justify-between items-center"> | ||||||
|       setLoading(true); |                 <p className="text-gray-600 dark:text-gray-400 font-medium">Location:</p> | ||||||
|       setErr(null); |                 <p className="font-semibold">{details.location}</p> | ||||||
|       try { |             </div> | ||||||
|         // ---- 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(); |             <div className="flex justify-between items-center"> | ||||||
|     return () => { cancelled = true; }; |                 <p className="text-gray-600 dark:text-gray-400 font-medium">Inverter Provider:</p> | ||||||
|   }, [siteId]); |                 <p className="font-semibold">{details.inverterProvider}</p> | ||||||
|  |             </div> | ||||||
| 
 | 
 | ||||||
|   const status = project?.status || fallbackStatus || 'Unknown'; |             <div className="flex justify-between items-center"> | ||||||
|   const statusColorClass = |                 <p className="text-gray-600 dark:text-gray-400 font-medium">Emergency Contact:</p> | ||||||
|     status === 'Active' ? 'text-green-500' : |                 <p className="font-semibold">{details.emergencyContact}</p> | ||||||
|     status === 'Inactive' ? 'text-orange-500' : |             </div> | ||||||
|     'text-red-500'; |  | ||||||
| 
 | 
 | ||||||
|   const niceAddress = useMemo(() => { |             <div className="flex justify-between items-center"> | ||||||
|     if (!project?.custom_address) return 'N/A'; |                 <p className="text-gray-600 dark:text-gray-400 font-medium">Last Sync:</p> | ||||||
|     return formatAddress(project.custom_address).multiLine; |                 <p className="font-semibold">{details.lastSyncTimestamp}</p> | ||||||
|   }, [project?.custom_address]); |             </div> | ||||||
| 
 | 
 | ||||||
|   const lastSync = useMemo(() => { |             {/* New: View Dashboard Button */} | ||||||
|     return formatCrmTimestamp(project?.modified, { includeSeconds: true }) || 'N/A'; |             <Link | ||||||
|   }, [project?.modified]); |                 href={{ | ||||||
| 
 |                     pathname: '/adminDashboard', // Path to your AdminDashboard page
 | ||||||
|   const inverterProvider = project?.project_type || 'N/A'; |                     query: { site: siteName }, // Pass the siteName as a query parameter
 | ||||||
|   const emergencyContact = |                 }} | ||||||
|     project?.custom_mobile_phone_no || |                 className="mt-4 w-full text-center text-sm btn-primary" // Tailwind classes for basic button styling
 | ||||||
|     project?.custom_email || |             > | ||||||
|     project?.customer || |                 View Dashboard | ||||||
|     'N/A'; |             </Link> | ||||||
| 
 |  | ||||||
|   return ( |  | ||||||
|     <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"> |  | ||||||
|         {project?.project_name || siteId} |  | ||||||
|       </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> |         </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"> |  | ||||||
|             <p className="text-gray-600 dark:text-gray-400 font-medium">Status:</p> |  | ||||||
|             <p className={`font-semibold ${statusColorClass}`}>{status}</p> |  | ||||||
|           </div> |  | ||||||
| 
 |  | ||||||
|           <div className="flex justify-between items-center"> |  | ||||||
|             <p className="text-gray-600 dark:text-gray-400 font-medium">Location:</p> |  | ||||||
|             <p className="font-medium whitespace-pre-line text-right">{niceAddress}</p> |  | ||||||
|           </div> |  | ||||||
| 
 |  | ||||||
|           <div className="flex justify-between items-center"> |  | ||||||
|             <p className="text-gray-600 dark:text-gray-400 font-medium">Inverter Provider:</p> |  | ||||||
|             <p className="font-medium">{inverterProvider}</p> |  | ||||||
|           </div> |  | ||||||
| 
 |  | ||||||
|           <div className="flex justify-between items-center"> |  | ||||||
|             <p className="text-gray-600 dark:text-gray-400 font-medium">Emergency Contact:</p> |  | ||||||
|             <p className="font-medium text-right">{emergencyContact}</p> |  | ||||||
|           </div> |  | ||||||
| 
 |  | ||||||
|           <div className="flex justify-between items-center"> |  | ||||||
|             <p className="text-gray-600 dark:text-gray-400 font-medium">Last Sync:</p> |  | ||||||
|             <p className="font-medium">{lastSync}</p> |  | ||||||
|           </div> |  | ||||||
|         </> |  | ||||||
|       )} |  | ||||||
| 
 |  | ||||||
|       <Link |  | ||||||
|         href={{ pathname: '/adminDashboard', query: { site: siteId } }} |  | ||||||
|         className="mt-4 w-full text-center text-sm btn-primary" |  | ||||||
|       > |  | ||||||
|         View Dashboard |  | ||||||
|       </Link> |  | ||||||
|     </div> |  | ||||||
|   ); |  | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export default SiteCard; | export default SiteCard; | ||||||
| @ -1,51 +1,26 @@ | |||||||
| 'use client'; |  | ||||||
| 
 | 
 | ||||||
| type Option = { label: string; value: string }; | import type { SiteName } from '@/components/dashboards/SiteStatus'; | ||||||
| 
 | 
 | ||||||
| type SiteSelectorProps = { | type SiteSelectorProps = { | ||||||
|   options: Option[];                 // e.g. [{label: 'Timo… (Installation)', value: 'PROJ-0008'}, …]
 |   selectedSite: SiteName; | ||||||
|   selectedValue: string | null;      // the selected project "name" (siteId) or null
 |   setSelectedSite: (site: SiteName) => void; | ||||||
|   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"> |       <label htmlFor="site" className="font-semibold text-lg dark:text-white">Select Site:</label> | ||||||
|         {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={selectedValue ?? ''}                 // keep controlled even when null
 |         value={selectedSite} | ||||||
|         onChange={(e) => onChange(e.target.value)} |         onChange={(e) => setSelectedSite(e.target.value as SiteName)} | ||||||
|         disabled={disabled || isEmpty} |  | ||||||
|       > |       > | ||||||
|         {/* Placeholder when nothing selected */} |         <option>Site A</option> | ||||||
|         <option value="" disabled> |         <option>Site B</option> | ||||||
|           {isEmpty ? 'No sites available' : 'Choose a site…'} |         <option>Site C</option> | ||||||
|         </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,152 +1,217 @@ | |||||||
| 'use client'; |  | ||||||
| 
 |  | ||||||
| import axios from "axios"; | import axios from "axios"; | ||||||
| import React, { useState, useEffect, useMemo } from "react"; | import React, { useState, useEffect } from "react"; | ||||||
| 
 | 
 | ||||||
| export type SiteName = string; | export type SiteName = 'Site A' | 'Site B' | 'Site C'; | ||||||
| 
 | 
 | ||||||
| interface SiteStatusProps { | interface SiteStatusProps { | ||||||
|   selectedSite: string;   // display label (e.g., CRM project_name)
 |     selectedSite: SiteName; | ||||||
|   siteId: string;         // canonical id (e.g., CRM Project.name like PROJ-0008)
 |     location: string; | ||||||
|   status?: string;        // CRM status (Open/Completed/On Hold/…)
 |     inverterProvider: string; | ||||||
|   location: string; |     emergencyContact: string; | ||||||
|   inverterProvider: string; |     lastSyncTimestamp: string; | ||||||
|   emergencyContact: 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, |     location, | ||||||
|   status, |     inverterProvider, | ||||||
|   location, |     emergencyContact, | ||||||
|   inverterProvider, |     lastSyncTimestamp, | ||||||
|   emergencyContact, |  | ||||||
|   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 [deviceId, setDeviceId] = useState(""); |  | ||||||
|   const [functionType, setFunctionType] = useState<"Grid" | "Solar">("Grid"); |  | ||||||
| 
 | 
 | ||||||
|   // Track devices connected per siteId (dynamic)
 |  | ||||||
|   const [loggedDevices, setLoggedDevices] = useState<Record<string, string[]>>({}); |  | ||||||
|   const devicesAtSite = loggedDevices[siteId] ?? []; |  | ||||||
| 
 | 
 | ||||||
|   const handleStartLogging = () => setShowModal(true); |     const [showModal, setShowModal] = useState(false); | ||||||
|  |     const [deviceId, setDeviceId] = useState(""); | ||||||
|  |     const [functionType, setFunctionType] = useState("Grid"); | ||||||
| 
 | 
 | ||||||
|   const handleConfirm = async () => { |     // Map site names to site IDs
 | ||||||
|     const id = deviceId.trim(); |     const siteIdMap: Record<SiteName, string> = { | ||||||
|     if (!id) return; |         "Site A": "site_01", | ||||||
|  |         "Site B": "site_02", | ||||||
|  |         "Site C": "site_03", | ||||||
|  |     }; | ||||||
| 
 | 
 | ||||||
|     const topic = `ADW300/${siteId}/${id}/${functionType.toLowerCase()}`; |     // Track devices connected per site
 | ||||||
|  |     const [loggedDevices, setLoggedDevices] = useState<Record<string, string[]>>({ | ||||||
|  |         site_01: [], | ||||||
|  |         site_02: [], | ||||||
|  |         site_03: [], | ||||||
|  |     }); | ||||||
| 
 | 
 | ||||||
|     try { |     const siteId = siteIdMap[selectedSite]; | ||||||
|       const response = await axios.post(`${API_URL}/start-logging`, { topics: [topic] }); |     const devicesAtSite = loggedDevices[siteId] || []; | ||||||
|       console.log("Started logging:", response.data); |  | ||||||
| 
 | 
 | ||||||
|       setLoggedDevices(prev => ({ |     const handleStartLogging = () => { | ||||||
|         ...prev, |         setShowModal(true); | ||||||
|         [siteId]: [...(prev[siteId] ?? []), id], |     }; | ||||||
|       })); |  | ||||||
|       setShowModal(false); |  | ||||||
|       setDeviceId(""); |  | ||||||
|     } catch (error) { |  | ||||||
|       console.error("Failed to start logging:", error); |  | ||||||
|     } |  | ||||||
|   }; |  | ||||||
| 
 | 
 | ||||||
|   const handleStopLogging = async () => { |     const handleConfirm = async () => { | ||||||
|     try { |         const siteId = siteIdMap[selectedSite]; | ||||||
|       // Stop only this site's topics (both function types for each device)
 |         const topic = `ADW300/${siteId}/${deviceId}/${functionType.toLowerCase()}`; | ||||||
|       const topics = (loggedDevices[siteId] ?? []).flatMap(did => [ |  | ||||||
|         `ADW300/${siteId}/${did}/grid`, |  | ||||||
|         `ADW300/${siteId}/${did}/solar`, |  | ||||||
|       ]); |  | ||||||
|       await axios.post(`${API_URL}/stop-logging`, topics.length ? { topics } : {}); |  | ||||||
| 
 | 
 | ||||||
|       setLoggedDevices(prev => ({ ...prev, [siteId]: [] })); |         try { | ||||||
|       console.log("Stopped logging for", siteId); |             const response = await axios.post("http://localhost:8000/start-logging", { | ||||||
|     } catch (error) { |                 topics: [topic], | ||||||
|       console.error("Failed to stop logging:", error); |             }); | ||||||
|     } |             console.log("Started logging:", response.data); | ||||||
|   }; |  | ||||||
| 
 | 
 | ||||||
|   const statusClass = useMemo(() => { |             // Add device to list
 | ||||||
|     const s = (status ?? "").toLowerCase(); |             setLoggedDevices((prev) => ({ | ||||||
|     if (s === "open" || s === "active") return "text-green-500"; |                 ...prev, | ||||||
|     if (s === "completed" || s === "closed") return "text-blue-500"; |                 [siteId]: [...(prev[siteId] || []), deviceId], | ||||||
|     if (s === "inactive" || s === "on hold") return "text-orange-500"; |             })); | ||||||
|     if (s === "faulty" || s === "cancelled") return "text-red-500"; |             setShowModal(false); | ||||||
|     return "text-gray-500"; |  | ||||||
|   }, [status]); |  | ||||||
| 
 | 
 | ||||||
|   return ( |         } catch (error) { | ||||||
|     <div className="bg-white p-4 rounded-lg shadow-md space-y-2 dark:bg-rtgray-800 dark:text-white-light"> |             console.error("Failed to start logging:", error); | ||||||
|       <h2 className="text-xl font-semibold mb-3">Site Details</h2> |         } | ||||||
|  |     }; | ||||||
| 
 | 
 | ||||||
|       {/* Status (from CRM) */} |      const handleStopLogging = async () => { | ||||||
|       <div className="flex justify-between items-center text-base"> |         try { | ||||||
|         <p className="text-gray-600 dark:text-white/85 font-medium">Status:</p> |             await axios.post("http://localhost:8000/stop-logging"); | ||||||
|         <p className={`font-semibold ${statusClass}`}>{status ?? "—"}</p> |  | ||||||
|       </div> |  | ||||||
| 
 | 
 | ||||||
|       {/* Site ID */} |             // Clear all devices for the site (or modify to remove only specific one)
 | ||||||
|       <div className="flex justify-between items-center text-base"> |             setLoggedDevices((prev) => ({ | ||||||
|         <p className="text-gray-600 dark:text-white/85 font-medium">Site ID:</p> |                 ...prev, | ||||||
|         <p className="font-medium">{siteId}</p> |                 [siteId]: [], | ||||||
|       </div> |             })); | ||||||
| 
 | 
 | ||||||
|       {/* Location */} |             console.log("Stopped logging for", siteId); | ||||||
|       <div className="flex justify-between items-center text-base"> |         } catch (error) { | ||||||
|         <p className="text-gray-600 dark:text-white/85 font-medium">Location:</p> |             console.error("Failed to stop logging:", error); | ||||||
|         <p className="font-medium">{location}</p> |         } | ||||||
|       </div> |     }; | ||||||
| 
 | 
 | ||||||
|       {/* Inverter Provider */} |     const statusMap: Record<SiteName, string> = { | ||||||
|       <div className="flex justify-between items-center text-base"> |         'Site A': 'Active', | ||||||
|         <p className="text-gray-600 dark:text-white/85 font-medium">Inverter Provider:</p> |         'Site B': 'Inactive', | ||||||
|         <p className="font-medium">{inverterProvider}</p> |         'Site C': 'Faulty', | ||||||
|       </div> |     }; | ||||||
| 
 | 
 | ||||||
|       {/* Emergency Contact */} |     return ( | ||||||
|       <div className="flex justify-between items-center text-base"> |         <div className="bg-white p-4 rounded-lg shadow-md space-y-2 dark:bg-rtgray-800 dark:text-white-light"> | ||||||
|         <p className="text-gray-600 dark:text-white/85 font-medium">Emergency Contact:</p> |             <h2 className="text-xl font-semibold mb-3">Site Details</h2> | ||||||
|         <p className="font-medium">{emergencyContact}</p> |  | ||||||
|       </div> |  | ||||||
| 
 | 
 | ||||||
|       {/* Last Sync */} |             {/* Status */} | ||||||
|       <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">Last Sync:</p> |                 <p className="text-gray-600 dark:text-white/85 font-medium">Status:</p> | ||||||
|         <p className="font-medium">{lastSyncTimestamp}</p> |                 <p className={`font-semibold ${ | ||||||
|       </div> |                     statusMap[selectedSite] === 'Active' ? 'text-green-500' : | ||||||
|     </div> |                     statusMap[selectedSite] === 'Inactive' ? 'text-orange-500' : | ||||||
|   ); |                     'text-red-500' | ||||||
|  |                 }`}>
 | ||||||
|  |                     {statusMap[selectedSite]} | ||||||
|  |                 </p> | ||||||
|  |             </div> | ||||||
|  | 
 | ||||||
|  |             {/* Site ID */} | ||||||
|  |             <div className="flex justify-between items-center text-base"> | ||||||
|  |                 <p className="text-gray-600 dark:text-white/85 font-medium">Site ID:</p> | ||||||
|  |                 <p className="font-medium">{siteId}</p> | ||||||
|  |             </div> | ||||||
|  | 
 | ||||||
|  |             {/* Location */} | ||||||
|  |             <div className="flex justify-between items-center text-base"> | ||||||
|  |                 <p className="text-gray-600 dark:text-white/85 font-medium">Location:</p> | ||||||
|  |                 <p className="font-medium">{location}</p> | ||||||
|  |             </div> | ||||||
|  | 
 | ||||||
|  |             {/* Inverter Provider */} | ||||||
|  |             <div className="flex justify-between items-center text-base"> | ||||||
|  |                 <p className="text-gray-600 dark:text-white/85 font-medium">Inverter Provider:</p> | ||||||
|  |                 <p className="font-medium">{inverterProvider}</p> | ||||||
|  |             </div> | ||||||
|  | 
 | ||||||
|  |             {/* Emergency Contact */} | ||||||
|  |             <div className="flex justify-between items-center text-base"> | ||||||
|  |                 <p className="text-gray-600 dark:text-white/85 font-medium">Emergency Contact:</p> | ||||||
|  |                 <p className="font-medium">{emergencyContact}</p> | ||||||
|  |             </div> | ||||||
|  | 
 | ||||||
|  |             {/* Last Sync */} | ||||||
|  |             <div className="flex justify-between items-center text-base"> | ||||||
|  |                 <p className="text-gray-600 dark:text-white/85 font-medium">Last Sync:</p> | ||||||
|  |                 <p className="font-medium">{lastSyncTimestamp}</p> | ||||||
|  |             </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> | ||||||
|  |     ); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export default SiteStatus; | export default SiteStatus; | ||||||
| 
 |  | ||||||
|  | |||||||
| @ -1,54 +0,0 @@ | |||||||
| // 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> |  | ||||||
|   ); |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| @ -1,74 +0,0 @@ | |||||||
| // 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,73 +1,57 @@ | |||||||
| // 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'; |  | ||||||
| 
 | 
 | ||||||
| type DropdownProps = { | const Dropdown = (props: any, forwardedRef: any) => { | ||||||
|   button?: ReactNode;             // 👈 make optional
 |     const [visibility, setVisibility] = useState<any>(false); | ||||||
|   children: ReactNode; |  | ||||||
|   btnClassName?: string; |  | ||||||
|   placement?: any; |  | ||||||
|   offset?: [number, number]; |  | ||||||
|   panelClassName?: string; |  | ||||||
|   closeOnItemClick?: boolean; |  | ||||||
| }; |  | ||||||
| 
 | 
 | ||||||
| const Dropdown = (props: DropdownProps, forwardedRef: any) => { |     const referenceRef = useRef<any>(); | ||||||
|   const [visible, setVisible] = useState(false); |     const popperRef = useRef<any>(); | ||||||
|   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: [{ name: 'offset', options: { offset: props.offset ?? [0, 8] } }], |         modifiers: [ | ||||||
|   }); |             { | ||||||
|  |                 name: 'offset', | ||||||
|  |                 options: { | ||||||
|  |                     offset: props.offset || [0], | ||||||
|  |                 }, | ||||||
|  |             }, | ||||||
|  |         ], | ||||||
|  |     }); | ||||||
| 
 | 
 | ||||||
|   useEffect(() => { |     const handleDocumentClick = (event: any) => { | ||||||
|     const onDoc = (e: MouseEvent) => { |         if (referenceRef.current.contains(event.target) || popperRef.current.contains(event.target)) { | ||||||
|       if (!referenceRef.current || !popperRef.current) return; |             return; | ||||||
|       if (referenceRef.current.contains(e.target as Node)) return; |         } | ||||||
|       if (popperRef.current.contains(e.target as Node)) return; | 
 | ||||||
|       setVisible(false); |         setVisibility(false); | ||||||
|     }; |     }; | ||||||
|     document.addEventListener('mousedown', onDoc); |  | ||||||
|     return () => document.removeEventListener('mousedown', onDoc); |  | ||||||
|   }, []); |  | ||||||
| 
 | 
 | ||||||
|   useImperativeHandle(forwardedRef, () => ({ close: () => setVisible(false) })); |     useEffect(() => { | ||||||
|  |         document.addEventListener('mousedown', handleDocumentClick); | ||||||
|  |         return () => { | ||||||
|  |             document.removeEventListener('mousedown', handleDocumentClick); | ||||||
|  |         }; | ||||||
|  |     }, []); | ||||||
| 
 | 
 | ||||||
|   const defaultButton = ( |     useImperativeHandle(forwardedRef, () => ({ | ||||||
|     <span className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-gray-200 dark:bg-rtgray-700" /> |         close() { | ||||||
|   ); |             setVisibility(false); | ||||||
|  |         }, | ||||||
|  |     })); | ||||||
| 
 | 
 | ||||||
|   return ( |     return ( | ||||||
|     <> |         <> | ||||||
|       <button |             <button ref={referenceRef} type="button" className={props.btnClassName} onClick={() => setVisibility(!visibility)}> | ||||||
|         ref={referenceRef} |                 {props.button} | ||||||
|         type="button" |             </button> | ||||||
|         className={props.btnClassName} |  | ||||||
|         onClick={() => setVisible((v) => !v)} |  | ||||||
|       > |  | ||||||
|         {props.button ?? defaultButton} {/* 👈 fallback */} |  | ||||||
|       </button> |  | ||||||
| 
 | 
 | ||||||
|       <div |             <div ref={popperRef} style={styles.popper} {...attributes.popper} className="z-50" onClick={() => setVisibility(!visibility)}> | ||||||
|         ref={popperRef} |                 {visibility && props.children} | ||||||
|         style={styles.popper} |             </div> | ||||||
|         {...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> |  | ||||||
|     </> |  | ||||||
|   ); |  | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export default forwardRef(Dropdown); | export default forwardRef(Dropdown); | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
|  | |||||||
| @ -4,175 +4,253 @@ 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'; | ||||||
| 
 | 
 | ||||||
| type UserData = { id: string; email: string; createdAt: string }; | const Header = () => { | ||||||
|  |     const pathname = usePathname(); | ||||||
|  |     const dispatch = useDispatch(); | ||||||
|  |     const router = useRouter(); | ||||||
|  |     const { t, i18n } = getTranslation(); | ||||||
| 
 | 
 | ||||||
| export default function Header() { |     useEffect(() => { | ||||||
|   const pathname = usePathname(); |         const selector = document.querySelector('ul.horizontal-menu a[href="' + window.location.pathname + '"]'); | ||||||
|   const dispatch = useDispatch(); |         if (selector) { | ||||||
|   const router = useRouter(); |             const all: any = document.querySelectorAll('ul.horizontal-menu .nav-link.active'); | ||||||
|   const themeConfig = useSelector((state: IRootState) => state.themeConfig); |             for (let i = 0; i < all.length; i++) { | ||||||
|   const isRtl = themeConfig.rtlClass === 'rtl'; |                 all[0]?.classList.remove('active'); | ||||||
|  |             } | ||||||
| 
 | 
 | ||||||
|   const [user, setUser] = useState<UserData | null>(null); |             let allLinks = document.querySelectorAll('ul.horizontal-menu a.active'); | ||||||
|   const [loadingUser, setLoadingUser] = useState(true); |             for (let i = 0; i < allLinks.length; i++) { | ||||||
|  |                 const element = allLinks[i]; | ||||||
|  |                 element?.classList.remove('active'); | ||||||
|  |             } | ||||||
|  |             selector?.classList.add('active'); | ||||||
| 
 | 
 | ||||||
|   // Highlight active menu (your original effect)
 |             const ul: any = selector.closest('ul.sub-menu'); | ||||||
|   useEffect(() => { |             if (ul) { | ||||||
|     const selector = document.querySelector( |                 let ele: any = ul.closest('li.menu').querySelectorAll('.nav-link'); | ||||||
|       'ul.horizontal-menu a[href="' + window.location.pathname + '"]' |                 if (ele) { | ||||||
|     ); |                     ele = ele[0]; | ||||||
|     if (selector) { |                     setTimeout(() => { | ||||||
|       document |                         ele?.classList.add('active'); | ||||||
|         .querySelectorAll('ul.horizontal-menu .nav-link.active') |                     }); | ||||||
|         .forEach((el) => el.classList.remove('active')); |                 } | ||||||
|       document |             } | ||||||
|         .querySelectorAll('ul.horizontal-menu a.active') |         } | ||||||
|         .forEach((el) => el.classList.remove('active')); |     }, [pathname]); | ||||||
|       selector.classList.add('active'); | 
 | ||||||
|       const ul: any = selector.closest('ul.sub-menu'); |     const isRtl = useSelector((state: IRootState) => state.themeConfig.rtlClass) === 'rtl'; | ||||||
|       if (ul) { | 
 | ||||||
|         const ele: any = ul.closest('li.menu')?.querySelector('.nav-link'); |     const themeConfig = useSelector((state: IRootState) => state.themeConfig); | ||||||
|         setTimeout(() => ele?.classList.add('active')); |     const setLocale = (flag: string) => { | ||||||
|       } |         if (flag.toLowerCase() === 'ae') { | ||||||
|  |             dispatch(toggleRTL('rtl')); | ||||||
|  |         } else { | ||||||
|  |             dispatch(toggleRTL('ltr')); | ||||||
|  |         } | ||||||
|  |         router.refresh(); | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     function createMarkup(messages: any) { | ||||||
|  |         return { __html: messages }; | ||||||
|     } |     } | ||||||
|   }, [pathname]); |     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', | ||||||
|  |         }, | ||||||
|  |     ]); | ||||||
| 
 | 
 | ||||||
|   async function loadUser() { |     const removeMessage = (value: number) => { | ||||||
|   try { |         setMessages(messages.filter((user) => user.id !== value)); | ||||||
|     const res = await fetch('/api/auth/me', { |     }; | ||||||
|       method: 'GET', |  | ||||||
|       credentials: 'include', // send cookie
 |  | ||||||
|       cache: 'no-store',      // avoid stale cached responses
 |  | ||||||
|     }); |  | ||||||
|     if (!res.ok) throw new Error(); |  | ||||||
|     const data = await res.json(); |  | ||||||
|     setUser(data.user); |  | ||||||
|   } catch { |  | ||||||
|     setUser(null); |  | ||||||
|   } finally { |  | ||||||
|     setLoadingUser(false); |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| 
 | 
 | ||||||
| useEffect(() => { |     const [notifications, setNotifications] = useState([ | ||||||
|   setLoadingUser(true); |         { | ||||||
|   loadUser(); |             id: 1, | ||||||
|   // eslint-disable-next-line react-hooks/exhaustive-deps
 |             profile: 'user-profile.jpeg', | ||||||
| }, [pathname]); // re-fetch on route change (after login redirect)
 |             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 handleLogout = async () => { |     const removeNotification = (value: number) => { | ||||||
|     await fetch('/api/auth/logout', { method: 'POST' }); |         setNotifications(notifications.filter((user) => user.id !== value)); | ||||||
|     setUser(null); |     }; | ||||||
|     router.push('/login'); // go to login
 |  | ||||||
|   }; |  | ||||||
| 
 | 
 | ||||||
|   return ( |     const [search, setSearch] = useState(false); | ||||||
|     <header className={`z-40 ${themeConfig.semidark && themeConfig.menu === 'horizontal' ? 'dark' : ''}`}> |  | ||||||
|       <div className="shadow-sm"> |  | ||||||
|         <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="relative h-10 w-32 sm:h-11 sm:w-36 md:h-12 md:w-27 shrink-0 max-h-12"> |  | ||||||
|                 <Image |  | ||||||
|                 src="/assets/images/newfulllogo.png" |  | ||||||
|                 alt="logo" |  | ||||||
|                 fill |  | ||||||
|                 className="object-cover" |  | ||||||
|                 priority |  | ||||||
|                 sizes="(max-width: 640px) 8rem, (max-width: 768px) 9rem, (max-width: 1024px) 10rem, 10rem" |  | ||||||
|                 /> |  | ||||||
|             </div> |  | ||||||
| 
 | 
 | ||||||
|             <button |     return ( | ||||||
|                 type="button" |         <header className={`z-40 ${themeConfig.semidark && themeConfig.menu === 'horizontal' ? 'dark' : ''}`}> | ||||||
|                 onClick={() => dispatch(toggleSidebar())} |             <div className="shadow-sm"> | ||||||
|                 className="collapse-icon flex p-2 rounded-full hover:bg-rtgray-200 dark:text-white dark:hover:bg-rtgray-700" |                 <div className="relative flex w-full items-center bg-white px-5 py-2.5 dark:bg-black"> | ||||||
|             > |                     <div className="horizontal-logo flex items-center justify-between ltr:mr-2 rtl:ml-2 lg:hidden"> | ||||||
|                 <IconMenu className="h-6 w-6" /> |                         <Link href="/" className="main-logo flex shrink-0 items-center"> | ||||||
|             </button> |                             <img className="inline w-8 ltr:-ml-1 rtl:-mr-1" src="/assets/images/newfulllogo.png" alt="logo" /> | ||||||
|             </div> |                             <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> | ||||||
|  |                         </Link> | ||||||
|  |                         <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())} | ||||||
|  |                         > | ||||||
|  |                             <IconMenu className="h-5 w-5" /> | ||||||
|  |                         </button> | ||||||
|  |                     </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"> | ||||||
| 
 | 
 | ||||||
|           {/* Right-side actions */} |                         {/* ------------------- Start Theme Switch ------------------- */} | ||||||
|           <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"> |                         <div> | ||||||
|             {/* Theme toggle */} |                             {themeConfig.theme === 'light' ? ( | ||||||
|             {themeConfig.theme === 'light' ? ( |                                 <button | ||||||
|               <button |                                     className={`${ | ||||||
|                 onClick={() => dispatch(toggleTheme('dark'))} |                                         themeConfig.theme === '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" |                                         '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' | ||||||
|               > |                                     }`}
 | ||||||
|                 <IconSun /> |                                     onClick={() => dispatch(toggleTheme('dark'))} | ||||||
|               </button> |                                 > | ||||||
|             ) : ( |                                     <IconSun /> | ||||||
|               <button |                                 </button> | ||||||
|                 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 /> |                             {themeConfig.theme === 'dark' && ( | ||||||
|               </button> |                                 <button | ||||||
|             )} |                                     className={`${ | ||||||
| 
 |                                         themeConfig.theme === 'dark' && | ||||||
|             {/* User dropdown */} |                                         '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' | ||||||
|             <div className="dropdown flex shrink-0 "> |                                     }`}
 | ||||||
|               {loadingUser ? ( |                                     onClick={() => dispatch(toggleTheme('light'))} | ||||||
|                 <div className="h-9 w-9 rounded-full animate-pulse bg-gray-300 dark:bg-rtgray-800" /> |                                 > | ||||||
|               ) : user ? ( |                                     <IconMoon /> | ||||||
|             <Dropdown |                                 </button> | ||||||
|                 placement={isRtl ? 'bottom-start' : 'bottom-end'} |                             )} | ||||||
|                 btnClassName="relative group block" |  | ||||||
|                 panelClassName="rounded-lg shadow-lg border border-white/10 bg-rtgray-100 dark:bg-rtgray-800 p-2" // ✅
 |  | ||||||
|                 button={ |  | ||||||
|                         <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"> |  | ||||||
|                         <IconUser className="h-5 w-5 text-gray-600 dark:text-gray-300" /> |  | ||||||
|                         </div> |                         </div> | ||||||
|                     } |                         {/* ------------------- End Theme Switch ------------------- */} | ||||||
|                 > | 
 | ||||||
|                 <ul className="w-[230px] font-semibold text-dark"> {/* make sure this stays transparent */} | 
 | ||||||
|                     <li className="px-4 py-4 flex items-center"> |                         <div className="dropdown flex shrink-0"> | ||||||
|                       <div className="truncate ltr:pl-1.5 rtl:pr-4"> |                             <Dropdown | ||||||
|                         <h4 className="text-sm text-left">{user.email}</h4> |                                 offset={[0, 8]} | ||||||
|                       </div> |                                 placement={`${isRtl ? 'bottom-start' : 'bottom-end'}`} | ||||||
|                     </li> |                                 btnClassName="relative group block" | ||||||
|                     <li> |                                 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" />} | ||||||
|                       <Link href="/users/profile" className="dark:hover:text-white"> |                             > | ||||||
|                         <IconUser className="h-4.5 w-4.5 mr-2" /> Profile |                                 <ul className="w-[230px] !py-0 font-semibold text-dark dark:text-white-dark dark:text-white-light/90"> | ||||||
|                       </Link> |                                     <li> | ||||||
|                     </li> |                                         <div className="flex items-center px-4 py-4"> | ||||||
|                     <li> |                                             <img className="h-10 w-10 rounded-md object-cover" src="/assets/images/user-profile.jpeg" alt="userProfile" /> | ||||||
|                       <Link href="/auth/boxed-lockscreen" className="dark:hover:text-white"> |                                             <div className="truncate ltr:pl-4 rtl:pr-4"> | ||||||
|                         <IconLockDots className="h-4.5 w-4.5 mr-2" /> Lock Screen |                                                 <h4 className="text-base"> | ||||||
|                       </Link> |                                                     John Doe | ||||||
|                     </li> |                                                     <span className="rounded bg-success-light px-1 text-xs text-success ltr:ml-2 rtl:ml-2">Pro</span> | ||||||
|                     <li className="border-t border-white-light dark:border-white-light/10"> |                                                 </h4> | ||||||
|                       <button onClick={handleLogout} className="flex w-full items-center py-3 text-danger"> |                                                 <button type="button" className="text-black/60 hover:text-primary dark:text-dark-light/60 dark:hover:text-white"> | ||||||
|                         <IconLogout className="h-4.5 w-4.5 mr-2 rotate-90" /> Sign Out |                                                     johndoe@gmail.com | ||||||
|                       </button> |                                                 </button> | ||||||
|                     </li> |                                             </div> | ||||||
|                   </ul> |                                         </div> | ||||||
|                 </Dropdown> |                                     </li> | ||||||
|               ) : ( |                                     <li> | ||||||
|                 <Link |                                         <Link href="/users/profile" className="dark:hover:text-white"> | ||||||
|                   href="/login" |                                             <IconUser className="h-4.5 w-4.5 shrink-0 ltr:mr-2 rtl:ml-2" /> | ||||||
|                   className="rounded-md bg-yellow-400 px-3 py-1.5 text-black font-semibold hover:brightness-95" |                                             Profile | ||||||
|                 > |                                         </Link> | ||||||
|                   Sign In |                                     </li> | ||||||
|                 </Link> |                                     <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> | ||||||
|  |                                     </li> | ||||||
|  |                                     <li> | ||||||
|  |                                         <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" /> | ||||||
|  |                                             Lock Screen | ||||||
|  |                                         </Link> | ||||||
|  |                                     </li> | ||||||
|  |                                     <li className="border-t border-white-light dark:border-white-light/10"> | ||||||
|  |                                         <Link href="/auth/boxed-signin" className="!py-3 text-danger"> | ||||||
|  |                                             <IconLogout className="h-4.5 w-4.5 shrink-0 rotate-90 ltr:mr-2 rtl:ml-2" /> | ||||||
|  |                                             Sign Out | ||||||
|  |                                         </Link> | ||||||
|  |                                     </li> | ||||||
|  |                                 </ul> | ||||||
|  |                             </Dropdown> | ||||||
|  |                         </div> | ||||||
|  |                     </div> | ||||||
|  |                 </div> | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|             </div> |             </div> | ||||||
|           </div> |         </header> | ||||||
|         </div> |     ); | ||||||
|       </div> | }; | ||||||
|     </header> | 
 | ||||||
|   ); | export default Header; | ||||||
| } |  | ||||||
|  | |||||||
| @ -67,15 +67,16 @@ 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-rtgray-900"> |                 <div className="h-full bg-[white] dark:bg-black"> | ||||||
|                     <div className="flex items-center justify-between px-4 pt-4"> |                     <div className="flex items-center justify-between px-4 py-3"> | ||||||
|                         <Link href="/" className="main-logo flex shrink-0 items-center"> |                         <Link href="/" className="main-logo flex shrink-0 items-center"> | ||||||
|                             <img className="max-w-[180px] h-auto flex" src="/assets/images/newfulllogo.png" alt="logo" /> |                             <img className="ml-[5px] w-8 flex-none" src="/assets/images/newlogo.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-rtgray-500/10 rtl:rotate-180 dark:text-white-light dark:hover:bg-rtgray-900/10" |                             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" | ||||||
|                             onClick={() => dispatch(toggleSidebar())} |                             onClick={() => dispatch(toggleSidebar())} | ||||||
|                         > |                         > | ||||||
|                             <IconCaretsDown className="m-auto rotate-90" /> |                             <IconCaretsDown className="m-auto rotate-90" /> | ||||||
| @ -83,7 +84,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 pb-3 pt-2 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 py-3 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> | ||||||
| @ -155,7 +156,6 @@ 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> | ||||||
|  | |||||||
							
								
								
									
										5918
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										5918
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @ -11,7 +11,6 @@ | |||||||
|     "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", | ||||||
| @ -28,8 +27,6 @@ | |||||||
|         "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", | ||||||
| @ -54,7 +51,6 @@ | |||||||
|     "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,46 +1,27 @@ | |||||||
| // pages/api/auth/me.ts
 | import { NextApiRequest, NextApiResponse } from "next"; | ||||||
| import type { NextApiRequest, NextApiResponse } from "next"; | import jwt from "jsonwebtoken"; | ||||||
| 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) { | ||||||
|   if (req.method !== "GET") return res.status(405).json({ message: "Method not allowed" }); |   const authHeader = req.headers.authorization; | ||||||
|  | 
 | ||||||
|  |   if (!authHeader || !authHeader.startsWith("Bearer ")) { | ||||||
|  |     return res.status(401).json({ message: "Unauthorized" }); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   const token = authHeader.split(" ")[1]; // Extract token
 | ||||||
| 
 | 
 | ||||||
|   try { |   try { | ||||||
|     const token = readAuthBearer(req) ?? readCookieToken(req); |     const decoded: any = jwt.verify(token, SECRET_KEY); | ||||||
|     if (!token) return res.status(401).json({ message: "Unauthorized" }); |     const user = await prisma.user.findUnique({ where: { id: decoded.userId } }); | ||||||
| 
 |  | ||||||
|     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 }); | 
 | ||||||
|   } catch { |     res.json({ user }); | ||||||
|     return res.status(401).json({ message: "Invalid token" }); |   } catch (error) { | ||||||
|  |     res.status(401).json({ message: "Invalid token" }); | ||||||
|   } |   } | ||||||
| } | } | ||||||
| 
 |  | ||||||
|  | |||||||
| @ -1,18 +1,15 @@ | |||||||
| // pages/api/login.ts
 | import { NextApiRequest, NextApiResponse } from "next"; | ||||||
| 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" }); | ||||||
| 
 | 
 | ||||||
|   try { |     const { email, password } = req.body; | ||||||
|     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" }); | ||||||
| @ -20,23 +17,8 @@ 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({ sub: String(user.id), email: user.email }, SECRET_KEY, { expiresIn: "1d" }); |     const token = jwt.sign({ email: user.email }, SECRET_KEY, { expiresIn: "1d" }); | ||||||
| 
 | 
 | ||||||
|     const isProd = process.env.NODE_ENV === "production"; |     res.setHeader("Set-Cookie", `token=${token}; HttpOnly; Path=/; Secure`); | ||||||
|     const cookie = [ |     res.json({ token }); | ||||||
|       `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" }); |  | ||||||
|   } |  | ||||||
| } | } | ||||||
| 
 |  | ||||||
|  | |||||||
| @ -1,20 +0,0 @@ | |||||||
| // 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,42 +1,27 @@ | |||||||
| // pages/api/register.ts
 | import { NextApiRequest, NextApiResponse } from "next"; | ||||||
| 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" }); | ||||||
| 
 | 
 | ||||||
|   try { |     const { email, password } = req.body; | ||||||
|     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" }); | ||||||
| 
 | 
 | ||||||
|     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({ sub: String(user.id), email: user.email }, SECRET_KEY, { expiresIn: "1d" }); |     const token = jwt.sign({ email: user.email }, SECRET_KEY, { expiresIn: "1d" }); | ||||||
| 
 | 
 | ||||||
|     // Set a secure, httpOnly cookie
 |     res.setHeader("Set-Cookie", `token=${token}; HttpOnly; Path=/; Secure`); | ||||||
|     const maxAge = 60 * 60 * 24; // 1 day
 |     res.status(201).json({ message: "User registered", user, token }); | ||||||
|     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: 91 KiB After Width: | Height: | Size: 54 KiB | 
| @ -449,7 +449,6 @@ 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
									
									
									
									
									
								
							
							
						
						
									
										14
									
								
								types/crm.ts
									
									
									
									
									
								
							| @ -1,14 +0,0 @@ | |||||||
| // 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