Compare commits
	
		
			No commits in common. "master" and "v.0.0.4" have entirely different histories.
		
	
	
		
	
		
| @ -38,8 +38,7 @@ jobs: | |||||||
|                   tags: | |                   tags: | | ||||||
|                       rooftopenergy/powermeter-frontend:${{ steps.extract_tag.outputs.tag }} |                       rooftopenergy/powermeter-frontend:${{ steps.extract_tag.outputs.tag }} | ||||||
|                   build-args: | |                   build-args: | | ||||||
|                       NEXT_PUBLIC_FASTAPI_URL=${{ secrets.NEXT_PUBLIC_FASTAPI_URL }} |                       NEXT_PUBLIC_FASTAPI_URL=${{ secrets.NEXT_PUBLIC_FASTAPI_URL }}         | ||||||
|                       NEXT_PUBLIC_GOOGLE_DRIVE_FOLDER_ID=${{ secrets.NEXT_PUBLIC_GOOGLE_DRIVE_FOLDER_ID }}         |  | ||||||
| 
 | 
 | ||||||
|             - name: SSH and Deploy |             - name: SSH and Deploy | ||||||
|               uses: appleboy/ssh-action@master |               uses: appleboy/ssh-action@master | ||||||
|  | |||||||
| @ -8,10 +8,8 @@ WORKDIR /app | |||||||
| ENV NEXT_TELEMETRY_DISABLED=1 | ENV NEXT_TELEMETRY_DISABLED=1 | ||||||
| 
 | 
 | ||||||
| # Build-time public env hook | # Build-time public env hook | ||||||
| ARG NEXT_PUBLIC_FASTAPI_URL\ | ARG NEXT_PUBLIC_FASTAPI_URL | ||||||
|     NEXT_PUBLIC_GOOGLE_DRIVE_FOLDER_ID | ENV NEXT_PUBLIC_FASTAPI_URL=${NEXT_PUBLIC_FASTAPI_URL} | ||||||
| ENV NEXT_PUBLIC_FASTAPI_URL=${NEXT_PUBLIC_FASTAPI_URL}\ |  | ||||||
|     NEXT_PUBLIC_GOOGLE_DRIVE_FOLDER_ID=${NEXT_PUBLIC_GOOGLE_DRIVE_FOLDER_ID} |  | ||||||
| 
 | 
 | ||||||
| # 1) Install deps with caching | # 1) Install deps with caching | ||||||
| COPY package.json package-lock.json* ./ | COPY package.json package-lock.json* ./ | ||||||
|  | |||||||
| @ -14,7 +14,6 @@ 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'; | import LoggingControlCard from '@/components/dashboards/LoggingControl'; | ||||||
| import { buildExportUrl, getFilenameFromCD } from "@/utils/export"; |  | ||||||
| 
 | 
 | ||||||
| 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 }); | ||||||
| @ -41,7 +40,7 @@ type CrmProject = { | |||||||
|   custom_mobile_phone_no?: string | null; |   custom_mobile_phone_no?: string | null; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| const API = process.env.NEXT_PUBLIC_FASTAPI_URL; | const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000'; | ||||||
| 
 | 
 | ||||||
| // Adjust this to your FastAPI route
 | // Adjust this to your FastAPI route
 | ||||||
| const START_LOGGING_ENDPOINT = (siteId: string) => | const START_LOGGING_ENDPOINT = (siteId: string) => | ||||||
| @ -337,95 +336,6 @@ useEffect(() => { | |||||||
|     } |     } | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   // helpers
 |  | ||||||
| // helpers
 |  | ||||||
| const ymd = (d: Date) => d.toISOString().slice(0, 10); |  | ||||||
| const excelUrl = (site: string, device: string, fn: 'grid' | 'solar', dateYMD: string) => |  | ||||||
|   `${API}/excel-fs/${encodeURIComponent(site)}/${encodeURIComponent(device)}/${fn}/${dateYMD}.xlsx`; |  | ||||||
| 
 |  | ||||||
| // popup state
 |  | ||||||
| const [isDownloadOpen, setIsDownloadOpen] = useState(false); |  | ||||||
| const [meter, setMeter] = useState('01');                         // ADW300 device id
 |  | ||||||
| const [fn, setFn] = useState<'grid' | 'solar'>('grid');           // which function
 |  | ||||||
| const [downloadDate, setDownloadDate] = useState(ymd(new Date())); // YYYY-MM-DD
 |  | ||||||
| const [downloading, setDownloading] = useState(false); |  | ||||||
| 
 |  | ||||||
| // action
 |  | ||||||
| // util: parse filename from Content-Disposition
 |  | ||||||
| function getFilenameFromCD(h: string | null): string | null { |  | ||||||
|   if (!h) return null; |  | ||||||
|   // filename*=UTF-8''name.ext  (RFC 5987)
 |  | ||||||
|   const star = /filename\*\s*=\s*([^']*)''([^;]+)/i.exec(h); |  | ||||||
|   if (star && star[2]) return decodeURIComponent(star[2]); |  | ||||||
| 
 |  | ||||||
|   // filename="name.ext" or filename=name.ext
 |  | ||||||
|   const plain = /filename\s*=\s*("?)([^";]+)\1/i.exec(h); |  | ||||||
|   if (plain && plain[2]) return plain[2]; |  | ||||||
| 
 |  | ||||||
|   return null; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| const downloadExcel = async () => { |  | ||||||
|   if (!selectedProject) return; |  | ||||||
|   try { |  | ||||||
|     setDownloading(true); |  | ||||||
| 
 |  | ||||||
|     // Prefer the simple day-based export
 |  | ||||||
|     const url = buildExportUrl({ |  | ||||||
|       baseUrl: process.env.NEXT_PUBLIC_FASTAPI_URL, |  | ||||||
|       site: selectedProject.name, |  | ||||||
|       suffix: fn, |  | ||||||
|       serial: meter?.trim() || undefined, |  | ||||||
|       day: downloadDate, // "YYYY-MM-DD"
 |  | ||||||
|     }); |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
|     const resp = await fetch(url, { credentials: "include" }); |  | ||||||
| 
 |  | ||||||
|     if (!resp.ok) { |  | ||||||
|       // server might return JSON error; try to surface it nicely
 |  | ||||||
|       const ctype = resp.headers.get("Content-Type") || ""; |  | ||||||
|       let msg = `HTTP ${resp.status}`; |  | ||||||
|       if (ctype.includes("application/json")) { |  | ||||||
|         const j = await resp.json().catch(() => null); |  | ||||||
|         if (j?.detail) msg = String(j.detail); |  | ||||||
|       } else { |  | ||||||
|         const t = await resp.text().catch(() => ""); |  | ||||||
|         if (t) msg = t; |  | ||||||
|       } |  | ||||||
|       throw new Error(msg); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     const blob = await resp.blob(); |  | ||||||
| 
 |  | ||||||
|     // 1) use server-provided filename if present
 |  | ||||||
|     const cd = resp.headers.get("Content-Disposition"); |  | ||||||
|     let downloadName = getFilenameFromCD(cd); |  | ||||||
| 
 |  | ||||||
|     // 2) client-side fallback (date-only as requested)
 |  | ||||||
|     if (!downloadName) { |  | ||||||
|       const serialPart = meter?.trim() ? meter.trim() : "ALL"; |  | ||||||
|       downloadName = `${selectedProject.name}_${serialPart}_${fn}_${downloadDate}.xlsx`; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     const a = document.createElement("a"); |  | ||||||
|     const href = URL.createObjectURL(blob); |  | ||||||
|     a.href = href; |  | ||||||
|     a.download = downloadName; |  | ||||||
|     document.body.appendChild(a); |  | ||||||
|     a.click(); |  | ||||||
|     a.remove(); |  | ||||||
|     URL.revokeObjectURL(href); |  | ||||||
|     setIsDownloadOpen(false); |  | ||||||
|   } catch (e: any) { |  | ||||||
|     alert(`Download failed: ${e?.message ?? e}`); |  | ||||||
|   } finally { |  | ||||||
|     setDownloading(false); |  | ||||||
|   } |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
|   // ---------- RENDER ----------
 |   // ---------- RENDER ----------
 | ||||||
|   if (!authChecked) { |   if (!authChecked) { | ||||||
|     return <div>Checking authentication…</div>; |     return <div>Checking authentication…</div>; | ||||||
| @ -552,151 +462,10 @@ const downloadExcel = async () => { | |||||||
|               <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 | ||||||
|               </button> |               </button> | ||||||
| 
 |  | ||||||
|               <button |  | ||||||
|                 onClick={() => setIsDownloadOpen(true)} |  | ||||||
|                 className="text-sm lg:text-lg btn-primary" |  | ||||||
|               > |  | ||||||
|                 Download Excel Log |  | ||||||
|               </button> |  | ||||||
|             </div> |             </div> | ||||||
| 
 |  | ||||||
|           </> |           </> | ||||||
|         )} |         )} | ||||||
| 
 | 
 | ||||||
|         {isDownloadOpen && ( |  | ||||||
|   <div |  | ||||||
|     className="fixed inset-0 z-50 flex items-center justify-center" |  | ||||||
|     aria-modal="true" |  | ||||||
|     role="dialog" |  | ||||||
|     onKeyDown={(e) => e.key === 'Escape' && setIsDownloadOpen(false)} |  | ||||||
|   > |  | ||||||
|     {/* Backdrop */} |  | ||||||
|     <div |  | ||||||
|       className="absolute inset-0 bg-black/50" |  | ||||||
|       onClick={() => setIsDownloadOpen(false)} |  | ||||||
|     /> |  | ||||||
| 
 |  | ||||||
|     {/* Modal */} |  | ||||||
|     <div className="relative w-full max-w-lg mx-4 rounded-2xl bg-white dark:bg-rtgray-800 shadow-2xl"> |  | ||||||
|       <div className="p-5 sm:p-6 border-b border-black/5 dark:border-white/10"> |  | ||||||
|         <h3 className="text-lg font-semibold text-black/90 dark:text-white"> |  | ||||||
|           Download Excel Log |  | ||||||
|         </h3> |  | ||||||
|         <p className="mt-1 text-sm text-black/60 dark:text-white/60"> |  | ||||||
|           Choose device, function, and date to export the .xlsx generated by the logger. |  | ||||||
|         </p> |  | ||||||
|       </div> |  | ||||||
| 
 |  | ||||||
|       <div className="p-5 sm:p-6 space-y-5"> |  | ||||||
|         {/* Site (read-only preview) */} |  | ||||||
|         <div> |  | ||||||
|           <label className="block text-sm opacity-80 mb-1 dark:text-white">Site</label> |  | ||||||
|           <div className="px-3 py-2 rounded-lg bg-black/5 dark:bg-white/5 text-sm truncate dark:text-white"> |  | ||||||
|             {selectedProject?.project_name || selectedProject?.name} |  | ||||||
|           </div> |  | ||||||
|         </div> |  | ||||||
| 
 |  | ||||||
|         {/* Device + Function */} |  | ||||||
|         <div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> |  | ||||||
|           <div> |  | ||||||
|             <label className="block text-sm opacity-80 mb-1 dark:text-white">Meter (Device)</label> |  | ||||||
|             <input |  | ||||||
|               value={meter} |  | ||||||
|               onChange={(e) => setMeter(e.target.value)} |  | ||||||
|               placeholder="01" |  | ||||||
|               className="input input-bordered w-full pl-2 rounded-lg" |  | ||||||
|             /> |  | ||||||
|             <p className="mt-1 text-xs opacity-70 dark:text-white"> |  | ||||||
|               Matches topic: <code>ADW300/<site>/<b>{meter || '01'}</b>/…</code> |  | ||||||
|             </p> |  | ||||||
|           </div> |  | ||||||
| 
 |  | ||||||
|           <div> |  | ||||||
|             <label className="block text-sm opacity-80 mb-1 dark:text-white">Function</label> |  | ||||||
|             <div className="flex rounded-xl overflow-hidden border border-black/10 dark:border-white/10"> |  | ||||||
|               <button |  | ||||||
|                 type="button" |  | ||||||
|                 onClick={() => setFn('grid')} |  | ||||||
|                 className={`flex-1 px-3 py-2 text-sm ${ |  | ||||||
|                   fn === 'grid' |  | ||||||
|                     ? 'bg-rtyellow-200 text-black' |  | ||||||
|                     : 'bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 dark:text-white' |  | ||||||
|                 }`}
 |  | ||||||
|               > |  | ||||||
|                 Grid |  | ||||||
|               </button> |  | ||||||
|               <button |  | ||||||
|                 type="button" |  | ||||||
|                 onClick={() => setFn('solar')} |  | ||||||
|                 className={`flex-1 px-3 py-2 text-sm ${ |  | ||||||
|                   fn === 'solar' |  | ||||||
|                     ? 'bg-rtyellow-200 text-black' |  | ||||||
|                     : 'bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 dark:text-white' |  | ||||||
|                 }`}
 |  | ||||||
|               > |  | ||||||
|                 Solar |  | ||||||
|               </button> |  | ||||||
|             </div> |  | ||||||
|           </div> |  | ||||||
|         </div> |  | ||||||
| 
 |  | ||||||
|         {/* Date + quick picks */} |  | ||||||
|         <div> |  | ||||||
|           <label className="block text-sm opacity-80 mb-1 dark:text-white">Date</label> |  | ||||||
|           <div className="flex items-center gap-3"> |  | ||||||
|             <input |  | ||||||
|               type="date" |  | ||||||
|               value={downloadDate} |  | ||||||
|               onChange={(e) => setDownloadDate(e.target.value)} |  | ||||||
|               className="input input-bordered w-48 pl-2 rounded-lg" |  | ||||||
|             /> |  | ||||||
|             <div className="flex gap-2"> |  | ||||||
|               <button |  | ||||||
|                 type="button" |  | ||||||
|                 className="px-3 py-1 rounded-full text-xs border border-black/10 dark:border-white/15 hover:bg-black/5 dark:hover:bg-white/10 dark:text-white" |  | ||||||
|                 onClick={() => setDownloadDate(ymd(new Date()))} |  | ||||||
|               > |  | ||||||
|                 Today |  | ||||||
|               </button> |  | ||||||
|               <button |  | ||||||
|                 type="button" |  | ||||||
|                 className="px-3 py-1 rounded-full text-xs border border-black/10 dark:border-white/15 hover:bg-black/5 dark:hover:bg:white/10 dark:text-white" |  | ||||||
|                 onClick={() => { |  | ||||||
|                   const d = new Date(); |  | ||||||
|                   d.setDate(d.getDate() - 1); |  | ||||||
|                   setDownloadDate(ymd(d)); |  | ||||||
|                 }} |  | ||||||
|               > |  | ||||||
|                 Yesterday |  | ||||||
|               </button> |  | ||||||
|             </div> |  | ||||||
|           </div> |  | ||||||
|         </div> |  | ||||||
|       </div> |  | ||||||
| 
 |  | ||||||
|       {/* Footer */} |  | ||||||
|       <div className="p-5 sm:p-6 flex justify-end gap-3 border-t border-black/5 dark:border-white/10"> |  | ||||||
|         <button |  | ||||||
|           type="button" |  | ||||||
|           className="btn btn-primary bg-red-500 hover:bg-red-600 border-transparent" |  | ||||||
|           onClick={() => setIsDownloadOpen(false)} |  | ||||||
|           disabled={downloading} |  | ||||||
|         > |  | ||||||
|           Cancel |  | ||||||
|         </button> |  | ||||||
|         <button |  | ||||||
|           type="button" |  | ||||||
|           className="btn btn-primary border-transparent" |  | ||||||
|           onClick={downloadExcel} |  | ||||||
|           disabled={downloading || !meter || !downloadDate} |  | ||||||
|         > |  | ||||||
|           {downloading ? 'Preparing…' : 'Download'} |  | ||||||
|         </button> |  | ||||||
|       </div> |  | ||||||
|     </div> |  | ||||||
|   </div> |  | ||||||
| )} |  | ||||||
| 
 | 
 | ||||||
|       </div> |       </div> | ||||||
|     </DashboardLayout> |     </DashboardLayout> | ||||||
|  | |||||||
| @ -16,7 +16,7 @@ type CrmProject = { | |||||||
|   custom_mobile_phone_no?: string | null; |   custom_mobile_phone_no?: string | null; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| const API = process.env.NEXT_PUBLIC_FASTAPI_URL; | const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000'; | ||||||
| 
 | 
 | ||||||
| const SitesPage = () => { | const SitesPage = () => { | ||||||
|   const [projects, setProjects] = useState<CrmProject[]>([]); |   const [projects, setProjects] = useState<CrmProject[]>([]); | ||||||
|  | |||||||
| @ -11,7 +11,7 @@ export default function LoginPage() { | |||||||
|   const [ready, setReady] = useState(false); // gate to avoid UI flash
 |   const [ready, setReady] = useState(false); // gate to avoid UI flash
 | ||||||
| 
 | 
 | ||||||
|   // Use ONE client-exposed API env var everywhere
 |   // Use ONE client-exposed API env var everywhere
 | ||||||
|   const API = process.env.NEXT_PUBLIC_FASTAPI_URL; |   const API = process.env.NEXT_PUBLIC_FASTAPI_URL || 'http://127.0.0.1:8000'; | ||||||
| 
 | 
 | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     let cancelled = false; |     let cancelled = false; | ||||||
|  | |||||||
| @ -55,7 +55,7 @@ const RegisterPage = (props: Props) => { | |||||||
|                             <div className="mt-6 text-sm text-gray-200 dark:text-gray-300"> |                             <div className="mt-6 text-sm text-gray-200 dark:text-gray-300"> | ||||||
|                                 Already have an account ?{" "} |                                 Already have an account ?{" "} | ||||||
|                                 <Link |                                 <Link | ||||||
|                                     href="/login" |                                     href="/register" | ||||||
|                                     className="text-yellow-400 font-semibold underline transition hover:text-white" |                                     className="text-yellow-400 font-semibold underline transition hover:text-white" | ||||||
|                                 > |                                 > | ||||||
|                                     SIGN IN |                                     SIGN IN | ||||||
|  | |||||||
							
								
								
									
										261
									
								
								app/(defaults)/chint/inverters/[id]/page.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										261
									
								
								app/(defaults)/chint/inverters/[id]/page.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,261 @@ | |||||||
|  | 'use client'; | ||||||
|  | 
 | ||||||
|  | import PanelCodeHighlight from '@/components/panel-code-highlight' | ||||||
|  | import React, { Fragment, useEffect, useState } from 'react'; | ||||||
|  | import { Tab } from '@headlessui/react'; | ||||||
|  | import IconHome from '@/components/icon/icon-home'; | ||||||
|  | import IconUser from '@/components/icon/icon-user'; | ||||||
|  | import IconPhone from '@/components/icon/icon-phone'; | ||||||
|  | import { useRouter } from 'next/router'; | ||||||
|  | import { useParams } from 'next/navigation'; | ||||||
|  | import axios from 'axios'; | ||||||
|  | 
 | ||||||
|  | type Props = {} | ||||||
|  | 
 | ||||||
|  | const InverterViewPage = (props: Props) => { | ||||||
|  |     const [isMounted, setIsMounted] = useState(false) | ||||||
|  |     const [loading, setLoading] = useState(true) | ||||||
|  |     const params = useParams() | ||||||
|  |     const [inverter, setInverter] = useState<any>({}) | ||||||
|  | 
 | ||||||
|  |     useEffect(() => { | ||||||
|  |         setIsMounted(true); | ||||||
|  |         fetchData() | ||||||
|  |     }, []) | ||||||
|  | 
 | ||||||
|  |     const fetchData = async () => { | ||||||
|  |         try { | ||||||
|  |             if (!params || !params.id) { | ||||||
|  |                 throw new Error("Invalid params or params.id is missing"); | ||||||
|  |             } | ||||||
|  |             const res = await axios.get(`https://api-a.fomware.com.cn/asset/v1/list?type=2&key=${params.id.toString()}`, { | ||||||
|  |                 headers: { | ||||||
|  |                     "Authorization": "Bearer " + process.env.NEXT_PUBLIC_CHINT_TOKEN | ||||||
|  |                 } | ||||||
|  |             }) | ||||||
|  |             console.log("res", res.data.data.devices[0]) | ||||||
|  |             setInverter(res.data.data.devices[0]) | ||||||
|  |         } catch (error) { | ||||||
|  |             console.error("Error fetching data:", error); | ||||||
|  |         } finally { | ||||||
|  |             setLoading(false); | ||||||
|  |         } | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |     return ( | ||||||
|  |         <> | ||||||
|  | 
 | ||||||
|  |         {loading ? <p>Loading...</p> : ( | ||||||
|  |         <> | ||||||
|  |         <PanelCodeHighlight title={params?.id?.toString() || ""}> | ||||||
|  |             <div className="mb-5"> | ||||||
|  |                 {isMounted && ( | ||||||
|  |                     <Tab.Group> | ||||||
|  |                         <Tab.List className="mt-3 flex flex-wrap border-b border-white-light dark:border-[#191e3a]"> | ||||||
|  |                             <Tab as={Fragment}> | ||||||
|  |                                 {({ selected }) => ( | ||||||
|  |                                     <button className={`${selected ? 'border-b !border-primary text-primary !outline-none' : ''} -mb-[1px] flex items-center border-transparent p-5 py-3 before:inline-block hover:border-b hover:!border-primary hover:text-primary`} > | ||||||
|  |                                         Brief | ||||||
|  |                                     </button> | ||||||
|  |                                 )} | ||||||
|  |                             </Tab> | ||||||
|  |                             <Tab as={Fragment}> | ||||||
|  |                                 {({ selected }) => ( | ||||||
|  |                                     <button className={`${selected ? 'border-b !border-primary text-primary !outline-none' : ''} -mb-[1px] flex items-center border-transparent p-5 py-3 before:inline-block hover:border-b hover:!border-primary hover:text-primary`} > | ||||||
|  |                                         Chart | ||||||
|  |                                     </button> | ||||||
|  |                                 )} | ||||||
|  |                             </Tab> | ||||||
|  |                         </Tab.List> | ||||||
|  |                         <Tab.Panels> | ||||||
|  |                             <Tab.Panel> | ||||||
|  |                                 <div className="active pt-5"> | ||||||
|  |                                     <p className="mb-3 text-base font-semibold">Last Updated ( 2025-02-24 16:03:10 +0800 )</p> | ||||||
|  | 
 | ||||||
|  |                                     <blockquote className="rounded-br-md rounded-tr-md border-l-2 !border-l-primary bg-white py-2 px-2 text-black dark:border-[#060818] dark:bg-[#060818]"> | ||||||
|  |                                         <div className="flex items-start"> | ||||||
|  |                                             <p className="m-0 font-semibold text-sm not-italic text-[#515365] dark:text-white-light">Basic Information</p> | ||||||
|  |                                         </div> | ||||||
|  |                                     </blockquote> | ||||||
|  | 
 | ||||||
|  |                                     <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 text-gray-600 mt-3"> | ||||||
|  |                                         <p><span className="font-semibold text-gray-400">Model: </span>{inverter.model}</p> | ||||||
|  |                                         <p><span className="font-semibold text-gray-400">SN: </span>{inverter.sn}</p> | ||||||
|  |                                         <p><span className="font-semibold text-gray-400">Total Energy: </span>{inverter.eTotalWithUnit}</p> | ||||||
|  |                                         <p><span className="font-semibold text-gray-400">Today Energy: </span>{inverter.eTodayWithUnit}</p> | ||||||
|  |                                         <p><span className="font-semibold text-gray-400">Reactive Power: </span>{inverter.lastRTP["Reactive Power"].value} var</p> | ||||||
|  |                                         <p><span className="font-semibold text-gray-400">Active Power: </span>{inverter.activePowerWithUnit}</p> | ||||||
|  |                                         <p><span className="font-semibold text-gray-400">Inverter Mode: </span>{inverter.lastRTP["Inverter Mode"].value}</p> | ||||||
|  |                                         <p><span className="font-semibold text-gray-400">Inner Temperature: </span>{inverter.lastRTP["Inner Temperature"].value} °C</p> | ||||||
|  |                                         <p><span className="font-semibold text-gray-400">Create Time: </span>{inverter.createdAtStr}</p> | ||||||
|  |                                         <p><span className="font-semibold text-gray-400">Modules: </span>{inverter.moduleFw.map((item: {module:string, value:string}) => `${item.module}: ${item.value}`.trim()).join(", ")}</p> | ||||||
|  |                                     </div> | ||||||
|  | 
 | ||||||
|  |                                 </div> | ||||||
|  |                             </Tab.Panel> | ||||||
|  |                             <Tab.Panel>Chart</Tab.Panel> | ||||||
|  |                         </Tab.Panels> | ||||||
|  |                     </Tab.Group> | ||||||
|  |                 )} | ||||||
|  |             </div> | ||||||
|  |         </PanelCodeHighlight> | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |         <div className="panel pt-1 mt-3"> | ||||||
|  |             {isMounted && ( | ||||||
|  |                 <Tab.Group> | ||||||
|  |                     <Tab.List className="flex flex-wrap border-b border-white-light dark:border-[#191e3a]"> | ||||||
|  |                         <Tab as={Fragment}> | ||||||
|  |                             {({ selected }) => ( | ||||||
|  |                                 <button className={`${selected ? 'border-b !border-primary text-primary !outline-none' : ''} -mb-[1px] flex items-center border-transparent p-5 py-3 before:inline-block hover:border-b hover:!border-primary hover:text-primary`} > | ||||||
|  |                                     INV-DC | ||||||
|  |                                 </button> | ||||||
|  |                             )} | ||||||
|  |                         </Tab> | ||||||
|  |                         <Tab as={Fragment}> | ||||||
|  |                             {({ selected }) => ( | ||||||
|  |                                 <button className={`${selected ? 'border-b !border-primary text-primary !outline-none' : ''} -mb-[1px] flex items-center border-transparent p-5 py-3 before:inline-block hover:border-b hover:!border-primary hover:text-primary`} > | ||||||
|  |                                     INV-AC | ||||||
|  |                                 </button> | ||||||
|  |                             )} | ||||||
|  |                         </Tab> | ||||||
|  |                         <Tab as={Fragment}> | ||||||
|  |                             {({ selected }) => ( | ||||||
|  |                                 <button className={`${selected ? 'border-b !border-primary text-primary !outline-none' : ''} -mb-[1px] flex items-center border-transparent p-5 py-3 before:inline-block hover:border-b hover:!border-primary hover:text-primary`} > | ||||||
|  |                                     Meter-AC | ||||||
|  |                                 </button> | ||||||
|  |                             )} | ||||||
|  |                         </Tab> | ||||||
|  |                         <Tab as={Fragment}> | ||||||
|  |                             {({ selected }) => ( | ||||||
|  |                                 <button className={`${selected ? 'border-b !border-primary text-primary !outline-none' : ''} -mb-[1px] flex items-center border-transparent p-5 py-3 before:inline-block hover:border-b hover:!border-primary hover:text-primary`} > | ||||||
|  |                                     Meter-Load | ||||||
|  |                                 </button> | ||||||
|  |                             )} | ||||||
|  |                         </Tab> | ||||||
|  |                     </Tab.List> | ||||||
|  |                     <Tab.Panels> | ||||||
|  |                         <Tab.Panel> | ||||||
|  |                             <div className="active pt-5"> | ||||||
|  |                                 <table className="w-full border-collapse"> | ||||||
|  |                                     <thead> | ||||||
|  |                                         <tr className="bg-gray-200 text-gray-600 text-left"> | ||||||
|  |                                             <th className="p-2"></th> | ||||||
|  |                                             <th className="p-2">Voltage(V)</th> | ||||||
|  |                                             <th className="p-2">Current(A)</th> | ||||||
|  |                                             <th className="p-2">Power(W)</th> | ||||||
|  |                                         </tr> | ||||||
|  |                                     </thead> | ||||||
|  |                                     <tbody> | ||||||
|  |                                         <tr className="border-b text-gray-600"> | ||||||
|  |                                             <td className="p-2">PV1/PV1</td> | ||||||
|  |                                             <td className="p-2">{inverter.lastRTP["PV1 Voltage"] && inverter.lastRTP["PV1 Voltage"].value}</td> | ||||||
|  |                                             <td className="p-2">{inverter.lastRTP["PV1 Current"] && inverter.lastRTP["PV1 Current"].value}</td> | ||||||
|  |                                             <td className="p-2">{inverter.lastRTP["MPPT1 Power"] && inverter.lastRTP["MPPT1 Power"].value}</td> | ||||||
|  |                                         </tr> | ||||||
|  |                                         <tr className="border-b text-gray-600"> | ||||||
|  |                                             <td className="p-2">PV2/PV2</td> | ||||||
|  |                                             <td className="p-2">{inverter.lastRTP["PV2 Voltage"] && inverter.lastRTP["PV2 Voltage"].value}</td> | ||||||
|  |                                             <td className="p-2">{inverter.lastRTP["PV2 Current"] && inverter.lastRTP["PV2 Current"].value}</td> | ||||||
|  |                                             <td className="p-2">{inverter.lastRTP["MPPT2 Power"] && inverter.lastRTP["MPPT2 Power"].value}</td> | ||||||
|  |                                         </tr> | ||||||
|  |                                         <tr className="border-b text-gray-600"> | ||||||
|  |                                             <td className="p-2">PV3/PV3</td> | ||||||
|  |                                             <td className="p-2">{inverter.lastRTP["PV3 Voltage"] && inverter.lastRTP["PV3 Voltage"].value}</td> | ||||||
|  |                                             <td className="p-2">{inverter.lastRTP["PV3 Current"] && inverter.lastRTP["PV3 Current"].value}</td> | ||||||
|  |                                             <td className="p-2">{inverter.lastRTP["MPPT3 Power"] && inverter.lastRTP["MPPT3 Power"].value}</td> | ||||||
|  |                                         </tr> | ||||||
|  |                                         <tr className="border-b text-gray-600"> | ||||||
|  |                                             <td className="p-2">PV3/PV3</td> | ||||||
|  |                                             <td className="p-2">{inverter.lastRTP["PV4 Voltage"] && inverter.lastRTP["PV4 Voltage"].value}</td> | ||||||
|  |                                             <td className="p-2">{inverter.lastRTP["PV4 Current"] && inverter.lastRTP["PV4 Current"].value}</td> | ||||||
|  |                                             <td className="p-2">{inverter.lastRTP["MPPT4 Power"] && inverter.lastRTP["MPPT4 Power"].value}</td> | ||||||
|  |                                         </tr> | ||||||
|  |                                     </tbody> | ||||||
|  |                                 </table> | ||||||
|  |                             </div> | ||||||
|  |                         </Tab.Panel> | ||||||
|  |                         <Tab.Panel> | ||||||
|  |                             <div className="pt-5"> | ||||||
|  |                                 <table className="w-full border-collapse"> | ||||||
|  |                                     <thead> | ||||||
|  |                                         <tr className="bg-gray-200 text-gray-600 text-left"> | ||||||
|  |                                             <th className="p-2"></th> | ||||||
|  |                                             <th className="p-2">Voltage(V)</th> | ||||||
|  |                                             <th className="p-2">Current(A)</th> | ||||||
|  |                                             <th className="p-2">Power(W)</th> | ||||||
|  |                                             <th className="p-2">Frequency(Hz)</th> | ||||||
|  |                                         </tr> | ||||||
|  |                                     </thead> | ||||||
|  |                                     <tbody> | ||||||
|  |                                         <tr className="border-b text-gray-600"> | ||||||
|  |                                             <td className="p-2">A</td> | ||||||
|  |                                             <td className="p-2">{inverter.lastRTP["Phase L1 Voltage"] && inverter.lastRTP["Phase L1 Voltage"].value}</td> | ||||||
|  |                                             <td className="p-2">{inverter.lastRTP["Phase L1 Current"] && inverter.lastRTP["Phase L1 Current"].value}</td> | ||||||
|  |                                             <td className="p-2">{inverter.lastRTP["Phase L1 Power"] && inverter.lastRTP["Phase L1 Power"].value}</td> | ||||||
|  |                                             <td className="p-2">{inverter.lastRTP["Phase L1 Frequency"] && inverter.lastRTP["Phase L1 Frequency"].value}</td> | ||||||
|  |                                         </tr> | ||||||
|  |                                         <tr className="border-b text-gray-600"> | ||||||
|  |                                             <td className="p-2">B</td> | ||||||
|  |                                             <td className="p-2">{inverter.lastRTP["Phase L2 Voltage"] && inverter.lastRTP["Phase L2 Voltage"].value}</td> | ||||||
|  |                                             <td className="p-2">{inverter.lastRTP["Phase L2 Current"] && inverter.lastRTP["Phase L2 Current"].value}</td> | ||||||
|  |                                             <td className="p-2">{inverter.lastRTP["Phase L2 Power"] && inverter.lastRTP["Phase L2 Power"].value}</td> | ||||||
|  |                                             <td className="p-2">{inverter.lastRTP["Phase L2 Frequency"] && inverter.lastRTP["Phase L2 Frequency"].value}</td> | ||||||
|  |                                         </tr> | ||||||
|  |                                         <tr className="border-b text-gray-600"> | ||||||
|  |                                             <td className="p-2">C</td> | ||||||
|  |                                             <td className="p-2">{inverter.lastRTP["Phase L3 Voltage"] && inverter.lastRTP["Phase L3 Voltage"].value}</td> | ||||||
|  |                                             <td className="p-2">{inverter.lastRTP["Phase L3 Current"] && inverter.lastRTP["Phase L3 Current"].value}</td> | ||||||
|  |                                             <td className="p-2">{inverter.lastRTP["Phase L3 Power"] && inverter.lastRTP["Phase L3 Power"].value}</td> | ||||||
|  |                                             <td className="p-2">{inverter.lastRTP["Phase L3 Frequency"] && inverter.lastRTP["Phase L3 Frequency"].value}</td> | ||||||
|  |                                         </tr> | ||||||
|  |                                     </tbody> | ||||||
|  |                                 </table> | ||||||
|  |                             </div> | ||||||
|  |                         </Tab.Panel> | ||||||
|  |                         <Tab.Panel> | ||||||
|  |                             <div className="pt-5"> | ||||||
|  |                                 <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 text-gray-600 mt-3"> | ||||||
|  |                                     <p><span className="font-semibold text-gray-400">Today import Energy: </span>{inverter.lastRTP["Today import Energy"] && inverter.lastRTP["Today import Energy"].value} kWh</p> | ||||||
|  |                                     <p><span className="font-semibold text-gray-400">L1-N phase voltage of grid: </span>{inverter.lastRTP["L1-N phase voltage of grid"] && inverter.lastRTP["L1-N phase voltage of grid"].value} V</p> | ||||||
|  |                                     <p><span className="font-semibold text-gray-400">L2-N phase voltage of grid: </span>{inverter.lastRTP["L2-N phase voltage of grid"] && inverter.lastRTP["L2-N phase voltage of grid"].value} V</p> | ||||||
|  |                                     <p><span className="font-semibold text-gray-400">L3-N phase voltage of grid: </span>{inverter.lastRTP["L3-N phase voltage of grid"] && inverter.lastRTP["L3-N phase voltage of grid"].value} V</p> | ||||||
|  |                                     <p><span className="font-semibold text-gray-400">Today export Energy: </span>{inverter.lastRTP["Today export Energy"] && inverter.lastRTP["Today export Energy"].value} kWh</p> | ||||||
|  |                                     <p><span className="font-semibold text-gray-400">L1 current of grid: </span>{inverter.lastRTP["L1 current of grid"] && inverter.lastRTP["L1 current of grid"].value} A</p> | ||||||
|  |                                     <p><span className="font-semibold text-gray-400">L2 current of grid: </span>{inverter.lastRTP["L2 current of grid"] && inverter.lastRTP["L2 current of grid"].value} A</p> | ||||||
|  |                                     <p><span className="font-semibold text-gray-400">L3 current of grid: </span>{inverter.lastRTP["L3 current of grid"] && inverter.lastRTP["L3 current of grid"].value} A</p> | ||||||
|  |                                     <p><span className="font-semibold text-gray-400">Accumulated energy of positive: </span>{inverter.lastRTP["Accumulated energy of positive"] && inverter.lastRTP["Accumulated energy of positive"].value} kWh</p> | ||||||
|  |                                     <p><span className="font-semibold text-gray-400">Phase L1 watt of grid: </span>{inverter.lastRTP["Phase L1 watt of grid"] && inverter.lastRTP["Phase L1 watt of grid"].value} KW</p> | ||||||
|  |                                     <p><span className="font-semibold text-gray-400">Phase L2 watt of grid: </span>{inverter.lastRTP["Phase L2 watt of grid"] && inverter.lastRTP["Phase L2 watt of grid"].value} KW</p> | ||||||
|  |                                     <p><span className="font-semibold text-gray-400">Phase L3 watt of grid: </span>{inverter.lastRTP["Phase L3 watt of grid"] && inverter.lastRTP["Phase L3 watt of grid"].value} KW</p> | ||||||
|  |                                     <p><span className="font-semibold text-gray-400">Accumulated energy of negative: </span>{inverter.lastRTP["Accumulated energy of negative"] && inverter.lastRTP["Accumulated energy of negative"].value} kWh</p> | ||||||
|  |                                 </div> | ||||||
|  |                             </div> | ||||||
|  |                         </Tab.Panel> | ||||||
|  |                         <Tab.Panel> | ||||||
|  |                             <div className="pt-5"> | ||||||
|  |                                 <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 text-gray-600 mt-3"> | ||||||
|  |                                     <p><span className="font-semibold text-gray-400">Today load Energy: </span>{inverter.lastRTP["Today load Energy"] && inverter.lastRTP["Today load Energy"].value} kWh</p> | ||||||
|  |                                     <p><span className="font-semibold text-gray-400">L1-N phase voltage of load: </span>{inverter.lastRTP["L1-N phase voltage of load"] && inverter.lastRTP["L1-N phase voltage of load"].value} V</p> | ||||||
|  |                                     <p><span className="font-semibold text-gray-400">L2-N phase voltage of load: </span>{inverter.lastRTP["L2-N phase voltage of load"] && inverter.lastRTP["L2-N phase voltage of load"].value} V</p> | ||||||
|  |                                     <p><span className="font-semibold text-gray-400">L3-N phase voltage of load: </span>{inverter.lastRTP["L3-N phase voltage of load"] && inverter.lastRTP["L3-N phase voltage of load"].value} V</p> | ||||||
|  |                                     <p><span className="font-semibold text-gray-400">Accumulated energy of load: </span>{inverter.lastRTP["Accumulated energy of load"] && inverter.lastRTP["Accumulated energy of load"].value} kWh</p> | ||||||
|  |                                     <p><span className="font-semibold text-gray-400">L1 current of load: </span>{inverter.lastRTP["L1 current of load"] && inverter.lastRTP["L1 current of load"].value} A</p> | ||||||
|  |                                     <p><span className="font-semibold text-gray-400">L2 current of load: </span>{inverter.lastRTP["L2 current of load"] && inverter.lastRTP["L2 current of load"].value} A</p> | ||||||
|  |                                     <p><span className="font-semibold text-gray-400">L3 current of load: </span>{inverter.lastRTP["L3 current of load"] && inverter.lastRTP["L3 current of load"].value} A</p> | ||||||
|  |                                 </div> | ||||||
|  |                             </div> | ||||||
|  |                         </Tab.Panel> | ||||||
|  |                     </Tab.Panels> | ||||||
|  |                 </Tab.Group> | ||||||
|  |             )} | ||||||
|  |         </div> | ||||||
|  |         </> | ||||||
|  |         )} | ||||||
|  | 
 | ||||||
|  |         </> | ||||||
|  |     ) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export default InverterViewPage | ||||||
							
								
								
									
										174
									
								
								app/(defaults)/chint/inverters/page.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										174
									
								
								app/(defaults)/chint/inverters/page.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,174 @@ | |||||||
|  | "use client"; | ||||||
|  | import IconTrashLines from '@/components/icon/icon-trash-lines'; | ||||||
|  | import PanelCodeHighlight from '@/components/panel-code-highlight'; | ||||||
|  | import ComponentsTablesSimple from '@/components/tables/components-tables-simple'; | ||||||
|  | import { formatUnixTimestamp } from '@/utils/helpers'; | ||||||
|  | import Tippy from '@tippyjs/react'; | ||||||
|  | import axios from 'axios'; | ||||||
|  | import React, { useEffect, useState } from 'react' | ||||||
|  | 
 | ||||||
|  | // import ReactApexChart from 'react-apexcharts';
 | ||||||
|  | import dynamic from 'next/dynamic'; | ||||||
|  | import Link from 'next/link'; | ||||||
|  | const ReactApexChart = dynamic(() => import('react-apexcharts'), { ssr: false }); | ||||||
|  | 
 | ||||||
|  | type Props = {} | ||||||
|  | 
 | ||||||
|  | const SungrowInverters = (props: Props) => { | ||||||
|  |     const [inverters, setInverters] = useState<any[]>([]) | ||||||
|  |     const [loading, setLoading] = useState(true) | ||||||
|  |     const [isMounted, setIsMounted] = useState(false) | ||||||
|  | 
 | ||||||
|  |     useEffect(() => { | ||||||
|  |         setIsMounted(true); | ||||||
|  |         const fetchData = async () => { | ||||||
|  |             try { | ||||||
|  |                 const res = await axios.get("https://api-a.fomware.com.cn/asset/v1/list?type=2", { | ||||||
|  |                     headers: { | ||||||
|  |                         "Authorization": "Bearer " + process.env.NEXT_PUBLIC_CHINT_TOKEN | ||||||
|  |                     } | ||||||
|  |                 }) | ||||||
|  |                 console.log("res", res.data.data.devices) | ||||||
|  |                 setInverters(res.data.data.devices) | ||||||
|  |             } catch (error) { | ||||||
|  |                 console.error("Error fetching data:", error); | ||||||
|  |             } finally { | ||||||
|  |                 setLoading(false); | ||||||
|  |             } | ||||||
|  |         }; | ||||||
|  | 
 | ||||||
|  |         fetchData() | ||||||
|  |     }, []) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |     const chartConfigs: any = { | ||||||
|  |         options: { | ||||||
|  |             chart: { | ||||||
|  |                 height: 58, | ||||||
|  |                 type: 'line', | ||||||
|  |                 fontFamily: 'Nunito, sans-serif', | ||||||
|  |                 sparkline: { | ||||||
|  |                     enabled: true, | ||||||
|  |                 }, | ||||||
|  |                 dropShadow: { | ||||||
|  |                     enabled: true, | ||||||
|  |                     blur: 3, | ||||||
|  |                     color: '#009688', | ||||||
|  |                     opacity: 0.4, | ||||||
|  |                 }, | ||||||
|  |             }, | ||||||
|  |             stroke: { | ||||||
|  |                 curve: 'smooth', | ||||||
|  |                 width: 2, | ||||||
|  |             }, | ||||||
|  |             colors: ['#009688'], | ||||||
|  |             grid: { | ||||||
|  |                 padding: { | ||||||
|  |                     top: 5, | ||||||
|  |                     bottom: 5, | ||||||
|  |                     left: 5, | ||||||
|  |                     right: 5, | ||||||
|  |                 }, | ||||||
|  |             }, | ||||||
|  |             tooltip: { | ||||||
|  |                 x: { | ||||||
|  |                     show: false, | ||||||
|  |                 }, | ||||||
|  |                 y: { | ||||||
|  |                     title: { | ||||||
|  |                         formatter: () => { | ||||||
|  |                             return ''; | ||||||
|  |                         }, | ||||||
|  |                     }, | ||||||
|  |                 }, | ||||||
|  |             }, | ||||||
|  |         }, | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     // inverter status 0: initial, 1: standby, 2: fault, 3: running, 5: offline, 9: shutdown, 10: unknown
 | ||||||
|  | 
 | ||||||
|  |     return ( | ||||||
|  |         <div> | ||||||
|  |             {loading ? <p>Loading...</p> : ( | ||||||
|  |             <PanelCodeHighlight title="Chint Inverters"> | ||||||
|  |                 <div className="table-responsive mb-5"> | ||||||
|  |                     <table> | ||||||
|  |                         <thead> | ||||||
|  |                             <tr> | ||||||
|  |                                 <th>Inverter Name</th> | ||||||
|  |                                 <th>Site Name</th> | ||||||
|  |                                 <th>Gateway SN</th> | ||||||
|  |                                 <th>Inverter Status</th> | ||||||
|  |                                 <th>Model</th> | ||||||
|  |                                 <th>SN</th> | ||||||
|  |                                 <th>Real Time Power</th> | ||||||
|  |                                 <th>E-Today</th> | ||||||
|  |                                 <th>WeekData</th> | ||||||
|  |                                 <th>Created At</th> | ||||||
|  |                                 <th>Updated At</th> | ||||||
|  |                             </tr> | ||||||
|  |                         </thead> | ||||||
|  |                         <tbody> | ||||||
|  |                             {inverters.map((data) => ( | ||||||
|  |                                 <tr key={data.id}> | ||||||
|  |                                     <td> | ||||||
|  |                                         <div className="whitespace-nowrap"><Link href={`/chint/inverters/${data.name}`}>{data.name}</Link></div> | ||||||
|  |                                     </td> | ||||||
|  |                                     <td> | ||||||
|  |                                         <div className="whitespace-nowrap">{data.siteName}</div> | ||||||
|  |                                     </td> | ||||||
|  |                                     <td> | ||||||
|  |                                         <div>{data.gatewaySn}</div> | ||||||
|  |                                     </td> | ||||||
|  |                                     <td> | ||||||
|  |                                         <div className={`whitespace-nowrap ${ | ||||||
|  |                                             data.status === 0 ? "text-gray-500"    // Initial
 | ||||||
|  |                                             : data.status === 1 ? "text-blue-500"  // Standby
 | ||||||
|  |                                             : data.status === 2 ? "text-red-500"  // Fault
 | ||||||
|  |                                             : data.status === 3 ? "text-green-500" // Running
 | ||||||
|  |                                             : data.status === 5 ? "text-yellow-500" // Offline
 | ||||||
|  |                                             : data.status === 9 ? "text-purple-500" // Shutdown
 | ||||||
|  |                                             : "text-gray-400" // Unknown (default)
 | ||||||
|  |                                         }`}>
 | ||||||
|  |                                             {data.statusLabel} | ||||||
|  |                                         </div> | ||||||
|  |                                     </td> | ||||||
|  |                                     <td> | ||||||
|  |                                         <div>{data.model}</div> | ||||||
|  |                                     </td> | ||||||
|  |                                     <td> | ||||||
|  |                                         <div>{data.sn}</div> | ||||||
|  |                                     </td> | ||||||
|  |                                     <td> | ||||||
|  |                                         <div>{data.activePowerWithUnit}</div> | ||||||
|  |                                     </td> | ||||||
|  |                                     <td> | ||||||
|  |                                         <div>{data.eTodayWithUnit}</div> | ||||||
|  |                                     </td> | ||||||
|  |                                     <td> | ||||||
|  |                                         {isMounted && ( | ||||||
|  |                                             <ReactApexChart | ||||||
|  |                                                 series={[{ data: data.weekTrend.map((point: any) => point.y) }]} | ||||||
|  |                                                 options={{ | ||||||
|  |                                                     ...chartConfigs.options, | ||||||
|  |                                                     xaxis: { categories: data.weekTrend.map((point: any) => point.x) }, | ||||||
|  |                                                 }} | ||||||
|  |                                                 type="line" | ||||||
|  |                                                 height={58} | ||||||
|  |                                                 width={'100%'} | ||||||
|  |                                             /> | ||||||
|  |                                         )}                                    </td> | ||||||
|  |                                     <td>{formatUnixTimestamp(data.createdAt)}</td> | ||||||
|  |                                     <td>{formatUnixTimestamp(data.updatedAt)}</td> | ||||||
|  |                                 </tr> | ||||||
|  |                             ))} | ||||||
|  |                         </tbody> | ||||||
|  |                     </table> | ||||||
|  |                 </div> | ||||||
|  |             </PanelCodeHighlight> | ||||||
|  |             )} | ||||||
|  |         </div> | ||||||
|  |     ) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export default SungrowInverters | ||||||
							
								
								
									
										13
									
								
								app/(defaults)/chint/page.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								app/(defaults)/chint/page.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,13 @@ | |||||||
|  | import axios from 'axios'; | ||||||
|  | import { Metadata } from 'next'; | ||||||
|  | import React from 'react'; | ||||||
|  | 
 | ||||||
|  | export const metadata: Metadata = { | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | const SungrowIndex = async () => { | ||||||
|  |     return <div>SungrowIndex</div>; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export default SungrowIndex; | ||||||
							
								
								
									
										39
									
								
								app/(defaults)/chint/sites/page.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								app/(defaults)/chint/sites/page.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,39 @@ | |||||||
|  | "use client"; | ||||||
|  | // app/(defaults)/sungrow/assets/page.tsx
 | ||||||
|  | 
 | ||||||
|  | import ComponentsTablesSimple from "@/components/tables/components-tables-simple"; | ||||||
|  | import axios from "axios"; | ||||||
|  | import React, { useEffect, useState } from "react"; | ||||||
|  | 
 | ||||||
|  | const SungrowAssets =  () => { | ||||||
|  |     const [sites, setSites] = useState<any[]>([]); | ||||||
|  |     const [loading, setLoading] = useState(true); | ||||||
|  | 
 | ||||||
|  |     useEffect(() => { | ||||||
|  |         const fetchData = async () => { | ||||||
|  |             try { | ||||||
|  |                 const res = await axios.get("https://api-a.fomware.com.cn/site/v1/list", { | ||||||
|  |                     headers: { | ||||||
|  |                         "Authorization": "Bearer " + process.env.NEXT_PUBLIC_CHINT_TOKEN | ||||||
|  |                     } | ||||||
|  |                 }) | ||||||
|  |                 console.log("res", res.data.data.siteInfos) | ||||||
|  |                 setSites(res.data.data.siteInfos) | ||||||
|  |             } catch (error) { | ||||||
|  |                 console.error("Error fetching data:", error); | ||||||
|  |             } finally { | ||||||
|  |                 setLoading(false); | ||||||
|  |             } | ||||||
|  |         }; | ||||||
|  | 
 | ||||||
|  |         fetchData() | ||||||
|  |     }, []) | ||||||
|  | 
 | ||||||
|  |     return ( | ||||||
|  |         <div> | ||||||
|  |             {loading ? <p>Loading...</p> : <ComponentsTablesSimple tableData={sites} />} | ||||||
|  |         </div> | ||||||
|  |     ) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export default SungrowAssets; | ||||||
							
								
								
									
										105
									
								
								app/(defaults)/sungrow/plant/page.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								app/(defaults)/sungrow/plant/page.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,105 @@ | |||||||
|  | "use client"; | ||||||
|  | 
 | ||||||
|  | import IconTrashLines from '@/components/icon/icon-trash-lines'; | ||||||
|  | import PanelCodeHighlight from '@/components/panel-code-highlight'; | ||||||
|  | import ComponentsTablesSimple from '@/components/tables/components-tables-simple' | ||||||
|  | import { formatUnixTimestamp } from '@/utils/helpers'; | ||||||
|  | import Tippy from '@tippyjs/react'; | ||||||
|  | import axios from 'axios'; | ||||||
|  | import React, { useEffect, useState } from "react" | ||||||
|  | 
 | ||||||
|  | type Props = {} | ||||||
|  | 
 | ||||||
|  | const SungrowPlant = (props: Props) => { | ||||||
|  |     const [sites, setSites] = useState<any[]>([]) | ||||||
|  |     const [loading, setLoading] = useState(true) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |     useEffect(() => { | ||||||
|  |         const fetchSites = async () => { | ||||||
|  |             try { | ||||||
|  |                 const res = await fetch("/api/sungrow/site") | ||||||
|  |                 const data = await res.json() | ||||||
|  |                 console.log("data", data) | ||||||
|  |                 setSites(data) | ||||||
|  |             } catch (error) { | ||||||
|  |                 console.error("Error fetching inverters:", error) | ||||||
|  |             } finally { | ||||||
|  |                 setLoading(false) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         fetchSites() | ||||||
|  |     }, []) | ||||||
|  | 
 | ||||||
|  |     const statusLabels: Record<number, string> = { | ||||||
|  |         0: "Offline", | ||||||
|  |         1: "Normal", | ||||||
|  |     } | ||||||
|  |     const plantTypeLabel: Record<number, string> = { | ||||||
|  |         3: "Commercial PV", | ||||||
|  |         4: "Residential PV", | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |     return ( | ||||||
|  |         <div> | ||||||
|  |             {loading ? <p>Loading...</p> : ( | ||||||
|  |                 <PanelCodeHighlight title="Sungrow Sites"> | ||||||
|  |                     <div className="table-responsive mb-5"> | ||||||
|  |                         <table> | ||||||
|  |                             <thead> | ||||||
|  |                                 <tr> | ||||||
|  |                                     <th>Site Name</th> | ||||||
|  |                                     <th>Status</th> | ||||||
|  |                                     <th>Plant Type</th> | ||||||
|  |                                     {/* <th>Installed Power</th> | ||||||
|  |                                     <th>Real-time Power</th> | ||||||
|  |                                     <th>Yield Today</th> | ||||||
|  |                                     <th>Monthly Yield</th> | ||||||
|  |                                     <th>Annual Yield</th> | ||||||
|  |                                     <th>Total Yield</th> | ||||||
|  |                                     <th>Equivalent Hours</th> | ||||||
|  |                                     <th>Remarks</th> */} | ||||||
|  |                                     <th className="text-center">Action</th> | ||||||
|  |                                 </tr> | ||||||
|  |                             </thead> | ||||||
|  |                             <tbody> | ||||||
|  |                                 {sites.map((data) => ( | ||||||
|  |                                     <tr key={data.id}> | ||||||
|  |                                         <td> | ||||||
|  |                                             <div className="whitespace-nowrap">{data.ps_name}</div> | ||||||
|  |                                         </td> | ||||||
|  |                                         <td> | ||||||
|  |                                             <div className={`whitespace-nowrap ${ data.online_status !== 1 ? "text-danger" : "text-success" }`} > | ||||||
|  |                                                 {statusLabels[data.online_status] || "-"} | ||||||
|  |                                             </div> | ||||||
|  |                                         </td> | ||||||
|  |                                         <td>{plantTypeLabel[data.ps_type] || "-"}</td> | ||||||
|  |                                         {/* <td></td> | ||||||
|  |                                         <td></td> | ||||||
|  |                                         <td></td> | ||||||
|  |                                         <td></td> | ||||||
|  |                                         <td></td> | ||||||
|  |                                         <td></td> | ||||||
|  |                                         <td></td> | ||||||
|  |                                         <td></td> */} | ||||||
|  |                                         <td className="text-center"> | ||||||
|  |                                             <Tippy content="Delete"> | ||||||
|  |                                                 <button type="button"> | ||||||
|  |                                                     <IconTrashLines className="m-auto" /> | ||||||
|  |                                                 </button> | ||||||
|  |                                             </Tippy> | ||||||
|  |                                         </td> | ||||||
|  |                                     </tr> | ||||||
|  |                                 ))} | ||||||
|  |                             </tbody> | ||||||
|  |                         </table> | ||||||
|  |                     </div> | ||||||
|  |                     </PanelCodeHighlight> | ||||||
|  |             )} | ||||||
|  |         </div> | ||||||
|  |     ) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export default SungrowPlant | ||||||
							
								
								
									
										22
									
								
								app/api/sungrow/site/route.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								app/api/sungrow/site/route.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,22 @@ | |||||||
|  | import { NextResponse } from "next/server"; | ||||||
|  | import axios from "axios"; | ||||||
|  | 
 | ||||||
|  | export async function GET() { | ||||||
|  |     try { | ||||||
|  |         const res = await axios.post("https://gateway.isolarcloud.com.hk/openapi/platform/queryPowerStationList", { | ||||||
|  |             "page": 1, | ||||||
|  |             "size": 10, | ||||||
|  |             "appkey": `${process.env.SUNGROW_APP_KEY}` | ||||||
|  |         } ,{ | ||||||
|  |             headers: { | ||||||
|  |                 "Authorization": `Bearer ${process.env.SUNGROW_ACCESS_TOKEN}`, | ||||||
|  |                 "x-access-key": `${process.env.SUNGROW_SECRET_KEY}` | ||||||
|  |             } | ||||||
|  |         }) | ||||||
|  |         // console.log("res", res.data)
 | ||||||
|  |         return NextResponse.json(res.data.result_data.pageList) | ||||||
|  |     } catch (error) { | ||||||
|  |         console.error("API fetch error:", error); | ||||||
|  |         return NextResponse.json({ error: "Failed to fetch inverters" }, { status: 500 }); | ||||||
|  |     } | ||||||
|  | } | ||||||
| @ -9,12 +9,12 @@ export interface TimeSeriesResponse { | |||||||
|   generation: TimeSeriesEntry[]; |   generation: TimeSeriesEntry[]; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| const API_URL = | const API_BASE_URL = | ||||||
|   process.env.NEXT_PUBLIC_FASTAPI_URL ; |   process.env.FASTAPI_URL ?? "http://127.0.0.1:8000"; | ||||||
| 
 | 
 | ||||||
| export const crmapi = { | export const crmapi = { | ||||||
|   getProjects: async () => { |   getProjects: async () => { | ||||||
|     const res = await fetch(`${API_URL}/crm/projects`, { |     const res = await fetch(`${API_BASE_URL}/crm/projects`, { | ||||||
|     }); |     }); | ||||||
|     if (!res.ok) throw new Error(`HTTP ${res.status}`); |     if (!res.ok) throw new Error(`HTTP ${res.status}`); | ||||||
|     return res.json(); |     return res.json(); | ||||||
| @ -28,7 +28,7 @@ export async function fetchPowerTimeseries( | |||||||
| ): Promise<TimeSeriesResponse> { // <-- Change here
 | ): Promise<TimeSeriesResponse> { // <-- Change here
 | ||||||
|   const params = new URLSearchParams({ site, start, end }); |   const params = new URLSearchParams({ site, start, end }); | ||||||
| 
 | 
 | ||||||
|   const res = await fetch(`${API_URL}/power-timeseries?${params.toString()}`); |   const res = await fetch(`http://localhost:8000/power-timeseries?${params.toString()}`); | ||||||
| 
 | 
 | ||||||
|   if (!res.ok) { |   if (!res.ok) { | ||||||
|     throw new Error(`Failed to fetch data: ${res.status}`); |     throw new Error(`Failed to fetch data: ${res.status}`); | ||||||
| @ -54,7 +54,7 @@ export async function fetchForecast( | |||||||
|     kwp: kwp.toString(), |     kwp: kwp.toString(), | ||||||
|   }).toString(); |   }).toString(); | ||||||
| 
 | 
 | ||||||
|   const res = await fetch(`${API_URL}/forecast?${query}`); |   const res = await fetch(`http://localhost:8000/forecast?${query}`); | ||||||
|   if (!res.ok) throw new Error("Failed to fetch forecast"); |   if (!res.ok) throw new Error("Failed to fetch forecast"); | ||||||
| 
 | 
 | ||||||
|   return res.json(); |   return res.json(); | ||||||
| @ -73,7 +73,7 @@ export type MonthlyKPI = { | |||||||
|   error?: string; |   error?: string; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| const API = process.env.NEXT_PUBLIC_FASTAPI_URL; | const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000"; | ||||||
| 
 | 
 | ||||||
| export async function fetchMonthlyKpi(params: { | export async function fetchMonthlyKpi(params: { | ||||||
|   site: string; |   site: string; | ||||||
|  | |||||||
| @ -12,7 +12,7 @@ const ComponentsAuthLoginForm = () => { | |||||||
|   const [password, setPassword] = useState(''); |   const [password, setPassword] = useState(''); | ||||||
|   const [loading, setLoading] = useState(false); |   const [loading, setLoading] = useState(false); | ||||||
|   const router = useRouter(); |   const router = useRouter(); | ||||||
|   const API = process.env.NEXT_PUBLIC_FASTAPI_URL; |   const API = process.env.NEXT_PUBLIC_FASTAPI_URL; // e.g. http://localhost:8000
 | ||||||
| 
 | 
 | ||||||
|   const submitForm = async (e: React.FormEvent<HTMLFormElement>) => { |   const submitForm = async (e: React.FormEvent<HTMLFormElement>) => { | ||||||
|     e.preventDefault(); |     e.preventDefault(); | ||||||
|  | |||||||
| @ -18,8 +18,6 @@ import { color } from 'html2canvas/dist/types/css/types/color'; | |||||||
| import DatePicker from 'react-datepicker'; | import DatePicker from 'react-datepicker'; | ||||||
| import 'react-datepicker/dist/react-datepicker.css'; | import 'react-datepicker/dist/react-datepicker.css'; | ||||||
| import './datepicker-dark.css'; // custom dark mode styles
 | import './datepicker-dark.css'; // custom dark mode styles
 | ||||||
| import 'chartjs-adapter-date-fns'; |  | ||||||
| 
 |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| ChartJS.register(zoomPlugin); | ChartJS.register(zoomPlugin); | ||||||
| @ -70,6 +68,7 @@ function powerSeriesToEnergySeries( | |||||||
|   return out; |   return out; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | 
 | ||||||
| function groupTimeSeries( | function groupTimeSeries( | ||||||
|   data: TimeSeriesEntry[], |   data: TimeSeriesEntry[], | ||||||
|   mode: 'day' | 'daily' | 'weekly' | 'monthly' | 'yearly', |   mode: 'day' | 'daily' | 'weekly' | 'monthly' | 'yearly', | ||||||
| @ -83,11 +82,11 @@ function groupTimeSeries( | |||||||
| 
 | 
 | ||||||
|     switch (mode) { |     switch (mode) { | ||||||
|       case 'day': { |       case 'day': { | ||||||
|         // Snap to 5-minute buckets in local (KL) time
 |  | ||||||
|         const local = new Date( |         const local = new Date( | ||||||
|           date.toLocaleString('en-US', { timeZone: 'Asia/Kuala_Lumpur' }) |           date.toLocaleString('en-US', { timeZone: 'Asia/Kuala_Lumpur' }) | ||||||
|         ); |         ); | ||||||
|         local.setSeconds(0, 0); |         const minute = local.getMinutes() < 30 ? 0 : 30; | ||||||
|  |         local.setMinutes(minute, 0, 0); | ||||||
|         key = local.toISOString(); |         key = local.toISOString(); | ||||||
|         break; |         break; | ||||||
|       } |       } | ||||||
| @ -126,17 +125,7 @@ function groupTimeSeries( | |||||||
|   }); |   }); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // ---- NEW: build a 5-minute time grid for the day view
 | 
 | ||||||
| function buildTimeGrid(start: Date, end: Date, stepMinutes = 5): string[] { |  | ||||||
|   const grid: string[] = []; |  | ||||||
|   const t = new Date(start); |  | ||||||
|   t.setSeconds(0, 0); |  | ||||||
|   while (t.getTime() <= end.getTime()) { |  | ||||||
|     grid.push(new Date(t).toISOString()); |  | ||||||
|     t.setTime(t.getTime() + stepMinutes * 60 * 1000); |  | ||||||
|   } |  | ||||||
|   return grid; |  | ||||||
| } |  | ||||||
| 
 | 
 | ||||||
| const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => { | const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => { | ||||||
|   const chartRef = useRef<any>(null); |   const chartRef = useRef<any>(null); | ||||||
| @ -149,6 +138,7 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => { | |||||||
|   const LIVE_REFRESH_MS = 300000;       // 5min when viewing a single day
 |   const LIVE_REFRESH_MS = 300000;       // 5min when viewing a single day
 | ||||||
|   const SLOW_REFRESH_MS = 600000;      // 10min for weekly/monthly/yearly
 |   const SLOW_REFRESH_MS = 600000;      // 10min for weekly/monthly/yearly
 | ||||||
| 
 | 
 | ||||||
|  | 
 | ||||||
|   const fetchAndSet = React.useCallback(async () => { |   const fetchAndSet = React.useCallback(async () => { | ||||||
|     const now = new Date(); |     const now = new Date(); | ||||||
|     let start: Date; |     let start: Date; | ||||||
| @ -184,6 +174,15 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => { | |||||||
|       const res = await fetchPowerTimeseries(siteId, isoStart, isoEnd); |       const res = await fetchPowerTimeseries(siteId, isoStart, isoEnd); | ||||||
|       setConsumption(res.consumption); |       setConsumption(res.consumption); | ||||||
|       setGeneration(res.generation); |       setGeneration(res.generation); | ||||||
|  | 
 | ||||||
|  |       // Forecast only needs updating for the selected day
 | ||||||
|  |       const forecastData = await fetchForecast(3.15, 101.7, 37, 0, 25.67); | ||||||
|  |       const selectedDateStr = selectedDate.toISOString().split('T')[0]; | ||||||
|  |       setForecast( | ||||||
|  |         forecastData | ||||||
|  |           .filter(({ time }: any) => time.startsWith(selectedDateStr)) | ||||||
|  |           .map(({ time, forecast }: any) => ({ time, value: forecast })) | ||||||
|  |       ); | ||||||
|     } catch (error) { |     } catch (error) { | ||||||
|       console.error('Failed to fetch energy timeseries:', error); |       console.error('Failed to fetch energy timeseries:', error); | ||||||
|     } |     } | ||||||
| @ -223,22 +222,23 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => { | |||||||
|     }; |     }; | ||||||
|   }, [fetchAndSet, viewMode]); |   }, [fetchAndSet, viewMode]); | ||||||
| 
 | 
 | ||||||
|  | 
 | ||||||
|   function useIsDarkMode() { |   function useIsDarkMode() { | ||||||
|     const [isDark, setIsDark] = useState(() => |   const [isDark, setIsDark] = useState(() => | ||||||
|       typeof document !== 'undefined' |     typeof document !== 'undefined' | ||||||
|         ? document.body.classList.contains('dark') |       ? document.body.classList.contains('dark') | ||||||
|         : false |       : false | ||||||
|     ); |   ); | ||||||
| 
 | 
 | ||||||
|     useEffect(() => { |   useEffect(() => { | ||||||
|       const check = () => setIsDark(document.body.classList.contains('dark')); |     const check = () => setIsDark(document.body.classList.contains('dark')); | ||||||
|       const observer = new MutationObserver(check); |     const observer = new MutationObserver(check); | ||||||
|       observer.observe(document.body, { attributes: true, attributeFilter: ['class'] }); |     observer.observe(document.body, { attributes: true, attributeFilter: ['class'] }); | ||||||
|       return () => observer.disconnect(); |     return () => observer.disconnect(); | ||||||
|     }, []); |   }, []); | ||||||
| 
 | 
 | ||||||
|     return isDark; |   return isDark; | ||||||
|   } | } | ||||||
| 
 | 
 | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     const now = new Date(); |     const now = new Date(); | ||||||
| @ -278,17 +278,17 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => { | |||||||
|         setGeneration(res.generation); |         setGeneration(res.generation); | ||||||
| 
 | 
 | ||||||
|         // ⬇️ ADD THIS here — fetch forecast
 |         // ⬇️ ADD THIS here — fetch forecast
 | ||||||
|         const forecastData = await fetchForecast(3.15, 101.7, 37, 0, 30.67); |       const forecastData = await fetchForecast(3.15, 101.7, 37, 0, 30.67); | ||||||
|         const selectedDateStr = selectedDate.toISOString().split('T')[0]; |       const selectedDateStr = selectedDate.toISOString().split('T')[0]; | ||||||
| 
 | 
 | ||||||
|         setForecast( |       setForecast( | ||||||
|           forecastData |         forecastData | ||||||
|             .filter(({ time }) => time.startsWith(selectedDateStr))  // ✅ filter only selected date
 |           .filter(({ time }) => time.startsWith(selectedDateStr))  // ✅ filter only selected date
 | ||||||
|             .map(({ time, forecast }) => ({ |           .map(({ time, forecast }) => ({ | ||||||
|               time, |             time, | ||||||
|               value: forecast |             value: forecast | ||||||
|             })) |           })) | ||||||
|         ); |       ); | ||||||
| 
 | 
 | ||||||
|       } catch (error) { |       } catch (error) { | ||||||
|         console.error('Failed to fetch energy timeseries:', error); |         console.error('Failed to fetch energy timeseries:', error); | ||||||
| @ -300,58 +300,48 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => { | |||||||
| 
 | 
 | ||||||
|   const isEnergyView = viewMode !== 'day'; |   const isEnergyView = viewMode !== 'day'; | ||||||
| 
 | 
 | ||||||
|   // Convert to energy series for aggregated views
 | // Convert to energy series for aggregated views
 | ||||||
|   const consumptionForGrouping = isEnergyView | const consumptionForGrouping = isEnergyView | ||||||
|     ? powerSeriesToEnergySeries(consumption, 30) |   ? powerSeriesToEnergySeries(consumption, 30) | ||||||
|     : consumption; |   : consumption; | ||||||
| 
 | 
 | ||||||
|   const generationForGrouping = isEnergyView | const generationForGrouping = isEnergyView | ||||||
|     ? powerSeriesToEnergySeries(generation, 30) |   ? powerSeriesToEnergySeries(generation, 30) | ||||||
|     : generation; |   : generation; | ||||||
| 
 | 
 | ||||||
|   const forecastForGrouping = isEnergyView | const forecastForGrouping = isEnergyView | ||||||
|     ? powerSeriesToEnergySeries(forecast, 60) // if forecast is hourly, guess 60
 |   ? powerSeriesToEnergySeries(forecast, 60) // if forecast is hourly, guess 60
 | ||||||
|     : forecast; |   : forecast; | ||||||
| 
 | 
 | ||||||
|   // Group: sum for energy views, mean for day view
 | // Group: sum for energy views, mean for day view
 | ||||||
|   const groupedConsumption = groupTimeSeries( | const groupedConsumption = groupTimeSeries( | ||||||
|     consumptionForGrouping, |   consumptionForGrouping, | ||||||
|     viewMode, |   viewMode, | ||||||
|     isEnergyView ? 'sum' : 'mean' |   isEnergyView ? 'sum' : 'mean' | ||||||
|   ); | ); | ||||||
| 
 | 
 | ||||||
|   const groupedGeneration = groupTimeSeries( | const groupedGeneration = groupTimeSeries( | ||||||
|     generationForGrouping, |   generationForGrouping, | ||||||
|     viewMode, |   viewMode, | ||||||
|     isEnergyView ? 'sum' : 'mean' |   isEnergyView ? 'sum' : 'mean' | ||||||
|   ); | ); | ||||||
| 
 | 
 | ||||||
|   const groupedForecast = groupTimeSeries( | const groupedForecast = groupTimeSeries( | ||||||
|     forecastForGrouping, |   forecastForGrouping, | ||||||
|     viewMode, |   viewMode, | ||||||
|     isEnergyView ? 'sum' : 'mean' |   isEnergyView ? 'sum' : 'mean' | ||||||
|   ); | ); | ||||||
| 
 | 
 | ||||||
|   const forecastMap = Object.fromEntries(groupedForecast.map(d => [d.time, d.value])); |   const forecastMap = Object.fromEntries(groupedForecast.map(d => [d.time, d.value])); | ||||||
| 
 | 
 | ||||||
|     const dataTimesDay = [ |  | ||||||
|     ...groupedConsumption.map(d => Date.parse(d.time)), |  | ||||||
|     ...groupedGeneration.map(d => Date.parse(d.time)), |  | ||||||
|     ...groupedForecast.map(d => Date.parse(d.time)), |  | ||||||
|   ].filter(Number.isFinite).sort((a, b) => a - b); |  | ||||||
| 
 | 
 | ||||||
|   const dayGrid = viewMode === 'day' |   const allTimes = Array.from(new Set([ | ||||||
|   ? buildTimeGrid(startOfDay(selectedDate), endOfDay(selectedDate), 1) |   ...groupedConsumption.map(d => d.time), | ||||||
|   : []; |   ...groupedGeneration.map(d => d.time), | ||||||
|    |   ...groupedForecast.map(d => d.time), | ||||||
|        | ])).sort((a, b) => new Date(a).getTime() - new Date(b).getTime()); | ||||||
|   const unionTimes = Array.from(new Set([ | 
 | ||||||
|     ...groupedConsumption.map(d => d.time), |  | ||||||
|     ...groupedGeneration.map(d => d.time), |  | ||||||
|     ...groupedForecast.map(d => d.time), |  | ||||||
|   ])).sort((a, b) => new Date(a).getTime() - new Date(b).getTime()); |  | ||||||
| 
 | 
 | ||||||
|   const allTimes = viewMode === 'day' ? dayGrid : unionTimes; |  | ||||||
| 
 | 
 | ||||||
|   const consumptionMap = Object.fromEntries(groupedConsumption.map(d => [d.time, d.value])); |   const consumptionMap = Object.fromEntries(groupedConsumption.map(d => [d.time, d.value])); | ||||||
|   const generationMap = Object.fromEntries(groupedGeneration.map(d => [d.time, d.value])); |   const generationMap = Object.fromEntries(groupedGeneration.map(d => [d.time, d.value])); | ||||||
| @ -359,39 +349,7 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => { | |||||||
|   const [startIndex, setStartIndex] = useState(0); |   const [startIndex, setStartIndex] = useState(0); | ||||||
|   const [endIndex, setEndIndex] = useState(allTimes.length - 1); |   const [endIndex, setEndIndex] = useState(allTimes.length - 1); | ||||||
| 
 | 
 | ||||||
|   // after allTimes, consumptionMap, generationMap, forecastMap
 |    | ||||||
| const hasDataAt = (t: string) => |  | ||||||
|   t in consumptionMap || t in generationMap || t in forecastMap; |  | ||||||
| 
 |  | ||||||
| const firstAvailableIndex = allTimes.findIndex(hasDataAt); |  | ||||||
| const lastAvailableIndex = (() => { |  | ||||||
|   for (let i = allTimes.length - 1; i >= 0; i--) { |  | ||||||
|     if (hasDataAt(allTimes[i])) return i; |  | ||||||
|   } |  | ||||||
|   return -1; |  | ||||||
| })(); |  | ||||||
| 
 |  | ||||||
| const selectableIndices = |  | ||||||
|   firstAvailableIndex === -1 || lastAvailableIndex === -1 |  | ||||||
|     ? [] |  | ||||||
|     : Array.from( |  | ||||||
|         { length: lastAvailableIndex - firstAvailableIndex + 1 }, |  | ||||||
|         (_, k) => firstAvailableIndex + k |  | ||||||
|       ); |  | ||||||
| 
 |  | ||||||
|   useEffect(() => { |  | ||||||
|   if (selectableIndices.length === 0) { |  | ||||||
|     setStartIndex(0); |  | ||||||
|     setEndIndex(Math.max(0, allTimes.length - 1)); |  | ||||||
|     return; |  | ||||||
|   } |  | ||||||
|   const minIdx = selectableIndices[0]; |  | ||||||
|   const maxIdx = selectableIndices[selectableIndices.length - 1]; |  | ||||||
|   setStartIndex(prev => Math.min(Math.max(prev, minIdx), maxIdx)); |  | ||||||
|   setEndIndex(prev => Math.min(Math.max(prev, minIdx), maxIdx)); |  | ||||||
| }, [viewMode, allTimes.length, firstAvailableIndex, lastAvailableIndex]); |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     if (typeof window !== 'undefined') { |     if (typeof window !== 'undefined') { | ||||||
|       import('hammerjs'); |       import('hammerjs'); | ||||||
| @ -399,18 +357,9 @@ const selectableIndices = | |||||||
|   }, []); |   }, []); | ||||||
| 
 | 
 | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|   if (selectableIndices.length) { |  | ||||||
|     const minIdx = selectableIndices[0]; |  | ||||||
|     const maxIdx = selectableIndices[selectableIndices.length - 1]; |  | ||||||
|     setStartIndex(minIdx); |  | ||||||
|     setEndIndex(maxIdx); |  | ||||||
|   } else { |  | ||||||
|     setStartIndex(0); |     setStartIndex(0); | ||||||
|     setEndIndex(Math.max(0, allTimes.length - 1)); |     setEndIndex(allTimes.length - 1); | ||||||
|   } |   }, [viewMode, allTimes.length]); | ||||||
|   // run whenever mode changes or the timeline changes
 |  | ||||||
| }, [viewMode, allTimes, firstAvailableIndex, lastAvailableIndex]); |  | ||||||
| 
 |  | ||||||
| 
 | 
 | ||||||
|   const formatLabel = (key: string) => { |   const formatLabel = (key: string) => { | ||||||
|     switch (viewMode) { |     switch (viewMode) { | ||||||
| @ -431,47 +380,35 @@ const selectableIndices = | |||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   const filteredLabels = allTimes.slice(startIndex, endIndex + 1); |   const filteredLabels = allTimes.slice(startIndex, endIndex + 1); | ||||||
|  |   const filteredConsumption = filteredLabels.map(t => consumptionMap[t] ?? 0); | ||||||
|  |   const filteredGeneration = filteredLabels.map(t => generationMap[t] ?? 0); | ||||||
|  |   const filteredForecast = filteredLabels.map(t => forecastMap[t] ?? null); | ||||||
| 
 | 
 | ||||||
|   const minutesOfDayForLabels = |  | ||||||
|   viewMode === 'day' |  | ||||||
|     ? filteredLabels.map((iso) => { |  | ||||||
|         const d = new Date(iso); |  | ||||||
|         const kl = new Date(d.toLocaleString('en-US', { timeZone: 'Asia/Kuala_Lumpur' })); |  | ||||||
|         return kl.getHours() * 60 + kl.getMinutes(); |  | ||||||
|       }) |  | ||||||
|     : []; |  | ||||||
| 
 | 
 | ||||||
|   // ---- CHANGED: use nulls for missing buckets (not zeros)
 |   const allValues = [...filteredConsumption, ...filteredGeneration].filter(v => v !== null) as number[]; | ||||||
|   const filteredConsumption = filteredLabels.map(t => (t in consumptionMap ? consumptionMap[t] : null)); |  | ||||||
|   const filteredGeneration  = filteredLabels.map(t => (t in generationMap  ? generationMap[t]  : null)); |  | ||||||
|   const filteredForecast    = filteredLabels.map(t => (t in forecastMap    ? forecastMap[t]    : null)); |  | ||||||
| 
 |  | ||||||
|   const allValues = [...filteredConsumption, ...filteredGeneration].filter( |  | ||||||
|     (v): v is number => v !== null |  | ||||||
|   ); |  | ||||||
|   const maxValue = allValues.length > 0 ? Math.max(...allValues) : 0; |   const maxValue = allValues.length > 0 ? Math.max(...allValues) : 0; | ||||||
|   const yAxisSuggestedMax = maxValue * 1.15; |   const yAxisSuggestedMax = maxValue * 1.15; | ||||||
| 
 | 
 | ||||||
|   const isDark = useIsDarkMode(); |   const isDark = useIsDarkMode(); | ||||||
| 
 | 
 | ||||||
|   const axisColor = isDark ? '#fff' : '#222'; | const axisColor = isDark ? '#fff' : '#222'; | ||||||
| 
 | 
 | ||||||
|   function areaGradient(ctx: any, hex: string, alphaTop = 0.22, alphaBottom = 0.02) { | function areaGradient(ctx: any, hex: string, alphaTop = 0.22, alphaBottom = 0.02) { | ||||||
|     const { ctx: g, chartArea } = ctx.chart; |   const { ctx: g, chartArea } = ctx.chart; | ||||||
|     if (!chartArea) return hex; // initial render fallback
 |   if (!chartArea) return hex; // initial render fallback
 | ||||||
|     const gradient = g.createLinearGradient(0, chartArea.top, 0, chartArea.bottom); |   const gradient = g.createLinearGradient(0, chartArea.top, 0, chartArea.bottom); | ||||||
|     // top more opaque → bottom fades out
 |   // top more opaque → bottom fades out
 | ||||||
|     gradient.addColorStop(0, hex + Math.floor(alphaTop * 255).toString(16).padStart(2, '0')); |   gradient.addColorStop(0, hex + Math.floor(alphaTop * 255).toString(16).padStart(2, '0')); | ||||||
|     gradient.addColorStop(1, hex + Math.floor(alphaBottom * 255).toString(16).padStart(2, '0')); |   gradient.addColorStop(1, hex + Math.floor(alphaBottom * 255).toString(16).padStart(2, '0')); | ||||||
|     return gradient; |   return gradient; | ||||||
|   } | } | ||||||
| 
 | 
 | ||||||
|   // Define colors for both light and dark modes
 | // Define colors for both light and dark modes
 | ||||||
|   const consumptionColor = isDark ? '#B80F0A' : '#EF4444'; // Example: Brighter red for dark mode
 | const consumptionColor = isDark ? '#B80F0A' : '#EF4444'; // Example: Brighter red for dark mode
 | ||||||
|   const generationColor = isDark ? '#48A860' : '#22C55E'; // Example: Brighter green for dark mode
 | const generationColor = isDark ? '#48A860' : '#22C55E'; // Example: Brighter green for dark mode
 | ||||||
|   const forecastColor = '#fcd913'; // A golden yellow that works well in both modes
 | const forecastColor = '#fcd913'; // A golden yellow that works well in both modes
 | ||||||
|   const yUnit = isEnergyView ? 'kWh' : 'kW'; | const yUnit = isEnergyView ? 'kWh' : 'kW'; | ||||||
|   const yTitle = isEnergyView ? 'Energy (kWh)' : 'Power (kW)'; | const yTitle = isEnergyView ? 'Energy (kWh)' : 'Power (kW)'; | ||||||
| 
 | 
 | ||||||
|   const data = { |   const data = { | ||||||
|     labels: filteredLabels.map(formatLabel), |     labels: filteredLabels.map(formatLabel), | ||||||
| @ -481,57 +418,42 @@ const selectableIndices = | |||||||
|         data: filteredConsumption, |         data: filteredConsumption, | ||||||
|         borderColor: consumptionColor, |         borderColor: consumptionColor, | ||||||
|         backgroundColor: (ctx: any) => areaGradient(ctx, consumptionColor), |         backgroundColor: (ctx: any) => areaGradient(ctx, consumptionColor), | ||||||
|         fill: true, |         fill: true,                                   // <-- fill under line
 | ||||||
|         tension: 0.2, |         tension: 0.4, | ||||||
|         spanGaps: true, |         spanGaps: true, | ||||||
|         pointRadius: 0.7,        // default is 3, make smaller
 |  | ||||||
|         pointHoverRadius: 4,   // a bit bigger on hover
 |  | ||||||
|         borderWidth: 2, |  | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         label: 'Generation', |         label: 'Generation', | ||||||
|         data: filteredGeneration, |         data: filteredGeneration, | ||||||
|         borderColor: generationColor, |         borderColor: generationColor, | ||||||
|         backgroundColor: (ctx: any) => areaGradient(ctx, generationColor), |         backgroundColor: (ctx: any) => areaGradient(ctx, generationColor), | ||||||
|         fill: true, |         fill: true,                                   // <-- fill under line
 | ||||||
|         tension: 0.2, |         tension: 0.4, | ||||||
|         spanGaps: true, |         spanGaps: true, | ||||||
|         pointRadius: 0.7,        // default is 3, make smaller
 |  | ||||||
|         pointHoverRadius: 4,   // a bit bigger on hover
 |  | ||||||
|         borderWidth: 2, |  | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         label: 'Forecasted Solar', |       label: 'Forecasted Solar', | ||||||
|         data: filteredForecast, |       data: filteredForecast, | ||||||
|         borderColor: '#fcd913', |       borderColor: '#fcd913', // orange
 | ||||||
|         backgroundColor: (ctx: any) => areaGradient(ctx, '#fcd913', 0.18, 0.03), |       backgroundColor: (ctx: any) => areaGradient(ctx, '#fcd913', 0.18, 0.03), | ||||||
|         tension: 0.4, |       tension: 0.4, | ||||||
|         borderDash: [5, 5], |       borderDash: [5, 5], // dashed line to distinguish forecast
 | ||||||
|         fill: true, |       fill: true, | ||||||
|         spanGaps: true, |       spanGaps: true, | ||||||
|         pointRadius: 1,        // default is 3, make smaller
 |     } | ||||||
|         pointHoverRadius: 4,   // a bit bigger on hover
 |  | ||||||
|         borderWidth: 2, |  | ||||||
|       } |  | ||||||
|     ], |     ], | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   const options = { |   const options = { | ||||||
|     responsive: true, |     responsive: true, | ||||||
|     maintainAspectRatio: false, |     maintainAspectRatio: false, | ||||||
|     normalized: true,                // faster lookup
 |  | ||||||
|     plugins: { |     plugins: { | ||||||
|       decimation: { |     legend: { | ||||||
|       enabled: true, |       position: 'top', | ||||||
|       algorithm: 'lttb',           // best visual fidelity
 |       labels: { | ||||||
|       samples: 400,                // cap points actually drawn (~400 is a good default)
 |         color: axisColor, // legend text color
 | ||||||
|     }, |  | ||||||
|       legend: { |  | ||||||
|         position: 'top', |  | ||||||
|         labels: { |  | ||||||
|           color: axisColor, // legend text color
 |  | ||||||
|         }, |  | ||||||
|       }, |       }, | ||||||
|  |     }, | ||||||
|       zoom: { |       zoom: { | ||||||
|         zoom: { |         zoom: { | ||||||
|           wheel: { enabled: true }, |           wheel: { enabled: true }, | ||||||
| @ -541,26 +463,25 @@ const selectableIndices = | |||||||
|         pan: { enabled: true, mode: 'x' as const }, |         pan: { enabled: true, mode: 'x' as const }, | ||||||
|       }, |       }, | ||||||
|       tooltip: { |       tooltip: { | ||||||
|         enabled: true, |       enabled: true, | ||||||
|         mode: 'index', |       mode: 'index', | ||||||
|         intersect: false, |       intersect: false, | ||||||
|         backgroundColor: isDark ? '#232b3e' : '#fff', |       backgroundColor: isDark ? '#232b3e' : '#fff', | ||||||
|         titleColor: axisColor, |       titleColor: axisColor, | ||||||
|         bodyColor: axisColor, |       bodyColor: axisColor, | ||||||
|         borderColor: isDark ? '#444' : '#ccc', |       borderColor: isDark ? '#444' : '#ccc', | ||||||
|         borderWidth: 1, |       borderWidth: 1, | ||||||
|         callbacks: { |       callbacks: { | ||||||
|           label: (ctx: any) => { |       label: (ctx: any) => { | ||||||
|             const dsLabel = ctx.dataset.label || ''; |         const dsLabel = ctx.dataset.label || ''; | ||||||
|             const val = ctx.parsed.y; |         const val = ctx.parsed.y; | ||||||
|             return `${dsLabel}: ${val?.toFixed(2)} ${yUnit}`; |         return `${dsLabel}: ${val?.toFixed(2)} ${yUnit}`; | ||||||
|           }, |  | ||||||
|         }, |  | ||||||
|       }, |       }, | ||||||
|     }, |     }, | ||||||
|  |     }, | ||||||
|  |     }, | ||||||
|     scales: { |     scales: { | ||||||
|       x: { |       x: { | ||||||
|         type: 'category' as const, |  | ||||||
|         title: { |         title: { | ||||||
|           display: true, |           display: true, | ||||||
|           color: axisColor, |           color: axisColor, | ||||||
| @ -577,44 +498,18 @@ const selectableIndices = | |||||||
|           font: { weight: 'normal' as const }, |           font: { weight: 'normal' as const }, | ||||||
|         }, |         }, | ||||||
|         ticks: { |         ticks: { | ||||||
|     color: axisColor, |         color: axisColor, | ||||||
|     autoSkip: false,          // let our callback decide
 |  | ||||||
|     maxRotation: 0, |  | ||||||
|     callback( |  | ||||||
|         this: any, |  | ||||||
|         tickValue: string | number, |  | ||||||
|         index: number, |  | ||||||
|         ticks: any[] |  | ||||||
|       ) { |  | ||||||
|         if (viewMode !== 'day') return this.getLabelForValue(tickValue as number); |  | ||||||
| 
 |  | ||||||
|         const scale = this.chart.scales.x; |  | ||||||
|         const min = Math.max(0, Math.floor(scale.min ?? 0)); |  | ||||||
|         const max = Math.min(ticks.length - 1, Math.ceil(scale.max ?? ticks.length - 1)); |  | ||||||
|         const visibleCount = Math.max(1, max - min + 1); |  | ||||||
| 
 |  | ||||||
|         let step = 30;                 // ≥ 6h
 |  | ||||||
|         if (visibleCount < 80) step = 10;// 2–6h
 |  | ||||||
| 
 |  | ||||||
|         // On a category scale, tickValue is usually the index (number).
 |  | ||||||
|         const idx = typeof tickValue === 'number' ? tickValue : index; |  | ||||||
|         const m = minutesOfDayForLabels[idx]; |  | ||||||
| 
 |  | ||||||
|         if (m != null && m % step === 0) { |  | ||||||
|           return this.getLabelForValue(idx); |  | ||||||
|         } |  | ||||||
|         return ''; // hide crowded labels
 |  | ||||||
|       }, |       }, | ||||||
|   }, |  | ||||||
|       }, |       }, | ||||||
|       y: { |       y: { | ||||||
|         beginAtZero: true, |         beginAtZero: true, | ||||||
|         suggestedMax: yAxisSuggestedMax, |         suggestedMax: yAxisSuggestedMax, | ||||||
|         title: { display: true, text: yTitle, color: axisColor, font: { weight: 'normal' as const } }, |         title: { display: true, text: yTitle, color: axisColor, font: { weight: 'normal' as const } }, | ||||||
|         ticks: { |         ticks: { | ||||||
|           color: axisColor, |         color: axisColor, | ||||||
|         }, |  | ||||||
|       }, |       }, | ||||||
|  |       }, | ||||||
|  |        | ||||||
|     }, |     }, | ||||||
|   } as const; |   } as const; | ||||||
| 
 | 
 | ||||||
| @ -624,7 +519,7 @@ const selectableIndices = | |||||||
| 
 | 
 | ||||||
|   return ( |   return ( | ||||||
|     <div className="bg-white p-4 rounded-lg shadow-md dark:bg-rtgray-800 dark:text-white-light"> |     <div className="bg-white p-4 rounded-lg shadow-md dark:bg-rtgray-800 dark:text-white-light"> | ||||||
|       <div className="h-98 w-full"  onDoubleClick={handleResetZoom}> |       <div className="h-98 w-full"> | ||||||
|         <div className="flex justify-between items-center mb-2"> |         <div className="flex justify-between items-center mb-2"> | ||||||
|           <h2 className="text-lg font-bold dark:text-white-light">Energy Consumption & Generation</h2> |           <h2 className="text-lg font-bold dark:text-white-light">Energy Consumption & Generation</h2> | ||||||
|           <button onClick={handleResetZoom} className="btn-primary px-8 py-2 text-sm"> |           <button onClick={handleResetZoom} className="btn-primary px-8 py-2 text-sm"> | ||||||
| @ -653,15 +548,13 @@ const selectableIndices = | |||||||
|                 const val = Number(e.target.value); |                 const val = Number(e.target.value); | ||||||
|                 setStartIndex(val <= endIndex ? val : endIndex); |                 setStartIndex(val <= endIndex ? val : endIndex); | ||||||
|               }} |               }} | ||||||
|               disabled={selectableIndices.length === 0} |  | ||||||
|               className="dark:bg-rtgray-700 border dark:border-rtgray-700 rounded p-1" |               className="dark:bg-rtgray-700 border dark:border-rtgray-700 rounded p-1" | ||||||
|             > |             > | ||||||
|               {selectableIndices.map((absIdx) => ( |               {allTimes.map((label, idx) => ( | ||||||
|                 <option key={absIdx} value={absIdx}>{formatLabel(allTimes[absIdx])}</option> |                 <option key={idx} value={idx}>{formatLabel(label)}</option> | ||||||
|               ))} |               ))} | ||||||
|             </select> |             </select> | ||||||
|           </label> |           </label> | ||||||
| 
 |  | ||||||
|           <label className="font-medium "> |           <label className="font-medium "> | ||||||
|             To:{' '} |             To:{' '} | ||||||
|             <select |             <select | ||||||
| @ -670,15 +563,13 @@ const selectableIndices = | |||||||
|                 const val = Number(e.target.value); |                 const val = Number(e.target.value); | ||||||
|                 setEndIndex(val >= startIndex ? val : startIndex); |                 setEndIndex(val >= startIndex ? val : startIndex); | ||||||
|               }} |               }} | ||||||
|               disabled={selectableIndices.length === 0} |  | ||||||
|               className="dark:bg-rtgray-700 border dark:border-rtgray-700 rounded p-1" |               className="dark:bg-rtgray-700 border dark:border-rtgray-700 rounded p-1" | ||||||
|             > |             > | ||||||
|               {selectableIndices.map((absIdx) => ( |               {allTimes.map((label, idx) => ( | ||||||
|                 <option key={absIdx} value={absIdx}>{formatLabel(allTimes[absIdx])}</option> |                 <option key={idx} value={idx}>{formatLabel(label)}</option> | ||||||
|               ))} |               ))} | ||||||
|             </select> |             </select> | ||||||
|           </label> |           </label> | ||||||
| 
 |  | ||||||
|           <label className="font-medium"> |           <label className="font-medium"> | ||||||
|             View:{' '} |             View:{' '} | ||||||
|             <select |             <select | ||||||
| @ -703,4 +594,11 @@ const selectableIndices = | |||||||
|   ); |   ); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export default EnergyLineChart; | export default EnergyLineChart; | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | |||||||
| @ -21,8 +21,6 @@ const KPI_Table: React.FC<KPI_TableProps> = ({ siteId, month }) => { | |||||||
|   const [kpiData, setKpiData] = useState<MonthlyKPI | null>(null); |   const [kpiData, setKpiData] = useState<MonthlyKPI | null>(null); | ||||||
|   const [loading, setLoading] = useState(false); |   const [loading, setLoading] = useState(false); | ||||||
| 
 | 
 | ||||||
|   const API_URL = process.env.NEXT_PUBLIC_FASTAPI_URL; |  | ||||||
| 
 |  | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     if (!siteId || !month) return; |     if (!siteId || !month) return; | ||||||
| 
 | 
 | ||||||
| @ -30,7 +28,7 @@ const KPI_Table: React.FC<KPI_TableProps> = ({ siteId, month }) => { | |||||||
|       setLoading(true); |       setLoading(true); | ||||||
|       try { |       try { | ||||||
|         const res = await fetch( |         const res = await fetch( | ||||||
|           `${API_URL}/kpi/monthly?site=${siteId}&month=${month}` |           `http://localhost:8000/kpi/monthly?site=${siteId}&month=${month}` | ||||||
|         ); |         ); | ||||||
|         setKpiData(await res.json()); |         setKpiData(await res.json()); | ||||||
|       } catch (err) { |       } catch (err) { | ||||||
|  | |||||||
| @ -11,7 +11,7 @@ interface LoggingControlCardProps { | |||||||
|   className?: string; |   className?: string; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| const API_URL = process.env.NEXT_PUBLIC_FASTAPI_URL; | const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000'; | ||||||
| 
 | 
 | ||||||
| type FnState = { | type FnState = { | ||||||
|   serial: string; |   serial: string; | ||||||
|  | |||||||
| @ -26,7 +26,7 @@ interface SiteCardProps { | |||||||
|   fallbackStatus?: string;       // optional backup status if CRM is missing it
 |   fallbackStatus?: string;       // optional backup status if CRM is missing it
 | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| const API = process.env.NEXT_PUBLIC_FASTAPI_URL; | const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000'; | ||||||
| 
 | 
 | ||||||
| const SiteCard: React.FC<SiteCardProps> = ({ siteId, className = '', fallbackStatus }) => { | const SiteCard: React.FC<SiteCardProps> = ({ siteId, className = '', fallbackStatus }) => { | ||||||
|   const [project, setProject] = useState<CrmProject | null>(null); |   const [project, setProject] = useState<CrmProject | null>(null); | ||||||
|  | |||||||
| @ -15,7 +15,8 @@ interface SiteStatusProps { | |||||||
|   lastSyncTimestamp: string; |   lastSyncTimestamp: string; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| const API_URL = process.env.NEXT_PUBLIC_FASTAPI_URL; | const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000"; | ||||||
|  | const WS_URL  = process.env.NEXT_PUBLIC_WS_URL  ?? "ws://localhost:8000/ws"; | ||||||
| 
 | 
 | ||||||
| const SiteStatus = ({ | const SiteStatus = ({ | ||||||
|   selectedSite, |   selectedSite, | ||||||
| @ -29,7 +30,7 @@ const SiteStatus = ({ | |||||||
| 
 | 
 | ||||||
|   // --- WebSocket to receive MQTT-forwarded messages ---
 |   // --- WebSocket to receive MQTT-forwarded messages ---
 | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     const ws = new WebSocket(`${API_URL}/ws`); |     const ws = new WebSocket(WS_URL); | ||||||
| 
 | 
 | ||||||
|     ws.onopen = () => console.log("WebSocket connected"); |     ws.onopen = () => console.log("WebSocket connected"); | ||||||
|     ws.onclose = () => console.log("WebSocket disconnected"); |     ws.onclose = () => console.log("WebSocket disconnected"); | ||||||
|  | |||||||
| @ -26,7 +26,7 @@ export default function Header() { | |||||||
|   const [user, setUser] = useState<UserData | null>(null); |   const [user, setUser] = useState<UserData | null>(null); | ||||||
|   const [loadingUser, setLoadingUser] = useState(true); |   const [loadingUser, setLoadingUser] = useState(true); | ||||||
| 
 | 
 | ||||||
|   const API = process.env.NEXT_PUBLIC_FASTAPI_URL; |   const API = process.env.NEXT_PUBLIC_FASTAPI_URL || 'http://127.0.0.1:8000'; | ||||||
| 
 | 
 | ||||||
|   // highlight active menu
 |   // highlight active menu
 | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|  | |||||||
							
								
								
									
										17
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										17
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							| @ -22,7 +22,6 @@ | |||||||
|                 "axios": "^1.7.9", |                 "axios": "^1.7.9", | ||||||
|                 "bcrypt": "^5.1.1", |                 "bcrypt": "^5.1.1", | ||||||
|                 "chart.js": "^4.4.9", |                 "chart.js": "^4.4.9", | ||||||
|                 "chartjs-adapter-date-fns": "^3.0.0", |  | ||||||
|                 "chartjs-plugin-zoom": "^2.2.0", |                 "chartjs-plugin-zoom": "^2.2.0", | ||||||
|                 "cookie": "^1.0.2", |                 "cookie": "^1.0.2", | ||||||
|                 "date-fns": "^4.1.0", |                 "date-fns": "^4.1.0", | ||||||
| @ -4795,16 +4794,6 @@ | |||||||
|                 "pnpm": ">=8" |                 "pnpm": ">=8" | ||||||
|             } |             } | ||||||
|         }, |         }, | ||||||
|         "node_modules/chartjs-adapter-date-fns": { |  | ||||||
|             "version": "3.0.0", |  | ||||||
|             "resolved": "https://registry.npmjs.org/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-3.0.0.tgz", |  | ||||||
|             "integrity": "sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg==", |  | ||||||
|             "license": "MIT", |  | ||||||
|             "peerDependencies": { |  | ||||||
|                 "chart.js": ">=2.8.0", |  | ||||||
|                 "date-fns": ">=2.0.0" |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "node_modules/chartjs-plugin-zoom": { |         "node_modules/chartjs-plugin-zoom": { | ||||||
|             "version": "2.2.0", |             "version": "2.2.0", | ||||||
|             "resolved": "https://registry.npmjs.org/chartjs-plugin-zoom/-/chartjs-plugin-zoom-2.2.0.tgz", |             "resolved": "https://registry.npmjs.org/chartjs-plugin-zoom/-/chartjs-plugin-zoom-2.2.0.tgz", | ||||||
| @ -13085,12 +13074,6 @@ | |||||||
|                 "@kurkle/color": "^0.3.0" |                 "@kurkle/color": "^0.3.0" | ||||||
|             } |             } | ||||||
|         }, |         }, | ||||||
|         "chartjs-adapter-date-fns": { |  | ||||||
|             "version": "3.0.0", |  | ||||||
|             "resolved": "https://registry.npmjs.org/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-3.0.0.tgz", |  | ||||||
|             "integrity": "sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg==", |  | ||||||
|             "requires": {} |  | ||||||
|         }, |  | ||||||
|         "chartjs-plugin-zoom": { |         "chartjs-plugin-zoom": { | ||||||
|             "version": "2.2.0", |             "version": "2.2.0", | ||||||
|             "resolved": "https://registry.npmjs.org/chartjs-plugin-zoom/-/chartjs-plugin-zoom-2.2.0.tgz", |             "resolved": "https://registry.npmjs.org/chartjs-plugin-zoom/-/chartjs-plugin-zoom-2.2.0.tgz", | ||||||
|  | |||||||
| @ -23,7 +23,6 @@ | |||||||
|         "axios": "^1.7.9", |         "axios": "^1.7.9", | ||||||
|         "bcrypt": "^5.1.1", |         "bcrypt": "^5.1.1", | ||||||
|         "chart.js": "^4.4.9", |         "chart.js": "^4.4.9", | ||||||
|         "chartjs-adapter-date-fns": "^3.0.0", |  | ||||||
|         "chartjs-plugin-zoom": "^2.2.0", |         "chartjs-plugin-zoom": "^2.2.0", | ||||||
|         "cookie": "^1.0.2", |         "cookie": "^1.0.2", | ||||||
|         "date-fns": "^4.1.0", |         "date-fns": "^4.1.0", | ||||||
|  | |||||||
| @ -1,70 +0,0 @@ | |||||||
| // utils/export.ts
 |  | ||||||
| export type ExportParams = { |  | ||||||
|   baseUrl?: string;               // e.g. process.env.NEXT_PUBLIC_API_URL
 |  | ||||||
|   site: string;                   // PROJ-0028
 |  | ||||||
|   suffix?: "grid" | "solar";      // default "grid"
 |  | ||||||
|   serial?: string | null;         // device id like "01"
 |  | ||||||
|   day?: string;                   // "YYYY-MM-DD" (preferred)
 |  | ||||||
|   start?: string;                 // ISO string (if not using day)
 |  | ||||||
|   end?: string;                   // ISO string (if not using day)
 |  | ||||||
|   columns?: string[];             // optional list of columns
 |  | ||||||
|   localTz?: string;               // default "Asia/Kuala_Lumpur"
 |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export function buildExportUrl(p: ExportParams): string { |  | ||||||
|   const { |  | ||||||
|     baseUrl = "", |  | ||||||
|     site, |  | ||||||
|     suffix = "grid", |  | ||||||
|     serial, |  | ||||||
|     day, |  | ||||||
|     start, |  | ||||||
|     end, |  | ||||||
|     columns, |  | ||||||
|     localTz = "Asia/Kuala_Lumpur", |  | ||||||
|   } = p; |  | ||||||
| 
 |  | ||||||
|   const params = new URLSearchParams(); |  | ||||||
|   params.set("site", site); |  | ||||||
|   params.set("suffix", suffix); |  | ||||||
|   params.set("local_tz", localTz); |  | ||||||
| 
 |  | ||||||
|   const s = serial?.trim(); |  | ||||||
|   if (s) params.set("serial", s); |  | ||||||
| 
 |  | ||||||
|   if (day) { |  | ||||||
|     params.set("day", day); // simple whole-day export
 |  | ||||||
|   } else { |  | ||||||
|     if (!start || !end) throw new Error("Provide either day=YYYY-MM-DD or both start and end."); |  | ||||||
|     params.set("start", start); // URLSearchParams will encode '+' correctly
 |  | ||||||
|     params.set("end", end); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   if (columns?.length) { |  | ||||||
|     // backend expects ?columns=... repeated; append each
 |  | ||||||
|     columns.forEach(c => params.append("columns", c)); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   // ensure there's a single slash join for /export/xlsx
 |  | ||||||
|   const root = baseUrl.replace(/\/+$/, ""); |  | ||||||
|   return `${root}/export/xlsx?${params.toString()}`; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| /** Parse filename from Content-Disposition (handles RFC5987 filename*) */ |  | ||||||
| export function getFilenameFromCD(cd: string | null): string | null { |  | ||||||
|   if (!cd) return null; |  | ||||||
| 
 |  | ||||||
|   // filename*=UTF-8''encoded-name.xlsx
 |  | ||||||
|   const star = /filename\*\s*=\s*([^']*)''([^;]+)/i.exec(cd); |  | ||||||
|   if (star && star[2]) { |  | ||||||
|     try { |  | ||||||
|       return decodeURIComponent(star[2]); |  | ||||||
|     } catch { |  | ||||||
|       return star[2]; |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   // filename="name.xlsx" OR filename=name.xlsx
 |  | ||||||
|   const plain = /filename\s*=\s*("?)([^";]+)\1/i.exec(cd); |  | ||||||
|   return plain ? plain[2] : null; |  | ||||||
| } |  | ||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user