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,129 +1,231 @@ | ||||
| 'use client'; | ||||
| 
 | ||||
| import { useState, useEffect, useRef } from 'react'; | ||||
| import { useState, useEffect, useMemo, useRef } from 'react'; | ||||
| import { useRouter, usePathname, useSearchParams } from 'next/navigation'; | ||||
| import SiteSelector from '@/components/dashboards/SiteSelector'; | ||||
| import SiteStatus from '@/components/dashboards/SiteStatus'; | ||||
| import KPI_Table from '@/components/dashboards/KPIStatus'; | ||||
| import DashboardLayout from './dashlayout'; | ||||
| import html2canvas from 'html2canvas'; | ||||
| import jsPDF from 'jspdf'; | ||||
| import dynamic from 'next/dynamic'; | ||||
| import { fetchPowerTimeseries } from '@/app/utils/api'; | ||||
| import KpiTop from '@/components/dashboards/kpitop'; | ||||
| import KpiBottom from '@/components/dashboards/kpibottom'; | ||||
| import { formatAddress } from '@/app/utils/formatAddress'; | ||||
| import { formatCrmTimestamp } from '@/app/utils/datetime'; | ||||
| import LoggingControlCard from '@/components/dashboards/LoggingControl'; | ||||
| 
 | ||||
| const EnergyLineChart = dynamic(() => import('@/components/dashboards/EnergyLineChart'), { | ||||
|   ssr: false, | ||||
| }); | ||||
| const EnergyLineChart = dynamic(() => import('@/components/dashboards/EnergyLineChart'), { ssr: false }); | ||||
| const MonthlyBarChart  = dynamic(() => import('@/components/dashboards/MonthlyBarChart'), { ssr: false }); | ||||
| 
 | ||||
| const MonthlyBarChart = dynamic(() => import('@/components/dashboards/MonthlyBarChart'), { | ||||
|   ssr: false, | ||||
| }); | ||||
| type MonthlyKPI = { | ||||
|   site: string; month: string; | ||||
|   yield_kwh: number | null; consumption_kwh: number | null; grid_draw_kwh: number | null; | ||||
|   efficiency: number | null; peak_demand_kw: number | null; | ||||
|   avg_power_factor: number | null; load_factor: number | null; | ||||
|   error?: string; | ||||
| }; | ||||
| 
 | ||||
| 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 router = useRouter(); | ||||
|   const pathname = usePathname(); | ||||
|   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'); | ||||
|   const validSiteNames: SiteName[] = ['Site A', 'Site B', 'Site C']; | ||||
|   // --- load CRM projects dynamically ---
 | ||||
|   const [sites, setSites] = useState<CrmProject[]>([]); | ||||
|   const [sitesLoading, setSitesLoading] = useState(true); | ||||
|   const [sitesError, setSitesError] = useState<unknown>(null); | ||||
|   // near other refs
 | ||||
|   const loggingRef = useRef<HTMLDivElement | null>(null); | ||||
| 
 | ||||
|   const [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(() => { | ||||
|     if ( | ||||
|       siteParam && | ||||
|       validSiteNames.includes(siteParam as SiteName) && | ||||
|       siteParam !== selectedSite | ||||
|     ) { | ||||
|       setSelectedSite(siteParam as SiteName); | ||||
|     } | ||||
|   }, [siteParam, selectedSite]); | ||||
|     setSitesLoading(true); | ||||
|     fetch(`${API}/crm/projects?limit=0`) | ||||
|       .then(r => r.json()) | ||||
|       .then(json => setSites(json?.data ?? [])) | ||||
|       .catch(setSitesError) | ||||
|       .finally(() => setSitesLoading(false)); | ||||
|   }, []); | ||||
| 
 | ||||
|   // The canonical siteId is the CRM Project "name" (e.g., PROJ-0008)
 | ||||
|   const siteParam = searchParams?.get('site') || null; | ||||
|   const [selectedSiteId, setSelectedSiteId] = useState<string | null>(siteParam); | ||||
| 
 | ||||
|   // Keep query param <-> state in sync
 | ||||
|   useEffect(() => { | ||||
|     if ((siteParam || null) !== selectedSiteId) { | ||||
|       setSelectedSiteId(siteParam); | ||||
|     } | ||||
|   }, [siteParam]); // eslint-disable-line
 | ||||
| 
 | ||||
|   // Default to the first site when loaded
 | ||||
|   useEffect(() => { | ||||
|     if (!selectedSiteId && sites.length) { | ||||
|       setSelectedSiteId(sites[0].name); | ||||
|       router.replace(`${pathname}?site=${encodeURIComponent(sites[0].name)}`); | ||||
|     } | ||||
|   }, [sites, selectedSiteId, pathname, router]); | ||||
| 
 | ||||
|   // Current selected CRM project
 | ||||
|   const selectedProject: CrmProject | null = useMemo( | ||||
|     () => sites.find(s => s.name === selectedSiteId) ?? null, | ||||
|     [sites, selectedSiteId] | ||||
|   ); | ||||
| 
 | ||||
|   // declare currentMonth BEFORE it’s used
 | ||||
|   const currentMonth = useMemo(() => new Date().toISOString().slice(0, 7), []); | ||||
| 
 | ||||
|   // --- Time-series state ---
 | ||||
|   const [timeSeriesData, setTimeSeriesData] = useState<{ | ||||
|     consumption: { time: string; value: number }[]; | ||||
|     generation: { time: string; value: number }[]; | ||||
|   }>({ consumption: [], generation: [] }); | ||||
| 
 | ||||
|   // data-availability flags
 | ||||
|   const [hasAnyData, setHasAnyData] = useState(false);   // historical window
 | ||||
|   const [hasTodayData, setHasTodayData] = useState(false); | ||||
|   const [isLogging, setIsLogging] = useState(false); | ||||
|   const [startError, setStartError] = useState<string | null>(null); | ||||
| 
 | ||||
|   // Fetch today’s timeseries for selected siteId
 | ||||
|   useEffect(() => { | ||||
|   const fetchData = async () => { | ||||
| 
 | ||||
|   const siteId = siteIdMap[selectedSite]; | ||||
|   const today = new Date(); | ||||
| 
 | ||||
|   // Format to YYYY-MM-DD
 | ||||
|   const yyyyMMdd = today.toISOString().split('T')[0]; | ||||
| 
 | ||||
|   // Append Malaysia's +08:00 time zone manually
 | ||||
|   const start = `${yyyyMMdd}T00:00:00+08:00`; | ||||
|   const end = `${yyyyMMdd}T23:59:59+08:00`; | ||||
|     if (!selectedSiteId) return; | ||||
| 
 | ||||
|     const fetchToday = async () => { | ||||
|       const { start, end } = withTZ(new Date()); | ||||
| 
 | ||||
|       try { | ||||
|       const raw = await fetchPowerTimeseries(siteId, start, end); | ||||
| 
 | ||||
|     const consumption = raw.consumption.map(d => ({ | ||||
|       time: d.time, | ||||
|       value: d.value, | ||||
|     })); | ||||
| 
 | ||||
|     const generation = raw.generation.map(d => ({ | ||||
|       time: d.time, | ||||
|       value: d.value, | ||||
|     }));   | ||||
| 
 | ||||
|         const raw = await fetchPowerTimeseries(selectedSiteId, start, end); | ||||
|         const consumption = raw.consumption.map((d: any) => ({ time: d.time, value: d.value })); | ||||
|         const generation  = raw.generation.map((d: any) => ({ time: d.time, value: d.value })); | ||||
|         setTimeSeriesData({ consumption, generation }); | ||||
| 
 | ||||
|         const anyToday = (consumption?.length ?? 0) > 0 || (generation?.length ?? 0) > 0; | ||||
|         setHasTodayData(anyToday); | ||||
|       } catch (error) { | ||||
|         console.error('Failed to fetch power time series:', error); | ||||
|         setHasTodayData(false); | ||||
|       } | ||||
|     }; | ||||
| 
 | ||||
|   fetchData(); | ||||
| }, [selectedSite]); | ||||
|     fetchToday(); | ||||
|   }, [selectedSiteId]); | ||||
| 
 | ||||
|   // Update query string when site is changed manually
 | ||||
|   const handleSiteChange = (newSite: SiteName) => { | ||||
|     setSelectedSite(newSite); | ||||
|     const newUrl = `${pathname}?site=${encodeURIComponent(newSite)}`; | ||||
|   // Check historical data (last 30 days) → controls empty state
 | ||||
|   useEffect(() => { | ||||
|     if (!selectedSiteId) return; | ||||
| 
 | ||||
|     const fetchHistorical = async () => { | ||||
|       try { | ||||
|         const endDate = new Date(); | ||||
|         const startDate = new Date(); | ||||
|         startDate.setDate(endDate.getDate() - 30); | ||||
| 
 | ||||
|         const startISO = `${startDate.toISOString().split('T')[0]}T00:00:00+08:00`; | ||||
|         const endISO   = `${endDate.toISOString().split('T')[0]}T23:59:59+08:00`; | ||||
| 
 | ||||
|         const raw = await fetchPowerTimeseries(selectedSiteId, startISO, endISO); | ||||
|         const anyHistorical = | ||||
|           (raw?.consumption?.length ?? 0) > 0 || | ||||
|           (raw?.generation?.length ?? 0) > 0; | ||||
| 
 | ||||
|         setHasAnyData(anyHistorical); | ||||
|       } catch (e) { | ||||
|         console.error('Failed to check historical data:', e); | ||||
|         setHasAnyData(false); | ||||
|       } | ||||
|     }; | ||||
| 
 | ||||
|     fetchHistorical(); | ||||
|   }, [selectedSiteId]); | ||||
| 
 | ||||
|   // --- KPI monthly ---
 | ||||
|   const [kpi, setKpi] = useState<MonthlyKPI | null>(null); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     if (!selectedSiteId) return; | ||||
|     const url = `${API}/kpi/monthly?site=${encodeURIComponent(selectedSiteId)}&month=${currentMonth}`; | ||||
|     fetch(url).then(r => r.json()).then(setKpi).catch(console.error); | ||||
|   }, [selectedSiteId, currentMonth]); | ||||
| 
 | ||||
|   // derived values with safe fallbacks
 | ||||
|   const yieldKwh       = kpi?.yield_kwh ?? 0; | ||||
|   const consumptionKwh = kpi?.consumption_kwh ?? 0; | ||||
|   const gridDrawKwh    = kpi?.grid_draw_kwh ?? Math.max(0, consumptionKwh - yieldKwh); | ||||
|   const efficiencyPct  = (kpi?.efficiency ?? 0) * 100; | ||||
|   const powerFactor    = kpi?.avg_power_factor ?? 0; | ||||
|   const loadFactor     = (kpi?.load_factor ?? 0); | ||||
| 
 | ||||
|   // Update URL when site is changed manually (expects a siteId/Project.name)
 | ||||
|   const handleSiteChange = (newSiteId: string) => { | ||||
|     setSelectedSiteId(newSiteId); | ||||
|     const newUrl = `${pathname}?site=${encodeURIComponent(newSiteId)}`; | ||||
|     router.push(newUrl); | ||||
|     // reset flags when switching
 | ||||
|     setHasAnyData(false); | ||||
|     setHasTodayData(false); | ||||
|     setIsLogging(false); | ||||
|     setStartError(null); | ||||
|   }; | ||||
| 
 | ||||
|   const currentSiteDetails: SiteDetails = mockSiteData[selectedSite] || { | ||||
|     location: 'N/A', | ||||
|     inverterProvider: 'N/A', | ||||
|     emergencyContact: 'N/A', | ||||
|     lastSyncTimestamp: 'N/A', | ||||
|     consumptionData: [], | ||||
|     generationData: [], | ||||
|     systemStatus: 'N/A', | ||||
|     temperature: 'N/A', | ||||
|     solarPower: 0, | ||||
|     realTimePower: 0, | ||||
|     installedPower: 0, | ||||
|   const locationFormatted = useMemo(() => { | ||||
|     const raw = selectedProject?.custom_address ?? ''; | ||||
|     if (!raw) return 'N/A'; | ||||
|     return formatAddress(raw).multiLine; | ||||
|   }, [selectedProject?.custom_address]); | ||||
| 
 | ||||
|   const lastSyncFormatted = useMemo( | ||||
|     () => formatCrmTimestamp(selectedProject?.modified, { includeSeconds: true }), | ||||
|     [selectedProject?.modified] | ||||
|   ); | ||||
| 
 | ||||
|   // Adapt CRM project -> SiteStatus props
 | ||||
|   const currentSiteDetails = { | ||||
|     location: locationFormatted, | ||||
|     inverterProvider: selectedProject?.project_type || 'N/A', | ||||
|     emergencyContact: | ||||
|       selectedProject?.custom_mobile_phone_no || | ||||
|       selectedProject?.custom_email || | ||||
|       selectedProject?.customer || | ||||
|       'N/A', | ||||
|     lastSyncTimestamp: lastSyncFormatted || 'N/A', | ||||
|   }; | ||||
| 
 | ||||
|   const handleCSVExport = () => { | ||||
|     alert('Exported raw data to CSV (mock)'); | ||||
|   }; | ||||
| 
 | ||||
|   const energyChartRef = useRef(null); | ||||
|   const monthlyChartRef = useRef(null); | ||||
|   const energyChartRef = useRef<HTMLDivElement | null>(null); | ||||
|   const monthlyChartRef = useRef<HTMLDivElement | null>(null); | ||||
| 
 | ||||
|   const handlePDFExport = async () => { | ||||
|   const doc = new jsPDF('p', 'mm', 'a4'); // portrait, millimeters, A4
 | ||||
|     const doc = new jsPDF('p', 'mm', 'a4'); | ||||
|     const chartRefs = [ | ||||
|       { ref: energyChartRef,  title: 'Energy Line Chart' }, | ||||
|       { ref: monthlyChartRef, title: 'Monthly Energy Yield' } | ||||
| @ -133,82 +235,200 @@ const AdminDashboard = () => { | ||||
| 
 | ||||
|     for (const chart of chartRefs) { | ||||
|       if (!chart.ref.current) continue; | ||||
| 
 | ||||
|     // Capture chart as image
 | ||||
|     const canvas = await html2canvas(chart.ref.current, { | ||||
|       scale: 2, // Higher scale for better resolution
 | ||||
|     }); | ||||
| 
 | ||||
|       const 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; // 10 margin each side
 | ||||
|       const pdfWidth = doc.internal.pageSize.getWidth() - 20; | ||||
|       const imgHeight = (imgProps.height * pdfWidth) / imgProps.width; | ||||
| 
 | ||||
|     // Add title and image
 | ||||
|       doc.setFontSize(14); | ||||
|       doc.text(chart.title, 10, yOffset); | ||||
|     yOffset += 6; // Space between title and chart
 | ||||
|       yOffset += 6; | ||||
| 
 | ||||
|     // If content will overflow page, add a new page
 | ||||
|       if (yOffset + imgHeight > doc.internal.pageSize.getHeight()) { | ||||
|         doc.addPage(); | ||||
|         yOffset = 10; | ||||
|       } | ||||
| 
 | ||||
|       doc.addImage(imgData, 'PNG', 10, yOffset, pdfWidth, imgHeight); | ||||
|     yOffset += imgHeight + 10; // Update offset for next chart
 | ||||
|       yOffset += imgHeight + 10; | ||||
|     } | ||||
| 
 | ||||
|     doc.save('dashboard_charts.pdf'); | ||||
| }; | ||||
|   const currentMonth = new Date().toISOString().slice(0, 7); // "YYYY-MM"
 | ||||
|   }; | ||||
| 
 | ||||
|   // Start logging then poll for data until it shows up
 | ||||
|   const startLogging = async () => { | ||||
|     if (!selectedSiteId) return; | ||||
|     setIsLogging(true); | ||||
|     setStartError(null); | ||||
| 
 | ||||
|     try { | ||||
|       const resp = await fetch(START_LOGGING_ENDPOINT(selectedSiteId), { | ||||
|         method: 'POST', | ||||
|         headers: { 'Content-Type': 'application/json' }, | ||||
|       }); | ||||
| 
 | ||||
|       if (!resp.ok) { | ||||
|         const text = await resp.text(); | ||||
|         throw new Error(text || `Failed with status ${resp.status}`); | ||||
|       } | ||||
| 
 | ||||
|       // Poll for data for up to ~45s (15 tries x 3s)
 | ||||
|       for (let i = 0; i < 15; i++) { | ||||
|         const today = new Date(); | ||||
|         const { start, end } = withTZ(today); | ||||
| 
 | ||||
|         try { | ||||
|           const raw = await fetchPowerTimeseries(selectedSiteId, start, end); | ||||
|           const consumption = raw.consumption ?? []; | ||||
|           const generation  = raw.generation ?? []; | ||||
|           if ((consumption.length ?? 0) > 0 || (generation.length ?? 0) > 0) { | ||||
|             setHasAnyData(true);     // site now has data
 | ||||
|             setHasTodayData(true);   // and today has data too
 | ||||
|             break; | ||||
|           } | ||||
|         } catch { | ||||
|           // ignore and keep polling
 | ||||
|         } | ||||
|         await new Promise(r => setTimeout(r, 3000)); | ||||
|       } | ||||
|     } catch (e: any) { | ||||
|       setStartError(e?.message ?? 'Failed to start logging'); | ||||
|       setIsLogging(false); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   // ---------- RENDER ----------
 | ||||
|   if (sitesLoading) { | ||||
|     return ( | ||||
|       <DashboardLayout> | ||||
|         <div className="px-6">Loading sites…</div> | ||||
|       </DashboardLayout> | ||||
|     ); | ||||
|   } | ||||
|   if (sitesError) { | ||||
|     return ( | ||||
|       <DashboardLayout> | ||||
|         <div className="px-6 text-red-600">Failed to load sites from CRM.</div> | ||||
|       </DashboardLayout> | ||||
|     ); | ||||
|   } | ||||
|   if (!selectedProject) { | ||||
|     return ( | ||||
|       <DashboardLayout> | ||||
|         <div className="px-6">No site selected.</div> | ||||
|       </DashboardLayout> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   // Build selector options from CRM
 | ||||
|   const siteOptions = sites.map(s => ({ | ||||
|     label: s.project_name || s.name, | ||||
|     value: s.name, | ||||
|   })); | ||||
| 
 | ||||
|   return ( | ||||
|     <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> | ||||
| 
 | ||||
|         <div className="grid md:grid-cols-2 gap-6"> | ||||
|           <div className="space-y-4"> | ||||
|         {/* Selector + status */} | ||||
|         <div className="grid grid-cols-1 gap-6 w-full min-w-0"> | ||||
|           <div className="space-y-4 w-full min-w-0"> | ||||
|             <SiteSelector | ||||
|               selectedSite={selectedSite} | ||||
|               setSelectedSite={handleSiteChange} | ||||
|               options={siteOptions} | ||||
|               selectedValue={selectedSiteId!} | ||||
|               onChange={handleSiteChange} | ||||
|             /> | ||||
| 
 | ||||
|             <SiteStatus | ||||
|               selectedSite={selectedSite} | ||||
|               selectedSite={selectedProject.project_name || selectedProject.name} | ||||
|               siteId={selectedProject.name} | ||||
|               location={currentSiteDetails.location} | ||||
|               inverterProvider={currentSiteDetails.inverterProvider} | ||||
|               emergencyContact={currentSiteDetails.emergencyContact} | ||||
|               lastSyncTimestamp={currentSiteDetails.lastSyncTimestamp} | ||||
|             /> | ||||
|           </div> | ||||
|              | ||||
|           <div> | ||||
|           <KPI_Table siteId={siteIdMap[selectedSite]} month={currentMonth} /> | ||||
| 
 | ||||
|           </div> | ||||
|         </div> | ||||
| 
 | ||||
|         <div className="grid md:grid-cols-2 gap-6 lg:flex-col justify-center"> | ||||
|         {/* Small dark yellow banner when there is ZERO historical data */} | ||||
|         {!hasAnyData && ( | ||||
|           <div className="rounded-lg border border-amber-400/40 bg-rtyellow-300/20 px-4 py-3 text-amber-600 dark:text-amber-100 flex flex-wrap items-center gap-3"> | ||||
|             <span className="font-semibold text-black/85 dark:text-white/85">No data yet.</span> | ||||
|             <span className="opacity-95">Enter the meter number and click <span className="font-semibold text-black/85 dark:text-white/85">Start</span> to begin streaming. | ||||
|             </span> | ||||
| 
 | ||||
|             {startError && <div className="basis-full text-sm text-red-300">{startError}</div>} | ||||
|           </div> | ||||
|         )} | ||||
| 
 | ||||
|         <div ref={loggingRef}> | ||||
|           <LoggingControlCard | ||||
|             siteId={selectedProject.name} | ||||
|             projectLabel={selectedProject.project_name || selectedProject.name} | ||||
|             className="w-full" | ||||
|           /> | ||||
|         </div> | ||||
| 
 | ||||
|         {/* Render the rest only if there is *any* data */} | ||||
|         {hasAnyData && ( | ||||
|           <> | ||||
|             {/* Tiny banner if today is empty but historical exists */} | ||||
|             {!hasTodayData && ( | ||||
|               <div className="rounded-lg border border-amber-300/50 bg-amber-50 dark:bg-amber-900/20 px-4 py-2 text-amber-800 dark:text-amber-200"> | ||||
|                 No data yet today — charts may be blank until new points arrive. | ||||
|               </div> | ||||
|             )} | ||||
|              | ||||
| 
 | ||||
|             {/* TOP 3 CARDS */} | ||||
|             <div className="space-y-4"> | ||||
|               <KpiTop | ||||
|                 yieldKwh={yieldKwh} | ||||
|                 consumptionKwh={consumptionKwh} | ||||
|                 gridDrawKwh={gridDrawKwh} | ||||
|               /> | ||||
|             </div> | ||||
| 
 | ||||
|             <div ref={energyChartRef} className="pb-5"> | ||||
|             <EnergyLineChart siteId={siteIdMap[selectedSite]} /> | ||||
|               <EnergyLineChart siteId={selectedProject.name} /> | ||||
|             </div> | ||||
| 
 | ||||
|             {/* BOTTOM 3 PANELS */} | ||||
|             <KpiBottom | ||||
|               efficiencyPct={efficiencyPct} | ||||
|               powerFactor={powerFactor} | ||||
|               loadFactor={loadFactor} | ||||
|               middle={ | ||||
|                 <div ref={monthlyChartRef} className="transform scale-90 origin-top"> | ||||
|                   <MonthlyBarChart siteId={selectedProject.name} /> | ||||
|                 </div> | ||||
|           <div ref={monthlyChartRef} className="pb-5"> | ||||
|             <MonthlyBarChart siteId={siteIdMap[selectedSite]} /> | ||||
|               } | ||||
|               right={ | ||||
|                 <div className="flex items-center justify-center w-full px-3 text-center"> | ||||
|                   <div className="text-3xl font-semibold"> | ||||
|                     {(kpi?.peak_demand_kw ?? 0).toFixed(2)} kW | ||||
|                   </div> | ||||
|                 </div> | ||||
|               } | ||||
|             /> | ||||
| 
 | ||||
|             <div className="flex flex-col md:flex-row gap-4 justify-center"> | ||||
|               <button onClick={handlePDFExport} className="text-sm lg:text-lg btn-primary"> | ||||
|                 Export Chart Images to PDF | ||||
|               </button> | ||||
|             </div> | ||||
|           </> | ||||
|         )} | ||||
| 
 | ||||
| 
 | ||||
|       </div> | ||||
|     </DashboardLayout> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default AdminDashboard; | ||||
| 
 | ||||
| 
 | ||||
|  | ||||
| @ -1,46 +1,187 @@ | ||||
| 'use client'; | ||||
| 
 | ||||
| import React from 'react'; | ||||
| import React, { useEffect, useMemo, useState } from 'react'; | ||||
| import DashboardLayout from '../adminDashboard/dashlayout'; | ||||
| import SiteCard from '@/components/dashboards/SiteCard'; // Import the new SiteCard component
 | ||||
| import { mockSiteData, SiteName } from '@/types/SiteData'; // Import your mock data and SiteName type
 | ||||
| import SiteCard from '@/components/dashboards/SiteCard'; | ||||
| 
 | ||||
| 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 = () => { | ||||
|     // Helper function to determine status (can be externalized if used elsewhere)
 | ||||
|     const getSiteStatus = (siteName: SiteName): string => { | ||||
|         const statusMap: Record<SiteName, string> = { | ||||
|             'Site A': 'Active', | ||||
|             'Site B': 'Inactive', | ||||
|             'Site C': 'Faulty', | ||||
|         }; | ||||
|         return statusMap[siteName]; | ||||
|   const [projects, setProjects] = useState<CrmProject[]>([]); | ||||
|   const [loading, setLoading] = useState(true); | ||||
|   const [err, setErr] = useState<string | null>(null); | ||||
|   const [q, setQ] = useState('');             // search filter
 | ||||
| 
 | ||||
|   // pagination
 | ||||
|   const [page, setPage] = useState(1); | ||||
|   const [pageSize, setPageSize] = useState(6); // tweak as you like
 | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     let cancelled = false; | ||||
|     const run = async () => { | ||||
|       setLoading(true); | ||||
|       setErr(null); | ||||
|       try { | ||||
|         const res = await fetch(`${API}/crm/projects?limit=0`); | ||||
|         if (!res.ok) throw new Error(await res.text()); | ||||
|         const json = await res.json(); | ||||
|         const data: CrmProject[] = json?.data ?? []; | ||||
|         if (!cancelled) setProjects(data); | ||||
|       } catch (e: any) { | ||||
|         if (!cancelled) setErr(e?.message ?? 'Failed to load CRM projects'); | ||||
|       } finally { | ||||
|         if (!cancelled) setLoading(false); | ||||
|       } | ||||
|     }; | ||||
|     run(); | ||||
|     return () => { cancelled = true; }; | ||||
|   }, []); | ||||
| 
 | ||||
|   // Reset to first page whenever search or pageSize changes
 | ||||
|   useEffect(() => { | ||||
|     setPage(1); | ||||
|   }, [q, pageSize]); | ||||
| 
 | ||||
|   const filtered = useMemo(() => { | ||||
|     if (!q.trim()) return projects; | ||||
|     const needle = q.toLowerCase(); | ||||
|     return projects.filter(p => | ||||
|       (p.project_name || '').toLowerCase().includes(needle) || | ||||
|       (p.name || '').toLowerCase().includes(needle) || | ||||
|       (p.customer || '').toLowerCase().includes(needle) | ||||
|     ); | ||||
|   }, [projects, q]); | ||||
| 
 | ||||
|   const total = filtered.length; | ||||
|   const totalPages = Math.max(1, Math.ceil(total / pageSize)); | ||||
|   const safePage = Math.min(page, totalPages); | ||||
|   const startIdx = (safePage - 1) * pageSize; | ||||
|   const endIdx = Math.min(startIdx + pageSize, total); | ||||
|   const pageItems = filtered.slice(startIdx, endIdx); | ||||
| 
 | ||||
|   const goPrev = () => setPage(p => Math.max(1, p - 1)); | ||||
|   const goNext = () => setPage(p => Math.min(totalPages, p + 1)); | ||||
| 
 | ||||
|   return ( | ||||
|     <DashboardLayout> | ||||
|       <div className="p-6 space-y-6"> | ||||
|                 <h1 className="text-2xl font-bold mb-6 dark:text-white-light">All Sites Overview</h1> | ||||
|         <div className="flex flex-col md:flex-row md:items-center md:justify-between gap-3"> | ||||
|           <h1 className="text-2xl font-bold dark:text-white-light">All Sites Overview</h1> | ||||
| 
 | ||||
|                 <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> | ||||
|                     {/* Iterate over the keys of mockSiteData (which are your SiteNames) */} | ||||
|                     {Object.keys(mockSiteData).map((siteNameKey) => { | ||||
|                         const siteName = siteNameKey as SiteName; // Cast to SiteName type
 | ||||
|                         const siteDetails = mockSiteData[siteName]; | ||||
|                         const siteStatus = getSiteStatus(siteName); | ||||
| 
 | ||||
|                         return ( | ||||
|                             <SiteCard | ||||
|                                 key={siteName} // Important for React list rendering
 | ||||
|                                 siteName={siteName} | ||||
|                                 details={siteDetails} | ||||
|                                 status={siteStatus} | ||||
|           <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; | ||||
| 
 | ||||
|  | ||||
| @ -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[]; | ||||
| } | ||||
| 
 | ||||
| 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( | ||||
|   site: string, | ||||
|   start: string, | ||||
| @ -48,3 +60,29 @@ export async function fetchForecast( | ||||
|   return res.json(); | ||||
| } | ||||
| 
 | ||||
| export type MonthlyKPI = { | ||||
|   site: string; | ||||
|   month: string;        // "YYYY-MM"
 | ||||
|   yield_kwh: number | null; | ||||
|   consumption_kwh: number | null; | ||||
|   grid_draw_kwh: number | null; | ||||
|   efficiency: number | null;        // 0..1 (fraction)
 | ||||
|   peak_demand_kw: number | null; | ||||
|   avg_power_factor: number | null;  // 0..1
 | ||||
|   load_factor: number | null;       // 0..1
 | ||||
|   error?: string; | ||||
| }; | ||||
| 
 | ||||
| const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000"; | ||||
| 
 | ||||
| export async function fetchMonthlyKpi(params: { | ||||
|   site: string; | ||||
|   month: string; // "YYYY-MM"
 | ||||
|   consumption_topic?: string; | ||||
|   generation_topic?: string; | ||||
| }): Promise<MonthlyKPI> { | ||||
|   const qs = new URLSearchParams(params as Record<string, string>); | ||||
|   const res = await fetch(`${API}/kpi/monthly?${qs.toString()}`, { cache: "no-store" }); | ||||
|   if (!res.ok) throw new Error(`HTTP ${res.status}`); | ||||
|   return res.json(); | ||||
| } | ||||
|  | ||||
							
								
								
									
										37
									
								
								app/utils/datetime.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								app/utils/datetime.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,37 @@ | ||||
| // app/utils/datetime.ts
 | ||||
| export function formatCrmTimestamp( | ||||
|   input: string | null | undefined, | ||||
|   opts?: { locale?: string; timeZone?: string; includeSeconds?: boolean } | ||||
| ): string { | ||||
|   if (!input) return 'N/A'; | ||||
| 
 | ||||
|   // Accept: 2025-06-30 10:04:58.387651 (also with 'T', with/without fraction)
 | ||||
|   const m = String(input).trim().match( | ||||
|     /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,6}))?$/ | ||||
|   ); | ||||
|   if (!m) return input; // fallback: show as-is
 | ||||
| 
 | ||||
|   const [, y, mo, d, hh, mm, ss, frac = ''] = m; | ||||
|   const ms = Number((frac + '000').slice(0, 3)); // micro→millis
 | ||||
| 
 | ||||
|   const dt = new Date( | ||||
|     Number(y), | ||||
|     Number(mo) - 1, | ||||
|     Number(d), | ||||
|     Number(hh), | ||||
|     Number(mm), | ||||
|     Number(ss), | ||||
|     ms | ||||
|   ); | ||||
| 
 | ||||
|   const locale = opts?.locale ?? 'en-MY'; | ||||
|   const timeZone = opts?.timeZone ?? 'Asia/Kuala_Lumpur'; | ||||
|   const timeStyle = opts?.includeSeconds ? 'medium' : 'short'; | ||||
| 
 | ||||
|   return new Intl.DateTimeFormat(locale, { | ||||
|     dateStyle: 'medium', | ||||
|     timeStyle,         // 'short'=no seconds, 'medium'=with seconds
 | ||||
|     timeZone, | ||||
|     hour12: true, | ||||
|   }).format(dt); | ||||
| } | ||||
							
								
								
									
										35
									
								
								app/utils/formatAddress.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								app/utils/formatAddress.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,35 @@ | ||||
| // utils/formatAddress.ts
 | ||||
| // npm i he  (for robust HTML entity decoding)
 | ||||
| import { decode } from "he"; | ||||
| 
 | ||||
| export function formatAddress(raw: string) { | ||||
|   // 1) decode entities (& → &), 2) <br> → \n, 3) tidy whitespace
 | ||||
|   const text = decode(raw) | ||||
|     .replace(/<br\s*\/?>/gi, "\n") | ||||
|     .replace(/\u00A0/g, " ")      //  
 | ||||
|     .replace(/[ \t]{2,}/g, " ")   // collapse spaces
 | ||||
|     .replace(/\n{2,}/g, "\n")     // collapse blank lines
 | ||||
|     .trim(); | ||||
| 
 | ||||
|   // split to lines, strip empties
 | ||||
|   const lines = text.split("\n").map(s => s.trim()).filter(Boolean); | ||||
| 
 | ||||
|   // If postcode is alone (e.g., "40150") before the city line, merge: "40150 Shah Alam"
 | ||||
|   const merged: string[] = []; | ||||
|   for (let i = 0; i < lines.length; i++) { | ||||
|     const cur = lines[i]; | ||||
|     const next = lines[i + 1]; | ||||
|     if (/^\d{5}$/.test(cur) && next) { | ||||
|       merged.push(`${cur} ${next}`); | ||||
|       i++; // skip the city line, already merged
 | ||||
|     } else { | ||||
|       merged.push(cur); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   return { | ||||
|     parts: merged,                 // array of lines
 | ||||
|     multiLine: merged.join("\n"),  // lines with \n
 | ||||
|     singleLine: merged.join(", "), // one-liner
 | ||||
|   }; | ||||
| } | ||||
| @ -2,53 +2,69 @@ | ||||
| import IconLockDots from '@/components/icon/icon-lock-dots'; | ||||
| import IconMail from '@/components/icon/icon-mail'; | ||||
| import { useRouter } from 'next/navigation'; | ||||
| import { useState } from "react"; | ||||
| import axios from "axios"; | ||||
| import { useState } from 'react'; | ||||
| import axios from 'axios'; | ||||
| import toast from 'react-hot-toast'; | ||||
| 
 | ||||
| const ComponentsAuthLoginForm = () => { | ||||
|     const [email, setEmail] = useState("") | ||||
|     const [password, setPassword] = useState("") | ||||
|     const [loading, setLoading] = useState(false) | ||||
|     const router = useRouter() | ||||
|   const [email, setEmail] = useState(''); | ||||
|   const [password, setPassword] = useState(''); | ||||
|   const [loading, setLoading] = useState(false); | ||||
|   const router = useRouter(); | ||||
| 
 | ||||
|   const submitForm = async (e: React.FormEvent) => { | ||||
|         e.preventDefault() | ||||
| 
 | ||||
|         setLoading(true) | ||||
|     e.preventDefault(); | ||||
|     setLoading(true); | ||||
|     try { | ||||
|             const res = await axios.post(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/login`, { | ||||
|                 email, | ||||
|                 password, | ||||
|             }) | ||||
| 
 | ||||
|             localStorage.setItem("token", res.data.token) | ||||
| 
 | ||||
|             toast.success("Login successful!") | ||||
|             router.push("/") | ||||
|       const res = await axios.post('/api/login', { email, password }); | ||||
|       toast.success(res.data?.message || 'Login successful!'); | ||||
|       router.push('/adminDashboard'); | ||||
|       router.refresh(); | ||||
|       // token cookie is already set by the server:
 | ||||
|     } catch (err: any) { | ||||
|             console.error("Login error:", err) | ||||
|             toast.error(err.response?.data?.error || "Invalid credentials") | ||||
|       console.error('Login error:', err); | ||||
|       const msg = | ||||
|         err?.response?.data?.message || | ||||
|         err?.message || | ||||
|         'Invalid credentials'; | ||||
|       toast.error(msg); | ||||
|     } finally { | ||||
|             setLoading(false) | ||||
|         } | ||||
|       setLoading(false); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   return ( | ||||
|     <form className="space-y-3 dark:text-white" onSubmit={submitForm}> | ||||
|       <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"> | ||||
|                     <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Enter Email" className="form-input ps-10 placeholder:text-white-dark" /> | ||||
|           <input | ||||
|             id="Email" | ||||
|             type="email" | ||||
|             value={email} | ||||
|             onChange={(e) => setEmail(e.target.value)} | ||||
|             placeholder="Enter Email" | ||||
|             className="form-input ps-10 placeholder:text-white-dark" | ||||
|             required | ||||
|           /> | ||||
|           <span className="absolute start-4 top-1/2 -translate-y-1/2"> | ||||
|             <IconMail fill={true} /> | ||||
|           </span> | ||||
|         </div> | ||||
|       </div> | ||||
|             <div className= "pb-2"> | ||||
|                 <label htmlFor="Password" className='text-yellow-400 text-left'>Password</label> | ||||
|       <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" /> | ||||
|           <input | ||||
|             id="Password" | ||||
|             type="password" | ||||
|             value={password} | ||||
|             onChange={(e) => setPassword(e.target.value)} | ||||
|             required | ||||
|             placeholder="Enter Password" | ||||
|             className="form-input ps-10 placeholder:text-white-dark" | ||||
|             minLength={8} | ||||
|           /> | ||||
|           <span className="absolute start-4 top-1/2 -translate-y-1/2"> | ||||
|             <IconLockDots fill={true} /> | ||||
|           </span> | ||||
| @ -57,12 +73,13 @@ const ComponentsAuthLoginForm = () => { | ||||
|       <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" | ||||
|         className="w-full uppercase border-0 rounded-md bg-[#fcd913] text-black font-semibold py-2 hover:bg-[#E6C812] transition disabled:opacity-70" | ||||
|       > | ||||
|                 {loading ? "Logging in..." : "Sign In"} | ||||
|         {loading ? 'Logging in...' : 'Sign In'} | ||||
|       </button> | ||||
|     </form> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default ComponentsAuthLoginForm; | ||||
| 
 | ||||
|  | ||||
| @ -1,74 +1,131 @@ | ||||
| 'use client'; | ||||
| import IconLockDots from '@/components/icon/icon-lock-dots'; | ||||
| import IconMail from '@/components/icon/icon-mail'; | ||||
| import IconUser from '@/components/icon/icon-user'; | ||||
| import axios from 'axios'; | ||||
| import { useRouter } from 'next/navigation'; | ||||
| import { useState } from "react"; | ||||
| import React from 'react'; | ||||
| import toast from 'react-hot-toast'; | ||||
| // components/auth/components-auth-register-form.tsx
 | ||||
| "use client"; | ||||
| 
 | ||||
| const ComponentsAuthRegisterForm = () => { | ||||
|     const [email, setEmail] = useState("") | ||||
|     const [password, setPassword] = useState("") | ||||
|     const [loading, setLoading] = useState(false) | ||||
|     const router = useRouter() | ||||
| import * as React from "react"; | ||||
| import { useRouter } from "next/navigation"; | ||||
| 
 | ||||
|     const submitForm = async(e: any) => { | ||||
|         e.preventDefault() | ||||
| type Props = { | ||||
|   redirectTo?: string; // optional override
 | ||||
| }; | ||||
| 
 | ||||
|         setLoading(true) | ||||
|         try { | ||||
|             const res = await axios.post(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/register`, { | ||||
|                 email, | ||||
|                 password, | ||||
|             }) | ||||
| 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); | ||||
| 
 | ||||
|             localStorage.setItem("token", res.data.token) | ||||
|   async function onSubmit(e: React.FormEvent) { | ||||
|     e.preventDefault(); | ||||
|     setError(null); | ||||
| 
 | ||||
|             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) | ||||
|     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 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> */} | ||||
|     <form onSubmit={onSubmit} className="space-y-4 text-left"> | ||||
|       <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> | ||||
|         <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 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> | ||||
|         <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> | ||||
|             </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"} | ||||
| 
 | ||||
|       {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> | ||||
|   ); | ||||
| }; | ||||
| } | ||||
| 
 | ||||
| export default ComponentsAuthRegisterForm; | ||||
|  | ||||
| @ -19,6 +19,7 @@ import DatePicker from 'react-datepicker'; | ||||
| import 'react-datepicker/dist/react-datepicker.css'; | ||||
| import './datepicker-dark.css'; // custom dark mode styles
 | ||||
| 
 | ||||
| 
 | ||||
| ChartJS.register(zoomPlugin); | ||||
| 
 | ||||
| interface TimeSeriesEntry { | ||||
| @ -30,9 +31,48 @@ interface EnergyLineChartProps { | ||||
|   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( | ||||
|   data: TimeSeriesEntry[], | ||||
|   mode: 'day' | 'daily' | 'weekly' | 'monthly' | 'yearly' | ||||
|   mode: 'day' | 'daily' | 'weekly' | 'monthly' | 'yearly', | ||||
|   agg: 'mean' | 'max' | 'sum' = 'mean' | ||||
| ): TimeSeriesEntry[] { | ||||
|   const groupMap = new Map<string, number[]>(); | ||||
| 
 | ||||
| @ -41,19 +81,22 @@ function groupTimeSeries( | ||||
|     let key = ''; | ||||
| 
 | ||||
|     switch (mode) { | ||||
|       case 'day': | ||||
|         const local = new Date(date.toLocaleString('en-US', { timeZone: 'Asia/Kuala_Lumpur' })); | ||||
|         const hour = local.getHours(); | ||||
|         const minute = local.getMinutes() < 30 ? '00' : '30'; | ||||
|         const adjusted = new Date(local.setMinutes(minute === '00' ? 0 : 30, 0)); // zero seconds
 | ||||
|         key = adjusted.toISOString();  // ✅ full timestamp key
 | ||||
|       case 'day': { | ||||
|         const local = new Date( | ||||
|           date.toLocaleString('en-US', { timeZone: 'Asia/Kuala_Lumpur' }) | ||||
|         ); | ||||
|         const minute = local.getMinutes() < 30 ? 0 : 30; | ||||
|         local.setMinutes(minute, 0, 0); | ||||
|         key = local.toISOString(); | ||||
|         break; | ||||
|       } | ||||
|       case 'daily': | ||||
|         key = date.toLocaleDateString('en-MY', { | ||||
|           timeZone: 'Asia/Kuala_Lumpur', | ||||
|           weekday: 'short', | ||||
|           day: '2-digit', | ||||
|           month: 'short', | ||||
|           year: 'numeric', | ||||
|         }); | ||||
|         break; | ||||
|       case 'weekly': | ||||
| @ -71,12 +114,19 @@ function groupTimeSeries( | ||||
|     groupMap.get(key)!.push(entry.value); | ||||
|   } | ||||
| 
 | ||||
|   return Array.from(groupMap.entries()).map(([time, values]) => ({ | ||||
|     time, | ||||
|     value: values.reduce((sum, v) => sum + v, 0), | ||||
|   })); | ||||
|   return Array.from(groupMap.entries()).map(([time, values]) => { | ||||
|     if (agg === 'sum') { | ||||
|       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 chartRef = useRef<any>(null); | ||||
|   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 [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() { | ||||
|   const [isDark, setIsDark] = useState(() => | ||||
|     typeof document !== 'undefined' | ||||
| @ -140,7 +278,7 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => { | ||||
|         setGeneration(res.generation); | ||||
| 
 | ||||
|         // ⬇️ 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]; | ||||
| 
 | ||||
|       setForecast( | ||||
| @ -160,9 +298,40 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => { | ||||
|     fetchData(); | ||||
|   }, [siteId, viewMode, selectedDate]); | ||||
| 
 | ||||
|   const groupedConsumption = groupTimeSeries(consumption, viewMode); | ||||
|   const groupedGeneration = groupTimeSeries(generation, viewMode); | ||||
|   const groupedForecast = groupTimeSeries(forecast, viewMode); | ||||
|   const isEnergyView = viewMode !== 'day'; | ||||
| 
 | ||||
| // 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])); | ||||
| 
 | ||||
| 
 | ||||
| @ -224,6 +393,22 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => { | ||||
| 
 | ||||
| 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 = { | ||||
|     labels: filteredLabels.map(formatLabel), | ||||
| @ -231,26 +416,29 @@ const axisColor = isDark ? '#fff' : '#222'; | ||||
|       { | ||||
|         label: 'Consumption', | ||||
|         data: filteredConsumption, | ||||
|         borderColor: '#8884d8', | ||||
|         borderColor: consumptionColor, | ||||
|         backgroundColor: (ctx: any) => areaGradient(ctx, consumptionColor), | ||||
|         fill: true,                                   // <-- fill under line
 | ||||
|         tension: 0.4, | ||||
|         fill: false, | ||||
|         spanGaps: true, | ||||
|       }, | ||||
|       { | ||||
|         label: 'Generation', | ||||
|         data: filteredGeneration, | ||||
|         borderColor: '#82ca9d', | ||||
|         borderColor: generationColor, | ||||
|         backgroundColor: (ctx: any) => areaGradient(ctx, generationColor), | ||||
|         fill: true,                                   // <-- fill under line
 | ||||
|         tension: 0.4, | ||||
|         fill: false, | ||||
|         spanGaps: true, | ||||
|       }, | ||||
|       { | ||||
|       label: 'Forecasted Solar', | ||||
|       data: filteredForecast, | ||||
|       borderColor: '#ffa500', // orange
 | ||||
|       borderColor: '#fcd913', // orange
 | ||||
|       backgroundColor: (ctx: any) => areaGradient(ctx, '#fcd913', 0.18, 0.03), | ||||
|       tension: 0.4, | ||||
|       borderDash: [5, 5], // dashed line to distinguish forecast
 | ||||
|       fill: false, | ||||
|       fill: true, | ||||
|       spanGaps: true, | ||||
|     } | ||||
|     ], | ||||
| @ -283,6 +471,13 @@ const axisColor = isDark ? '#fff' : '#222'; | ||||
|       bodyColor: axisColor, | ||||
|       borderColor: isDark ? '#444' : '#ccc', | ||||
|       borderWidth: 1, | ||||
|       callbacks: { | ||||
|       label: (ctx: any) => { | ||||
|         const dsLabel = ctx.dataset.label || ''; | ||||
|         const val = ctx.parsed.y; | ||||
|         return `${dsLabel}: ${val?.toFixed(2)} ${yUnit}`; | ||||
|       }, | ||||
|     }, | ||||
|     }, | ||||
|     }, | ||||
|     scales: { | ||||
| @ -309,12 +504,7 @@ const axisColor = isDark ? '#fff' : '#222'; | ||||
|       y: { | ||||
|         beginAtZero: true, | ||||
|         suggestedMax: yAxisSuggestedMax, | ||||
|         title: { | ||||
|           display: true, | ||||
|           text: 'Power (kW)', | ||||
|           color: axisColor, | ||||
|           font: { weight: 'normal' as const }, | ||||
|         }, | ||||
|         title: { display: true, text: yTitle, color: axisColor, font: { weight: 'normal' as const } }, | ||||
|         ticks: { | ||||
|         color: axisColor, | ||||
|       }, | ||||
|  | ||||
| @ -1,4 +1,4 @@ | ||||
| import React, { useEffect, useState } from 'react'; | ||||
| import React, { useEffect, useState } from "react"; | ||||
| 
 | ||||
| interface KPI_TableProps { | ||||
|   siteId: string; | ||||
| @ -12,8 +12,8 @@ interface MonthlyKPI { | ||||
|   consumption_kwh: number | null; | ||||
|   grid_draw_kwh: number | null; | ||||
|   efficiency: number | null; | ||||
|   peak_demand_kw: number | null; // ✅ new
 | ||||
|   avg_power_factor: number | null; // ✅ new
 | ||||
|   peak_demand_kw: number | null; | ||||
|   avg_power_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); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     if (!siteId || !month) return; | ||||
| 
 | ||||
|     const fetchKPI = async () => { | ||||
|       setLoading(true); | ||||
|       try { | ||||
|         const res = await fetch(`http://localhost:8000/kpi/monthly?site=${siteId}&month=${month}`); | ||||
|         const data = await res.json(); | ||||
|         setKpiData(data); | ||||
|         const res = await fetch( | ||||
|           `http://localhost:8000/kpi/monthly?site=${siteId}&month=${month}` | ||||
|         ); | ||||
|         setKpiData(await res.json()); | ||||
|       } catch (err) { | ||||
|         console.error('Failed to fetch KPI:', err); | ||||
|         setKpiData(null); // fallback
 | ||||
|         console.error("Failed to fetch KPI:", err); | ||||
|         setKpiData(null); | ||||
|       } finally { | ||||
|         setLoading(false); | ||||
|       } | ||||
|     }; | ||||
| 
 | ||||
|     if (siteId && month) fetchKPI(); | ||||
|     fetchKPI(); | ||||
|   }, [siteId, month]); | ||||
| 
 | ||||
|   if (!siteId) { | ||||
|     return ( | ||||
|       <div> | ||||
|         <h2 className="text-lg font-bold mb-2">Monthly KPI</h2> | ||||
|         <div className="min-h-[275px] w-full flex items-center justify-center border"> | ||||
|           <p>No site selected</p> | ||||
|         </div> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
|   const formatValue = (value: number | null, unit = "", decimals = 2) => | ||||
|     value != null ? `${value.toFixed(decimals)}${unit}` : "—"; | ||||
| 
 | ||||
|   if (loading) { | ||||
|     return ( | ||||
|       <div> | ||||
|         <h2 className="text-lg font-bold mb-2">Monthly KPI</h2> | ||||
|         <div className="min-h-[275px] w-full flex items-center justify-center border"> | ||||
|           <p>Loading...</p> | ||||
|         </div> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   // Use optional chaining and nullish coalescing to safely default values to 0
 | ||||
|   const yield_kwh = kpiData?.yield_kwh ?? 0; | ||||
|   const consumption_kwh = kpiData?.consumption_kwh ?? 0; | ||||
|   const grid_draw_kwh = kpiData?.grid_draw_kwh ?? 0; | ||||
|   const efficiency = kpiData?.efficiency ?? 0; | ||||
|   const peak_demand_kw = kpiData?.peak_demand_kw ?? 0; | ||||
|   const power_factor = kpiData?.avg_power_factor ?? 0; | ||||
|   const load_factor = kpiData?.load_factor ?? 0; | ||||
| 
 | ||||
| const data = [ | ||||
|   { kpi: 'Monthly Yield', value: `${yield_kwh.toFixed(0)} kWh` }, | ||||
|   { kpi: 'Monthly Consumption', value: `${consumption_kwh.toFixed(0)} kWh` }, | ||||
|   { kpi: 'Monthly Grid Draw', value: `${grid_draw_kwh.toFixed(0)} kWh` }, | ||||
|   { kpi: 'Efficiency', value: `${efficiency.toFixed(1)}%` }, | ||||
|   { kpi: 'Peak Demand', value: `${peak_demand_kw.toFixed(2)} kW` }, // ✅ added
 | ||||
|   { kpi: 'Power Factor', value: `${power_factor.toFixed(2)} kW` }, // ✅ added
 | ||||
|   { kpi: 'Load Factor', value: `${load_factor.toFixed(2)} kW` }, // ✅ added
 | ||||
| ]; | ||||
|   const rows = [ | ||||
|     { label: "Monthly Yield", value: formatValue(kpiData?.yield_kwh ?? null, " kWh", 0) }, | ||||
|     { label: "Monthly Consumption", value: formatValue(kpiData?.consumption_kwh ?? null, " kWh", 0) }, | ||||
|     { label: "Monthly Grid Draw", value: formatValue(kpiData?.grid_draw_kwh ?? null, " kWh", 0) }, | ||||
|     { label: "Efficiency", value: formatValue(kpiData?.efficiency ?? null, "%", 1) }, | ||||
|     { label: "Peak Demand", value: formatValue(kpiData?.peak_demand_kw ?? null, " kW") }, | ||||
|     { label: "Power Factor", value: formatValue(kpiData?.avg_power_factor ?? null) }, | ||||
|     { label: "Load Factor", value: formatValue(kpiData?.load_factor ?? null) }, | ||||
|   ]; | ||||
| 
 | ||||
|   return ( | ||||
|     <div> | ||||
|       <h2 className="text-lg font-bold mb-2 dark:text-white">Monthly KPI</h2> | ||||
|       <table className="min-h-[275px] w-full border-collapse border border-gray-300 dark:border-rtgray-700 text-black dark:text-white bg-white dark:bg-rtgray-700"> | ||||
|       <div className="min-h-[275px] border rounded"> | ||||
|         {!siteId ? ( | ||||
|           <p className="text-center py-10">No site selected</p> | ||||
|         ) : loading ? ( | ||||
|           <p className="text-center py-10">Loading...</p> | ||||
|         ) : ( | ||||
|           <table className="w-full border-collapse"> | ||||
|             <thead> | ||||
|           <tr className="bg-rtgray-100 dark:bg-rtgray-800 text-black dark:text-white"> | ||||
|             <th className="border border-rtgray-300 dark:border-rtgray-700 p-3 text-left dark:bg-rtgray-900">KPI</th> | ||||
|             <th className="border border-rtgray-300 dark:border-rtgray-700 p-3 text-left dark:bg-rtgray-900">Value</th> | ||||
|               <tr className="bg-gray-100 dark:bg-rtgray-800"> | ||||
|                 <th className="border p-3 text-left dark:text-white">KPI</th> | ||||
|                 <th className="border p-3 text-left dark:text-white">Value</th> | ||||
|               </tr> | ||||
|             </thead> | ||||
|             <tbody> | ||||
|           {data.map((row) => ( | ||||
|             <tr key={row.kpi} className="even:bg-rtgray-50 dark:even:bg-rtgray-800"> | ||||
|               <td className="border border-rtgray-300 dark:border-rtgray-700 p-2.5">{row.kpi}</td> | ||||
|               <td className="border border-rtgray-300 dark:border-rtgray-700 p-2.5">{row.value}</td> | ||||
|               {rows.map((row) => ( | ||||
|                 <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> | ||||
|   ); | ||||
| }; | ||||
| @ -106,3 +89,4 @@ const data = [ | ||||
| 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 { | ||||
|   BarChart, | ||||
|   Bar, | ||||
| @ -9,46 +9,25 @@ import { | ||||
|   Legend, | ||||
| } from 'recharts'; | ||||
| import { format } from 'date-fns'; | ||||
| import { fetchPowerTimeseries } from '@/app/utils/api'; | ||||
| 
 | ||||
| import { fetchMonthlyKpi, type MonthlyKPI } from '@/app/utils/api'; | ||||
| 
 | ||||
| interface MonthlyBarChartProps { | ||||
|   siteId: string; | ||||
| } | ||||
| 
 | ||||
| interface TimeSeriesEntry { | ||||
|   time: string; | ||||
|   value: number; | ||||
| } | ||||
| 
 | ||||
| const groupTimeSeries = ( | ||||
|   data: TimeSeriesEntry[], | ||||
|   mode: 'monthly' | ||||
| ): TimeSeriesEntry[] => { | ||||
|   const groupMap = new Map<string, number[]>(); | ||||
| 
 | ||||
|   for (const entry of data) { | ||||
|     const date = new Date(entry.time); | ||||
|     const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; | ||||
|     if (!groupMap.has(key)) groupMap.set(key, []); | ||||
|     groupMap.get(key)!.push(entry.value); | ||||
| const getLastNMonthKeys = (n: number): string[] => { | ||||
|   const out: string[] = []; | ||||
|   const now = new Date(); | ||||
|   // include current month, go back n-1 months
 | ||||
|   for (let i = 0; i < n; i++) { | ||||
|     const d = new Date(now.getFullYear(), now.getMonth() - (n - 1 - i), 1); | ||||
|     const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; // YYYY-MM
 | ||||
|     out.push(key); | ||||
|   } | ||||
| 
 | ||||
|   return Array.from(groupMap.entries()).map(([time, values]) => ({ | ||||
|     time, | ||||
|     value: values.reduce((sum, v) => sum + v, 0), | ||||
|   })); | ||||
|   return out; | ||||
| }; | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| const MonthlyBarChart: React.FC<MonthlyBarChartProps> = ({ siteId }) => { | ||||
|   const [chartData, setChartData] = useState< | ||||
|     { month: string; consumption: number; generation: number }[] | ||||
|   >([]); | ||||
|   const [loading, setLoading] = useState(true); | ||||
| 
 | ||||
|   function useIsDarkMode() { | ||||
| function useIsDarkMode() { | ||||
|   const [isDark, setIsDark] = useState(() => | ||||
|     typeof document !== 'undefined' | ||||
|       ? document.body.classList.contains('dark') | ||||
| @ -58,79 +37,82 @@ const MonthlyBarChart: React.FC<MonthlyBarChartProps> = ({ siteId }) => { | ||||
|   useEffect(() => { | ||||
|     const check = () => setIsDark(document.body.classList.contains('dark')); | ||||
|     check(); | ||||
| 
 | ||||
|     // Listen for class changes on <body>
 | ||||
|     const observer = new MutationObserver(check); | ||||
|     observer.observe(document.body, { attributes: true, attributeFilter: ['class'] }); | ||||
| 
 | ||||
|     return () => observer.disconnect(); | ||||
|   }, []); | ||||
| 
 | ||||
|   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 generationColor = isDark ? '#fcd913' : '#669bbc';   | ||||
|   const isDark = useIsDarkMode(); | ||||
|   const consumptionColor = isDark ? '#ba8e23' : '#003049'; | ||||
|   const generationColor = isDark ? '#fcd913' : '#669bbc'; | ||||
| 
 | ||||
|   const monthKeys = useMemo(() => getLastNMonthKeys(6), []); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     if (!siteId) return; | ||||
| 
 | ||||
|     const fetchMonthlyData = async () => { | ||||
|     const load = async () => { | ||||
|       setLoading(true); | ||||
|       const start = '2025-01-01T00:00:00+08:00'; | ||||
|       const end = '2025-12-31T23:59:59+08:00'; | ||||
| 
 | ||||
|       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'); | ||||
|         const groupedGeneration = groupTimeSeries(res.generation, 'monthly'); | ||||
|         // Map to chart rows; default nulls to 0 for stacking/tooltip friendliness
 | ||||
|         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 }>(); | ||||
| 
 | ||||
|         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([]); | ||||
|         setChartData(rows); | ||||
|       } finally { | ||||
|         setLoading(false); | ||||
|       } | ||||
|     }; | ||||
| 
 | ||||
|     fetchMonthlyData(); | ||||
|   }, [siteId]); | ||||
|     load(); | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
|   }, [siteId]); // monthKeys are stable via useMemo
 | ||||
| 
 | ||||
|   if (loading || !siteId || chartData.length === 0) { | ||||
|     return ( | ||||
|       <div className="bg-white p-4 rounded-lg shadow-md dark:bg-rtgray-800 dark:text-white-light"> | ||||
|         <div className="flex justify-between items-center mb-2"> | ||||
|           <h2 className="text-lg font-bold pb-3">Monthly Energy Yield</h2> | ||||
|         </div> | ||||
|         <div className="h-96 w-full flex items-center justify-center"> | ||||
|       <div className="bg-white p-3 rounded-lg shadow-md dark:bg-rtgray-800 dark:text-white-light"> | ||||
|         <div className="h-[200px] w-full flex items-center justify-center"> | ||||
|           <p className="text-white/70"> | ||||
|             {loading ? 'Loading data...' : 'No data available for chart.'} | ||||
|           </p> | ||||
| @ -140,12 +122,8 @@ const generationColor = isDark ? '#fcd913' : '#669bbc'; | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <div className="bg-white p-4 rounded-lg shadow-md dark:bg-rtgray-800 dark:text-white-light"> | ||||
|       <div className="flex justify-between items-center mb-2"> | ||||
|         <h2 className="text-lg font-bold pb-3">Monthly Energy Yield</h2> | ||||
|       </div> | ||||
| 
 | ||||
|       <div className="lg:h-[22.6vw] h-[290px] w-full pt-10"> | ||||
|     <div className="bg-white p-3 rounded-lg dark:bg-rtgray-800 dark:text-white-light"> | ||||
|       <div className="h-[200px] w-full"> | ||||
|         <ResponsiveContainer width="100%" height="100%"> | ||||
|           <BarChart data={chartData}> | ||||
|             <XAxis | ||||
| @ -158,6 +136,16 @@ const generationColor = isDark ? '#fcd913' : '#669bbc'; | ||||
|               tick={{ fontSize: 10, fill: isDark ? '#fff' : '#222' }} | ||||
|               axisLine={{ 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 | ||||
|               formatter={(value: number) => [`${value.toFixed(2)} kWh`]} | ||||
| @ -171,15 +159,11 @@ const generationColor = isDark ? '#fcd913' : '#669bbc'; | ||||
|                 color: isDark ? '#fff' : '#222', | ||||
|               }} | ||||
|               cursor={{ | ||||
|                 fill: isDark ? '#808080' : '#e0e7ef', // dark mode bg, light mode bg
 | ||||
|                 fillOpacity: isDark ? 0.6 : 0.3,      // adjust opacity as you like
 | ||||
|               }} | ||||
|             /> | ||||
|             <Legend | ||||
|               wrapperStyle={{ | ||||
|                 color: isDark ? '#fff' : '#222', | ||||
|                 fill: isDark ? '#808080' : '#e0e7ef', | ||||
|                 fillOpacity: isDark ? 0.6 : 0.3, | ||||
|               }} | ||||
|             /> | ||||
|             <Legend wrapperStyle={{ color: isDark ? '#fff' : '#222' }} /> | ||||
|             <Bar dataKey="consumption" fill={consumptionColor} name="Consumption (kWh)" /> | ||||
|             <Bar dataKey="generation" fill={generationColor} name="Generation (kWh)" /> | ||||
|           </BarChart> | ||||
| @ -191,3 +175,4 @@ const generationColor = isDark ? '#fcd913' : '#669bbc'; | ||||
| 
 | ||||
| export default MonthlyBarChart; | ||||
| 
 | ||||
| 
 | ||||
|  | ||||
| @ -1,26 +1,111 @@ | ||||
| // components/dashboards/SiteCard.tsx
 | ||||
| import React from 'react'; | ||||
| import Link from 'next/link'; // Import Link from Next.js
 | ||||
| import { SiteName, SiteDetails } from '@/types/SiteData'; // Adjust path as necessary
 | ||||
| 'use client'; | ||||
| 
 | ||||
| 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 { | ||||
|     siteName: SiteName; | ||||
|     details: SiteDetails; | ||||
|     status: string; | ||||
|   siteId: string;                // CRM Project "name" (canonical id)
 | ||||
|   className?: string;            // optional styling hook
 | ||||
|   fallbackStatus?: string;       // optional backup status if CRM is missing it
 | ||||
| } | ||||
| 
 | ||||
| const SiteCard: React.FC<SiteCardProps> = ({ siteName, details, status }) => { | ||||
| const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000'; | ||||
| 
 | ||||
| const SiteCard: React.FC<SiteCardProps> = ({ siteId, className = '', fallbackStatus }) => { | ||||
|   const [project, setProject] = useState<CrmProject | null>(null); | ||||
|   const [loading, setLoading] = useState(true); | ||||
|   const [err, setErr] = useState<string | null>(null); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     let cancelled = false; | ||||
| 
 | ||||
|     const fetchProject = async () => { | ||||
|       setLoading(true); | ||||
|       setErr(null); | ||||
|       try { | ||||
|         // ---- Try a single-project endpoint first (best) ----
 | ||||
|         // e.g. GET /crm/projects/PROJ-0008
 | ||||
|         const single = await fetch(`${API}/crm/projects/${encodeURIComponent(siteId)}`); | ||||
|         if (single.ok) { | ||||
|           const pj = await single.json(); | ||||
|           if (!cancelled) setProject(pj?.data ?? pj ?? null); | ||||
|         } else { | ||||
|           // ---- Fallback: fetch all and find by name (works with your existing API) ----
 | ||||
|           const list = await fetch(`${API}/crm/projects?limit=0`); | ||||
|           if (!list.ok) throw new Error(await list.text()); | ||||
|           const json = await list.json(); | ||||
|           const found = (json?.data ?? []).find((p: CrmProject) => p.name === siteId) ?? null; | ||||
|           if (!cancelled) setProject(found); | ||||
|         } | ||||
|       } catch (e: any) { | ||||
|         if (!cancelled) setErr(e?.message ?? 'Failed to load CRM project'); | ||||
|       } finally { | ||||
|         if (!cancelled) setLoading(false); | ||||
|       } | ||||
|     }; | ||||
| 
 | ||||
|     fetchProject(); | ||||
|     return () => { cancelled = true; }; | ||||
|   }, [siteId]); | ||||
| 
 | ||||
|   const status = project?.status || fallbackStatus || 'Unknown'; | ||||
|   const statusColorClass = | ||||
|     status === 'Active' ? 'text-green-500' : | ||||
|     status === 'Inactive' ? 'text-orange-500' : | ||||
|     'text-red-500'; | ||||
| 
 | ||||
|   const niceAddress = useMemo(() => { | ||||
|     if (!project?.custom_address) return 'N/A'; | ||||
|     return formatAddress(project.custom_address).multiLine; | ||||
|   }, [project?.custom_address]); | ||||
| 
 | ||||
|   const lastSync = useMemo(() => { | ||||
|     return formatCrmTimestamp(project?.modified, { includeSeconds: true }) || 'N/A'; | ||||
|   }, [project?.modified]); | ||||
| 
 | ||||
|   const inverterProvider = project?.project_type || 'N/A'; | ||||
|   const emergencyContact = | ||||
|     project?.custom_mobile_phone_no || | ||||
|     project?.custom_email || | ||||
|     project?.customer || | ||||
|     'N/A'; | ||||
| 
 | ||||
|   return ( | ||||
|         <div className="bg-white p-4 rounded-lg shadow-md dark:bg-gray-800 dark:text-white-light flex flex-col space-y-2"> | ||||
|     <div className={`bg-white p-4 rounded-lg shadow-md dark:bg-rtgray-800 dark:text-white-light flex flex-col space-y-2 ${className}`}> | ||||
|       <h3 className="text-xl font-bold text-primary-600 dark:text-primary-400 border-b pb-2 mb-2"> | ||||
|                 {siteName} | ||||
|         {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> | ||||
|       ) : 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> | ||||
| @ -28,31 +113,29 @@ const SiteCard: React.FC<SiteCardProps> = ({ siteName, details, status }) => { | ||||
| 
 | ||||
|           <div className="flex justify-between items-center"> | ||||
|             <p className="text-gray-600 dark:text-gray-400 font-medium">Location:</p> | ||||
|                 <p className="font-semibold">{details.location}</p> | ||||
|             <p className="font-medium whitespace-pre-line text-right">{niceAddress}</p> | ||||
|           </div> | ||||
| 
 | ||||
|           <div className="flex justify-between items-center"> | ||||
|             <p className="text-gray-600 dark:text-gray-400 font-medium">Inverter Provider:</p> | ||||
|                 <p className="font-semibold">{details.inverterProvider}</p> | ||||
|             <p className="font-medium">{inverterProvider}</p> | ||||
|           </div> | ||||
| 
 | ||||
|           <div className="flex justify-between items-center"> | ||||
|             <p className="text-gray-600 dark:text-gray-400 font-medium">Emergency Contact:</p> | ||||
|                 <p className="font-semibold">{details.emergencyContact}</p> | ||||
|             <p className="font-medium text-right">{emergencyContact}</p> | ||||
|           </div> | ||||
| 
 | ||||
|           <div className="flex justify-between items-center"> | ||||
|             <p className="text-gray-600 dark:text-gray-400 font-medium">Last Sync:</p> | ||||
|                 <p className="font-semibold">{details.lastSyncTimestamp}</p> | ||||
|             <p className="font-medium">{lastSync}</p> | ||||
|           </div> | ||||
|         </> | ||||
|       )} | ||||
| 
 | ||||
|             {/* New: View Dashboard Button */} | ||||
|       <Link | ||||
|                 href={{ | ||||
|                     pathname: '/adminDashboard', // Path to your AdminDashboard page
 | ||||
|                     query: { site: siteName }, // Pass the siteName as a query parameter
 | ||||
|                 }} | ||||
|                 className="mt-4 w-full text-center text-sm btn-primary" // Tailwind classes for basic button styling
 | ||||
|         href={{ pathname: '/adminDashboard', query: { site: siteId } }} | ||||
|         className="mt-4 w-full text-center text-sm btn-primary" | ||||
|       > | ||||
|         View Dashboard | ||||
|       </Link> | ||||
|  | ||||
| @ -1,26 +1,51 @@ | ||||
| 'use client'; | ||||
| 
 | ||||
| import type { SiteName } from '@/components/dashboards/SiteStatus'; | ||||
| type Option = { label: string; value: string }; | ||||
| 
 | ||||
| type SiteSelectorProps = { | ||||
|   selectedSite: SiteName; | ||||
|   setSelectedSite: (site: SiteName) => void; | ||||
|   options: Option[];                 // e.g. [{label: 'Timo… (Installation)', value: 'PROJ-0008'}, …]
 | ||||
|   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 ( | ||||
|     <div className="flex flex-col "> | ||||
|       <label htmlFor="site" className="font-semibold text-lg dark:text-white">Select Site:</label> | ||||
|     <div className="flex flex-col"> | ||||
|       <label htmlFor="site" className="font-semibold text-lg dark:text-white"> | ||||
|         {label} | ||||
|       </label> | ||||
| 
 | ||||
|       <select | ||||
|         id="site" | ||||
|         className="border p-2 rounded dark:text-white dark:bg-rtgray-800 dark:border-rtgray-700" | ||||
|         value={selectedSite} | ||||
|         onChange={(e) => setSelectedSite(e.target.value as SiteName)} | ||||
|         value={selectedValue ?? ''}                 // keep controlled even when null
 | ||||
|         onChange={(e) => onChange(e.target.value)} | ||||
|         disabled={disabled || isEmpty} | ||||
|       > | ||||
|         <option>Site A</option> | ||||
|         <option>Site B</option> | ||||
|         <option>Site C</option> | ||||
|         {/* Placeholder when nothing selected */} | ||||
|         <option value="" disabled> | ||||
|           {isEmpty ? 'No sites available' : 'Choose a site…'} | ||||
|         </option> | ||||
| 
 | ||||
|         {options.map((opt) => ( | ||||
|           <option key={opt.value} value={opt.value}> | ||||
|             {opt.label} | ||||
|           </option> | ||||
|         ))} | ||||
|       </select> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default SiteSelector; | ||||
| 
 | ||||
|  | ||||
| @ -1,82 +1,80 @@ | ||||
| import axios from "axios"; | ||||
| import React, { useState, useEffect } from "react"; | ||||
| 'use client'; | ||||
| 
 | ||||
| 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 { | ||||
|     selectedSite: SiteName; | ||||
|   selectedSite: string;   // display label (e.g., CRM project_name)
 | ||||
|   siteId: string;         // canonical id (e.g., CRM Project.name like PROJ-0008)
 | ||||
|   status?: string;        // CRM status (Open/Completed/On Hold/…)
 | ||||
|   location: string; | ||||
|   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 = ({ | ||||
|   selectedSite, | ||||
|   siteId, | ||||
|   status, | ||||
|   location, | ||||
|   inverterProvider, | ||||
|   emergencyContact, | ||||
|   lastSyncTimestamp, | ||||
| }: SiteStatusProps) => { | ||||
| 
 | ||||
|   // --- WebSocket to receive MQTT-forwarded messages ---
 | ||||
|   useEffect(() => { | ||||
|     const ws = new WebSocket("ws://localhost:8000/ws"); | ||||
| 
 | ||||
|     ws.onmessage = (event) => { | ||||
|         const data = event.data; | ||||
|         alert(`MQTT: ${data}`); | ||||
|     }; | ||||
|     const ws = new WebSocket(WS_URL); | ||||
| 
 | ||||
|     ws.onopen = () => console.log("WebSocket connected"); | ||||
|     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(); | ||||
| }, []); | ||||
| 
 | ||||
| 
 | ||||
|   }, []); | ||||
| 
 | ||||
|   const [showModal, setShowModal] = useState(false); | ||||
|   const [deviceId, setDeviceId] = useState(""); | ||||
|     const [functionType, setFunctionType] = useState("Grid"); | ||||
|   const [functionType, setFunctionType] = useState<"Grid" | "Solar">("Grid"); | ||||
| 
 | ||||
|     // Map site names to site IDs
 | ||||
|     const siteIdMap: Record<SiteName, string> = { | ||||
|         "Site A": "site_01", | ||||
|         "Site B": "site_02", | ||||
|         "Site C": "site_03", | ||||
|     }; | ||||
|   // Track devices connected per siteId (dynamic)
 | ||||
|   const [loggedDevices, setLoggedDevices] = useState<Record<string, string[]>>({}); | ||||
|   const devicesAtSite = loggedDevices[siteId] ?? []; | ||||
| 
 | ||||
|     // Track devices connected per site
 | ||||
|     const [loggedDevices, setLoggedDevices] = useState<Record<string, string[]>>({ | ||||
|         site_01: [], | ||||
|         site_02: [], | ||||
|         site_03: [], | ||||
|     }); | ||||
| 
 | ||||
|     const siteId = siteIdMap[selectedSite]; | ||||
|     const devicesAtSite = loggedDevices[siteId] || []; | ||||
| 
 | ||||
|     const handleStartLogging = () => { | ||||
|         setShowModal(true); | ||||
|     }; | ||||
|   const handleStartLogging = () => setShowModal(true); | ||||
| 
 | ||||
|   const handleConfirm = async () => { | ||||
|         const siteId = siteIdMap[selectedSite]; | ||||
|         const topic = `ADW300/${siteId}/${deviceId}/${functionType.toLowerCase()}`; | ||||
|     const id = deviceId.trim(); | ||||
|     if (!id) return; | ||||
| 
 | ||||
|     const topic = `ADW300/${siteId}/${id}/${functionType.toLowerCase()}`; | ||||
| 
 | ||||
|     try { | ||||
|             const response = await axios.post("http://localhost:8000/start-logging", { | ||||
|                 topics: [topic], | ||||
|             }); | ||||
|       const response = await axios.post(`${API_URL}/start-logging`, { topics: [topic] }); | ||||
|       console.log("Started logging:", response.data); | ||||
| 
 | ||||
|             // Add device to list
 | ||||
|             setLoggedDevices((prev) => ({ | ||||
|       setLoggedDevices(prev => ({ | ||||
|         ...prev, | ||||
|                 [siteId]: [...(prev[siteId] || []), deviceId], | ||||
|         [siteId]: [...(prev[siteId] ?? []), id], | ||||
|       })); | ||||
|       setShowModal(false); | ||||
| 
 | ||||
|       setDeviceId(""); | ||||
|     } catch (error) { | ||||
|       console.error("Failed to start logging:", error); | ||||
|     } | ||||
| @ -84,40 +82,37 @@ const SiteStatus = ({ | ||||
| 
 | ||||
|   const handleStopLogging = async () => { | ||||
|     try { | ||||
|             await axios.post("http://localhost:8000/stop-logging"); | ||||
| 
 | ||||
|             // Clear all devices for the site (or modify to remove only specific one)
 | ||||
|             setLoggedDevices((prev) => ({ | ||||
|                 ...prev, | ||||
|                 [siteId]: [], | ||||
|             })); | ||||
|       // 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 } : {}); | ||||
| 
 | ||||
|       setLoggedDevices(prev => ({ ...prev, [siteId]: [] })); | ||||
|       console.log("Stopped logging for", siteId); | ||||
|     } catch (error) { | ||||
|       console.error("Failed to stop logging:", error); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|     const statusMap: Record<SiteName, string> = { | ||||
|         'Site A': 'Active', | ||||
|         'Site B': 'Inactive', | ||||
|         'Site C': 'Faulty', | ||||
|     }; | ||||
|   const statusClass = useMemo(() => { | ||||
|     const s = (status ?? "").toLowerCase(); | ||||
|     if (s === "open" || s === "active") return "text-green-500"; | ||||
|     if (s === "completed" || s === "closed") return "text-blue-500"; | ||||
|     if (s === "inactive" || s === "on hold") return "text-orange-500"; | ||||
|     if (s === "faulty" || s === "cancelled") return "text-red-500"; | ||||
|     return "text-gray-500"; | ||||
|   }, [status]); | ||||
| 
 | ||||
|   return ( | ||||
|     <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> | ||||
| 
 | ||||
|             {/* Status */} | ||||
|       {/* Status (from CRM) */} | ||||
|       <div className="flex justify-between items-center text-base"> | ||||
|         <p className="text-gray-600 dark:text-white/85 font-medium">Status:</p> | ||||
|                 <p className={`font-semibold ${ | ||||
|                     statusMap[selectedSite] === 'Active' ? 'text-green-500' : | ||||
|                     statusMap[selectedSite] === 'Inactive' ? 'text-orange-500' : | ||||
|                     'text-red-500' | ||||
|                 }`}>
 | ||||
|                     {statusMap[selectedSite]} | ||||
|                 </p> | ||||
|         <p className={`font-semibold ${statusClass}`}>{status ?? "—"}</p> | ||||
|       </div> | ||||
| 
 | ||||
|       {/* Site ID */} | ||||
| @ -149,69 +144,9 @@ const SiteStatus = ({ | ||||
|         <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; | ||||
| 
 | ||||
|  | ||||
							
								
								
									
										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'; | ||||
| import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'; | ||||
| import { usePopper } from 'react-popper'; | ||||
| import type { ReactNode } from 'react'; | ||||
| 
 | ||||
| const Dropdown = (props: any, forwardedRef: any) => { | ||||
|     const [visibility, setVisibility] = useState<any>(false); | ||||
| type DropdownProps = { | ||||
|   button?: ReactNode;             // 👈 make optional
 | ||||
|   children: ReactNode; | ||||
|   btnClassName?: string; | ||||
|   placement?: any; | ||||
|   offset?: [number, number]; | ||||
|   panelClassName?: string; | ||||
|   closeOnItemClick?: boolean; | ||||
| }; | ||||
| 
 | ||||
|     const referenceRef = useRef<any>(); | ||||
|     const popperRef = useRef<any>(); | ||||
| const Dropdown = (props: DropdownProps, forwardedRef: 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, { | ||||
|     placement: props.placement || 'bottom-end', | ||||
|         modifiers: [ | ||||
|             { | ||||
|                 name: 'offset', | ||||
|                 options: { | ||||
|                     offset: props.offset || [0], | ||||
|                 }, | ||||
|             }, | ||||
|         ], | ||||
|     modifiers: [{ name: 'offset', options: { offset: props.offset ?? [0, 8] } }], | ||||
|   }); | ||||
| 
 | ||||
|     const handleDocumentClick = (event: any) => { | ||||
|         if (referenceRef.current.contains(event.target) || popperRef.current.contains(event.target)) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         setVisibility(false); | ||||
|     }; | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|         document.addEventListener('mousedown', handleDocumentClick); | ||||
|         return () => { | ||||
|             document.removeEventListener('mousedown', handleDocumentClick); | ||||
|     const onDoc = (e: MouseEvent) => { | ||||
|       if (!referenceRef.current || !popperRef.current) return; | ||||
|       if (referenceRef.current.contains(e.target as Node)) return; | ||||
|       if (popperRef.current.contains(e.target as Node)) return; | ||||
|       setVisible(false); | ||||
|     }; | ||||
|     document.addEventListener('mousedown', onDoc); | ||||
|     return () => document.removeEventListener('mousedown', onDoc); | ||||
|   }, []); | ||||
| 
 | ||||
|     useImperativeHandle(forwardedRef, () => ({ | ||||
|         close() { | ||||
|             setVisibility(false); | ||||
|         }, | ||||
|     })); | ||||
|   useImperativeHandle(forwardedRef, () => ({ close: () => setVisible(false) })); | ||||
| 
 | ||||
|   const defaultButton = ( | ||||
|     <span className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-gray-200 dark:bg-rtgray-700" /> | ||||
|   ); | ||||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|             <button ref={referenceRef} type="button" className={props.btnClassName} onClick={() => setVisibility(!visibility)}> | ||||
|                 {props.button} | ||||
|       <button | ||||
|         ref={referenceRef} | ||||
|         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)}> | ||||
|                 {visibility && props.children} | ||||
|       <div | ||||
|         ref={popperRef} | ||||
|         style={styles.popper} | ||||
|         {...attributes.popper} | ||||
|         className="z-50" | ||||
|       > | ||||
|         {visible && ( | ||||
|           <div className={props.panelClassName ?? 'rounded-lg bg-white dark:bg-rtgray-700 shadow-lg ring-1 ring-black/5'}> | ||||
|             {props.children} | ||||
|           </div> | ||||
|         )} | ||||
|       </div> | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default forwardRef(Dropdown); | ||||
| 
 | ||||
| 
 | ||||
|  | ||||
| @ -4,253 +4,175 @@ import { useDispatch, useSelector } from 'react-redux'; | ||||
| import Link from 'next/link'; | ||||
| import { IRootState } from '@/store'; | ||||
| import { toggleTheme, toggleSidebar, toggleRTL } from '@/store/themeConfigSlice'; | ||||
| import Image from 'next/image'; | ||||
| import Dropdown from '@/components/dropdown'; | ||||
| 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 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 IconMail from '@/components/icon/icon-mail'; | ||||
| import IconLockDots from '@/components/icon/icon-lock-dots'; | ||||
| 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 { getTranslation } from '@/i18n'; | ||||
| 
 | ||||
| const Header = () => { | ||||
| type UserData = { id: string; email: string; createdAt: string }; | ||||
| 
 | ||||
| export default function Header() { | ||||
|   const pathname = usePathname(); | ||||
|   const dispatch = useDispatch(); | ||||
|   const router = useRouter(); | ||||
|     const { t, i18n } = getTranslation(); | ||||
|   const themeConfig = useSelector((state: IRootState) => state.themeConfig); | ||||
|   const isRtl = themeConfig.rtlClass === 'rtl'; | ||||
| 
 | ||||
|   const [user, setUser] = useState<UserData | null>(null); | ||||
|   const [loadingUser, setLoadingUser] = useState(true); | ||||
| 
 | ||||
|   // Highlight active menu (your original effect)
 | ||||
|   useEffect(() => { | ||||
|         const selector = document.querySelector('ul.horizontal-menu a[href="' + window.location.pathname + '"]'); | ||||
|     const selector = document.querySelector( | ||||
|       'ul.horizontal-menu a[href="' + window.location.pathname + '"]' | ||||
|     ); | ||||
|     if (selector) { | ||||
|             const all: any = document.querySelectorAll('ul.horizontal-menu .nav-link.active'); | ||||
|             for (let i = 0; i < all.length; i++) { | ||||
|                 all[0]?.classList.remove('active'); | ||||
|             } | ||||
| 
 | ||||
|             let allLinks = document.querySelectorAll('ul.horizontal-menu a.active'); | ||||
|             for (let i = 0; i < allLinks.length; i++) { | ||||
|                 const element = allLinks[i]; | ||||
|                 element?.classList.remove('active'); | ||||
|             } | ||||
|             selector?.classList.add('active'); | ||||
| 
 | ||||
|       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) { | ||||
|                 let ele: any = ul.closest('li.menu').querySelectorAll('.nav-link'); | ||||
|                 if (ele) { | ||||
|                     ele = ele[0]; | ||||
|                     setTimeout(() => { | ||||
|                         ele?.classList.add('active'); | ||||
|                     }); | ||||
|                 } | ||||
|         const ele: any = ul.closest('li.menu')?.querySelector('.nav-link'); | ||||
|         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')); | ||||
|   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); | ||||
|   } | ||||
|         router.refresh(); | ||||
| } | ||||
| 
 | ||||
| useEffect(() => { | ||||
|   setLoadingUser(true); | ||||
|   loadUser(); | ||||
|   // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
| }, [pathname]); // re-fetch on route change (after login redirect)
 | ||||
| 
 | ||||
|   const handleLogout = async () => { | ||||
|     await fetch('/api/auth/logout', { method: 'POST' }); | ||||
|     setUser(null); | ||||
|     router.push('/login'); // go to login
 | ||||
|   }; | ||||
| 
 | ||||
|     function createMarkup(messages: any) { | ||||
|         return { __html: messages }; | ||||
|     } | ||||
|     const [messages, setMessages] = useState([ | ||||
|         { | ||||
|             id: 1, | ||||
|             image: '<span class="grid place-content-center w-9 h-9 rounded-full bg-success-light dark:bg-success text-success dark:text-success-light"><svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path></svg></span>', | ||||
|             title: 'Congratulations!', | ||||
|             message: 'Your OS has been updated.', | ||||
|             time: '1hr', | ||||
|         }, | ||||
|         { | ||||
|             id: 2, | ||||
|             image: '<span class="grid place-content-center w-9 h-9 rounded-full bg-info-light dark:bg-info text-info dark:text-info-light"><svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg></span>', | ||||
|             title: 'Did you know?', | ||||
|             message: 'You can switch between artboards.', | ||||
|             time: '2hr', | ||||
|         }, | ||||
|         { | ||||
|             id: 3, | ||||
|             image: '<span class="grid place-content-center w-9 h-9 rounded-full bg-danger-light dark:bg-danger text-danger dark:text-danger-light"> <svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></span>', | ||||
|             title: 'Something went wrong!', | ||||
|             message: 'Send Reposrt', | ||||
|             time: '2days', | ||||
|         }, | ||||
|         { | ||||
|             id: 4, | ||||
|             image: '<span class="grid place-content-center w-9 h-9 rounded-full bg-warning-light dark:bg-warning text-warning dark:text-warning-light"><svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">    <circle cx="12" cy="12" r="10"></circle>    <line x1="12" y1="8" x2="12" y2="12"></line>    <line x1="12" y1="16" x2="12.01" y2="16"></line></svg></span>', | ||||
|             title: 'Warning', | ||||
|             message: 'Your password strength is low.', | ||||
|             time: '5days', | ||||
|         }, | ||||
|     ]); | ||||
| 
 | ||||
|     const removeMessage = (value: number) => { | ||||
|         setMessages(messages.filter((user) => user.id !== value)); | ||||
|     }; | ||||
| 
 | ||||
|     const [notifications, setNotifications] = useState([ | ||||
|         { | ||||
|             id: 1, | ||||
|             profile: 'user-profile.jpeg', | ||||
|             message: '<strong class="text-sm mr-1">John Doe</strong>invite you to <strong>Prototyping</strong>', | ||||
|             time: '45 min ago', | ||||
|         }, | ||||
|         { | ||||
|             id: 2, | ||||
|             profile: 'profile-34.jpeg', | ||||
|             message: '<strong class="text-sm mr-1">Adam Nolan</strong>mentioned you to <strong>UX Basics</strong>', | ||||
|             time: '9h Ago', | ||||
|         }, | ||||
|         { | ||||
|             id: 3, | ||||
|             profile: 'profile-16.jpeg', | ||||
|             message: '<strong class="text-sm mr-1">Anna Morgan</strong>Upload a file', | ||||
|             time: '9h Ago', | ||||
|         }, | ||||
|     ]); | ||||
| 
 | ||||
|     const removeNotification = (value: number) => { | ||||
|         setNotifications(notifications.filter((user) => user.id !== value)); | ||||
|     }; | ||||
| 
 | ||||
|     const [search, setSearch] = useState(false); | ||||
| 
 | ||||
|   return ( | ||||
|     <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="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"> | ||||
|                         <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> | ||||
|             <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" | ||||
|                             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())} | ||||
|                 className="collapse-icon flex p-2 rounded-full hover:bg-rtgray-200 dark:text-white dark:hover:bg-rtgray-700" | ||||
|             > | ||||
|                             <IconMenu className="h-5 w-5" /> | ||||
|                 <IconMenu className="h-6 w-6" /> | ||||
|             </button> | ||||
|             </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> | ||||
|           {/* 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 | ||||
|                                     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'))} | ||||
|                 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> | ||||
|             ) : ( | ||||
|                                 '' | ||||
|                             )} | ||||
|                             {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'))} | ||||
|                 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> | ||||
|             )} | ||||
|                         </div> | ||||
|                         {/* ------------------- End Theme Switch ------------------- */} | ||||
| 
 | ||||
| 
 | ||||
|                         <div className="dropdown flex shrink-0"> | ||||
|             {/* 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 | ||||
|                                 offset={[0, 8]} | ||||
|                                 placement={`${isRtl ? 'bottom-start' : 'bottom-end'}`} | ||||
|                 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> | ||||
|                 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 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 | ||||
|                         <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 shrink-0 ltr:mr-2 rtl:ml-2" /> | ||||
|                                             Lock Screen | ||||
|                         <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"> | ||||
|                                         <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> | ||||
|                       <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> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default Header; | ||||
| } | ||||
|  | ||||
| @ -67,16 +67,15 @@ const Sidebar = () => { | ||||
|             <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' : ''}`} | ||||
|             > | ||||
|                 <div className="h-full bg-[white] dark:bg-black"> | ||||
|                     <div className="flex items-center justify-between px-4 py-3"> | ||||
|                 <div className="h-full bg-[white] dark:bg-rtgray-900"> | ||||
|                     <div className="flex items-center justify-between px-4 pt-4"> | ||||
|                         <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" /> | ||||
|                             <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> | ||||
|                             <img className="max-w-[180px] h-auto flex" src="/assets/images/newfulllogo.png" alt="logo" /> | ||||
|                         </Link> | ||||
| 
 | ||||
|                         <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())} | ||||
|                         > | ||||
|                             <IconCaretsDown className="m-auto rotate-90" /> | ||||
| @ -84,7 +83,7 @@ const Sidebar = () => { | ||||
|                     </div> | ||||
|                     <PerfectScrollbar className="relative h-[calc(100vh-80px)] "> | ||||
|                         <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" /> | ||||
|                                 <span>Customer</span> | ||||
|                             </h2> | ||||
| @ -156,6 +155,7 @@ const Sidebar = () => { | ||||
|                                     </div> | ||||
|                                 </Link> | ||||
|                             </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"> | ||||
|                                 <IconMinus className="hidden h-5 w-4 flex-none" /> | ||||
|                                 <span>Providers</span> | ||||
| @ -239,7 +239,7 @@ const Sidebar = () => { | ||||
|                                     </ul> | ||||
|                                 </AnimateHeight> | ||||
|                             </li> | ||||
| 
 | ||||
|                             */} | ||||
| 
 | ||||
|                         </ul> | ||||
|                     </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": { | ||||
|         "@emotion/react": "^11.10.6", | ||||
|         "@headlessui/react": "^1.7.8", | ||||
|         "@heroui/react": "^2.8.2", | ||||
|         "@prisma/client": "^6.8.2", | ||||
|         "@reduxjs/toolkit": "^1.9.1", | ||||
|         "@tippyjs/react": "^4.2.6", | ||||
| @ -27,6 +28,8 @@ | ||||
|         "date-fns": "^4.1.0", | ||||
|         "eslint": "8.32.0", | ||||
|         "eslint-config-next": "13.1.2", | ||||
|         "framer-motion": "^12.23.12", | ||||
|         "he": "^1.2.0", | ||||
|         "html2canvas": "^1.4.1", | ||||
|         "i18next": "^22.4.10", | ||||
|         "jsonwebtoken": "^9.0.2", | ||||
| @ -51,6 +54,7 @@ | ||||
|     "devDependencies": { | ||||
|         "@tailwindcss/forms": "^0.5.3", | ||||
|         "@tailwindcss/typography": "^0.5.8", | ||||
|         "@types/he": "^1.2.3", | ||||
|         "@types/jsonwebtoken": "^9.0.9", | ||||
|         "@types/lodash": "^4.14.191", | ||||
|         "@types/react-redux": "^7.1.32", | ||||
|  | ||||
| @ -1,27 +1,46 @@ | ||||
| import { NextApiRequest, NextApiResponse } from "next"; | ||||
| import jwt from "jsonwebtoken"; | ||||
| // pages/api/auth/me.ts
 | ||||
| import type { NextApiRequest, NextApiResponse } from "next"; | ||||
| import jwt, { JwtPayload } from "jsonwebtoken"; | ||||
| import { PrismaClient } from "@prisma/client"; | ||||
| 
 | ||||
| const prisma = new PrismaClient(); | ||||
| 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) { | ||||
|   const authHeader = req.headers.authorization; | ||||
| 
 | ||||
|   if (!authHeader || !authHeader.startsWith("Bearer ")) { | ||||
|     return res.status(401).json({ message: "Unauthorized" }); | ||||
|   } | ||||
| 
 | ||||
|   const token = authHeader.split(" ")[1]; // Extract token
 | ||||
|   if (req.method !== "GET") return res.status(405).json({ message: "Method not allowed" }); | ||||
| 
 | ||||
|   try { | ||||
|     const decoded: any = jwt.verify(token, SECRET_KEY); | ||||
|     const user = await prisma.user.findUnique({ where: { id: decoded.userId } }); | ||||
|     const token = readAuthBearer(req) ?? readCookieToken(req); | ||||
|     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" }); | ||||
| 
 | ||||
|     res.json({ user }); | ||||
|   } catch (error) { | ||||
|     res.status(401).json({ message: "Invalid token" }); | ||||
|     return res.status(200).json({ user }); | ||||
|   } catch { | ||||
|     return 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 bcrypt from "bcrypt"; | ||||
| import jwt from "jsonwebtoken"; | ||||
| 
 | ||||
| const prisma = new PrismaClient() | ||||
| const prisma = new PrismaClient(); | ||||
| const SECRET_KEY = process.env.JWT_SECRET as string; | ||||
| 
 | ||||
| export default async function handler(req: NextApiRequest, res: NextApiResponse) { | ||||
|   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 } }); | ||||
|     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); | ||||
|     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`); | ||||
|     res.json({ token }); | ||||
|     const isProd = process.env.NODE_ENV === "production"; | ||||
|     const cookie = [ | ||||
|       `token=${token}`, | ||||
|       "HttpOnly", | ||||
|       "Path=/", | ||||
|       "SameSite=Strict", | ||||
|       `Max-Age=${60 * 60 * 24}`, // 1 day
 | ||||
|       isProd ? "Secure" : "",    // only secure in prod
 | ||||
|     ].filter(Boolean).join("; "); | ||||
| 
 | ||||
|     res.setHeader("Set-Cookie", cookie); | ||||
|     return res.status(200).json({ message: "Login successful" }); | ||||
|   } catch (e) { | ||||
|     console.error(e); | ||||
|     return res.status(500).json({ message: "Something went wrong" }); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
|  | ||||
							
								
								
									
										20
									
								
								pages/api/logout.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								pages/api/logout.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,20 @@ | ||||
| // pages/api/auth/logout.ts
 | ||||
| import type { NextApiRequest, NextApiResponse } from "next"; | ||||
| 
 | ||||
| export default function handler(req: NextApiRequest, res: NextApiResponse) { | ||||
|   const isProd = process.env.NODE_ENV === "production"; | ||||
|   res.setHeader( | ||||
|     "Set-Cookie", | ||||
|     [ | ||||
|       "token=", // empty token
 | ||||
|       "HttpOnly", | ||||
|       "Path=/", | ||||
|       "SameSite=Strict", | ||||
|       "Max-Age=0", // expire immediately
 | ||||
|       isProd ? "Secure" : "", | ||||
|     ] | ||||
|       .filter(Boolean) | ||||
|       .join("; ") | ||||
|   ); | ||||
|   return res.status(200).json({ message: "Logged out" }); | ||||
| } | ||||
| @ -1,16 +1,19 @@ | ||||
| import { NextApiRequest, NextApiResponse } from "next"; | ||||
| // pages/api/register.ts
 | ||||
| import type { NextApiRequest, NextApiResponse } from "next"; | ||||
| import { PrismaClient } from "@prisma/client"; | ||||
| import bcrypt from "bcrypt"; | ||||
| import jwt from "jsonwebtoken"; | ||||
| 
 | ||||
| const prisma = new PrismaClient() | ||||
| const prisma = new PrismaClient(); | ||||
| const SECRET_KEY = process.env.JWT_SECRET as string; | ||||
| 
 | ||||
| 
 | ||||
| export default async function handler(req: NextApiRequest, res: NextApiResponse) { | ||||
|   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 } }); | ||||
|     if (existingUser) return res.status(400).json({ message: "User already exists" }); | ||||
| @ -18,10 +21,22 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) | ||||
|     const hashedPassword = await bcrypt.hash(password, 10); | ||||
|     const user = await prisma.user.create({ | ||||
|       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`); | ||||
|     res.status(201).json({ message: "User registered", user, token }); | ||||
|     // Set a secure, httpOnly cookie
 | ||||
|     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 { | ||||
|         @apply relative; | ||||
|         @apply z-50; | ||||
|     } | ||||
|     .dropdown > button { | ||||
|         @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