integrate with crm
This commit is contained in:
		
							parent
							
								
									9ab01d2655
								
							
						
					
					
						commit
						e47951fb7e
					
				| @ -1,10 +1,9 @@ | |||||||
| 'use client'; | 'use client'; | ||||||
| 
 | 
 | ||||||
| import { useState, useEffect, useRef } from 'react'; | import { useState, useEffect, useMemo, useRef } from 'react'; | ||||||
| import { useRouter, usePathname, useSearchParams } from 'next/navigation'; | import { useRouter, usePathname, useSearchParams } from 'next/navigation'; | ||||||
| import SiteSelector from '@/components/dashboards/SiteSelector'; | import SiteSelector from '@/components/dashboards/SiteSelector'; | ||||||
| import SiteStatus from '@/components/dashboards/SiteStatus'; | import SiteStatus from '@/components/dashboards/SiteStatus'; | ||||||
| import KPI_Table from '@/components/dashboards/KPIStatus'; |  | ||||||
| import DashboardLayout from './dashlayout'; | import DashboardLayout from './dashlayout'; | ||||||
| import html2canvas from 'html2canvas'; | import html2canvas from 'html2canvas'; | ||||||
| import jsPDF from 'jspdf'; | import jsPDF from 'jspdf'; | ||||||
| @ -12,14 +11,12 @@ import dynamic from 'next/dynamic'; | |||||||
| import { fetchPowerTimeseries } from '@/app/utils/api'; | import { fetchPowerTimeseries } from '@/app/utils/api'; | ||||||
| import KpiTop from '@/components/dashboards/kpitop'; | import KpiTop from '@/components/dashboards/kpitop'; | ||||||
| import KpiBottom from '@/components/dashboards/kpibottom'; | import KpiBottom from '@/components/dashboards/kpibottom'; | ||||||
|  | import { formatAddress } from '@/app/utils/formatAddress'; | ||||||
|  | import { formatCrmTimestamp } from '@/app/utils/datetime'; | ||||||
| 
 | 
 | ||||||
| const EnergyLineChart = dynamic(() => import('@/components/dashboards/EnergyLineChart'), { |  | ||||||
|   ssr: false, |  | ||||||
| }); |  | ||||||
| 
 | 
 | ||||||
| const MonthlyBarChart = dynamic(() => import('@/components/dashboards/MonthlyBarChart'), { | const EnergyLineChart = dynamic(() => import('@/components/dashboards/EnergyLineChart'), { ssr: false }); | ||||||
|   ssr: false, | const MonthlyBarChart  = dynamic(() => import('@/components/dashboards/MonthlyBarChart'), { ssr: false }); | ||||||
| }); |  | ||||||
| 
 | 
 | ||||||
| type MonthlyKPI = { | type MonthlyKPI = { | ||||||
|   site: string; month: string; |   site: string; month: string; | ||||||
| @ -29,171 +26,208 @@ type MonthlyKPI = { | |||||||
|   error?: string; |   error?: string; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000'; | 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; | ||||||
|  | }; | ||||||
| 
 | 
 | ||||||
| import { SiteName, SiteDetails, mockSiteData } from '@/types/SiteData'; | const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000'; | ||||||
| 
 | 
 | ||||||
| const AdminDashboard = () => { | const AdminDashboard = () => { | ||||||
|   const router = useRouter(); |   const router = useRouter(); | ||||||
|   const pathname = usePathname(); |   const pathname = usePathname(); | ||||||
|   const searchParams = useSearchParams(); |   const searchParams = useSearchParams(); | ||||||
|   const siteIdMap: Record<SiteName, string> = { |  | ||||||
|   'Site A': 'site_01', |  | ||||||
|   'Site B': 'site_02', |  | ||||||
|   'Site C': 'site_03', |  | ||||||
| }; |  | ||||||
| 
 | 
 | ||||||
|   const siteParam = searchParams?.get('site'); |   // --- NEW: load CRM projects dynamically ---
 | ||||||
|   const validSiteNames: SiteName[] = ['Site A', 'Site B', 'Site C']; |   const [sites, setSites] = useState<CrmProject[]>([]); | ||||||
|  |   const [sitesLoading, setSitesLoading] = useState(true); | ||||||
|  |   const [sitesError, setSitesError] = useState<unknown>(null); | ||||||
| 
 | 
 | ||||||
|   const [kpi, setKpi] = useState<MonthlyKPI | null>(null); |  | ||||||
| 
 |  | ||||||
|   const [selectedSite, setSelectedSite] = useState<SiteName>(() => { |  | ||||||
|     if (siteParam && validSiteNames.includes(siteParam as SiteName)) { |  | ||||||
|       return siteParam as SiteName; |  | ||||||
|     } |  | ||||||
|     return 'Site A'; |  | ||||||
|   }); |  | ||||||
| 
 |  | ||||||
|   // Keep siteParam and selectedSite in sync
 |  | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     if ( |     setSitesLoading(true); | ||||||
|       siteParam && |     fetch(`${API}/crm/projects?limit=0`) | ||||||
|       validSiteNames.includes(siteParam as SiteName) && |       .then(r => r.json()) | ||||||
|       siteParam !== selectedSite |       .then(json => setSites(json?.data ?? [])) | ||||||
|     ) { |       .catch(setSitesError) | ||||||
|       setSelectedSite(siteParam as SiteName); |       .finally(() => setSitesLoading(false)); | ||||||
|     } |   }, []); | ||||||
|   }, [siteParam, selectedSite]); |  | ||||||
| 
 | 
 | ||||||
|  |   // The canonical siteId is the CRM Project "name" (e.g., PROJ-0008)
 | ||||||
|  |   const siteParam = searchParams?.get('site') || null; | ||||||
|  |   const [selectedSiteId, setSelectedSiteId] = useState<string | null>(siteParam); | ||||||
|  | 
 | ||||||
|  |   // Keep query param <-> state in sync
 | ||||||
|  |   useEffect(() => { | ||||||
|  |     if ((siteParam || null) !== selectedSiteId) { | ||||||
|  |       setSelectedSiteId(siteParam); | ||||||
|  |     } | ||||||
|  |   }, [siteParam]); // eslint-disable-line
 | ||||||
|  | 
 | ||||||
|  |   // Default to the first site when loaded
 | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (!selectedSiteId && sites.length) { | ||||||
|  |       setSelectedSiteId(sites[0].name); | ||||||
|  |       router.replace(`${pathname}?site=${encodeURIComponent(sites[0].name)}`); | ||||||
|  |     } | ||||||
|  |   }, [sites, selectedSiteId, pathname, router]); | ||||||
|  | 
 | ||||||
|  |   // Current selected CRM project
 | ||||||
|  |   const selectedProject: CrmProject | null = useMemo( | ||||||
|  |       () => sites.find(s => s.name === selectedSiteId) ?? null, | ||||||
|  |       [sites, selectedSiteId] | ||||||
|  |   ); | ||||||
|  | 
 | ||||||
|  |   // --- FIX: declare currentMonth BEFORE it’s used ---
 | ||||||
|  |   const currentMonth = useMemo(() => new Date().toISOString().slice(0, 7), []); | ||||||
|  | 
 | ||||||
|  |   // --- Time-series state (unchanged) ---
 | ||||||
|   const [timeSeriesData, setTimeSeriesData] = useState<{ |   const [timeSeriesData, setTimeSeriesData] = useState<{ | ||||||
|     consumption: { time: string; value: number }[]; |     consumption: { time: string; value: number }[]; | ||||||
|     generation: { time: string; value: number }[]; |     generation: { time: string; value: number }[]; | ||||||
|   }>({ consumption: [], generation: [] }); |   }>({ consumption: [], generation: [] }); | ||||||
| 
 | 
 | ||||||
|  |   // Fetch today’s timeseries for selected siteId (from CRM)
 | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|   const fetchData = async () => { |     if (!selectedSiteId) return; | ||||||
| 
 | 
 | ||||||
|   const siteId = siteIdMap[selectedSite]; |     const fetchData = async () => { | ||||||
|   const today = new Date(); |       const today = new Date(); | ||||||
|  |       const yyyyMMdd = today.toISOString().split('T')[0]; | ||||||
|  |       const start = `${yyyyMMdd}T00:00:00+08:00`; | ||||||
|  |       const end   = `${yyyyMMdd}T23:59:59+08:00`; | ||||||
| 
 | 
 | ||||||
|   // Format to YYYY-MM-DD
 |       try { | ||||||
|   const yyyyMMdd = today.toISOString().split('T')[0]; |         const raw = await fetchPowerTimeseries(selectedSiteId, start, end); | ||||||
|  |         const consumption = raw.consumption.map((d: any) => ({ time: d.time, value: d.value })); | ||||||
|  |         const generation  = raw.generation.map((d: any) => ({ time: d.time, value: d.value })); | ||||||
|  |         setTimeSeriesData({ consumption, generation }); | ||||||
|  |       } catch (error) { | ||||||
|  |         console.error('Failed to fetch power time series:', error); | ||||||
|  |       } | ||||||
|  |     }; | ||||||
| 
 | 
 | ||||||
|   // Append Malaysia's +08:00 time zone manually
 |     fetchData(); | ||||||
|   const start = `${yyyyMMdd}T00:00:00+08:00`; |   }, [selectedSiteId]); | ||||||
|   const end = `${yyyyMMdd}T23:59:59+08:00`; |  | ||||||
| 
 | 
 | ||||||
|  |   // --- KPI monthly (uses your FastAPI) ---
 | ||||||
|  |   const [kpi, setKpi] = useState<MonthlyKPI | null>(null); | ||||||
| 
 | 
 | ||||||
|     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, |  | ||||||
|     }));   |  | ||||||
| 
 |  | ||||||
|     setTimeSeriesData({ consumption, generation }); |  | ||||||
| 
 |  | ||||||
|     } catch (error) { |  | ||||||
|       console.error('Failed to fetch power time series:', error); |  | ||||||
|     } |  | ||||||
|   }; |  | ||||||
| 
 |  | ||||||
|   fetchData(); |  | ||||||
| }, [selectedSite]); |  | ||||||
| 
 |  | ||||||
| // fetch KPI monthly (uses your FastAPI)
 |  | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     const siteId = siteIdMap[selectedSite]; |     if (!selectedSiteId) return; | ||||||
|     const url = `${API}/kpi/monthly?site=${encodeURIComponent(siteId)}&month=${currentMonth}`; |     const url = `${API}/kpi/monthly?site=${encodeURIComponent(selectedSiteId)}&month=${currentMonth}`; | ||||||
|     fetch(url).then(r => r.json()).then(setKpi).catch(console.error); |     fetch(url).then(r => r.json()).then(setKpi).catch(console.error); | ||||||
|   }, [selectedSite]); |   }, [selectedSiteId, currentMonth]); | ||||||
| 
 | 
 | ||||||
|   // derived values with safe fallbacks
 |   // derived values with safe fallbacks
 | ||||||
|   const yieldKwh       = kpi?.yield_kwh ?? 0; |   const yieldKwh       = kpi?.yield_kwh ?? 0; | ||||||
|   const consumptionKwh = kpi?.consumption_kwh ?? 0; |   const consumptionKwh = kpi?.consumption_kwh ?? 0; | ||||||
|   const gridDrawKwh    = kpi?.grid_draw_kwh ?? Math.max(0, consumptionKwh - yieldKwh); |   const gridDrawKwh    = kpi?.grid_draw_kwh ?? Math.max(0, consumptionKwh - yieldKwh); | ||||||
| 
 |  | ||||||
|   const efficiencyPct  = (kpi?.efficiency ?? 0) * 100; |   const efficiencyPct  = (kpi?.efficiency ?? 0) * 100; | ||||||
|   const powerFactor    = kpi?.avg_power_factor ?? 0; |   const powerFactor    = kpi?.avg_power_factor ?? 0; | ||||||
|   const loadFactor  = (kpi?.load_factor ?? 0); |   const loadFactor     = (kpi?.load_factor ?? 0); | ||||||
| 
 | 
 | ||||||
|   // ...your existing code above return()
 |   // Update URL when site is changed manually (now expects a siteId/Project.name)
 | ||||||
|   // Update query string when site is changed manually
 |   const handleSiteChange = (newSiteId: string) => { | ||||||
|   const handleSiteChange = (newSite: SiteName) => { |     setSelectedSiteId(newSiteId); | ||||||
|     setSelectedSite(newSite); |     const newUrl = `${pathname}?site=${encodeURIComponent(newSiteId)}`; | ||||||
|     const newUrl = `${pathname}?site=${encodeURIComponent(newSite)}`; |  | ||||||
|     router.push(newUrl); |     router.push(newUrl); | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   const currentSiteDetails: SiteDetails = mockSiteData[selectedSite] || { |   const locationFormatted = useMemo(() => { | ||||||
|     location: 'N/A', |   const raw = selectedProject?.custom_address ?? ''; | ||||||
|     inverterProvider: 'N/A', |   if (!raw) return 'N/A'; | ||||||
|     emergencyContact: 'N/A', |   return formatAddress(raw).multiLine; // pretty, multi-line version
 | ||||||
|     lastSyncTimestamp: 'N/A', | }, [selectedProject?.custom_address]); | ||||||
|     consumptionData: [], |  | ||||||
|     generationData: [], |  | ||||||
|     systemStatus: 'N/A', |  | ||||||
|     temperature: 'N/A', |  | ||||||
|     solarPower: 0, |  | ||||||
|     realTimePower: 0, |  | ||||||
|     installedPower: 0, |  | ||||||
|   }; |  | ||||||
| 
 | 
 | ||||||
|   const handleCSVExport = () => { | const lastSyncFormatted = useMemo( | ||||||
|     alert('Exported raw data to CSV (mock)'); |   () => formatCrmTimestamp(selectedProject?.modified, { includeSeconds: true }), | ||||||
|   }; |   [selectedProject?.modified] | ||||||
|  | ); | ||||||
| 
 | 
 | ||||||
|   const energyChartRef = useRef(null); |   // Adapt CRM project -> SiteStatus props
 | ||||||
|   const monthlyChartRef = useRef(null); | const currentSiteDetails = { | ||||||
|  |   location: locationFormatted,                    // <- formatted!
 | ||||||
|  |   inverterProvider: selectedProject?.project_type || 'N/A', | ||||||
|  |   emergencyContact: | ||||||
|  |     selectedProject?.custom_mobile_phone_no || | ||||||
|  |     selectedProject?.custom_email || | ||||||
|  |     selectedProject?.customer || | ||||||
|  |     'N/A', | ||||||
|  |   lastSyncTimestamp: lastSyncFormatted || 'N/A', | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  |   const energyChartRef = useRef<HTMLDivElement | null>(null); | ||||||
|  |   const monthlyChartRef = useRef<HTMLDivElement | null>(null); | ||||||
| 
 | 
 | ||||||
|   const handlePDFExport = async () => { |   const handlePDFExport = async () => { | ||||||
|   const doc = new jsPDF('p', 'mm', 'a4'); // portrait, millimeters, A4
 |     const doc = new jsPDF('p', 'mm', 'a4'); | ||||||
|   const chartRefs = [ |     const chartRefs = [ | ||||||
|     { ref: energyChartRef, title: 'Energy Line Chart' }, |       { ref: energyChartRef,  title: 'Energy Line Chart' }, | ||||||
|     { ref: monthlyChartRef, title: 'Monthly Energy Yield' } |       { ref: monthlyChartRef, title: 'Monthly Energy Yield' } | ||||||
|   ]; |     ]; | ||||||
| 
 | 
 | ||||||
|   let yOffset = 10; |     let yOffset = 10; | ||||||
| 
 | 
 | ||||||
|   for (const chart of chartRefs) { |     for (const chart of chartRefs) { | ||||||
|     if (!chart.ref.current) continue; |       if (!chart.ref.current) continue; | ||||||
|  |       const canvas = await html2canvas(chart.ref.current, { scale: 2 }); | ||||||
|  |       const imgData = canvas.toDataURL('image/png'); | ||||||
|  |       const imgProps = doc.getImageProperties(imgData); | ||||||
|  |       const pdfWidth = doc.internal.pageSize.getWidth() - 20; | ||||||
|  |       const imgHeight = (imgProps.height * pdfWidth) / imgProps.width; | ||||||
| 
 | 
 | ||||||
|     // Capture chart as image
 |       doc.setFontSize(14); | ||||||
|     const canvas = await html2canvas(chart.ref.current, { |       doc.text(chart.title, 10, yOffset); | ||||||
|       scale: 2, // Higher scale for better resolution
 |       yOffset += 6; | ||||||
|     }); |  | ||||||
| 
 | 
 | ||||||
|     const imgData = canvas.toDataURL('image/png'); |       if (yOffset + imgHeight > doc.internal.pageSize.getHeight()) { | ||||||
|     const imgProps = doc.getImageProperties(imgData); |         doc.addPage(); | ||||||
|  |         yOffset = 10; | ||||||
|  |       } | ||||||
| 
 | 
 | ||||||
|     const pdfWidth = doc.internal.pageSize.getWidth() - 20; // 10 margin each side
 |       doc.addImage(imgData, 'PNG', 10, yOffset, pdfWidth, imgHeight); | ||||||
|     const imgHeight = (imgProps.height * pdfWidth) / imgProps.width; |       yOffset += imgHeight + 10; | ||||||
| 
 |  | ||||||
|     // Add title and image
 |  | ||||||
|     doc.setFontSize(14); |  | ||||||
|     doc.text(chart.title, 10, yOffset); |  | ||||||
|     yOffset += 6; // Space between title and chart
 |  | ||||||
| 
 |  | ||||||
|     // If content will overflow page, add a new page
 |  | ||||||
|     if (yOffset + imgHeight > doc.internal.pageSize.getHeight()) { |  | ||||||
|       doc.addPage(); |  | ||||||
|       yOffset = 10; |  | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     doc.addImage(imgData, 'PNG', 10, yOffset, pdfWidth, imgHeight); |     doc.save('dashboard_charts.pdf'); | ||||||
|     yOffset += imgHeight + 10; // Update offset for next chart
 |   }; | ||||||
|  | 
 | ||||||
|  |   if (sitesLoading) { | ||||||
|  |     return ( | ||||||
|  |       <DashboardLayout> | ||||||
|  |         <div className="px-6">Loading sites…</div> | ||||||
|  |       </DashboardLayout> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |   if (sitesError) { | ||||||
|  |     return ( | ||||||
|  |       <DashboardLayout> | ||||||
|  |         <div className="px-6 text-red-600">Failed to load sites from CRM.</div> | ||||||
|  |       </DashboardLayout> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |   if (!selectedProject) { | ||||||
|  |     return ( | ||||||
|  |       <DashboardLayout> | ||||||
|  |         <div className="px-6">No site selected.</div> | ||||||
|  |       </DashboardLayout> | ||||||
|  |     ); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   doc.save('dashboard_charts.pdf'); |   // Build selector options from CRM
 | ||||||
| }; |   const siteOptions = sites.map(s => ({ | ||||||
|   const currentMonth = new Date().toISOString().slice(0, 7); // "YYYY-MM"
 |     label: s.project_name || s.name,  // nice display
 | ||||||
|  |     value: s.name,                    // siteId used everywhere
 | ||||||
|  |   })); | ||||||
| 
 | 
 | ||||||
|   return ( |   return ( | ||||||
|     <DashboardLayout> |     <DashboardLayout> | ||||||
| @ -202,12 +236,17 @@ const AdminDashboard = () => { | |||||||
| 
 | 
 | ||||||
|         <div className="grid gap-6"> |         <div className="grid gap-6"> | ||||||
|           <div className="space-y-4"> |           <div className="space-y-4"> | ||||||
|  |             {/* UPDATE SiteSelector to accept these props */} | ||||||
|             <SiteSelector |             <SiteSelector | ||||||
|               selectedSite={selectedSite} |               options={siteOptions} | ||||||
|               setSelectedSite={handleSiteChange} |               selectedValue={selectedSiteId!} | ||||||
|  |               onChange={handleSiteChange} | ||||||
|             /> |             /> | ||||||
|  | 
 | ||||||
|  |             {/* UPDATE SiteStatus to accept siteId & dynamic fields */} | ||||||
|             <SiteStatus |             <SiteStatus | ||||||
|               selectedSite={selectedSite} |               selectedSite={selectedProject.project_name || selectedProject.name} | ||||||
|  |               siteId={selectedProject.name}  // <-- use for MQTT topics inside SiteStatus
 | ||||||
|               location={currentSiteDetails.location} |               location={currentSiteDetails.location} | ||||||
|               inverterProvider={currentSiteDetails.inverterProvider} |               inverterProvider={currentSiteDetails.inverterProvider} | ||||||
|               emergencyContact={currentSiteDetails.emergencyContact} |               emergencyContact={currentSiteDetails.emergencyContact} | ||||||
| @ -215,36 +254,39 @@ const AdminDashboard = () => { | |||||||
|             /> |             /> | ||||||
|           </div> |           </div> | ||||||
|         </div> |         </div> | ||||||
|          {/* TOP 3 CARDS */} | 
 | ||||||
|           <div className="space-y-4"> |         {/* TOP 3 CARDS */} | ||||||
|             <KpiTop |         <div className="space-y-4"> | ||||||
|               yieldKwh={yieldKwh} |           <KpiTop | ||||||
|               consumptionKwh={consumptionKwh} |             yieldKwh={yieldKwh} | ||||||
|               gridDrawKwh={gridDrawKwh} |             consumptionKwh={consumptionKwh} | ||||||
|             /> |             gridDrawKwh={gridDrawKwh} | ||||||
|           </div> |           /> | ||||||
|  |         </div> | ||||||
| 
 | 
 | ||||||
|         <div ref={energyChartRef} className="pb-5"> |         <div ref={energyChartRef} className="pb-5"> | ||||||
|             <EnergyLineChart siteId={siteIdMap[selectedSite]} /> |           <EnergyLineChart siteId={selectedProject.name} /> | ||||||
|         </div> |         </div> | ||||||
|          {/* BOTTOM 3 PANELS */} | 
 | ||||||
|  |         {/* BOTTOM 3 PANELS */} | ||||||
|         <KpiBottom |         <KpiBottom | ||||||
|           efficiencyPct={efficiencyPct} |           efficiencyPct={efficiencyPct} | ||||||
|           powerFactor={powerFactor} |           powerFactor={powerFactor} | ||||||
|           loadFactor={loadFactor} |           loadFactor={loadFactor} | ||||||
|           middle={ |           middle={ | ||||||
|           <div ref={monthlyChartRef} className="transform scale-90 origin-top"> |             <div ref={monthlyChartRef} className="transform scale-90 origin-top"> | ||||||
|             <MonthlyBarChart siteId={siteIdMap[selectedSite]} /> |               <MonthlyBarChart siteId={selectedProject.name} /> | ||||||
|           </div> |             </div> | ||||||
|         } |           } | ||||||
|           right={ |           right={ | ||||||
|             <div className="flex items-center justify-center w-full  px-3 text-center"> |             <div className="flex items-center justify-center w-full px-3 text-center"> | ||||||
|               <div className="text-3xl font-semibold"> |               <div className="text-3xl font-semibold"> | ||||||
|                 {(kpi?.peak_demand_kw ?? 0).toFixed(2)} kW |                 {(kpi?.peak_demand_kw ?? 0).toFixed(2)} kW | ||||||
|               </div> |               </div> | ||||||
|             </div> |             </div> | ||||||
|           } |           } | ||||||
|         /> |         /> | ||||||
|  | 
 | ||||||
|         <div className="flex flex-col md:flex-row gap-4 justify-center"> |         <div className="flex flex-col md:flex-row gap-4 justify-center"> | ||||||
|           <button onClick={handlePDFExport} className="text-sm lg:text-lg btn-primary"> |           <button onClick={handlePDFExport} className="text-sm lg:text-lg btn-primary"> | ||||||
|             Export Chart Images to PDF |             Export Chart Images to PDF | ||||||
| @ -258,3 +300,4 @@ const AdminDashboard = () => { | |||||||
| export default AdminDashboard; | export default AdminDashboard; | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | 
 | ||||||
|  | |||||||
							
								
								
									
										20
									
								
								app/hooks/useCrmProjects.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								app/hooks/useCrmProjects.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,20 @@ | |||||||
|  | // src/hooks/useCrmProjects.ts
 | ||||||
|  | import { useEffect, useState } from "react"; | ||||||
|  | import { crmapi } from "../utils/api"; | ||||||
|  | import { CrmProject } from "@/types/crm"; | ||||||
|  | 
 | ||||||
|  | export function useCrmProjects() { | ||||||
|  |   const [data, setData] = useState<CrmProject[]>([]); | ||||||
|  |   const [loading, setLoading] = useState(true); | ||||||
|  |   const [error, setError] = useState<unknown>(null); | ||||||
|  | 
 | ||||||
|  |   useEffect(() => { | ||||||
|  |     setLoading(true); | ||||||
|  |     crmapi.getProjects() | ||||||
|  |       .then(res => setData(res.data?.data ?? [])) | ||||||
|  |       .catch(setError) | ||||||
|  |       .finally(() => setLoading(false)); | ||||||
|  |   }, []); | ||||||
|  | 
 | ||||||
|  |   return { data, loading, error }; | ||||||
|  | }  | ||||||
| @ -9,6 +9,18 @@ export interface TimeSeriesResponse { | |||||||
|   generation: TimeSeriesEntry[]; |   generation: TimeSeriesEntry[]; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | const API_BASE_URL = | ||||||
|  |   process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://127.0.0.1:8000"; | ||||||
|  | 
 | ||||||
|  | export const crmapi = { | ||||||
|  |   getProjects: async () => { | ||||||
|  |     const res = await fetch(`${API_BASE_URL}/crm/projects`, { | ||||||
|  |     }); | ||||||
|  |     if (!res.ok) throw new Error(`HTTP ${res.status}`); | ||||||
|  |     return res.json(); | ||||||
|  |   }, | ||||||
|  | }; | ||||||
|  | 
 | ||||||
| export async function fetchPowerTimeseries( | export async function fetchPowerTimeseries( | ||||||
|   site: string, |   site: string, | ||||||
|   start: string, |   start: string, | ||||||
|  | |||||||
							
								
								
									
										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
 | ||||||
|  |   }; | ||||||
|  | } | ||||||
							
								
								
									
										29
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										29
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							| @ -26,6 +26,7 @@ | |||||||
|                 "date-fns": "^4.1.0", |                 "date-fns": "^4.1.0", | ||||||
|                 "eslint": "8.32.0", |                 "eslint": "8.32.0", | ||||||
|                 "eslint-config-next": "13.1.2", |                 "eslint-config-next": "13.1.2", | ||||||
|  |                 "he": "^1.2.0", | ||||||
|                 "html2canvas": "^1.4.1", |                 "html2canvas": "^1.4.1", | ||||||
|                 "i18next": "^22.4.10", |                 "i18next": "^22.4.10", | ||||||
|                 "jsonwebtoken": "^9.0.2", |                 "jsonwebtoken": "^9.0.2", | ||||||
| @ -50,6 +51,7 @@ | |||||||
|             "devDependencies": { |             "devDependencies": { | ||||||
|                 "@tailwindcss/forms": "^0.5.3", |                 "@tailwindcss/forms": "^0.5.3", | ||||||
|                 "@tailwindcss/typography": "^0.5.8", |                 "@tailwindcss/typography": "^0.5.8", | ||||||
|  |                 "@types/he": "^1.2.3", | ||||||
|                 "@types/jsonwebtoken": "^9.0.9", |                 "@types/jsonwebtoken": "^9.0.9", | ||||||
|                 "@types/lodash": "^4.14.191", |                 "@types/lodash": "^4.14.191", | ||||||
|                 "@types/react-redux": "^7.1.32", |                 "@types/react-redux": "^7.1.32", | ||||||
| @ -931,6 +933,13 @@ | |||||||
|             "integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==", |             "integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==", | ||||||
|             "license": "MIT" |             "license": "MIT" | ||||||
|         }, |         }, | ||||||
|  |         "node_modules/@types/he": { | ||||||
|  |             "version": "1.2.3", | ||||||
|  |             "resolved": "https://registry.npmjs.org/@types/he/-/he-1.2.3.tgz", | ||||||
|  |             "integrity": "sha512-q67/qwlxblDzEDvzHhVkwc1gzVWxaNxeyHUBF4xElrvjL11O+Ytze+1fGpBHlr/H9myiBUaUXNnNPmBHxxfAcA==", | ||||||
|  |             "dev": true, | ||||||
|  |             "license": "MIT" | ||||||
|  |         }, | ||||||
|         "node_modules/@types/hoist-non-react-statics": { |         "node_modules/@types/hoist-non-react-statics": { | ||||||
|             "version": "3.3.1", |             "version": "3.3.1", | ||||||
|             "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", |             "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", | ||||||
| @ -3403,6 +3412,15 @@ | |||||||
|                 "node": ">= 0.4" |                 "node": ">= 0.4" | ||||||
|             } |             } | ||||||
|         }, |         }, | ||||||
|  |         "node_modules/he": { | ||||||
|  |             "version": "1.2.0", | ||||||
|  |             "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", | ||||||
|  |             "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", | ||||||
|  |             "license": "MIT", | ||||||
|  |             "bin": { | ||||||
|  |                 "he": "bin/he" | ||||||
|  |             } | ||||||
|  |         }, | ||||||
|         "node_modules/hoist-non-react-statics": { |         "node_modules/hoist-non-react-statics": { | ||||||
|             "version": "3.3.2", |             "version": "3.3.2", | ||||||
|             "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", |             "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", | ||||||
| @ -6997,6 +7015,12 @@ | |||||||
|             "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz", |             "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz", | ||||||
|             "integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==" |             "integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==" | ||||||
|         }, |         }, | ||||||
|  |         "@types/he": { | ||||||
|  |             "version": "1.2.3", | ||||||
|  |             "resolved": "https://registry.npmjs.org/@types/he/-/he-1.2.3.tgz", | ||||||
|  |             "integrity": "sha512-q67/qwlxblDzEDvzHhVkwc1gzVWxaNxeyHUBF4xElrvjL11O+Ytze+1fGpBHlr/H9myiBUaUXNnNPmBHxxfAcA==", | ||||||
|  |             "dev": true | ||||||
|  |         }, | ||||||
|         "@types/hoist-non-react-statics": { |         "@types/hoist-non-react-statics": { | ||||||
|             "version": "3.3.1", |             "version": "3.3.1", | ||||||
|             "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", |             "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", | ||||||
| @ -8756,6 +8780,11 @@ | |||||||
|                 "function-bind": "^1.1.2" |                 "function-bind": "^1.1.2" | ||||||
|             } |             } | ||||||
|         }, |         }, | ||||||
|  |         "he": { | ||||||
|  |             "version": "1.2.0", | ||||||
|  |             "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", | ||||||
|  |             "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==" | ||||||
|  |         }, | ||||||
|         "hoist-non-react-statics": { |         "hoist-non-react-statics": { | ||||||
|             "version": "3.3.2", |             "version": "3.3.2", | ||||||
|             "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", |             "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", | ||||||
|  | |||||||
| @ -27,6 +27,7 @@ | |||||||
|         "date-fns": "^4.1.0", |         "date-fns": "^4.1.0", | ||||||
|         "eslint": "8.32.0", |         "eslint": "8.32.0", | ||||||
|         "eslint-config-next": "13.1.2", |         "eslint-config-next": "13.1.2", | ||||||
|  |         "he": "^1.2.0", | ||||||
|         "html2canvas": "^1.4.1", |         "html2canvas": "^1.4.1", | ||||||
|         "i18next": "^22.4.10", |         "i18next": "^22.4.10", | ||||||
|         "jsonwebtoken": "^9.0.2", |         "jsonwebtoken": "^9.0.2", | ||||||
| @ -51,6 +52,7 @@ | |||||||
|     "devDependencies": { |     "devDependencies": { | ||||||
|         "@tailwindcss/forms": "^0.5.3", |         "@tailwindcss/forms": "^0.5.3", | ||||||
|         "@tailwindcss/typography": "^0.5.8", |         "@tailwindcss/typography": "^0.5.8", | ||||||
|  |         "@types/he": "^1.2.3", | ||||||
|         "@types/jsonwebtoken": "^9.0.9", |         "@types/jsonwebtoken": "^9.0.9", | ||||||
|         "@types/lodash": "^4.14.191", |         "@types/lodash": "^4.14.191", | ||||||
|         "@types/react-redux": "^7.1.32", |         "@types/react-redux": "^7.1.32", | ||||||
|  | |||||||
							
								
								
									
										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