From e47951fb7e852b697d7e01b0b65b45317cee8453 Mon Sep 17 00:00:00 2001 From: Syasya Date: Wed, 13 Aug 2025 12:30:11 +0800 Subject: [PATCH] integrate with crm --- app/(admin)/adminDashboard/page.tsx | 335 ++++++++++++++++------------ app/hooks/useCrmProjects.ts | 20 ++ app/utils/api.ts | 12 + app/utils/datetime.ts | 37 +++ app/utils/formatAddress.ts | 35 +++ package-lock.json | 29 +++ package.json | 2 + types/crm.ts | 14 ++ 8 files changed, 338 insertions(+), 146 deletions(-) create mode 100644 app/hooks/useCrmProjects.ts create mode 100644 app/utils/datetime.ts create mode 100644 app/utils/formatAddress.ts create mode 100644 types/crm.ts diff --git a/app/(admin)/adminDashboard/page.tsx b/app/(admin)/adminDashboard/page.tsx index 6c577e9..2f6fb88 100644 --- a/app/(admin)/adminDashboard/page.tsx +++ b/app/(admin)/adminDashboard/page.tsx @@ -1,10 +1,9 @@ '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'; @@ -12,14 +11,12 @@ 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'; -const EnergyLineChart = dynamic(() => import('@/components/dashboards/EnergyLineChart'), { - ssr: false, -}); -const MonthlyBarChart = dynamic(() => import('@/components/dashboards/MonthlyBarChart'), { - ssr: false, -}); +const EnergyLineChart = dynamic(() => import('@/components/dashboards/EnergyLineChart'), { ssr: false }); +const MonthlyBarChart = dynamic(() => import('@/components/dashboards/MonthlyBarChart'), { ssr: false }); type MonthlyKPI = { site: string; month: string; @@ -29,171 +26,208 @@ type MonthlyKPI = { 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 router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); - const siteIdMap: Record = { - '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']; + // --- NEW: load CRM projects dynamically --- + const [sites, setSites] = useState([]); + const [sitesLoading, setSitesLoading] = useState(true); + const [sitesError, setSitesError] = useState(null); - const [kpi, setKpi] = useState(null); - - const [selectedSite, setSelectedSite] = useState(() => { - 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(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<{ consumption: { time: string; value: number }[]; generation: { time: string; value: number }[]; }>({ consumption: [], generation: [] }); + // Fetch today’s timeseries for selected siteId (from CRM) useEffect(() => { - const fetchData = async () => { + if (!selectedSiteId) return; - const siteId = siteIdMap[selectedSite]; - const today = new Date(); + const fetchData = async () => { + 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 - const yyyyMMdd = today.toISOString().split('T')[0]; + try { + 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 - const start = `${yyyyMMdd}T00:00:00+08:00`; - const end = `${yyyyMMdd}T23:59:59+08:00`; - + fetchData(); + }, [selectedSiteId]); - try { - const raw = await fetchPowerTimeseries(siteId, start, end); + // --- KPI monthly (uses your FastAPI) --- + const [kpi, setKpi] = useState(null); - 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(() => { - const siteId = siteIdMap[selectedSite]; - const url = `${API}/kpi/monthly?site=${encodeURIComponent(siteId)}&month=${currentMonth}`; + if (!selectedSiteId) return; + const url = `${API}/kpi/monthly?site=${encodeURIComponent(selectedSiteId)}&month=${currentMonth}`; fetch(url).then(r => r.json()).then(setKpi).catch(console.error); - }, [selectedSite]); + }, [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); + const loadFactor = (kpi?.load_factor ?? 0); - // ...your existing code above return() - // Update query string when site is changed manually - const handleSiteChange = (newSite: SiteName) => { - setSelectedSite(newSite); - const newUrl = `${pathname}?site=${encodeURIComponent(newSite)}`; + // Update URL when site is changed manually (now expects a siteId/Project.name) + const handleSiteChange = (newSiteId: string) => { + setSelectedSiteId(newSiteId); + const newUrl = `${pathname}?site=${encodeURIComponent(newSiteId)}`; router.push(newUrl); }; - 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; // pretty, multi-line version +}, [selectedProject?.custom_address]); - const handleCSVExport = () => { - alert('Exported raw data to CSV (mock)'); - }; +const lastSyncFormatted = useMemo( + () => formatCrmTimestamp(selectedProject?.modified, { includeSeconds: true }), + [selectedProject?.modified] +); - const energyChartRef = useRef(null); - const monthlyChartRef = useRef(null); + // Adapt CRM project -> SiteStatus props +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(null); + const monthlyChartRef = useRef(null); const handlePDFExport = async () => { - const doc = new jsPDF('p', 'mm', 'a4'); // portrait, millimeters, A4 - const chartRefs = [ - { ref: energyChartRef, title: 'Energy Line Chart' }, - { ref: monthlyChartRef, title: 'Monthly Energy Yield' } - ]; + const doc = new jsPDF('p', 'mm', 'a4'); + const chartRefs = [ + { ref: energyChartRef, title: 'Energy Line Chart' }, + { ref: monthlyChartRef, title: 'Monthly Energy Yield' } + ]; - let yOffset = 10; + let yOffset = 10; - for (const chart of chartRefs) { - if (!chart.ref.current) continue; + for (const chart of chartRefs) { + if (!chart.ref.current) continue; + const canvas = await html2canvas(chart.ref.current, { scale: 2 }); + const imgData = canvas.toDataURL('image/png'); + const imgProps = doc.getImageProperties(imgData); + const pdfWidth = doc.internal.pageSize.getWidth() - 20; + const imgHeight = (imgProps.height * pdfWidth) / imgProps.width; - // Capture chart as image - const canvas = await html2canvas(chart.ref.current, { - scale: 2, // Higher scale for better resolution - }); + doc.setFontSize(14); + doc.text(chart.title, 10, yOffset); + yOffset += 6; - const imgData = canvas.toDataURL('image/png'); - const imgProps = doc.getImageProperties(imgData); + if (yOffset + imgHeight > doc.internal.pageSize.getHeight()) { + doc.addPage(); + yOffset = 10; + } - const pdfWidth = doc.internal.pageSize.getWidth() - 20; // 10 margin each side - const imgHeight = (imgProps.height * pdfWidth) / imgProps.width; - - // Add title and image - doc.setFontSize(14); - doc.text(chart.title, 10, yOffset); - yOffset += 6; // Space between title and chart - - // If content will overflow page, add a new page - if (yOffset + imgHeight > doc.internal.pageSize.getHeight()) { - doc.addPage(); - yOffset = 10; + doc.addImage(imgData, 'PNG', 10, yOffset, pdfWidth, imgHeight); + yOffset += imgHeight + 10; } - doc.addImage(imgData, 'PNG', 10, yOffset, pdfWidth, imgHeight); - yOffset += imgHeight + 10; // Update offset for next chart + doc.save('dashboard_charts.pdf'); + }; + + if (sitesLoading) { + return ( + +
Loading sites…
+
+ ); + } + if (sitesError) { + return ( + +
Failed to load sites from CRM.
+
+ ); + } + if (!selectedProject) { + return ( + +
No site selected.
+
+ ); } - doc.save('dashboard_charts.pdf'); -}; - const currentMonth = new Date().toISOString().slice(0, 7); // "YYYY-MM" + // Build selector options from CRM + const siteOptions = sites.map(s => ({ + label: s.project_name || s.name, // nice display + value: s.name, // siteId used everywhere + })); return ( @@ -202,12 +236,17 @@ const AdminDashboard = () => {
+ {/* UPDATE SiteSelector to accept these props */} + + {/* UPDATE SiteStatus to accept siteId & dynamic fields */} { />
- {/* TOP 3 CARDS */} -
- -
- -
- + + {/* TOP 3 CARDS */} +
+
- {/* BOTTOM 3 PANELS */} + +
+ +
+ + {/* BOTTOM 3 PANELS */} - -
- } +
+ +
+ } right={ -
+
{(kpi?.peak_demand_kw ?? 0).toFixed(2)} kW
} /> +