Compare commits
	
		
			No commits in common. "master" and "v.0.0.12" have entirely different histories.
		
	
	
		
	
		
| @ -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 }); | ||||||
| @ -351,71 +350,24 @@ 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); | ||||||
|     // Prefer the simple day-based export
 |     const resp = await fetch(url, { credentials: 'include' }); | ||||||
|     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) { | ||||||
|       // server might return JSON error; try to surface it nicely
 |       const text = await resp.text().catch(() => ''); | ||||||
|       const ctype = resp.headers.get("Content-Type") || ""; |       throw new Error(text || `HTTP ${resp.status}`); | ||||||
|       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'); | ||||||
|     // 1) use server-provided filename if present
 |     a.href = URL.createObjectURL(blob); | ||||||
|     const cd = resp.headers.get("Content-Disposition"); |     a.download = `${meter}_${fn}_${downloadDate}.xlsx`; | ||||||
|     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(href); |     URL.revokeObjectURL(a.href); | ||||||
|     setIsDownloadOpen(false); |     setIsDownloadOpen(false); | ||||||
|   } catch (e: any) { |   } catch (e: any) { | ||||||
|     alert(`Download failed: ${e?.message ?? e}`); |     alert(`Download failed: ${e?.message ?? e}`); | ||||||
| @ -425,7 +377,6 @@ const downloadExcel = async () => { | |||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| 
 |  | ||||||
|   // ---------- RENDER ----------
 |   // ---------- RENDER ----------
 | ||||||
|   if (!authChecked) { |   if (!authChecked) { | ||||||
|     return <div>Checking authentication…</div>; |     return <div>Checking authentication…</div>; | ||||||
|  | |||||||
| @ -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