Compare commits
	
		
			No commits in common. "master" and "v.0.0.9" 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 }); | ||||||
| @ -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>; | ||||||
| @ -549,154 +459,25 @@ const downloadExcel = async () => { | |||||||
|             /> |             /> | ||||||
| 
 | 
 | ||||||
|             <div className="flex flex-col md:flex-row gap-4 justify-center"> |             <div className="flex flex-col md:flex-row gap-4 justify-center"> | ||||||
|               <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 |               <a | ||||||
|                 onClick={() => setIsDownloadOpen(true)} |                 href={`https://drive.google.com/drive/folders/${process.env.NEXT_PUBLIC_GOOGLE_DRIVE_FOLDER_ID}`} | ||||||
|  |                 target="_blank" | ||||||
|  |                 rel="noopener noreferrer" | ||||||
|                 className="text-sm lg:text-lg btn-primary" |                 className="text-sm lg:text-lg btn-primary" | ||||||
|               > |               > | ||||||
|                 Download Excel Log |                 View Excel Logs | ||||||
|               </button> |               </a> | ||||||
|             </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> | ||||||
|  | |||||||
| @ -509,7 +509,7 @@ const selectableIndices = | |||||||
|         borderDash: [5, 5], |         borderDash: [5, 5], | ||||||
|         fill: true, |         fill: true, | ||||||
|         spanGaps: true, |         spanGaps: true, | ||||||
|         pointRadius: 1,        // default is 3, make smaller
 |         pointRadius: 2,        // default is 3, make smaller
 | ||||||
|         pointHoverRadius: 4,   // a bit bigger on hover
 |         pointHoverRadius: 4,   // a bit bigger on hover
 | ||||||
|         borderWidth: 2, |         borderWidth: 2, | ||||||
|       } |       } | ||||||
|  | |||||||
| @ -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