new excel export
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m50s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m50s
This commit is contained in:
parent
ed131acab4
commit
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