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