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 { formatCrmTimestamp } from '@/app/utils/datetime'; | ||||
| import LoggingControlCard from '@/components/dashboards/LoggingControl'; | ||||
| import { buildExportUrl, getFilenameFromCD } from "@/utils/export"; | ||||
| 
 | ||||
| const EnergyLineChart = dynamic(() => import('@/components/dashboards/EnergyLineChart'), { 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); | ||||
| 
 | ||||
| // 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); | ||||
|     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) { | ||||
|       const text = await resp.text().catch(() => ''); | ||||
|       throw new Error(text || `HTTP ${resp.status}`); | ||||
|       // 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(); | ||||
|     const a = document.createElement('a'); | ||||
|     a.href = URL.createObjectURL(blob); | ||||
|     a.download = `${meter}_${fn}_${downloadDate}.xlsx`; | ||||
| 
 | ||||
|     // 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(a.href); | ||||
|     URL.revokeObjectURL(href); | ||||
|     setIsDownloadOpen(false); | ||||
|   } catch (e: any) { | ||||
|     alert(`Download failed: ${e?.message ?? e}`); | ||||
| @ -377,6 +425,7 @@ const downloadExcel = async () => { | ||||
| }; | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
|   // ---------- RENDER ----------
 | ||||
|   if (!authChecked) { | ||||
|     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