Compare commits
No commits in common. "master" and "v.0.0.10" 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