new excel export
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m50s

This commit is contained in:
Syasya 2025-08-29 20:22:30 +08:00
parent ed131acab4
commit 00fe939804
2 changed files with 127 additions and 8 deletions

View File

@ -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
View 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;
}