Compare commits

..

12 Commits

Author SHA1 Message Date
00fe939804 new excel export
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m50s
2025-08-29 20:22:30 +08:00
ed131acab4 download excel
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m51s
2025-08-29 15:29:59 +08:00
eac2bb51e2 point
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m45s
2025-08-27 17:08:28 +08:00
4c6a1a0cb4 amend env
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
2025-08-27 17:06:13 +08:00
f5b41dd230 granularity check
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m2s
2025-08-27 16:54:51 +08:00
418f23586b link to gdrive + granularity fixes 2025-08-27 15:44:31 +08:00
86682398db tidy up
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m2s
2025-08-27 10:46:11 +08:00
fce26a2bc4 amend api endpoints
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m56s
2025-08-26 15:10:14 +08:00
f77aa0358e rename backend api
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m50s
2025-08-26 09:16:45 +08:00
c28cb86fdb amend deploy.yml 3
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m52s
2025-08-26 08:59:31 +08:00
9ac0f389dd amend deploy.yml 2
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m49s
2025-08-26 08:46:36 +08:00
1ec15ac214 Merge pull request 'update deploy yml' (#11) from feature/syasya/testlayout into master
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m1s
Reviewed-on: #11
2025-08-25 10:10:43 +00:00
24 changed files with 598 additions and 779 deletions

View File

@ -37,8 +37,9 @@ jobs:
push: true push: true
tags: | tags: |
rooftopenergy/powermeter-frontend:${{ steps.extract_tag.outputs.tag }} rooftopenergy/powermeter-frontend:${{ steps.extract_tag.outputs.tag }}
env: build-args: |
NEXT_PUBLIC_FASTAPI_URL: ${{ secrets.NEXT_PUBLIC_FASTAPI_URL }} NEXT_PUBLIC_FASTAPI_URL=${{ secrets.NEXT_PUBLIC_FASTAPI_URL }}
NEXT_PUBLIC_GOOGLE_DRIVE_FOLDER_ID=${{ secrets.NEXT_PUBLIC_GOOGLE_DRIVE_FOLDER_ID }}
- name: SSH and Deploy - name: SSH and Deploy
uses: appleboy/ssh-action@master uses: appleboy/ssh-action@master

View File

@ -8,8 +8,10 @@ WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
# Build-time public env hook # Build-time public env hook
ARG NEXT_PUBLIC_FASTAPI_URL ARG NEXT_PUBLIC_FASTAPI_URL\
ENV NEXT_PUBLIC_FASTAPI_URL=${NEXT_PUBLIC_FASTAPI_URL} NEXT_PUBLIC_GOOGLE_DRIVE_FOLDER_ID
ENV NEXT_PUBLIC_FASTAPI_URL=${NEXT_PUBLIC_FASTAPI_URL}\
NEXT_PUBLIC_GOOGLE_DRIVE_FOLDER_ID=${NEXT_PUBLIC_GOOGLE_DRIVE_FOLDER_ID}
# 1) Install deps with caching # 1) Install deps with caching
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./

View File

@ -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 });
@ -40,7 +41,7 @@ type CrmProject = {
custom_mobile_phone_no?: string | null; custom_mobile_phone_no?: string | null;
}; };
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000'; const API = process.env.NEXT_PUBLIC_FASTAPI_URL;
// Adjust this to your FastAPI route // Adjust this to your FastAPI route
const START_LOGGING_ENDPOINT = (siteId: string) => const START_LOGGING_ENDPOINT = (siteId: string) =>
@ -336,6 +337,95 @@ 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>;
@ -462,10 +552,151 @@ useEffect(() => {
<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
onClick={() => setIsDownloadOpen(true)}
className="text-sm lg:text-lg btn-primary"
>
Download Excel Log
</button>
</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/&lt;site&gt;/<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>

View File

@ -16,7 +16,7 @@ type CrmProject = {
custom_mobile_phone_no?: string | null; custom_mobile_phone_no?: string | null;
}; };
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000'; const API = process.env.NEXT_PUBLIC_FASTAPI_URL;
const SitesPage = () => { const SitesPage = () => {
const [projects, setProjects] = useState<CrmProject[]>([]); const [projects, setProjects] = useState<CrmProject[]>([]);

View File

@ -11,7 +11,7 @@ export default function LoginPage() {
const [ready, setReady] = useState(false); // gate to avoid UI flash const [ready, setReady] = useState(false); // gate to avoid UI flash
// Use ONE client-exposed API env var everywhere // Use ONE client-exposed API env var everywhere
const API = process.env.NEXT_PUBLIC_FASTAPI_URL || 'http://127.0.0.1:8000'; const API = process.env.NEXT_PUBLIC_FASTAPI_URL;
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;

View File

@ -55,7 +55,7 @@ const RegisterPage = (props: Props) => {
<div className="mt-6 text-sm text-gray-200 dark:text-gray-300"> <div className="mt-6 text-sm text-gray-200 dark:text-gray-300">
Already have an account ?{" "} Already have an account ?{" "}
<Link <Link
href="/register" href="/login"
className="text-yellow-400 font-semibold underline transition hover:text-white" className="text-yellow-400 font-semibold underline transition hover:text-white"
> >
SIGN IN SIGN IN

View File

@ -1,261 +0,0 @@
'use client';
import PanelCodeHighlight from '@/components/panel-code-highlight'
import React, { Fragment, useEffect, useState } from 'react';
import { Tab } from '@headlessui/react';
import IconHome from '@/components/icon/icon-home';
import IconUser from '@/components/icon/icon-user';
import IconPhone from '@/components/icon/icon-phone';
import { useRouter } from 'next/router';
import { useParams } from 'next/navigation';
import axios from 'axios';
type Props = {}
const InverterViewPage = (props: Props) => {
const [isMounted, setIsMounted] = useState(false)
const [loading, setLoading] = useState(true)
const params = useParams()
const [inverter, setInverter] = useState<any>({})
useEffect(() => {
setIsMounted(true);
fetchData()
}, [])
const fetchData = async () => {
try {
if (!params || !params.id) {
throw new Error("Invalid params or params.id is missing");
}
const res = await axios.get(`https://api-a.fomware.com.cn/asset/v1/list?type=2&key=${params.id.toString()}`, {
headers: {
"Authorization": "Bearer " + process.env.NEXT_PUBLIC_CHINT_TOKEN
}
})
console.log("res", res.data.data.devices[0])
setInverter(res.data.data.devices[0])
} catch (error) {
console.error("Error fetching data:", error);
} finally {
setLoading(false);
}
};
return (
<>
{loading ? <p>Loading...</p> : (
<>
<PanelCodeHighlight title={params?.id?.toString() || ""}>
<div className="mb-5">
{isMounted && (
<Tab.Group>
<Tab.List className="mt-3 flex flex-wrap border-b border-white-light dark:border-[#191e3a]">
<Tab as={Fragment}>
{({ selected }) => (
<button className={`${selected ? 'border-b !border-primary text-primary !outline-none' : ''} -mb-[1px] flex items-center border-transparent p-5 py-3 before:inline-block hover:border-b hover:!border-primary hover:text-primary`} >
Brief
</button>
)}
</Tab>
<Tab as={Fragment}>
{({ selected }) => (
<button className={`${selected ? 'border-b !border-primary text-primary !outline-none' : ''} -mb-[1px] flex items-center border-transparent p-5 py-3 before:inline-block hover:border-b hover:!border-primary hover:text-primary`} >
Chart
</button>
)}
</Tab>
</Tab.List>
<Tab.Panels>
<Tab.Panel>
<div className="active pt-5">
<p className="mb-3 text-base font-semibold">Last Updated ( 2025-02-24 16:03:10 +0800 )</p>
<blockquote className="rounded-br-md rounded-tr-md border-l-2 !border-l-primary bg-white py-2 px-2 text-black dark:border-[#060818] dark:bg-[#060818]">
<div className="flex items-start">
<p className="m-0 font-semibold text-sm not-italic text-[#515365] dark:text-white-light">Basic Information</p>
</div>
</blockquote>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 text-gray-600 mt-3">
<p><span className="font-semibold text-gray-400">Model: </span>{inverter.model}</p>
<p><span className="font-semibold text-gray-400">SN: </span>{inverter.sn}</p>
<p><span className="font-semibold text-gray-400">Total Energy: </span>{inverter.eTotalWithUnit}</p>
<p><span className="font-semibold text-gray-400">Today Energy: </span>{inverter.eTodayWithUnit}</p>
<p><span className="font-semibold text-gray-400">Reactive Power: </span>{inverter.lastRTP["Reactive Power"].value} var</p>
<p><span className="font-semibold text-gray-400">Active Power: </span>{inverter.activePowerWithUnit}</p>
<p><span className="font-semibold text-gray-400">Inverter Mode: </span>{inverter.lastRTP["Inverter Mode"].value}</p>
<p><span className="font-semibold text-gray-400">Inner Temperature: </span>{inverter.lastRTP["Inner Temperature"].value} °C</p>
<p><span className="font-semibold text-gray-400">Create Time: </span>{inverter.createdAtStr}</p>
<p><span className="font-semibold text-gray-400">Modules: </span>{inverter.moduleFw.map((item: {module:string, value:string}) => `${item.module}: ${item.value}`.trim()).join(", ")}</p>
</div>
</div>
</Tab.Panel>
<Tab.Panel>Chart</Tab.Panel>
</Tab.Panels>
</Tab.Group>
)}
</div>
</PanelCodeHighlight>
<div className="panel pt-1 mt-3">
{isMounted && (
<Tab.Group>
<Tab.List className="flex flex-wrap border-b border-white-light dark:border-[#191e3a]">
<Tab as={Fragment}>
{({ selected }) => (
<button className={`${selected ? 'border-b !border-primary text-primary !outline-none' : ''} -mb-[1px] flex items-center border-transparent p-5 py-3 before:inline-block hover:border-b hover:!border-primary hover:text-primary`} >
INV-DC
</button>
)}
</Tab>
<Tab as={Fragment}>
{({ selected }) => (
<button className={`${selected ? 'border-b !border-primary text-primary !outline-none' : ''} -mb-[1px] flex items-center border-transparent p-5 py-3 before:inline-block hover:border-b hover:!border-primary hover:text-primary`} >
INV-AC
</button>
)}
</Tab>
<Tab as={Fragment}>
{({ selected }) => (
<button className={`${selected ? 'border-b !border-primary text-primary !outline-none' : ''} -mb-[1px] flex items-center border-transparent p-5 py-3 before:inline-block hover:border-b hover:!border-primary hover:text-primary`} >
Meter-AC
</button>
)}
</Tab>
<Tab as={Fragment}>
{({ selected }) => (
<button className={`${selected ? 'border-b !border-primary text-primary !outline-none' : ''} -mb-[1px] flex items-center border-transparent p-5 py-3 before:inline-block hover:border-b hover:!border-primary hover:text-primary`} >
Meter-Load
</button>
)}
</Tab>
</Tab.List>
<Tab.Panels>
<Tab.Panel>
<div className="active pt-5">
<table className="w-full border-collapse">
<thead>
<tr className="bg-gray-200 text-gray-600 text-left">
<th className="p-2"></th>
<th className="p-2">Voltage(V)</th>
<th className="p-2">Current(A)</th>
<th className="p-2">Power(W)</th>
</tr>
</thead>
<tbody>
<tr className="border-b text-gray-600">
<td className="p-2">PV1/PV1</td>
<td className="p-2">{inverter.lastRTP["PV1 Voltage"] && inverter.lastRTP["PV1 Voltage"].value}</td>
<td className="p-2">{inverter.lastRTP["PV1 Current"] && inverter.lastRTP["PV1 Current"].value}</td>
<td className="p-2">{inverter.lastRTP["MPPT1 Power"] && inverter.lastRTP["MPPT1 Power"].value}</td>
</tr>
<tr className="border-b text-gray-600">
<td className="p-2">PV2/PV2</td>
<td className="p-2">{inverter.lastRTP["PV2 Voltage"] && inverter.lastRTP["PV2 Voltage"].value}</td>
<td className="p-2">{inverter.lastRTP["PV2 Current"] && inverter.lastRTP["PV2 Current"].value}</td>
<td className="p-2">{inverter.lastRTP["MPPT2 Power"] && inverter.lastRTP["MPPT2 Power"].value}</td>
</tr>
<tr className="border-b text-gray-600">
<td className="p-2">PV3/PV3</td>
<td className="p-2">{inverter.lastRTP["PV3 Voltage"] && inverter.lastRTP["PV3 Voltage"].value}</td>
<td className="p-2">{inverter.lastRTP["PV3 Current"] && inverter.lastRTP["PV3 Current"].value}</td>
<td className="p-2">{inverter.lastRTP["MPPT3 Power"] && inverter.lastRTP["MPPT3 Power"].value}</td>
</tr>
<tr className="border-b text-gray-600">
<td className="p-2">PV3/PV3</td>
<td className="p-2">{inverter.lastRTP["PV4 Voltage"] && inverter.lastRTP["PV4 Voltage"].value}</td>
<td className="p-2">{inverter.lastRTP["PV4 Current"] && inverter.lastRTP["PV4 Current"].value}</td>
<td className="p-2">{inverter.lastRTP["MPPT4 Power"] && inverter.lastRTP["MPPT4 Power"].value}</td>
</tr>
</tbody>
</table>
</div>
</Tab.Panel>
<Tab.Panel>
<div className="pt-5">
<table className="w-full border-collapse">
<thead>
<tr className="bg-gray-200 text-gray-600 text-left">
<th className="p-2"></th>
<th className="p-2">Voltage(V)</th>
<th className="p-2">Current(A)</th>
<th className="p-2">Power(W)</th>
<th className="p-2">Frequency(Hz)</th>
</tr>
</thead>
<tbody>
<tr className="border-b text-gray-600">
<td className="p-2">A</td>
<td className="p-2">{inverter.lastRTP["Phase L1 Voltage"] && inverter.lastRTP["Phase L1 Voltage"].value}</td>
<td className="p-2">{inverter.lastRTP["Phase L1 Current"] && inverter.lastRTP["Phase L1 Current"].value}</td>
<td className="p-2">{inverter.lastRTP["Phase L1 Power"] && inverter.lastRTP["Phase L1 Power"].value}</td>
<td className="p-2">{inverter.lastRTP["Phase L1 Frequency"] && inverter.lastRTP["Phase L1 Frequency"].value}</td>
</tr>
<tr className="border-b text-gray-600">
<td className="p-2">B</td>
<td className="p-2">{inverter.lastRTP["Phase L2 Voltage"] && inverter.lastRTP["Phase L2 Voltage"].value}</td>
<td className="p-2">{inverter.lastRTP["Phase L2 Current"] && inverter.lastRTP["Phase L2 Current"].value}</td>
<td className="p-2">{inverter.lastRTP["Phase L2 Power"] && inverter.lastRTP["Phase L2 Power"].value}</td>
<td className="p-2">{inverter.lastRTP["Phase L2 Frequency"] && inverter.lastRTP["Phase L2 Frequency"].value}</td>
</tr>
<tr className="border-b text-gray-600">
<td className="p-2">C</td>
<td className="p-2">{inverter.lastRTP["Phase L3 Voltage"] && inverter.lastRTP["Phase L3 Voltage"].value}</td>
<td className="p-2">{inverter.lastRTP["Phase L3 Current"] && inverter.lastRTP["Phase L3 Current"].value}</td>
<td className="p-2">{inverter.lastRTP["Phase L3 Power"] && inverter.lastRTP["Phase L3 Power"].value}</td>
<td className="p-2">{inverter.lastRTP["Phase L3 Frequency"] && inverter.lastRTP["Phase L3 Frequency"].value}</td>
</tr>
</tbody>
</table>
</div>
</Tab.Panel>
<Tab.Panel>
<div className="pt-5">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 text-gray-600 mt-3">
<p><span className="font-semibold text-gray-400">Today import Energy: </span>{inverter.lastRTP["Today import Energy"] && inverter.lastRTP["Today import Energy"].value} kWh</p>
<p><span className="font-semibold text-gray-400">L1-N phase voltage of grid: </span>{inverter.lastRTP["L1-N phase voltage of grid"] && inverter.lastRTP["L1-N phase voltage of grid"].value} V</p>
<p><span className="font-semibold text-gray-400">L2-N phase voltage of grid: </span>{inverter.lastRTP["L2-N phase voltage of grid"] && inverter.lastRTP["L2-N phase voltage of grid"].value} V</p>
<p><span className="font-semibold text-gray-400">L3-N phase voltage of grid: </span>{inverter.lastRTP["L3-N phase voltage of grid"] && inverter.lastRTP["L3-N phase voltage of grid"].value} V</p>
<p><span className="font-semibold text-gray-400">Today export Energy: </span>{inverter.lastRTP["Today export Energy"] && inverter.lastRTP["Today export Energy"].value} kWh</p>
<p><span className="font-semibold text-gray-400">L1 current of grid: </span>{inverter.lastRTP["L1 current of grid"] && inverter.lastRTP["L1 current of grid"].value} A</p>
<p><span className="font-semibold text-gray-400">L2 current of grid: </span>{inverter.lastRTP["L2 current of grid"] && inverter.lastRTP["L2 current of grid"].value} A</p>
<p><span className="font-semibold text-gray-400">L3 current of grid: </span>{inverter.lastRTP["L3 current of grid"] && inverter.lastRTP["L3 current of grid"].value} A</p>
<p><span className="font-semibold text-gray-400">Accumulated energy of positive: </span>{inverter.lastRTP["Accumulated energy of positive"] && inverter.lastRTP["Accumulated energy of positive"].value} kWh</p>
<p><span className="font-semibold text-gray-400">Phase L1 watt of grid: </span>{inverter.lastRTP["Phase L1 watt of grid"] && inverter.lastRTP["Phase L1 watt of grid"].value} KW</p>
<p><span className="font-semibold text-gray-400">Phase L2 watt of grid: </span>{inverter.lastRTP["Phase L2 watt of grid"] && inverter.lastRTP["Phase L2 watt of grid"].value} KW</p>
<p><span className="font-semibold text-gray-400">Phase L3 watt of grid: </span>{inverter.lastRTP["Phase L3 watt of grid"] && inverter.lastRTP["Phase L3 watt of grid"].value} KW</p>
<p><span className="font-semibold text-gray-400">Accumulated energy of negative: </span>{inverter.lastRTP["Accumulated energy of negative"] && inverter.lastRTP["Accumulated energy of negative"].value} kWh</p>
</div>
</div>
</Tab.Panel>
<Tab.Panel>
<div className="pt-5">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 text-gray-600 mt-3">
<p><span className="font-semibold text-gray-400">Today load Energy: </span>{inverter.lastRTP["Today load Energy"] && inverter.lastRTP["Today load Energy"].value} kWh</p>
<p><span className="font-semibold text-gray-400">L1-N phase voltage of load: </span>{inverter.lastRTP["L1-N phase voltage of load"] && inverter.lastRTP["L1-N phase voltage of load"].value} V</p>
<p><span className="font-semibold text-gray-400">L2-N phase voltage of load: </span>{inverter.lastRTP["L2-N phase voltage of load"] && inverter.lastRTP["L2-N phase voltage of load"].value} V</p>
<p><span className="font-semibold text-gray-400">L3-N phase voltage of load: </span>{inverter.lastRTP["L3-N phase voltage of load"] && inverter.lastRTP["L3-N phase voltage of load"].value} V</p>
<p><span className="font-semibold text-gray-400">Accumulated energy of load: </span>{inverter.lastRTP["Accumulated energy of load"] && inverter.lastRTP["Accumulated energy of load"].value} kWh</p>
<p><span className="font-semibold text-gray-400">L1 current of load: </span>{inverter.lastRTP["L1 current of load"] && inverter.lastRTP["L1 current of load"].value} A</p>
<p><span className="font-semibold text-gray-400">L2 current of load: </span>{inverter.lastRTP["L2 current of load"] && inverter.lastRTP["L2 current of load"].value} A</p>
<p><span className="font-semibold text-gray-400">L3 current of load: </span>{inverter.lastRTP["L3 current of load"] && inverter.lastRTP["L3 current of load"].value} A</p>
</div>
</div>
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
)}
</div>
</>
)}
</>
)
}
export default InverterViewPage

View File

@ -1,174 +0,0 @@
"use client";
import IconTrashLines from '@/components/icon/icon-trash-lines';
import PanelCodeHighlight from '@/components/panel-code-highlight';
import ComponentsTablesSimple from '@/components/tables/components-tables-simple';
import { formatUnixTimestamp } from '@/utils/helpers';
import Tippy from '@tippyjs/react';
import axios from 'axios';
import React, { useEffect, useState } from 'react'
// import ReactApexChart from 'react-apexcharts';
import dynamic from 'next/dynamic';
import Link from 'next/link';
const ReactApexChart = dynamic(() => import('react-apexcharts'), { ssr: false });
type Props = {}
const SungrowInverters = (props: Props) => {
const [inverters, setInverters] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [isMounted, setIsMounted] = useState(false)
useEffect(() => {
setIsMounted(true);
const fetchData = async () => {
try {
const res = await axios.get("https://api-a.fomware.com.cn/asset/v1/list?type=2", {
headers: {
"Authorization": "Bearer " + process.env.NEXT_PUBLIC_CHINT_TOKEN
}
})
console.log("res", res.data.data.devices)
setInverters(res.data.data.devices)
} catch (error) {
console.error("Error fetching data:", error);
} finally {
setLoading(false);
}
};
fetchData()
}, [])
const chartConfigs: any = {
options: {
chart: {
height: 58,
type: 'line',
fontFamily: 'Nunito, sans-serif',
sparkline: {
enabled: true,
},
dropShadow: {
enabled: true,
blur: 3,
color: '#009688',
opacity: 0.4,
},
},
stroke: {
curve: 'smooth',
width: 2,
},
colors: ['#009688'],
grid: {
padding: {
top: 5,
bottom: 5,
left: 5,
right: 5,
},
},
tooltip: {
x: {
show: false,
},
y: {
title: {
formatter: () => {
return '';
},
},
},
},
},
};
// inverter status 0: initial, 1: standby, 2: fault, 3: running, 5: offline, 9: shutdown, 10: unknown
return (
<div>
{loading ? <p>Loading...</p> : (
<PanelCodeHighlight title="Chint Inverters">
<div className="table-responsive mb-5">
<table>
<thead>
<tr>
<th>Inverter Name</th>
<th>Site Name</th>
<th>Gateway SN</th>
<th>Inverter Status</th>
<th>Model</th>
<th>SN</th>
<th>Real Time Power</th>
<th>E-Today</th>
<th>WeekData</th>
<th>Created At</th>
<th>Updated At</th>
</tr>
</thead>
<tbody>
{inverters.map((data) => (
<tr key={data.id}>
<td>
<div className="whitespace-nowrap"><Link href={`/chint/inverters/${data.name}`}>{data.name}</Link></div>
</td>
<td>
<div className="whitespace-nowrap">{data.siteName}</div>
</td>
<td>
<div>{data.gatewaySn}</div>
</td>
<td>
<div className={`whitespace-nowrap ${
data.status === 0 ? "text-gray-500" // Initial
: data.status === 1 ? "text-blue-500" // Standby
: data.status === 2 ? "text-red-500" // Fault
: data.status === 3 ? "text-green-500" // Running
: data.status === 5 ? "text-yellow-500" // Offline
: data.status === 9 ? "text-purple-500" // Shutdown
: "text-gray-400" // Unknown (default)
}`}>
{data.statusLabel}
</div>
</td>
<td>
<div>{data.model}</div>
</td>
<td>
<div>{data.sn}</div>
</td>
<td>
<div>{data.activePowerWithUnit}</div>
</td>
<td>
<div>{data.eTodayWithUnit}</div>
</td>
<td>
{isMounted && (
<ReactApexChart
series={[{ data: data.weekTrend.map((point: any) => point.y) }]}
options={{
...chartConfigs.options,
xaxis: { categories: data.weekTrend.map((point: any) => point.x) },
}}
type="line"
height={58}
width={'100%'}
/>
)} </td>
<td>{formatUnixTimestamp(data.createdAt)}</td>
<td>{formatUnixTimestamp(data.updatedAt)}</td>
</tr>
))}
</tbody>
</table>
</div>
</PanelCodeHighlight>
)}
</div>
)
}
export default SungrowInverters

View File

@ -1,13 +0,0 @@
import axios from 'axios';
import { Metadata } from 'next';
import React from 'react';
export const metadata: Metadata = {
};
const SungrowIndex = async () => {
return <div>SungrowIndex</div>;
};
export default SungrowIndex;

View File

@ -1,39 +0,0 @@
"use client";
// app/(defaults)/sungrow/assets/page.tsx
import ComponentsTablesSimple from "@/components/tables/components-tables-simple";
import axios from "axios";
import React, { useEffect, useState } from "react";
const SungrowAssets = () => {
const [sites, setSites] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
try {
const res = await axios.get("https://api-a.fomware.com.cn/site/v1/list", {
headers: {
"Authorization": "Bearer " + process.env.NEXT_PUBLIC_CHINT_TOKEN
}
})
console.log("res", res.data.data.siteInfos)
setSites(res.data.data.siteInfos)
} catch (error) {
console.error("Error fetching data:", error);
} finally {
setLoading(false);
}
};
fetchData()
}, [])
return (
<div>
{loading ? <p>Loading...</p> : <ComponentsTablesSimple tableData={sites} />}
</div>
)
}
export default SungrowAssets;

View File

@ -1,105 +0,0 @@
"use client";
import IconTrashLines from '@/components/icon/icon-trash-lines';
import PanelCodeHighlight from '@/components/panel-code-highlight';
import ComponentsTablesSimple from '@/components/tables/components-tables-simple'
import { formatUnixTimestamp } from '@/utils/helpers';
import Tippy from '@tippyjs/react';
import axios from 'axios';
import React, { useEffect, useState } from "react"
type Props = {}
const SungrowPlant = (props: Props) => {
const [sites, setSites] = useState<any[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
const fetchSites = async () => {
try {
const res = await fetch("/api/sungrow/site")
const data = await res.json()
console.log("data", data)
setSites(data)
} catch (error) {
console.error("Error fetching inverters:", error)
} finally {
setLoading(false)
}
}
fetchSites()
}, [])
const statusLabels: Record<number, string> = {
0: "Offline",
1: "Normal",
}
const plantTypeLabel: Record<number, string> = {
3: "Commercial PV",
4: "Residential PV",
}
return (
<div>
{loading ? <p>Loading...</p> : (
<PanelCodeHighlight title="Sungrow Sites">
<div className="table-responsive mb-5">
<table>
<thead>
<tr>
<th>Site Name</th>
<th>Status</th>
<th>Plant Type</th>
{/* <th>Installed Power</th>
<th>Real-time Power</th>
<th>Yield Today</th>
<th>Monthly Yield</th>
<th>Annual Yield</th>
<th>Total Yield</th>
<th>Equivalent Hours</th>
<th>Remarks</th> */}
<th className="text-center">Action</th>
</tr>
</thead>
<tbody>
{sites.map((data) => (
<tr key={data.id}>
<td>
<div className="whitespace-nowrap">{data.ps_name}</div>
</td>
<td>
<div className={`whitespace-nowrap ${ data.online_status !== 1 ? "text-danger" : "text-success" }`} >
{statusLabels[data.online_status] || "-"}
</div>
</td>
<td>{plantTypeLabel[data.ps_type] || "-"}</td>
{/* <td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td> */}
<td className="text-center">
<Tippy content="Delete">
<button type="button">
<IconTrashLines className="m-auto" />
</button>
</Tippy>
</td>
</tr>
))}
</tbody>
</table>
</div>
</PanelCodeHighlight>
)}
</div>
)
}
export default SungrowPlant

View File

@ -1,22 +0,0 @@
import { NextResponse } from "next/server";
import axios from "axios";
export async function GET() {
try {
const res = await axios.post("https://gateway.isolarcloud.com.hk/openapi/platform/queryPowerStationList", {
"page": 1,
"size": 10,
"appkey": `${process.env.SUNGROW_APP_KEY}`
} ,{
headers: {
"Authorization": `Bearer ${process.env.SUNGROW_ACCESS_TOKEN}`,
"x-access-key": `${process.env.SUNGROW_SECRET_KEY}`
}
})
// console.log("res", res.data)
return NextResponse.json(res.data.result_data.pageList)
} catch (error) {
console.error("API fetch error:", error);
return NextResponse.json({ error: "Failed to fetch inverters" }, { status: 500 });
}
}

View File

@ -9,12 +9,12 @@ export interface TimeSeriesResponse {
generation: TimeSeriesEntry[]; generation: TimeSeriesEntry[];
} }
const API_BASE_URL = const API_URL =
process.env.FASTAPI_URL ?? "http://127.0.0.1:8000"; process.env.NEXT_PUBLIC_FASTAPI_URL ;
export const crmapi = { export const crmapi = {
getProjects: async () => { getProjects: async () => {
const res = await fetch(`${API_BASE_URL}/crm/projects`, { const res = await fetch(`${API_URL}/crm/projects`, {
}); });
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json(); return res.json();
@ -28,7 +28,7 @@ export async function fetchPowerTimeseries(
): Promise<TimeSeriesResponse> { // <-- Change here ): Promise<TimeSeriesResponse> { // <-- Change here
const params = new URLSearchParams({ site, start, end }); const params = new URLSearchParams({ site, start, end });
const res = await fetch(`http://localhost:8000/power-timeseries?${params.toString()}`); const res = await fetch(`${API_URL}/power-timeseries?${params.toString()}`);
if (!res.ok) { if (!res.ok) {
throw new Error(`Failed to fetch data: ${res.status}`); throw new Error(`Failed to fetch data: ${res.status}`);
@ -54,7 +54,7 @@ export async function fetchForecast(
kwp: kwp.toString(), kwp: kwp.toString(),
}).toString(); }).toString();
const res = await fetch(`http://localhost:8000/forecast?${query}`); const res = await fetch(`${API_URL}/forecast?${query}`);
if (!res.ok) throw new Error("Failed to fetch forecast"); if (!res.ok) throw new Error("Failed to fetch forecast");
return res.json(); return res.json();
@ -73,7 +73,7 @@ export type MonthlyKPI = {
error?: string; error?: string;
}; };
const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000"; const API = process.env.NEXT_PUBLIC_FASTAPI_URL;
export async function fetchMonthlyKpi(params: { export async function fetchMonthlyKpi(params: {
site: string; site: string;

View File

@ -12,7 +12,7 @@ const ComponentsAuthLoginForm = () => {
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const router = useRouter(); const router = useRouter();
const API = process.env.NEXT_PUBLIC_FASTAPI_URL; // e.g. http://localhost:8000 const API = process.env.NEXT_PUBLIC_FASTAPI_URL;
const submitForm = async (e: React.FormEvent<HTMLFormElement>) => { const submitForm = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();

View File

@ -18,6 +18,8 @@ import { color } from 'html2canvas/dist/types/css/types/color';
import DatePicker from 'react-datepicker'; import DatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css'; import 'react-datepicker/dist/react-datepicker.css';
import './datepicker-dark.css'; // custom dark mode styles import './datepicker-dark.css'; // custom dark mode styles
import 'chartjs-adapter-date-fns';
ChartJS.register(zoomPlugin); ChartJS.register(zoomPlugin);
@ -68,7 +70,6 @@ function powerSeriesToEnergySeries(
return out; return out;
} }
function groupTimeSeries( function groupTimeSeries(
data: TimeSeriesEntry[], data: TimeSeriesEntry[],
mode: 'day' | 'daily' | 'weekly' | 'monthly' | 'yearly', mode: 'day' | 'daily' | 'weekly' | 'monthly' | 'yearly',
@ -82,11 +83,11 @@ function groupTimeSeries(
switch (mode) { switch (mode) {
case 'day': { case 'day': {
// Snap to 5-minute buckets in local (KL) time
const local = new Date( const local = new Date(
date.toLocaleString('en-US', { timeZone: 'Asia/Kuala_Lumpur' }) date.toLocaleString('en-US', { timeZone: 'Asia/Kuala_Lumpur' })
); );
const minute = local.getMinutes() < 30 ? 0 : 30; local.setSeconds(0, 0);
local.setMinutes(minute, 0, 0);
key = local.toISOString(); key = local.toISOString();
break; break;
} }
@ -125,7 +126,17 @@ function groupTimeSeries(
}); });
} }
// ---- NEW: build a 5-minute time grid for the day view
function buildTimeGrid(start: Date, end: Date, stepMinutes = 5): string[] {
const grid: string[] = [];
const t = new Date(start);
t.setSeconds(0, 0);
while (t.getTime() <= end.getTime()) {
grid.push(new Date(t).toISOString());
t.setTime(t.getTime() + stepMinutes * 60 * 1000);
}
return grid;
}
const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => { const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => {
const chartRef = useRef<any>(null); const chartRef = useRef<any>(null);
@ -138,7 +149,6 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => {
const LIVE_REFRESH_MS = 300000; // 5min when viewing a single day const LIVE_REFRESH_MS = 300000; // 5min when viewing a single day
const SLOW_REFRESH_MS = 600000; // 10min for weekly/monthly/yearly const SLOW_REFRESH_MS = 600000; // 10min for weekly/monthly/yearly
const fetchAndSet = React.useCallback(async () => { const fetchAndSet = React.useCallback(async () => {
const now = new Date(); const now = new Date();
let start: Date; let start: Date;
@ -174,15 +184,6 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => {
const res = await fetchPowerTimeseries(siteId, isoStart, isoEnd); const res = await fetchPowerTimeseries(siteId, isoStart, isoEnd);
setConsumption(res.consumption); setConsumption(res.consumption);
setGeneration(res.generation); setGeneration(res.generation);
// Forecast only needs updating for the selected day
const forecastData = await fetchForecast(3.15, 101.7, 37, 0, 25.67);
const selectedDateStr = selectedDate.toISOString().split('T')[0];
setForecast(
forecastData
.filter(({ time }: any) => time.startsWith(selectedDateStr))
.map(({ time, forecast }: any) => ({ time, value: forecast }))
);
} catch (error) { } catch (error) {
console.error('Failed to fetch energy timeseries:', error); console.error('Failed to fetch energy timeseries:', error);
} }
@ -222,23 +223,22 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => {
}; };
}, [fetchAndSet, viewMode]); }, [fetchAndSet, viewMode]);
function useIsDarkMode() { function useIsDarkMode() {
const [isDark, setIsDark] = useState(() => const [isDark, setIsDark] = useState(() =>
typeof document !== 'undefined' typeof document !== 'undefined'
? document.body.classList.contains('dark') ? document.body.classList.contains('dark')
: false : false
); );
useEffect(() => { useEffect(() => {
const check = () => setIsDark(document.body.classList.contains('dark')); const check = () => setIsDark(document.body.classList.contains('dark'));
const observer = new MutationObserver(check); const observer = new MutationObserver(check);
observer.observe(document.body, { attributes: true, attributeFilter: ['class'] }); observer.observe(document.body, { attributes: true, attributeFilter: ['class'] });
return () => observer.disconnect(); return () => observer.disconnect();
}, []); }, []);
return isDark; return isDark;
} }
useEffect(() => { useEffect(() => {
const now = new Date(); const now = new Date();
@ -278,17 +278,17 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => {
setGeneration(res.generation); setGeneration(res.generation);
// ⬇️ ADD THIS here — fetch forecast // ⬇️ ADD THIS here — fetch forecast
const forecastData = await fetchForecast(3.15, 101.7, 37, 0, 30.67); const forecastData = await fetchForecast(3.15, 101.7, 37, 0, 30.67);
const selectedDateStr = selectedDate.toISOString().split('T')[0]; const selectedDateStr = selectedDate.toISOString().split('T')[0];
setForecast( setForecast(
forecastData forecastData
.filter(({ time }) => time.startsWith(selectedDateStr)) // ✅ filter only selected date .filter(({ time }) => time.startsWith(selectedDateStr)) // ✅ filter only selected date
.map(({ time, forecast }) => ({ .map(({ time, forecast }) => ({
time, time,
value: forecast value: forecast
})) }))
); );
} catch (error) { } catch (error) {
console.error('Failed to fetch energy timeseries:', error); console.error('Failed to fetch energy timeseries:', error);
@ -300,48 +300,58 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => {
const isEnergyView = viewMode !== 'day'; const isEnergyView = viewMode !== 'day';
// Convert to energy series for aggregated views // Convert to energy series for aggregated views
const consumptionForGrouping = isEnergyView const consumptionForGrouping = isEnergyView
? powerSeriesToEnergySeries(consumption, 30) ? powerSeriesToEnergySeries(consumption, 30)
: consumption; : consumption;
const generationForGrouping = isEnergyView const generationForGrouping = isEnergyView
? powerSeriesToEnergySeries(generation, 30) ? powerSeriesToEnergySeries(generation, 30)
: generation; : generation;
const forecastForGrouping = isEnergyView const forecastForGrouping = isEnergyView
? powerSeriesToEnergySeries(forecast, 60) // if forecast is hourly, guess 60 ? powerSeriesToEnergySeries(forecast, 60) // if forecast is hourly, guess 60
: forecast; : forecast;
// Group: sum for energy views, mean for day view // Group: sum for energy views, mean for day view
const groupedConsumption = groupTimeSeries( const groupedConsumption = groupTimeSeries(
consumptionForGrouping, consumptionForGrouping,
viewMode, viewMode,
isEnergyView ? 'sum' : 'mean' isEnergyView ? 'sum' : 'mean'
); );
const groupedGeneration = groupTimeSeries( const groupedGeneration = groupTimeSeries(
generationForGrouping, generationForGrouping,
viewMode, viewMode,
isEnergyView ? 'sum' : 'mean' isEnergyView ? 'sum' : 'mean'
); );
const groupedForecast = groupTimeSeries( const groupedForecast = groupTimeSeries(
forecastForGrouping, forecastForGrouping,
viewMode, viewMode,
isEnergyView ? 'sum' : 'mean' isEnergyView ? 'sum' : 'mean'
); );
const forecastMap = Object.fromEntries(groupedForecast.map(d => [d.time, d.value])); const forecastMap = Object.fromEntries(groupedForecast.map(d => [d.time, d.value]));
const dataTimesDay = [
...groupedConsumption.map(d => Date.parse(d.time)),
...groupedGeneration.map(d => Date.parse(d.time)),
...groupedForecast.map(d => Date.parse(d.time)),
].filter(Number.isFinite).sort((a, b) => a - b);
const allTimes = Array.from(new Set([ const dayGrid = viewMode === 'day'
...groupedConsumption.map(d => d.time), ? buildTimeGrid(startOfDay(selectedDate), endOfDay(selectedDate), 1)
...groupedGeneration.map(d => d.time), : [];
...groupedForecast.map(d => d.time),
])).sort((a, b) => new Date(a).getTime() - new Date(b).getTime());
const unionTimes = Array.from(new Set([
...groupedConsumption.map(d => d.time),
...groupedGeneration.map(d => d.time),
...groupedForecast.map(d => d.time),
])).sort((a, b) => new Date(a).getTime() - new Date(b).getTime());
const allTimes = viewMode === 'day' ? dayGrid : unionTimes;
const consumptionMap = Object.fromEntries(groupedConsumption.map(d => [d.time, d.value])); const consumptionMap = Object.fromEntries(groupedConsumption.map(d => [d.time, d.value]));
const generationMap = Object.fromEntries(groupedGeneration.map(d => [d.time, d.value])); const generationMap = Object.fromEntries(groupedGeneration.map(d => [d.time, d.value]));
@ -349,7 +359,39 @@ const groupedForecast = groupTimeSeries(
const [startIndex, setStartIndex] = useState(0); const [startIndex, setStartIndex] = useState(0);
const [endIndex, setEndIndex] = useState(allTimes.length - 1); const [endIndex, setEndIndex] = useState(allTimes.length - 1);
// after allTimes, consumptionMap, generationMap, forecastMap
const hasDataAt = (t: string) =>
t in consumptionMap || t in generationMap || t in forecastMap;
const firstAvailableIndex = allTimes.findIndex(hasDataAt);
const lastAvailableIndex = (() => {
for (let i = allTimes.length - 1; i >= 0; i--) {
if (hasDataAt(allTimes[i])) return i;
}
return -1;
})();
const selectableIndices =
firstAvailableIndex === -1 || lastAvailableIndex === -1
? []
: Array.from(
{ length: lastAvailableIndex - firstAvailableIndex + 1 },
(_, k) => firstAvailableIndex + k
);
useEffect(() => {
if (selectableIndices.length === 0) {
setStartIndex(0);
setEndIndex(Math.max(0, allTimes.length - 1));
return;
}
const minIdx = selectableIndices[0];
const maxIdx = selectableIndices[selectableIndices.length - 1];
setStartIndex(prev => Math.min(Math.max(prev, minIdx), maxIdx));
setEndIndex(prev => Math.min(Math.max(prev, minIdx), maxIdx));
}, [viewMode, allTimes.length, firstAvailableIndex, lastAvailableIndex]);
useEffect(() => { useEffect(() => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
import('hammerjs'); import('hammerjs');
@ -357,9 +399,18 @@ const groupedForecast = groupTimeSeries(
}, []); }, []);
useEffect(() => { useEffect(() => {
if (selectableIndices.length) {
const minIdx = selectableIndices[0];
const maxIdx = selectableIndices[selectableIndices.length - 1];
setStartIndex(minIdx);
setEndIndex(maxIdx);
} else {
setStartIndex(0); setStartIndex(0);
setEndIndex(allTimes.length - 1); setEndIndex(Math.max(0, allTimes.length - 1));
}, [viewMode, allTimes.length]); }
// run whenever mode changes or the timeline changes
}, [viewMode, allTimes, firstAvailableIndex, lastAvailableIndex]);
const formatLabel = (key: string) => { const formatLabel = (key: string) => {
switch (viewMode) { switch (viewMode) {
@ -380,35 +431,47 @@ const groupedForecast = groupTimeSeries(
}; };
const filteredLabels = allTimes.slice(startIndex, endIndex + 1); const filteredLabels = allTimes.slice(startIndex, endIndex + 1);
const filteredConsumption = filteredLabels.map(t => consumptionMap[t] ?? 0);
const filteredGeneration = filteredLabels.map(t => generationMap[t] ?? 0);
const filteredForecast = filteredLabels.map(t => forecastMap[t] ?? null);
const minutesOfDayForLabels =
viewMode === 'day'
? filteredLabels.map((iso) => {
const d = new Date(iso);
const kl = new Date(d.toLocaleString('en-US', { timeZone: 'Asia/Kuala_Lumpur' }));
return kl.getHours() * 60 + kl.getMinutes();
})
: [];
const allValues = [...filteredConsumption, ...filteredGeneration].filter(v => v !== null) as number[]; // ---- CHANGED: use nulls for missing buckets (not zeros)
const filteredConsumption = filteredLabels.map(t => (t in consumptionMap ? consumptionMap[t] : null));
const filteredGeneration = filteredLabels.map(t => (t in generationMap ? generationMap[t] : null));
const filteredForecast = filteredLabels.map(t => (t in forecastMap ? forecastMap[t] : null));
const allValues = [...filteredConsumption, ...filteredGeneration].filter(
(v): v is number => v !== null
);
const maxValue = allValues.length > 0 ? Math.max(...allValues) : 0; const maxValue = allValues.length > 0 ? Math.max(...allValues) : 0;
const yAxisSuggestedMax = maxValue * 1.15; const yAxisSuggestedMax = maxValue * 1.15;
const isDark = useIsDarkMode(); const isDark = useIsDarkMode();
const axisColor = isDark ? '#fff' : '#222'; const axisColor = isDark ? '#fff' : '#222';
function areaGradient(ctx: any, hex: string, alphaTop = 0.22, alphaBottom = 0.02) { function areaGradient(ctx: any, hex: string, alphaTop = 0.22, alphaBottom = 0.02) {
const { ctx: g, chartArea } = ctx.chart; const { ctx: g, chartArea } = ctx.chart;
if (!chartArea) return hex; // initial render fallback if (!chartArea) return hex; // initial render fallback
const gradient = g.createLinearGradient(0, chartArea.top, 0, chartArea.bottom); const gradient = g.createLinearGradient(0, chartArea.top, 0, chartArea.bottom);
// top more opaque → bottom fades out // top more opaque → bottom fades out
gradient.addColorStop(0, hex + Math.floor(alphaTop * 255).toString(16).padStart(2, '0')); gradient.addColorStop(0, hex + Math.floor(alphaTop * 255).toString(16).padStart(2, '0'));
gradient.addColorStop(1, hex + Math.floor(alphaBottom * 255).toString(16).padStart(2, '0')); gradient.addColorStop(1, hex + Math.floor(alphaBottom * 255).toString(16).padStart(2, '0'));
return gradient; return gradient;
} }
// Define colors for both light and dark modes // Define colors for both light and dark modes
const consumptionColor = isDark ? '#B80F0A' : '#EF4444'; // Example: Brighter red for dark mode const consumptionColor = isDark ? '#B80F0A' : '#EF4444'; // Example: Brighter red for dark mode
const generationColor = isDark ? '#48A860' : '#22C55E'; // Example: Brighter green for dark mode const generationColor = isDark ? '#48A860' : '#22C55E'; // Example: Brighter green for dark mode
const forecastColor = '#fcd913'; // A golden yellow that works well in both modes const forecastColor = '#fcd913'; // A golden yellow that works well in both modes
const yUnit = isEnergyView ? 'kWh' : 'kW'; const yUnit = isEnergyView ? 'kWh' : 'kW';
const yTitle = isEnergyView ? 'Energy (kWh)' : 'Power (kW)'; const yTitle = isEnergyView ? 'Energy (kWh)' : 'Power (kW)';
const data = { const data = {
labels: filteredLabels.map(formatLabel), labels: filteredLabels.map(formatLabel),
@ -418,42 +481,57 @@ const yTitle = isEnergyView ? 'Energy (kWh)' : 'Power (kW)';
data: filteredConsumption, data: filteredConsumption,
borderColor: consumptionColor, borderColor: consumptionColor,
backgroundColor: (ctx: any) => areaGradient(ctx, consumptionColor), backgroundColor: (ctx: any) => areaGradient(ctx, consumptionColor),
fill: true, // <-- fill under line fill: true,
tension: 0.4, tension: 0.2,
spanGaps: true, spanGaps: true,
pointRadius: 0.7, // default is 3, make smaller
pointHoverRadius: 4, // a bit bigger on hover
borderWidth: 2,
}, },
{ {
label: 'Generation', label: 'Generation',
data: filteredGeneration, data: filteredGeneration,
borderColor: generationColor, borderColor: generationColor,
backgroundColor: (ctx: any) => areaGradient(ctx, generationColor), backgroundColor: (ctx: any) => areaGradient(ctx, generationColor),
fill: true, // <-- fill under line fill: true,
tension: 0.4, tension: 0.2,
spanGaps: true, spanGaps: true,
pointRadius: 0.7, // default is 3, make smaller
pointHoverRadius: 4, // a bit bigger on hover
borderWidth: 2,
}, },
{ {
label: 'Forecasted Solar', label: 'Forecasted Solar',
data: filteredForecast, data: filteredForecast,
borderColor: '#fcd913', // orange borderColor: '#fcd913',
backgroundColor: (ctx: any) => areaGradient(ctx, '#fcd913', 0.18, 0.03), backgroundColor: (ctx: any) => areaGradient(ctx, '#fcd913', 0.18, 0.03),
tension: 0.4, tension: 0.4,
borderDash: [5, 5], // dashed line to distinguish forecast borderDash: [5, 5],
fill: true, fill: true,
spanGaps: true, spanGaps: true,
} pointRadius: 1, // default is 3, make smaller
pointHoverRadius: 4, // a bit bigger on hover
borderWidth: 2,
}
], ],
}; };
const options = { const options = {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
normalized: true, // faster lookup
plugins: { plugins: {
legend: { decimation: {
position: 'top', enabled: true,
labels: { algorithm: 'lttb', // best visual fidelity
color: axisColor, // legend text color samples: 400, // cap points actually drawn (~400 is a good default)
},
}, },
legend: {
position: 'top',
labels: {
color: axisColor, // legend text color
},
},
zoom: { zoom: {
zoom: { zoom: {
wheel: { enabled: true }, wheel: { enabled: true },
@ -463,25 +541,26 @@ const yTitle = isEnergyView ? 'Energy (kWh)' : 'Power (kW)';
pan: { enabled: true, mode: 'x' as const }, pan: { enabled: true, mode: 'x' as const },
}, },
tooltip: { tooltip: {
enabled: true, enabled: true,
mode: 'index', mode: 'index',
intersect: false, intersect: false,
backgroundColor: isDark ? '#232b3e' : '#fff', backgroundColor: isDark ? '#232b3e' : '#fff',
titleColor: axisColor, titleColor: axisColor,
bodyColor: axisColor, bodyColor: axisColor,
borderColor: isDark ? '#444' : '#ccc', borderColor: isDark ? '#444' : '#ccc',
borderWidth: 1, borderWidth: 1,
callbacks: { callbacks: {
label: (ctx: any) => { label: (ctx: any) => {
const dsLabel = ctx.dataset.label || ''; const dsLabel = ctx.dataset.label || '';
const val = ctx.parsed.y; const val = ctx.parsed.y;
return `${dsLabel}: ${val?.toFixed(2)} ${yUnit}`; return `${dsLabel}: ${val?.toFixed(2)} ${yUnit}`;
},
},
}, },
}, },
},
},
scales: { scales: {
x: { x: {
type: 'category' as const,
title: { title: {
display: true, display: true,
color: axisColor, color: axisColor,
@ -498,18 +577,44 @@ const yTitle = isEnergyView ? 'Energy (kWh)' : 'Power (kW)';
font: { weight: 'normal' as const }, font: { weight: 'normal' as const },
}, },
ticks: { ticks: {
color: axisColor, color: axisColor,
autoSkip: false, // let our callback decide
maxRotation: 0,
callback(
this: any,
tickValue: string | number,
index: number,
ticks: any[]
) {
if (viewMode !== 'day') return this.getLabelForValue(tickValue as number);
const scale = this.chart.scales.x;
const min = Math.max(0, Math.floor(scale.min ?? 0));
const max = Math.min(ticks.length - 1, Math.ceil(scale.max ?? ticks.length - 1));
const visibleCount = Math.max(1, max - min + 1);
let step = 30; // ≥ 6h
if (visibleCount < 80) step = 10;// 26h
// On a category scale, tickValue is usually the index (number).
const idx = typeof tickValue === 'number' ? tickValue : index;
const m = minutesOfDayForLabels[idx];
if (m != null && m % step === 0) {
return this.getLabelForValue(idx);
}
return ''; // hide crowded labels
}, },
},
}, },
y: { y: {
beginAtZero: true, beginAtZero: true,
suggestedMax: yAxisSuggestedMax, suggestedMax: yAxisSuggestedMax,
title: { display: true, text: yTitle, color: axisColor, font: { weight: 'normal' as const } }, title: { display: true, text: yTitle, color: axisColor, font: { weight: 'normal' as const } },
ticks: { ticks: {
color: axisColor, color: axisColor,
},
}, },
},
}, },
} as const; } as const;
@ -519,7 +624,7 @@ const yTitle = isEnergyView ? 'Energy (kWh)' : 'Power (kW)';
return ( return (
<div className="bg-white p-4 rounded-lg shadow-md dark:bg-rtgray-800 dark:text-white-light"> <div className="bg-white p-4 rounded-lg shadow-md dark:bg-rtgray-800 dark:text-white-light">
<div className="h-98 w-full"> <div className="h-98 w-full" onDoubleClick={handleResetZoom}>
<div className="flex justify-between items-center mb-2"> <div className="flex justify-between items-center mb-2">
<h2 className="text-lg font-bold dark:text-white-light">Energy Consumption & Generation</h2> <h2 className="text-lg font-bold dark:text-white-light">Energy Consumption & Generation</h2>
<button onClick={handleResetZoom} className="btn-primary px-8 py-2 text-sm"> <button onClick={handleResetZoom} className="btn-primary px-8 py-2 text-sm">
@ -548,13 +653,15 @@ const yTitle = isEnergyView ? 'Energy (kWh)' : 'Power (kW)';
const val = Number(e.target.value); const val = Number(e.target.value);
setStartIndex(val <= endIndex ? val : endIndex); setStartIndex(val <= endIndex ? val : endIndex);
}} }}
disabled={selectableIndices.length === 0}
className="dark:bg-rtgray-700 border dark:border-rtgray-700 rounded p-1" className="dark:bg-rtgray-700 border dark:border-rtgray-700 rounded p-1"
> >
{allTimes.map((label, idx) => ( {selectableIndices.map((absIdx) => (
<option key={idx} value={idx}>{formatLabel(label)}</option> <option key={absIdx} value={absIdx}>{formatLabel(allTimes[absIdx])}</option>
))} ))}
</select> </select>
</label> </label>
<label className="font-medium "> <label className="font-medium ">
To:{' '} To:{' '}
<select <select
@ -563,13 +670,15 @@ const yTitle = isEnergyView ? 'Energy (kWh)' : 'Power (kW)';
const val = Number(e.target.value); const val = Number(e.target.value);
setEndIndex(val >= startIndex ? val : startIndex); setEndIndex(val >= startIndex ? val : startIndex);
}} }}
disabled={selectableIndices.length === 0}
className="dark:bg-rtgray-700 border dark:border-rtgray-700 rounded p-1" className="dark:bg-rtgray-700 border dark:border-rtgray-700 rounded p-1"
> >
{allTimes.map((label, idx) => ( {selectableIndices.map((absIdx) => (
<option key={idx} value={idx}>{formatLabel(label)}</option> <option key={absIdx} value={absIdx}>{formatLabel(allTimes[absIdx])}</option>
))} ))}
</select> </select>
</label> </label>
<label className="font-medium"> <label className="font-medium">
View:{' '} View:{' '}
<select <select
@ -594,11 +703,4 @@ const yTitle = isEnergyView ? 'Energy (kWh)' : 'Power (kW)';
); );
}; };
export default EnergyLineChart; export default EnergyLineChart;

View File

@ -21,6 +21,8 @@ const KPI_Table: React.FC<KPI_TableProps> = ({ siteId, month }) => {
const [kpiData, setKpiData] = useState<MonthlyKPI | null>(null); const [kpiData, setKpiData] = useState<MonthlyKPI | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const API_URL = process.env.NEXT_PUBLIC_FASTAPI_URL;
useEffect(() => { useEffect(() => {
if (!siteId || !month) return; if (!siteId || !month) return;
@ -28,7 +30,7 @@ const KPI_Table: React.FC<KPI_TableProps> = ({ siteId, month }) => {
setLoading(true); setLoading(true);
try { try {
const res = await fetch( const res = await fetch(
`http://localhost:8000/kpi/monthly?site=${siteId}&month=${month}` `${API_URL}/kpi/monthly?site=${siteId}&month=${month}`
); );
setKpiData(await res.json()); setKpiData(await res.json());
} catch (err) { } catch (err) {

View File

@ -11,7 +11,7 @@ interface LoggingControlCardProps {
className?: string; className?: string;
} }
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000'; const API_URL = process.env.NEXT_PUBLIC_FASTAPI_URL;
type FnState = { type FnState = {
serial: string; serial: string;

View File

@ -26,7 +26,7 @@ interface SiteCardProps {
fallbackStatus?: string; // optional backup status if CRM is missing it fallbackStatus?: string; // optional backup status if CRM is missing it
} }
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000'; const API = process.env.NEXT_PUBLIC_FASTAPI_URL;
const SiteCard: React.FC<SiteCardProps> = ({ siteId, className = '', fallbackStatus }) => { const SiteCard: React.FC<SiteCardProps> = ({ siteId, className = '', fallbackStatus }) => {
const [project, setProject] = useState<CrmProject | null>(null); const [project, setProject] = useState<CrmProject | null>(null);

View File

@ -15,8 +15,7 @@ interface SiteStatusProps {
lastSyncTimestamp: string; lastSyncTimestamp: string;
} }
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000"; const API_URL = process.env.NEXT_PUBLIC_FASTAPI_URL;
const WS_URL = process.env.NEXT_PUBLIC_WS_URL ?? "ws://localhost:8000/ws";
const SiteStatus = ({ const SiteStatus = ({
selectedSite, selectedSite,
@ -30,7 +29,7 @@ const SiteStatus = ({
// --- WebSocket to receive MQTT-forwarded messages --- // --- WebSocket to receive MQTT-forwarded messages ---
useEffect(() => { useEffect(() => {
const ws = new WebSocket(WS_URL); const ws = new WebSocket(`${API_URL}/ws`);
ws.onopen = () => console.log("WebSocket connected"); ws.onopen = () => console.log("WebSocket connected");
ws.onclose = () => console.log("WebSocket disconnected"); ws.onclose = () => console.log("WebSocket disconnected");

View File

@ -26,7 +26,7 @@ export default function Header() {
const [user, setUser] = useState<UserData | null>(null); const [user, setUser] = useState<UserData | null>(null);
const [loadingUser, setLoadingUser] = useState(true); const [loadingUser, setLoadingUser] = useState(true);
const API = process.env.NEXT_PUBLIC_FASTAPI_URL || 'http://127.0.0.1:8000'; const API = process.env.NEXT_PUBLIC_FASTAPI_URL;
// highlight active menu // highlight active menu
useEffect(() => { useEffect(() => {

View File

@ -6,6 +6,14 @@ const nextConfig = {
eslint: { eslint: {
ignoreDuringBuilds: true, ignoreDuringBuilds: true,
}, },
async rewrites() {
return [
{
source: "/api/:path*",
destination: "http://backend:8000/:path*", // internal Docker hostname
},
]
},
}; };
module.exports = nextConfig; module.exports = nextConfig;

17
package-lock.json generated
View File

@ -22,6 +22,7 @@
"axios": "^1.7.9", "axios": "^1.7.9",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"chart.js": "^4.4.9", "chart.js": "^4.4.9",
"chartjs-adapter-date-fns": "^3.0.0",
"chartjs-plugin-zoom": "^2.2.0", "chartjs-plugin-zoom": "^2.2.0",
"cookie": "^1.0.2", "cookie": "^1.0.2",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
@ -4794,6 +4795,16 @@
"pnpm": ">=8" "pnpm": ">=8"
} }
}, },
"node_modules/chartjs-adapter-date-fns": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-3.0.0.tgz",
"integrity": "sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg==",
"license": "MIT",
"peerDependencies": {
"chart.js": ">=2.8.0",
"date-fns": ">=2.0.0"
}
},
"node_modules/chartjs-plugin-zoom": { "node_modules/chartjs-plugin-zoom": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/chartjs-plugin-zoom/-/chartjs-plugin-zoom-2.2.0.tgz", "resolved": "https://registry.npmjs.org/chartjs-plugin-zoom/-/chartjs-plugin-zoom-2.2.0.tgz",
@ -13074,6 +13085,12 @@
"@kurkle/color": "^0.3.0" "@kurkle/color": "^0.3.0"
} }
}, },
"chartjs-adapter-date-fns": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-3.0.0.tgz",
"integrity": "sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg==",
"requires": {}
},
"chartjs-plugin-zoom": { "chartjs-plugin-zoom": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/chartjs-plugin-zoom/-/chartjs-plugin-zoom-2.2.0.tgz", "resolved": "https://registry.npmjs.org/chartjs-plugin-zoom/-/chartjs-plugin-zoom-2.2.0.tgz",

View File

@ -23,6 +23,7 @@
"axios": "^1.7.9", "axios": "^1.7.9",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"chart.js": "^4.4.9", "chart.js": "^4.4.9",
"chartjs-adapter-date-fns": "^3.0.0",
"chartjs-plugin-zoom": "^2.2.0", "chartjs-plugin-zoom": "^2.2.0",
"cookie": "^1.0.2", "cookie": "^1.0.2",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",

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