Compare commits
	
		
			No commits in common. "44bb94ded851f7eed2dbf2d6bd63e3e1e8bd9b05" and "401a89dd7a4de937219a66fff1b99849d4cf6707" have entirely different histories.
		
	
	
		
			44bb94ded8
			...
			401a89dd7a
		
	
		
| @ -13,7 +13,7 @@ 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 { formatAddress } from '@/app/utils/formatAddress'; | ||||||
| import { formatCrmTimestamp } from '@/app/utils/datetime'; | 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 }); | ||||||
| @ -42,31 +42,15 @@ type CrmProject = { | |||||||
| 
 | 
 | ||||||
| const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000'; | const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000'; | ||||||
| 
 | 
 | ||||||
| // Adjust this to your FastAPI route
 |  | ||||||
| const START_LOGGING_ENDPOINT = (siteId: string) => |  | ||||||
|   `${API}/logging/start?site=${encodeURIComponent(siteId)}`; |  | ||||||
| 
 |  | ||||||
| // helper to build ISO strings with +08:00
 |  | ||||||
| const withTZ = (d: Date) => { |  | ||||||
|   const yyyyMMdd = d.toISOString().split('T')[0]; |  | ||||||
|   return { |  | ||||||
|     start: `${yyyyMMdd}T00:00:00+08:00`, |  | ||||||
|     end:   `${yyyyMMdd}T23:59:59+08:00`, |  | ||||||
|   }; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| const AdminDashboard = () => { | const AdminDashboard = () => { | ||||||
|   const router = useRouter(); |   const router = useRouter(); | ||||||
|   const pathname = usePathname(); |   const pathname = usePathname(); | ||||||
|   const searchParams = useSearchParams(); |   const searchParams = useSearchParams(); | ||||||
| 
 | 
 | ||||||
|   // --- load CRM projects dynamically ---
 |   // --- NEW: load CRM projects dynamically ---
 | ||||||
|   const [sites, setSites] = useState<CrmProject[]>([]); |   const [sites, setSites] = useState<CrmProject[]>([]); | ||||||
|   const [sitesLoading, setSitesLoading] = useState(true); |   const [sitesLoading, setSitesLoading] = useState(true); | ||||||
|   const [sitesError, setSitesError] = useState<unknown>(null); |   const [sitesError, setSitesError] = useState<unknown>(null); | ||||||
|   // near other refs
 |  | ||||||
|   const loggingRef = useRef<HTMLDivElement | null>(null); |  | ||||||
| 
 |  | ||||||
| 
 | 
 | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     setSitesLoading(true); |     setSitesLoading(true); | ||||||
| @ -98,78 +82,43 @@ const AdminDashboard = () => { | |||||||
| 
 | 
 | ||||||
|   // Current selected CRM project
 |   // Current selected CRM project
 | ||||||
|   const selectedProject: CrmProject | null = useMemo( |   const selectedProject: CrmProject | null = useMemo( | ||||||
|     () => sites.find(s => s.name === selectedSiteId) ?? null, |       () => sites.find(s => s.name === selectedSiteId) ?? null, | ||||||
|     [sites, selectedSiteId] |       [sites, selectedSiteId] | ||||||
|   ); |   ); | ||||||
| 
 | 
 | ||||||
|   // declare currentMonth BEFORE it’s used
 |   // --- FIX: declare currentMonth BEFORE it’s used ---
 | ||||||
|   const currentMonth = useMemo(() => new Date().toISOString().slice(0, 7), []); |   const currentMonth = useMemo(() => new Date().toISOString().slice(0, 7), []); | ||||||
| 
 | 
 | ||||||
|   // --- Time-series state ---
 |   // --- 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: [] }); | ||||||
| 
 | 
 | ||||||
|   // data-availability flags
 |   // Fetch today’s timeseries for selected siteId (from CRM)
 | ||||||
|   const [hasAnyData, setHasAnyData] = useState(false);   // historical window
 |  | ||||||
|   const [hasTodayData, setHasTodayData] = useState(false); |  | ||||||
|   const [isLogging, setIsLogging] = useState(false); |  | ||||||
|   const [startError, setStartError] = useState<string | null>(null); |  | ||||||
| 
 |  | ||||||
|   // Fetch today’s timeseries for selected siteId
 |  | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     if (!selectedSiteId) return; |     if (!selectedSiteId) return; | ||||||
| 
 | 
 | ||||||
|     const fetchToday = async () => { |     const fetchData = async () => { | ||||||
|       const { start, end } = withTZ(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`; | ||||||
| 
 | 
 | ||||||
|       try { |       try { | ||||||
|         const raw = await fetchPowerTimeseries(selectedSiteId, start, end); |         const raw = await fetchPowerTimeseries(selectedSiteId, start, end); | ||||||
|         const consumption = raw.consumption.map((d: any) => ({ time: d.time, value: d.value })); |         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 })); |         const generation  = raw.generation.map((d: any) => ({ time: d.time, value: d.value })); | ||||||
|         setTimeSeriesData({ consumption, generation }); |         setTimeSeriesData({ consumption, generation }); | ||||||
| 
 |  | ||||||
|         const anyToday = (consumption?.length ?? 0) > 0 || (generation?.length ?? 0) > 0; |  | ||||||
|         setHasTodayData(anyToday); |  | ||||||
|       } catch (error) { |       } catch (error) { | ||||||
|         console.error('Failed to fetch power time series:', error); |         console.error('Failed to fetch power time series:', error); | ||||||
|         setHasTodayData(false); |  | ||||||
|       } |       } | ||||||
|     }; |     }; | ||||||
| 
 | 
 | ||||||
|     fetchToday(); |     fetchData(); | ||||||
|   }, [selectedSiteId]); |   }, [selectedSiteId]); | ||||||
| 
 | 
 | ||||||
|   // Check historical data (last 30 days) → controls empty state
 |   // --- KPI monthly (uses your FastAPI) ---
 | ||||||
|   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); |   const [kpi, setKpi] = useState<MonthlyKPI | null>(null); | ||||||
| 
 | 
 | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
| @ -186,40 +135,35 @@ const AdminDashboard = () => { | |||||||
|   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); | ||||||
| 
 | 
 | ||||||
|   // Update URL when site is changed manually (expects a siteId/Project.name)
 |   // Update URL when site is changed manually (now expects a siteId/Project.name)
 | ||||||
|   const handleSiteChange = (newSiteId: string) => { |   const handleSiteChange = (newSiteId: string) => { | ||||||
|     setSelectedSiteId(newSiteId); |     setSelectedSiteId(newSiteId); | ||||||
|     const newUrl = `${pathname}?site=${encodeURIComponent(newSiteId)}`; |     const newUrl = `${pathname}?site=${encodeURIComponent(newSiteId)}`; | ||||||
|     router.push(newUrl); |     router.push(newUrl); | ||||||
|     // reset flags when switching
 |  | ||||||
|     setHasAnyData(false); |  | ||||||
|     setHasTodayData(false); |  | ||||||
|     setIsLogging(false); |  | ||||||
|     setStartError(null); |  | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   const locationFormatted = useMemo(() => { |   const locationFormatted = useMemo(() => { | ||||||
|     const raw = selectedProject?.custom_address ?? ''; |   const raw = selectedProject?.custom_address ?? ''; | ||||||
|     if (!raw) return 'N/A'; |   if (!raw) return 'N/A'; | ||||||
|     return formatAddress(raw).multiLine; |   return formatAddress(raw).multiLine; // pretty, multi-line version
 | ||||||
|   }, [selectedProject?.custom_address]); | }, [selectedProject?.custom_address]); | ||||||
| 
 | 
 | ||||||
|   const lastSyncFormatted = useMemo( | const lastSyncFormatted = useMemo( | ||||||
|     () => formatCrmTimestamp(selectedProject?.modified, { includeSeconds: true }), |   () => formatCrmTimestamp(selectedProject?.modified, { includeSeconds: true }), | ||||||
|     [selectedProject?.modified] |   [selectedProject?.modified] | ||||||
|   ); | ); | ||||||
| 
 | 
 | ||||||
|   // Adapt CRM project -> SiteStatus props
 |   // Adapt CRM project -> SiteStatus props
 | ||||||
|   const currentSiteDetails = { | const currentSiteDetails = { | ||||||
|     location: locationFormatted, |   location: locationFormatted,                    // <- formatted!
 | ||||||
|     inverterProvider: selectedProject?.project_type || 'N/A', |   inverterProvider: selectedProject?.project_type || 'N/A', | ||||||
|     emergencyContact: |   emergencyContact: | ||||||
|       selectedProject?.custom_mobile_phone_no || |     selectedProject?.custom_mobile_phone_no || | ||||||
|       selectedProject?.custom_email || |     selectedProject?.custom_email || | ||||||
|       selectedProject?.customer || |     selectedProject?.customer || | ||||||
|       'N/A', |     'N/A', | ||||||
|     lastSyncTimestamp: lastSyncFormatted || 'N/A', |   lastSyncTimestamp: lastSyncFormatted || 'N/A', | ||||||
|   }; | }; | ||||||
| 
 | 
 | ||||||
|   const energyChartRef = useRef<HTMLDivElement | null>(null); |   const energyChartRef = useRef<HTMLDivElement | null>(null); | ||||||
|   const monthlyChartRef = useRef<HTMLDivElement | null>(null); |   const monthlyChartRef = useRef<HTMLDivElement | null>(null); | ||||||
| @ -257,49 +201,6 @@ const AdminDashboard = () => { | |||||||
|     doc.save('dashboard_charts.pdf'); |     doc.save('dashboard_charts.pdf'); | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   // Start logging then poll for data until it shows up
 |  | ||||||
|   const startLogging = async () => { |  | ||||||
|     if (!selectedSiteId) return; |  | ||||||
|     setIsLogging(true); |  | ||||||
|     setStartError(null); |  | ||||||
| 
 |  | ||||||
|     try { |  | ||||||
|       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) { |   if (sitesLoading) { | ||||||
|     return ( |     return ( | ||||||
|       <DashboardLayout> |       <DashboardLayout> | ||||||
| @ -324,111 +225,79 @@ const AdminDashboard = () => { | |||||||
| 
 | 
 | ||||||
|   // Build selector options from CRM
 |   // Build selector options from CRM
 | ||||||
|   const siteOptions = sites.map(s => ({ |   const siteOptions = sites.map(s => ({ | ||||||
|     label: s.project_name || s.name, |     label: s.project_name || s.name,  // nice display
 | ||||||
|     value: s.name, |     value: s.name,                    // siteId used everywhere
 | ||||||
|   })); |   })); | ||||||
| 
 | 
 | ||||||
|   return ( |   return ( | ||||||
|     <DashboardLayout> |     <DashboardLayout> | ||||||
|       <div className="px-3 space-y-6 w-full max-w-screen-3xl mx-auto"> |       <div className="px-6 space-y-6"> | ||||||
|         <h1 className="text-lg font-semibold dark:text-white">Admin Dashboard</h1> |         <h1 className="text-lg font-semibold dark:text-white">Admin Dashboard</h1> | ||||||
| 
 | 
 | ||||||
|         {/* Selector + status */} |         <div className="grid gap-6"> | ||||||
|         <div className="grid grid-cols-1 gap-6 w-full min-w-0"> |           <div className="space-y-4"> | ||||||
|           <div className="space-y-4 w-full min-w-0"> |             {/* UPDATE SiteSelector to accept these props */} | ||||||
|             <SiteSelector |             <SiteSelector | ||||||
|               options={siteOptions} |               options={siteOptions} | ||||||
|               selectedValue={selectedSiteId!} |               selectedValue={selectedSiteId!} | ||||||
|               onChange={handleSiteChange} |               onChange={handleSiteChange} | ||||||
|             /> |             /> | ||||||
| 
 | 
 | ||||||
|  |             {/* UPDATE SiteStatus to accept siteId & dynamic fields */} | ||||||
|             <SiteStatus |             <SiteStatus | ||||||
|               selectedSite={selectedProject.project_name || selectedProject.name} |               selectedSite={selectedProject.project_name || selectedProject.name} | ||||||
|               siteId={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} | ||||||
|               lastSyncTimestamp={currentSiteDetails.lastSyncTimestamp} |               lastSyncTimestamp={currentSiteDetails.lastSyncTimestamp} | ||||||
|             /> |             /> | ||||||
|              |  | ||||||
| 
 |  | ||||||
|           </div> |           </div> | ||||||
|         </div> |         </div> | ||||||
| 
 | 
 | ||||||
|         {/* Small dark yellow banner when there is ZERO historical data */} |         {/* TOP 3 CARDS */} | ||||||
|         {!hasAnyData && ( |         <div className="space-y-4"> | ||||||
|           <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"> |           <KpiTop | ||||||
|             <span className="font-semibold text-black/85 dark:text-white/85">No data yet.</span> |             yieldKwh={yieldKwh} | ||||||
|             <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. |             consumptionKwh={consumptionKwh} | ||||||
|             </span> |             gridDrawKwh={gridDrawKwh} | ||||||
| 
 |  | ||||||
|             {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> |         </div> | ||||||
| 
 | 
 | ||||||
|         {/* Render the rest only if there is *any* data */} |         <div ref={energyChartRef} className="pb-5"> | ||||||
|         {hasAnyData && ( |           <EnergyLineChart siteId={selectedProject.name} /> | ||||||
|           <> |         </div> | ||||||
|             {/* Tiny banner if today is empty but historical exists */} | 
 | ||||||
|             {!hasTodayData && ( |         {/* BOTTOM 3 PANELS */} | ||||||
|               <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"> |         <KpiBottom | ||||||
|                 No data yet today — charts may be blank until new points arrive. |           efficiencyPct={efficiencyPct} | ||||||
|  |           powerFactor={powerFactor} | ||||||
|  |           loadFactor={loadFactor} | ||||||
|  |           middle={ | ||||||
|  |             <div ref={monthlyChartRef} className="transform scale-90 origin-top"> | ||||||
|  |               <MonthlyBarChart siteId={selectedProject.name} /> | ||||||
|  |             </div> | ||||||
|  |           } | ||||||
|  |           right={ | ||||||
|  |             <div className="flex items-center justify-center w-full px-3 text-center"> | ||||||
|  |               <div className="text-3xl font-semibold"> | ||||||
|  |                 {(kpi?.peak_demand_kw ?? 0).toFixed(2)} kW | ||||||
|               </div> |               </div> | ||||||
|             )} |  | ||||||
|              |  | ||||||
| 
 |  | ||||||
|             {/* TOP 3 CARDS */} |  | ||||||
|             <div className="space-y-4"> |  | ||||||
|               <KpiTop |  | ||||||
|                 yieldKwh={yieldKwh} |  | ||||||
|                 consumptionKwh={consumptionKwh} |  | ||||||
|                 gridDrawKwh={gridDrawKwh} |  | ||||||
|               /> |  | ||||||
|             </div> |             </div> | ||||||
|  |           } | ||||||
|  |         /> | ||||||
| 
 | 
 | ||||||
|             <div ref={energyChartRef} className="pb-5"> |         <div className="flex flex-col md:flex-row gap-4 justify-center"> | ||||||
|               <EnergyLineChart siteId={selectedProject.name} /> |           <button onClick={handlePDFExport} className="text-sm lg:text-lg btn-primary"> | ||||||
|             </div> |             Export Chart Images to PDF | ||||||
| 
 |           </button> | ||||||
|             {/* BOTTOM 3 PANELS */} |         </div> | ||||||
|             <KpiBottom |  | ||||||
|               efficiencyPct={efficiencyPct} |  | ||||||
|               powerFactor={powerFactor} |  | ||||||
|               loadFactor={loadFactor} |  | ||||||
|               middle={ |  | ||||||
|                 <div ref={monthlyChartRef} className="transform scale-90 origin-top"> |  | ||||||
|                   <MonthlyBarChart siteId={selectedProject.name} /> |  | ||||||
|                 </div> |  | ||||||
|               } |  | ||||||
|               right={ |  | ||||||
|                 <div className="flex items-center justify-center w-full px-3 text-center"> |  | ||||||
|                   <div className="text-3xl font-semibold"> |  | ||||||
|                     {(kpi?.peak_demand_kw ?? 0).toFixed(2)} kW |  | ||||||
|                   </div> |  | ||||||
|                 </div> |  | ||||||
|               } |  | ||||||
|             /> |  | ||||||
| 
 |  | ||||||
|             <div className="flex flex-col md:flex-row gap-4 justify-center"> |  | ||||||
|               <button onClick={handlePDFExport} className="text-sm lg:text-lg btn-primary"> |  | ||||||
|                 Export Chart Images to PDF |  | ||||||
|               </button> |  | ||||||
|             </div> |  | ||||||
|           </> |  | ||||||
|         )} |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
|       </div> |       </div> | ||||||
|     </DashboardLayout> |     </DashboardLayout> | ||||||
|   ); |   ); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export default AdminDashboard; | export default AdminDashboard; | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | |||||||
| @ -1,187 +1,46 @@ | |||||||
| 'use client'; | 'use client'; | ||||||
| 
 | 
 | ||||||
| import React, { useEffect, useMemo, useState } from 'react'; | import React from 'react'; | ||||||
| import DashboardLayout from '../adminDashboard/dashlayout'; | import DashboardLayout from '../adminDashboard/dashlayout'; | ||||||
| import SiteCard from '@/components/dashboards/SiteCard'; | import SiteCard from '@/components/dashboards/SiteCard'; // Import the new SiteCard component
 | ||||||
| 
 | import { mockSiteData, SiteName } from '@/types/SiteData'; // Import your mock data and SiteName type
 | ||||||
| type CrmProject = { |  | ||||||
|   name: string;                 // e.g. PROJ-0008 (siteId)
 |  | ||||||
|   project_name: string; |  | ||||||
|   status?: string | null; |  | ||||||
|   modified?: string | null; |  | ||||||
|   customer?: string | null; |  | ||||||
|   project_type?: string | null; |  | ||||||
|   custom_address?: string | null; |  | ||||||
|   custom_email?: string | null; |  | ||||||
|   custom_mobile_phone_no?: string | null; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000'; |  | ||||||
| 
 | 
 | ||||||
| const SitesPage = () => { | const SitesPage = () => { | ||||||
|   const [projects, setProjects] = useState<CrmProject[]>([]); |     // Helper function to determine status (can be externalized if used elsewhere)
 | ||||||
|   const [loading, setLoading] = useState(true); |     const getSiteStatus = (siteName: SiteName): string => { | ||||||
|   const [err, setErr] = useState<string | null>(null); |         const statusMap: Record<SiteName, string> = { | ||||||
|   const [q, setQ] = useState('');             // search filter
 |             'Site A': 'Active', | ||||||
| 
 |             'Site B': 'Inactive', | ||||||
|   // pagination
 |             'Site C': 'Faulty', | ||||||
|   const [page, setPage] = useState(1); |         }; | ||||||
|   const [pageSize, setPageSize] = useState(6); // tweak as you like
 |         return statusMap[siteName]; | ||||||
| 
 |  | ||||||
|   useEffect(() => { |  | ||||||
|     let cancelled = false; |  | ||||||
|     const run = async () => { |  | ||||||
|       setLoading(true); |  | ||||||
|       setErr(null); |  | ||||||
|       try { |  | ||||||
|         const res = await fetch(`${API}/crm/projects?limit=0`); |  | ||||||
|         if (!res.ok) throw new Error(await res.text()); |  | ||||||
|         const json = await res.json(); |  | ||||||
|         const data: CrmProject[] = json?.data ?? []; |  | ||||||
|         if (!cancelled) setProjects(data); |  | ||||||
|       } catch (e: any) { |  | ||||||
|         if (!cancelled) setErr(e?.message ?? 'Failed to load CRM projects'); |  | ||||||
|       } finally { |  | ||||||
|         if (!cancelled) setLoading(false); |  | ||||||
|       } |  | ||||||
|     }; |     }; | ||||||
|     run(); |  | ||||||
|     return () => { cancelled = true; }; |  | ||||||
|   }, []); |  | ||||||
| 
 | 
 | ||||||
|   // Reset to first page whenever search or pageSize changes
 |     return ( | ||||||
|   useEffect(() => { |         <DashboardLayout> | ||||||
|     setPage(1); |             <div className="p-6 space-y-6"> | ||||||
|   }, [q, pageSize]); |                 <h1 className="text-2xl font-bold mb-6 dark:text-white-light">All Sites Overview</h1> | ||||||
| 
 | 
 | ||||||
|   const filtered = useMemo(() => { |                 <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> | ||||||
|     if (!q.trim()) return projects; |                     {/* Iterate over the keys of mockSiteData (which are your SiteNames) */} | ||||||
|     const needle = q.toLowerCase(); |                     {Object.keys(mockSiteData).map((siteNameKey) => { | ||||||
|     return projects.filter(p => |                         const siteName = siteNameKey as SiteName; // Cast to SiteName type
 | ||||||
|       (p.project_name || '').toLowerCase().includes(needle) || |                         const siteDetails = mockSiteData[siteName]; | ||||||
|       (p.name || '').toLowerCase().includes(needle) || |                         const siteStatus = getSiteStatus(siteName); | ||||||
|       (p.customer || '').toLowerCase().includes(needle) | 
 | ||||||
|  |                         return ( | ||||||
|  |                             <SiteCard | ||||||
|  |                                 key={siteName} // Important for React list rendering
 | ||||||
|  |                                 siteName={siteName} | ||||||
|  |                                 details={siteDetails} | ||||||
|  |                                 status={siteStatus} | ||||||
|  |                             /> | ||||||
|  |                         ); | ||||||
|  |                     })} | ||||||
|  |                 </div> | ||||||
|  |             </div> | ||||||
|  |         </DashboardLayout> | ||||||
|     ); |     ); | ||||||
|   }, [projects, q]); |  | ||||||
| 
 |  | ||||||
|   const total = filtered.length; |  | ||||||
|   const totalPages = Math.max(1, Math.ceil(total / pageSize)); |  | ||||||
|   const safePage = Math.min(page, totalPages); |  | ||||||
|   const startIdx = (safePage - 1) * pageSize; |  | ||||||
|   const endIdx = Math.min(startIdx + pageSize, total); |  | ||||||
|   const pageItems = filtered.slice(startIdx, endIdx); |  | ||||||
| 
 |  | ||||||
|   const goPrev = () => setPage(p => Math.max(1, p - 1)); |  | ||||||
|   const goNext = () => setPage(p => Math.min(totalPages, p + 1)); |  | ||||||
| 
 |  | ||||||
|   return ( |  | ||||||
|     <DashboardLayout> |  | ||||||
|       <div className="p-6 space-y-6"> |  | ||||||
|         <div className="flex flex-col md:flex-row md:items-center md:justify-between gap-3"> |  | ||||||
|           <h1 className="text-2xl font-bold dark:text-white-light">All Sites Overview</h1> |  | ||||||
| 
 |  | ||||||
|           <div className="flex items-center gap-3"> |  | ||||||
|             <input |  | ||||||
|               value={q} |  | ||||||
|               onChange={e => setQ(e.target.value)} |  | ||||||
|               placeholder="Search by name / ID / customer" |  | ||||||
|               className="w-64 max-w-full px-3 py-2 rounded-md border dark:border-gray-700 bg-white dark:bg-gray-900 dark:text-white" |  | ||||||
|             /> |  | ||||||
|             <select |  | ||||||
|               value={pageSize} |  | ||||||
|               onChange={e => setPageSize(Number(e.target.value))} |  | ||||||
|               className="px-3 py-2 rounded-md border dark:border-gray-700 bg-white dark:bg-gray-900 dark:text-white" |  | ||||||
|               aria-label="Items per page" |  | ||||||
|             > |  | ||||||
|               <option value={6}>6 / page</option> |  | ||||||
|               <option value={9}>9 / page</option> |  | ||||||
|               <option value={12}>12 / page</option> |  | ||||||
|             </select> |  | ||||||
|           </div> |  | ||||||
|         </div> |  | ||||||
| 
 |  | ||||||
|         {loading && ( |  | ||||||
|           <div className="text-gray-600 dark:text-gray-400">Loading CRM projects…</div> |  | ||||||
|         )} |  | ||||||
| 
 |  | ||||||
|         {err && ( |  | ||||||
|           <div className="text-red-600">Error: {err}</div> |  | ||||||
|         )} |  | ||||||
| 
 |  | ||||||
|         {!loading && !err && total === 0 && ( |  | ||||||
|           <div className="text-amber-600">No sites found.</div> |  | ||||||
|         )} |  | ||||||
| 
 |  | ||||||
|         {!loading && !err && total > 0 && ( |  | ||||||
|           <> |  | ||||||
|             {/* Pagination header */} |  | ||||||
|             <div className="flex items-center justify-between"> |  | ||||||
|               <div className="text-sm text-gray-600 dark:text-gray-400"> |  | ||||||
|                 Showing <span className="font-semibold">{startIdx + 1}</span>–<span className="font-semibold">{endIdx}</span> of <span className="font-semibold">{total}</span> |  | ||||||
|               </div> |  | ||||||
|               <div className="flex items-center gap-2 dark:text-white"> |  | ||||||
|                 <button |  | ||||||
|                   onClick={goPrev} |  | ||||||
|                   disabled={safePage <= 1} |  | ||||||
|                   className={`px-3 py-1.5 rounded-md border dark:border-gray-700 ${safePage <= 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-50 dark:hover:bg-gray-800'}`} |  | ||||||
|                 > |  | ||||||
|                   Previous |  | ||||||
|                 </button> |  | ||||||
|                 <span className="text-sm text-gray-600 dark:text-gray-400"> |  | ||||||
|                   Page <span className="font-semibold">{safePage}</span> / {totalPages} |  | ||||||
|                 </span> |  | ||||||
|                 <button |  | ||||||
|                   onClick={goNext} |  | ||||||
|                   disabled={safePage >= totalPages} |  | ||||||
|                   className={`px-3 py-1.5 rounded-md border dark:border-gray-700 ${safePage >= totalPages ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-50 dark:hover:bg-gray-800 '}`} |  | ||||||
|                 > |  | ||||||
|                   Next |  | ||||||
|                 </button> |  | ||||||
|               </div> |  | ||||||
|             </div> |  | ||||||
| 
 |  | ||||||
|             {/* Cards */} |  | ||||||
|             <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> |  | ||||||
|               {pageItems.map(p => ( |  | ||||||
|                 <SiteCard |  | ||||||
|                   key={p.name} |  | ||||||
|                   siteId={p.name}          // SiteCard self-fetches details
 |  | ||||||
|                   fallbackStatus={p.status ?? undefined} |  | ||||||
|                 /> |  | ||||||
|               ))} |  | ||||||
|             </div> |  | ||||||
| 
 |  | ||||||
|             {/* Pagination footer mirrors header for convenience */} |  | ||||||
|             <div className="flex items-center justify-between"> |  | ||||||
|               <div className="text-sm text-gray-600 dark:text-gray-400"> |  | ||||||
|                 Showing <span className="font-semibold">{startIdx + 1}</span>–<span className="font-semibold">{endIdx}</span> of <span className="font-semibold">{total}</span> |  | ||||||
|               </div> |  | ||||||
|               <div className="flex items-center gap-2 dark:text-white"> |  | ||||||
|                 <button |  | ||||||
|                   onClick={goPrev} |  | ||||||
|                   disabled={safePage <= 1} |  | ||||||
|                   className={`px-3 py-1.5 rounded-md border dark:border-gray-700 ${safePage <= 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-50 dark:hover:bg-gray-800'}`} |  | ||||||
|                 > |  | ||||||
|                   Previous |  | ||||||
|                 </button> |  | ||||||
|                 <span className="text-sm text-gray-600 dark:text-gray-400"> |  | ||||||
|                   Page <span className="font-semibold">{safePage}</span> / {totalPages} |  | ||||||
|                 </span> |  | ||||||
|                 <button |  | ||||||
|                   onClick={goNext} |  | ||||||
|                   disabled={safePage >= totalPages} |  | ||||||
|                   className={`px-3 py-1.5 rounded-md border dark:border-gray-700 ${safePage >= totalPages ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-50 dark:hover:bg-gray-800'}`} |  | ||||||
|                 > |  | ||||||
|                   Next |  | ||||||
|                 </button> |  | ||||||
|               </div> |  | ||||||
|             </div> |  | ||||||
|           </> |  | ||||||
|         )} |  | ||||||
|       </div> |  | ||||||
|     </DashboardLayout> |  | ||||||
|   ); |  | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export default SitesPage; | export default SitesPage; | ||||||
| 
 |  | ||||||
| Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 84 KiB | 
| @ -1,223 +0,0 @@ | |||||||
| 'use client'; |  | ||||||
| 
 |  | ||||||
| import React, { useEffect, useMemo, useState } from 'react'; |  | ||||||
| import axios from 'axios'; |  | ||||||
| 
 |  | ||||||
| type FnType = 'grid' | 'solar'; |  | ||||||
| 
 |  | ||||||
| interface LoggingControlCardProps { |  | ||||||
|   siteId: string; |  | ||||||
|   projectLabel?: string; // nice display (e.g., CRM project_name)
 |  | ||||||
|   className?: string; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000'; |  | ||||||
| 
 |  | ||||||
| type FnState = { |  | ||||||
|   serial: string; |  | ||||||
|   isLogging: boolean; |  | ||||||
|   isBusy: boolean; // to block double clicks while calling API
 |  | ||||||
|   error?: string | null; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| const emptyFnState: FnState = { serial: '', isLogging: false, isBusy: false, error: null }; |  | ||||||
| 
 |  | ||||||
| const storageKey = (siteId: string) => `logging_control_${siteId}`; |  | ||||||
| 
 |  | ||||||
| export default function LoggingControlCard({ |  | ||||||
|   siteId, |  | ||||||
|   projectLabel, |  | ||||||
|   className = '', |  | ||||||
| }: LoggingControlCardProps) { |  | ||||||
|   const [grid, setGrid] = useState<FnState>(emptyFnState); |  | ||||||
|   const [solar, setSolar] = useState<FnState>(emptyFnState); |  | ||||||
| 
 |  | ||||||
|   // Load persisted state (if any)
 |  | ||||||
|   useEffect(() => { |  | ||||||
|     try { |  | ||||||
|       const raw = localStorage.getItem(storageKey(siteId)); |  | ||||||
|       if (raw) { |  | ||||||
|         const parsed = JSON.parse(raw); |  | ||||||
|         setGrid({ ...emptyFnState, ...(parsed.grid ?? {}) }); |  | ||||||
|         setSolar({ ...emptyFnState, ...(parsed.solar ?? {}) }); |  | ||||||
|       } else { |  | ||||||
|         setGrid(emptyFnState); |  | ||||||
|         setSolar(emptyFnState); |  | ||||||
|       } |  | ||||||
|     } catch { |  | ||||||
|       setGrid(emptyFnState); |  | ||||||
|       setSolar(emptyFnState); |  | ||||||
|     } |  | ||||||
|   }, [siteId]); |  | ||||||
| 
 |  | ||||||
|   // Persist on any change
 |  | ||||||
|   useEffect(() => { |  | ||||||
|     const data = { grid, solar }; |  | ||||||
|     try { |  | ||||||
|       localStorage.setItem(storageKey(siteId), JSON.stringify(data)); |  | ||||||
|     } catch { |  | ||||||
|       // ignore storage errors
 |  | ||||||
|     } |  | ||||||
|   }, [siteId, grid, solar]); |  | ||||||
| 
 |  | ||||||
|   const title = useMemo( |  | ||||||
|     () => `Logging Control${projectLabel ? ` — ${projectLabel}` : ''}`, |  | ||||||
|     [projectLabel] |  | ||||||
|   ); |  | ||||||
| 
 |  | ||||||
|   const topicsFor = (fn: FnType, serial: string) => { |  | ||||||
|     return [`ADW300/${siteId}/${serial}/${fn}`]; |  | ||||||
|   }; |  | ||||||
| 
 |  | ||||||
|   const start = async (fn: FnType) => { |  | ||||||
|     const state = fn === 'grid' ? grid : solar; |  | ||||||
|     const setState = fn === 'grid' ? setGrid : setSolar; |  | ||||||
| 
 |  | ||||||
|     if (!state.serial.trim()) { |  | ||||||
|       setState((s) => ({ ...s, error: 'Please enter a meter serial number.' })); |  | ||||||
|       return; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     setState((s) => ({ ...s, isBusy: true, error: null })); |  | ||||||
| 
 |  | ||||||
|     try { |  | ||||||
|       const topics = topicsFor(fn, state.serial.trim()); |  | ||||||
|       const res = await axios.post(`${API_URL}/start-logging`, { topics }); |  | ||||||
|       console.log('Start logging:', res.data); |  | ||||||
|       setState((s) => ({ ...s, isLogging: true, isBusy: false })); |  | ||||||
|     } catch (e: any) { |  | ||||||
|       console.error('Failed to start logging', e); |  | ||||||
|       setState((s) => ({ |  | ||||||
|         ...s, |  | ||||||
|         isBusy: false, |  | ||||||
|         error: e?.response?.data?.detail || e?.message || 'Failed to start logging', |  | ||||||
|       })); |  | ||||||
|     } |  | ||||||
|   }; |  | ||||||
| 
 |  | ||||||
|   const stop = async (fn: FnType) => { |  | ||||||
|     const state = fn === 'grid' ? grid : solar; |  | ||||||
|     const setState = fn === 'grid' ? setGrid : setSolar; |  | ||||||
| 
 |  | ||||||
|     if (!state.isLogging) return; |  | ||||||
| 
 |  | ||||||
|     const confirmed = window.confirm( |  | ||||||
|       `Stop logging for ${fn.toUpperCase()} meter "${state.serial}" at site ${siteId}?` |  | ||||||
|     ); |  | ||||||
|     if (!confirmed) return; |  | ||||||
| 
 |  | ||||||
|     setState((s) => ({ ...s, isBusy: true, error: null })); |  | ||||||
| 
 |  | ||||||
|     try { |  | ||||||
|       const topics = topicsFor(fn, state.serial.trim()); |  | ||||||
|       const res = await axios.post(`${API_URL}/stop-logging`, { topics }); |  | ||||||
|       console.log('Stop logging:', res.data); |  | ||||||
|       setState((s) => ({ ...s, isLogging: false, isBusy: false })); |  | ||||||
|     } catch (e: any) { |  | ||||||
|       console.error('Failed to stop logging', e); |  | ||||||
|       setState((s) => ({ |  | ||||||
|         ...s, |  | ||||||
|         isBusy: false, |  | ||||||
|         error: e?.response?.data?.detail || e?.message || 'Failed to stop logging', |  | ||||||
|       })); |  | ||||||
|     } |  | ||||||
|   }; |  | ||||||
| 
 |  | ||||||
|   // Responsive utility classes
 |  | ||||||
|   const field = |  | ||||||
|     'w-full px-3 py-2 sm:py-2.5 border rounded-md text-sm sm:text-base placeholder:text-gray-400 dark:border-rtgray-700 dark:bg-rtgray-700 dark:text-white'; |  | ||||||
| 
 |  | ||||||
|   const label = |  | ||||||
|     'text-gray-600 dark:text-white/85 font-medium text-sm sm:text-base mb-1 flex items-center justify-between mr-2.5'; |  | ||||||
| 
 |  | ||||||
|   const section = ( |  | ||||||
|     fn: FnType, |  | ||||||
|     labelText: string, |  | ||||||
|     state: FnState, |  | ||||||
|     setState: React.Dispatch<React.SetStateAction<FnState>> |  | ||||||
|   ) => ( |  | ||||||
|     <div className="space-y-2"> |  | ||||||
|       <div className={label}> |  | ||||||
|         <span>{labelText}</span> |  | ||||||
|         {state.isLogging && ( |  | ||||||
|           <span |  | ||||||
|             className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] sm:text-xs font-semibold |  | ||||||
|                        bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-300" |  | ||||||
|             aria-live="polite" |  | ||||||
|           > |  | ||||||
|             Logging |  | ||||||
|           </span> |  | ||||||
|         )} |  | ||||||
|       </div> |  | ||||||
| 
 |  | ||||||
|       {/* Input + Button: stack on mobile, row on ≥sm */} |  | ||||||
|       <div className="flex flex-col sm:flex-row gap-2 sm:gap-3"> |  | ||||||
|         <input |  | ||||||
|           type="text" |  | ||||||
|           autoComplete="off" |  | ||||||
|           inputMode="text" |  | ||||||
|           placeholder="Meter serial number" |  | ||||||
|           className={`${field} flex-1`} |  | ||||||
|           value={state.serial} |  | ||||||
|           onChange={(e) => setState((s) => ({ ...s, serial: e.target.value }))} |  | ||||||
|           disabled={state.isLogging || state.isBusy} |  | ||||||
|           aria-label={`${labelText} serial number`} |  | ||||||
|         /> |  | ||||||
| 
 |  | ||||||
|         {!state.isLogging ? ( |  | ||||||
|           <button |  | ||||||
|             onClick={() => start(fn)} |  | ||||||
|             disabled={state.isBusy || !state.serial.trim()} |  | ||||||
|             className={`h-10 sm:h-11 rounded-full font-medium transition
 |  | ||||||
|               w-full sm:w-auto sm:min-w-28 mt-2 lg:mt-0 |  | ||||||
|               ${state.isBusy || !state.serial.trim() |  | ||||||
|                 ? 'bg-gray-400 cursor-not-allowed text-black/70' |  | ||||||
|                 : 'bg-rtyellow-200 hover:bg-rtyellow-300 text-black'}`}
 |  | ||||||
|             aria-disabled={state.isBusy || !state.serial.trim()} |  | ||||||
|           > |  | ||||||
|             {state.isBusy ? 'Starting…' : 'Start'} |  | ||||||
|           </button> |  | ||||||
|         ) : ( |  | ||||||
|           <button |  | ||||||
|             onClick={() => stop(fn)} |  | ||||||
|             disabled={state.isBusy} |  | ||||||
|             className={`h-10 sm:h-11 rounded-full font-medium transition
 |  | ||||||
|               w-full sm:w-auto sm:min-w-28 mt-2 lg:mt-0 |  | ||||||
|               ${state.isBusy ? 'bg-gray-400 cursor-not-allowed' : 'bg-red-500 hover:bg-red-600'} text-white`}
 |  | ||||||
|             aria-disabled={state.isBusy} |  | ||||||
|           > |  | ||||||
|             {state.isBusy ? 'Stopping…' : 'Stop'} |  | ||||||
|           </button> |  | ||||||
|         )} |  | ||||||
|       </div> |  | ||||||
| 
 |  | ||||||
|       {!!state.error && <div className="text-sm sm:text-[15px] text-red-600">{state.error}</div>} |  | ||||||
|     </div> |  | ||||||
|   ); |  | ||||||
| 
 |  | ||||||
|   return ( |  | ||||||
|     <div |  | ||||||
|       className={`bg-white p-4 sm:p-5 md:p-6 rounded-xl md:rounded-2xl shadow-md space-y-4 md:space-y-5
 |  | ||||||
|                   dark:bg-rtgray-800 dark:text-white-light w-full  ${className}`}
 |  | ||||||
|     > |  | ||||||
|       <h2 className="text-lg sm:text-xl md:text-2xl font-semibold truncate" title={title}> |  | ||||||
|         {title} |  | ||||||
|       </h2> |  | ||||||
| 
 |  | ||||||
|       {section('grid', 'Grid Meter', grid, setGrid)} |  | ||||||
|       <div className="border-t dark:border-rtgray-700" /> |  | ||||||
|       {section('solar', 'Solar Meter', solar, setSolar)} |  | ||||||
| 
 |  | ||||||
|       <div className="text-[11px] sm:text-xs text-gray-500 dark:text-gray-400 pt-2 leading-relaxed break-words"> |  | ||||||
|         • Inputs lock while logging is active. Stop to edit the serial. |  | ||||||
|         <br /> |  | ||||||
|         • Topics follow{' '} |  | ||||||
|         <code className="break-all"> |  | ||||||
|           ADW300/{'{'}siteId{'}'}/{'{'}serial{'}'}/(grid|solar) |  | ||||||
|         </code> |  | ||||||
|         . |  | ||||||
|       </div> |  | ||||||
|     </div> |  | ||||||
|   ); |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| @ -1,146 +1,63 @@ | |||||||
| // components/dashboards/SiteCard.tsx
 | // components/dashboards/SiteCard.tsx
 | ||||||
| 'use client'; | import React from 'react'; | ||||||
| 
 | import Link from 'next/link'; // Import Link from Next.js
 | ||||||
| import React, { useEffect, useMemo, useState } from 'react'; | import { SiteName, SiteDetails } from '@/types/SiteData'; // Adjust path as necessary
 | ||||||
| import Link from 'next/link'; |  | ||||||
| import { formatAddress } from '@/app/utils/formatAddress'; |  | ||||||
| import { formatCrmTimestamp } from '@/app/utils/datetime'; |  | ||||||
| 
 |  | ||||||
| type CrmProject = { |  | ||||||
|   name: string;                  // e.g. PROJ-0008 (siteId)
 |  | ||||||
|   project_name: string; |  | ||||||
|   status?: string; |  | ||||||
|   percent_complete?: number | null; |  | ||||||
|   owner?: string | null; |  | ||||||
|   modified?: string | null; |  | ||||||
|   customer?: string | null; |  | ||||||
|   project_type?: string | null; |  | ||||||
|   custom_address?: string | null; |  | ||||||
|   custom_email?: string | null; |  | ||||||
|   custom_mobile_phone_no?: string | null; |  | ||||||
| }; |  | ||||||
| 
 | 
 | ||||||
| interface SiteCardProps { | interface SiteCardProps { | ||||||
|   siteId: string;                // CRM Project "name" (canonical id)
 |     siteName: SiteName; | ||||||
|   className?: string;            // optional styling hook
 |     details: SiteDetails; | ||||||
|   fallbackStatus?: string;       // optional backup status if CRM is missing it
 |     status: string; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000'; | const SiteCard: React.FC<SiteCardProps> = ({ siteName, details, status }) => { | ||||||
|  |     const statusColorClass = | ||||||
|  |         status === 'Active' ? 'text-green-500' : | ||||||
|  |         status === 'Inactive' ? 'text-orange-500' : | ||||||
|  |         'text-red-500'; | ||||||
| 
 | 
 | ||||||
| const SiteCard: React.FC<SiteCardProps> = ({ siteId, className = '', fallbackStatus }) => { |     return ( | ||||||
|   const [project, setProject] = useState<CrmProject | null>(null); |         <div className="bg-white p-4 rounded-lg shadow-md dark:bg-gray-800 dark:text-white-light flex flex-col space-y-2"> | ||||||
|   const [loading, setLoading] = useState(true); |             <h3 className="text-xl font-bold text-primary-600 dark:text-primary-400 border-b pb-2 mb-2"> | ||||||
|   const [err, setErr] = useState<string | null>(null); |                 {siteName} | ||||||
|  |             </h3> | ||||||
| 
 | 
 | ||||||
|   useEffect(() => { |             <div className="flex justify-between items-center"> | ||||||
|     let cancelled = false; |                 <p className="text-gray-600 dark:text-gray-400 font-medium">Status:</p> | ||||||
|  |                 <p className={`font-semibold ${statusColorClass}`}>{status}</p> | ||||||
|  |             </div> | ||||||
| 
 | 
 | ||||||
|     const fetchProject = async () => { |             <div className="flex justify-between items-center"> | ||||||
|       setLoading(true); |                 <p className="text-gray-600 dark:text-gray-400 font-medium">Location:</p> | ||||||
|       setErr(null); |                 <p className="font-semibold">{details.location}</p> | ||||||
|       try { |             </div> | ||||||
|         // ---- Try a single-project endpoint first (best) ----
 |  | ||||||
|         // e.g. GET /crm/projects/PROJ-0008
 |  | ||||||
|         const single = await fetch(`${API}/crm/projects/${encodeURIComponent(siteId)}`); |  | ||||||
|         if (single.ok) { |  | ||||||
|           const pj = await single.json(); |  | ||||||
|           if (!cancelled) setProject(pj?.data ?? pj ?? null); |  | ||||||
|         } else { |  | ||||||
|           // ---- Fallback: fetch all and find by name (works with your existing API) ----
 |  | ||||||
|           const list = await fetch(`${API}/crm/projects?limit=0`); |  | ||||||
|           if (!list.ok) throw new Error(await list.text()); |  | ||||||
|           const json = await list.json(); |  | ||||||
|           const found = (json?.data ?? []).find((p: CrmProject) => p.name === siteId) ?? null; |  | ||||||
|           if (!cancelled) setProject(found); |  | ||||||
|         } |  | ||||||
|       } catch (e: any) { |  | ||||||
|         if (!cancelled) setErr(e?.message ?? 'Failed to load CRM project'); |  | ||||||
|       } finally { |  | ||||||
|         if (!cancelled) setLoading(false); |  | ||||||
|       } |  | ||||||
|     }; |  | ||||||
| 
 | 
 | ||||||
|     fetchProject(); |             <div className="flex justify-between items-center"> | ||||||
|     return () => { cancelled = true; }; |                 <p className="text-gray-600 dark:text-gray-400 font-medium">Inverter Provider:</p> | ||||||
|   }, [siteId]); |                 <p className="font-semibold">{details.inverterProvider}</p> | ||||||
|  |             </div> | ||||||
| 
 | 
 | ||||||
|   const status = project?.status || fallbackStatus || 'Unknown'; |             <div className="flex justify-between items-center"> | ||||||
|   const statusColorClass = |                 <p className="text-gray-600 dark:text-gray-400 font-medium">Emergency Contact:</p> | ||||||
|     status === 'Active' ? 'text-green-500' : |                 <p className="font-semibold">{details.emergencyContact}</p> | ||||||
|     status === 'Inactive' ? 'text-orange-500' : |             </div> | ||||||
|     'text-red-500'; |  | ||||||
| 
 | 
 | ||||||
|   const niceAddress = useMemo(() => { |             <div className="flex justify-between items-center"> | ||||||
|     if (!project?.custom_address) return 'N/A'; |                 <p className="text-gray-600 dark:text-gray-400 font-medium">Last Sync:</p> | ||||||
|     return formatAddress(project.custom_address).multiLine; |                 <p className="font-semibold">{details.lastSyncTimestamp}</p> | ||||||
|   }, [project?.custom_address]); |             </div> | ||||||
| 
 | 
 | ||||||
|   const lastSync = useMemo(() => { |             {/* New: View Dashboard Button */} | ||||||
|     return formatCrmTimestamp(project?.modified, { includeSeconds: true }) || 'N/A'; |             <Link | ||||||
|   }, [project?.modified]); |                 href={{ | ||||||
| 
 |                     pathname: '/adminDashboard', // Path to your AdminDashboard page
 | ||||||
|   const inverterProvider = project?.project_type || 'N/A'; |                     query: { site: siteName }, // Pass the siteName as a query parameter
 | ||||||
|   const emergencyContact = |                 }} | ||||||
|     project?.custom_mobile_phone_no || |                 className="mt-4 w-full text-center text-sm btn-primary" // Tailwind classes for basic button styling
 | ||||||
|     project?.custom_email || |             > | ||||||
|     project?.customer || |                 View Dashboard | ||||||
|     'N/A'; |             </Link> | ||||||
| 
 |  | ||||||
|   return ( |  | ||||||
|     <div className={`bg-white p-4 rounded-lg shadow-md dark:bg-gray-800 dark:text-white-light flex flex-col space-y-2 ${className}`}> |  | ||||||
|       <h3 className="text-xl font-bold text-primary-600 dark:text-primary-400 border-b pb-2 mb-2"> |  | ||||||
|         {project?.project_name || siteId} |  | ||||||
|       </h3> |  | ||||||
| 
 |  | ||||||
|       {loading ? ( |  | ||||||
|         <div className="animate-pulse space-y-2"> |  | ||||||
|           <div className="h-4 w-24 bg-gray-200 dark:bg-gray-700 rounded" /> |  | ||||||
|           <div className="h-4 w-48 bg-gray-200 dark:bg-gray-700 rounded" /> |  | ||||||
|           <div className="h-4 w-40 bg-gray-200 dark:bg-gray-700 rounded" /> |  | ||||||
|           <div className="h-4 w-36 bg-gray-200 dark:bg-gray-700 rounded" /> |  | ||||||
|         </div> |         </div> | ||||||
|       ) : err ? ( |     ); | ||||||
|         <div className="text-red-500 text-sm">Failed to load CRM: {err}</div> |  | ||||||
|       ) : !project ? ( |  | ||||||
|         <div className="text-amber-500 text-sm">No CRM project found for <span className="font-semibold">{siteId}</span>.</div> |  | ||||||
|       ) : ( |  | ||||||
|         <> |  | ||||||
|           <div className="flex justify-between items-center"> |  | ||||||
|             <p className="text-gray-600 dark:text-gray-400 font-medium">Status:</p> |  | ||||||
|             <p className={`font-semibold ${statusColorClass}`}>{status}</p> |  | ||||||
|           </div> |  | ||||||
| 
 |  | ||||||
|           <div className="flex justify-between items-center"> |  | ||||||
|             <p className="text-gray-600 dark:text-gray-400 font-medium">Location:</p> |  | ||||||
|             <p className="font-medium whitespace-pre-line text-right">{niceAddress}</p> |  | ||||||
|           </div> |  | ||||||
| 
 |  | ||||||
|           <div className="flex justify-between items-center"> |  | ||||||
|             <p className="text-gray-600 dark:text-gray-400 font-medium">Inverter Provider:</p> |  | ||||||
|             <p className="font-medium">{inverterProvider}</p> |  | ||||||
|           </div> |  | ||||||
| 
 |  | ||||||
|           <div className="flex justify-between items-center"> |  | ||||||
|             <p className="text-gray-600 dark:text-gray-400 font-medium">Emergency Contact:</p> |  | ||||||
|             <p className="font-medium text-right">{emergencyContact}</p> |  | ||||||
|           </div> |  | ||||||
| 
 |  | ||||||
|           <div className="flex justify-between items-center"> |  | ||||||
|             <p className="text-gray-600 dark:text-gray-400 font-medium">Last Sync:</p> |  | ||||||
|             <p className="font-medium">{lastSync}</p> |  | ||||||
|           </div> |  | ||||||
|         </> |  | ||||||
|       )} |  | ||||||
| 
 |  | ||||||
|       <Link |  | ||||||
|         href={{ pathname: '/adminDashboard', query: { site: siteId } }} |  | ||||||
|         className="mt-4 w-full text-center text-sm btn-primary" |  | ||||||
|       > |  | ||||||
|         View Dashboard |  | ||||||
|       </Link> |  | ||||||
|     </div> |  | ||||||
|   ); |  | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export default SiteCard; | export default SiteCard; | ||||||
| @ -144,6 +144,67 @@ const SiteStatus = ({ | |||||||
|         <p className="text-gray-600 dark:text-white/85 font-medium">Last Sync:</p> |         <p className="text-gray-600 dark:text-white/85 font-medium">Last Sync:</p> | ||||||
|         <p className="font-medium">{lastSyncTimestamp}</p> |         <p className="font-medium">{lastSyncTimestamp}</p> | ||||||
|       </div> |       </div> | ||||||
|  | 
 | ||||||
|  |       {/* Start/Stop */} | ||||||
|  |       <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 dark:bg-rtgray-800 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 dark:border-rtgray-800 dark:bg-rtgray-700 dark:text-white" | ||||||
|  |               value={deviceId} | ||||||
|  |               onChange={(e) => setDeviceId(e.target.value)} | ||||||
|  |             /> | ||||||
|  | 
 | ||||||
|  |             <select | ||||||
|  |               className="w-full p-2 mb-4 border rounded dark:border-rtgray-800 dark:bg-rtgray-700 dark:text-white" | ||||||
|  |               value={functionType} | ||||||
|  |               onChange={(e) => setFunctionType(e.target.value as "Grid" | "Solar")} | ||||||
|  |             > | ||||||
|  |               <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" | ||||||
|  |                 disabled={!deviceId.trim()} | ||||||
|  |               > | ||||||
|  |                 Confirm | ||||||
|  |               </button> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |       )} | ||||||
|     </div> |     </div> | ||||||
|   ); |   ); | ||||||
| }; | }; | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user