Compare commits
	
		
			1 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 00fe939804 | 
| @ -14,6 +14,7 @@ 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 }); | ||||||
| @ -350,24 +351,71 @@ const [downloadDate, setDownloadDate] = useState(ymd(new Date())); // YYYY-MM-DD | |||||||
| const [downloading, setDownloading] = useState(false); | const [downloading, setDownloading] = useState(false); | ||||||
| 
 | 
 | ||||||
| // action
 | // 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 () => { | const downloadExcel = async () => { | ||||||
|   if (!selectedProject) return; |   if (!selectedProject) return; | ||||||
|   try { |   try { | ||||||
|     setDownloading(true); |     setDownloading(true); | ||||||
|     const url = excelUrl(selectedProject.name, meter.trim(), fn, downloadDate); | 
 | ||||||
|     const resp = await fetch(url, { credentials: 'include' }); |     // 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) { |     if (!resp.ok) { | ||||||
|       const text = await resp.text().catch(() => ''); |       // server might return JSON error; try to surface it nicely
 | ||||||
|       throw new Error(text || `HTTP ${resp.status}`); |       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(); |     const blob = await resp.blob(); | ||||||
|     const a = document.createElement('a'); | 
 | ||||||
|     a.href = URL.createObjectURL(blob); |     // 1) use server-provided filename if present
 | ||||||
|     a.download = `${meter}_${fn}_${downloadDate}.xlsx`; |     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); |     document.body.appendChild(a); | ||||||
|     a.click(); |     a.click(); | ||||||
|     a.remove(); |     a.remove(); | ||||||
|     URL.revokeObjectURL(a.href); |     URL.revokeObjectURL(href); | ||||||
|     setIsDownloadOpen(false); |     setIsDownloadOpen(false); | ||||||
|   } catch (e: any) { |   } catch (e: any) { | ||||||
|     alert(`Download failed: ${e?.message ?? e}`); |     alert(`Download failed: ${e?.message ?? e}`); | ||||||
| @ -377,6 +425,7 @@ const downloadExcel = async () => { | |||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | 
 | ||||||
|   // ---------- RENDER ----------
 |   // ---------- RENDER ----------
 | ||||||
|   if (!authChecked) { |   if (!authChecked) { | ||||||
|     return <div>Checking authentication…</div>; |     return <div>Checking authentication…</div>; | ||||||
|  | |||||||
							
								
								
									
										70
									
								
								utils/export.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								utils/export.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,70 @@ | |||||||
|  | // 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