Syasya 00fe939804
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m50s
new excel export
2025-08-29 20:22:30 +08:00

707 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useState, useEffect, useMemo, useRef } from 'react';
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import SiteSelector from '@/components/dashboards/SiteSelector';
import SiteStatus from '@/components/dashboards/SiteStatus';
import DashboardLayout from './dashlayout';
import html2canvas from 'html2canvas';
import jsPDF from 'jspdf';
import dynamic from 'next/dynamic';
import { fetchPowerTimeseries } from '@/app/utils/api';
import KpiTop from '@/components/dashboards/kpitop';
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 });
type MonthlyKPI = {
site: string; month: string;
yield_kwh: number | null; consumption_kwh: number | null; grid_draw_kwh: number | null;
efficiency: number | null; peak_demand_kw: number | null;
avg_power_factor: number | null; load_factor: number | null;
error?: string;
};
type CrmProject = {
name: string; // e.g. PROJ-0008 <-- use as siteId
project_name: string;
status?: string;
percent_complete?: number | null;
owner?: string | null;
modified?: string | null;
customer?: string | null;
project_type?: string | null;
custom_address?: string | null;
custom_email?: string | null;
custom_mobile_phone_no?: string | null;
};
const API = process.env.NEXT_PUBLIC_FASTAPI_URL;
// Adjust this to your FastAPI route
const START_LOGGING_ENDPOINT = (siteId: string) =>
`${API}/logging/start?site=${encodeURIComponent(siteId)}`;
// helper to build ISO strings with +08:00
const withTZ = (d: Date) => {
const yyyyMMdd = d.toISOString().split('T')[0];
return {
start: `${yyyyMMdd}T00:00:00+08:00`,
end: `${yyyyMMdd}T23:59:59+08:00`,
};
};
const AdminDashboard = () => {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [authChecked, setAuthChecked] = useState(false);
// --- load CRM projects dynamically ---
const [sites, setSites] = useState<CrmProject[]>([]);
const [sitesLoading, setSitesLoading] = useState(true);
const [sitesError, setSitesError] = useState<unknown>(null);
// near other refs
const loggingRef = useRef<HTMLDivElement | null>(null);
const API = process.env.NEXT_PUBLIC_FASTAPI_URL || 'http://127.0.0.1:8000';
useEffect(() => {
let cancelled = false;
const checkAuth = async () => {
try {
const res = await fetch(`${API}/auth/me`, {
credentials: 'include',
cache: 'no-store',
});
if (!res.ok) {
router.replace('/login');
return;
}
const user = await res.json().catch(() => null);
if (!user?.id) {
router.replace('/login');
return;
}
// authenticated
} catch {
router.replace('/login');
return;
} finally {
if (!cancelled) setAuthChecked(true);
}
};
checkAuth();
return () => { cancelled = true; };
}, [router, API]);
useEffect(() => {
setSitesLoading(true);
fetch(`${API}/crm/projects?limit=0`)
.then(r => r.json())
.then(json => setSites(json?.data ?? []))
.catch(setSitesError)
.finally(() => setSitesLoading(false));
}, []);
// The canonical siteId is the CRM Project "name" (e.g., PROJ-0008)
const siteParam = searchParams?.get('site') || null;
const [selectedSiteId, setSelectedSiteId] = useState<string | null>(siteParam);
// Keep query param <-> state in sync
useEffect(() => {
if ((siteParam || null) !== selectedSiteId) {
setSelectedSiteId(siteParam);
}
}, [siteParam]); // eslint-disable-line
// Default to the first site when loaded
useEffect(() => {
if (!selectedSiteId && sites.length) {
setSelectedSiteId(sites[0].name);
router.replace(`${pathname}?site=${encodeURIComponent(sites[0].name)}`);
}
}, [sites, selectedSiteId, pathname, router]);
// Current selected CRM project
const selectedProject: CrmProject | null = useMemo(
() => sites.find(s => s.name === selectedSiteId) ?? null,
[sites, selectedSiteId]
);
// declare currentMonth BEFORE its used
const currentMonth = useMemo(() => new Date().toISOString().slice(0, 7), []);
// --- Time-series state ---
const [timeSeriesData, setTimeSeriesData] = useState<{
consumption: { time: string; value: number }[];
generation: { time: string; value: number }[];
}>({ consumption: [], generation: [] });
// data-availability flags
const [hasAnyData, setHasAnyData] = useState(false); // historical window
const [hasTodayData, setHasTodayData] = useState(false);
const [isLogging, setIsLogging] = useState(false);
const [startError, setStartError] = useState<string | null>(null);
// Fetch todays timeseries for selected siteId
useEffect(() => {
if (!selectedSiteId) return;
const fetchToday = async () => {
const { start, end } = withTZ(new Date());
try {
const raw = await fetchPowerTimeseries(selectedSiteId, start, end);
const consumption = raw.consumption.map((d: any) => ({ time: d.time, value: d.value }));
const generation = raw.generation.map((d: any) => ({ time: d.time, value: d.value }));
setTimeSeriesData({ consumption, generation });
const anyToday = (consumption?.length ?? 0) > 0 || (generation?.length ?? 0) > 0;
setHasTodayData(anyToday);
} catch (error) {
console.error('Failed to fetch power time series:', error);
setHasTodayData(false);
}
};
fetchToday();
}, [selectedSiteId]);
// Check historical data (last 30 days) → controls empty state
useEffect(() => {
if (!selectedSiteId) return;
const fetchHistorical = async () => {
try {
const endDate = new Date();
const startDate = new Date();
startDate.setDate(endDate.getDate() - 30);
const startISO = `${startDate.toISOString().split('T')[0]}T00:00:00+08:00`;
const endISO = `${endDate.toISOString().split('T')[0]}T23:59:59+08:00`;
const raw = await fetchPowerTimeseries(selectedSiteId, startISO, endISO);
const anyHistorical =
(raw?.consumption?.length ?? 0) > 0 ||
(raw?.generation?.length ?? 0) > 0;
setHasAnyData(anyHistorical);
} catch (e) {
console.error('Failed to check historical data:', e);
setHasAnyData(false);
}
};
fetchHistorical();
}, [selectedSiteId]);
// --- KPI monthly ---
const [kpi, setKpi] = useState<MonthlyKPI | null>(null);
useEffect(() => {
if (!selectedSiteId) return;
const url = `${API}/kpi/monthly?site=${encodeURIComponent(selectedSiteId)}&month=${currentMonth}`;
fetch(url).then(r => r.json()).then(setKpi).catch(console.error);
}, [selectedSiteId, currentMonth]);
// derived values with safe fallbacks
const yieldKwh = kpi?.yield_kwh ?? 0;
const consumptionKwh = kpi?.consumption_kwh ?? 0;
const gridDrawKwh = kpi?.grid_draw_kwh ?? Math.max(0, consumptionKwh - yieldKwh);
const efficiencyPct = (kpi?.efficiency ?? 0) * 100;
const powerFactor = kpi?.avg_power_factor ?? 0;
const loadFactor = (kpi?.load_factor ?? 0);
// Update URL when site is changed manually (expects a siteId/Project.name)
const handleSiteChange = (newSiteId: string) => {
setSelectedSiteId(newSiteId);
const newUrl = `${pathname}?site=${encodeURIComponent(newSiteId)}`;
router.push(newUrl);
// reset flags when switching
setHasAnyData(false);
setHasTodayData(false);
setIsLogging(false);
setStartError(null);
};
const locationFormatted = useMemo(() => {
const raw = selectedProject?.custom_address ?? '';
if (!raw) return 'N/A';
return formatAddress(raw).multiLine;
}, [selectedProject?.custom_address]);
const lastSyncFormatted = useMemo(
() => formatCrmTimestamp(selectedProject?.modified, { includeSeconds: true }),
[selectedProject?.modified]
);
// Adapt CRM project -> SiteStatus props
const currentSiteDetails = {
location: locationFormatted,
inverterProvider: selectedProject?.project_type || 'N/A',
emergencyContact:
selectedProject?.custom_mobile_phone_no ||
selectedProject?.custom_email ||
selectedProject?.customer ||
'N/A',
lastSyncTimestamp: lastSyncFormatted || 'N/A',
};
const energyChartRef = useRef<HTMLDivElement | null>(null);
const monthlyChartRef = useRef<HTMLDivElement | null>(null);
const handlePDFExport = async () => {
const doc = new jsPDF('p', 'mm', 'a4');
const chartRefs = [
{ ref: energyChartRef, title: 'Energy Line Chart' },
{ ref: monthlyChartRef, title: 'Monthly Energy Yield' }
];
let yOffset = 10;
for (const chart of chartRefs) {
if (!chart.ref.current) continue;
const canvas = await html2canvas(chart.ref.current, { scale: 2 });
const imgData = canvas.toDataURL('image/png');
const imgProps = doc.getImageProperties(imgData);
const pdfWidth = doc.internal.pageSize.getWidth() - 20;
const imgHeight = (imgProps.height * pdfWidth) / imgProps.width;
doc.setFontSize(14);
doc.text(chart.title, 10, yOffset);
yOffset += 6;
if (yOffset + imgHeight > doc.internal.pageSize.getHeight()) {
doc.addPage();
yOffset = 10;
}
doc.addImage(imgData, 'PNG', 10, yOffset, pdfWidth, imgHeight);
yOffset += imgHeight + 10;
}
doc.save('dashboard_charts.pdf');
};
// Start logging then poll for data until it shows up
const startLogging = async () => {
if (!selectedSiteId) return;
setIsLogging(true);
setStartError(null);
try {
const resp = await fetch(START_LOGGING_ENDPOINT(selectedSiteId), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
if (!resp.ok) {
const text = await resp.text();
throw new Error(text || `Failed with status ${resp.status}`);
}
// Poll for data for up to ~45s (15 tries x 3s)
for (let i = 0; i < 15; i++) {
const today = new Date();
const { start, end } = withTZ(today);
try {
const raw = await fetchPowerTimeseries(selectedSiteId, start, end);
const consumption = raw.consumption ?? [];
const generation = raw.generation ?? [];
if ((consumption.length ?? 0) > 0 || (generation.length ?? 0) > 0) {
setHasAnyData(true); // site now has data
setHasTodayData(true); // and today has data too
break;
}
} catch {
// ignore and keep polling
}
await new Promise(r => setTimeout(r, 3000));
}
} catch (e: any) {
setStartError(e?.message ?? 'Failed to start logging');
setIsLogging(false);
}
};
// 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 ----------
if (!authChecked) {
return <div>Checking authentication</div>;
}
if (sitesLoading) {
return (
<DashboardLayout>
<div className="px-6">Loading sites</div>
</DashboardLayout>
);
}
if (sitesError) {
return (
<DashboardLayout>
<div className="px-6 text-red-600">Failed to load sites from CRM.</div>
</DashboardLayout>
);
}
if (!selectedProject) {
return (
<DashboardLayout>
<div className="px-6">No site selected.</div>
</DashboardLayout>
);
}
// Build selector options from CRM
const siteOptions = sites.map(s => ({
label: s.project_name || s.name,
value: s.name,
}));
return (
<DashboardLayout>
<div className="px-3 space-y-6 w-full max-w-screen-3xl mx-auto">
<h1 className="text-lg font-semibold dark:text-white">Admin Dashboard</h1>
{/* Selector + status */}
<div className="grid grid-cols-1 gap-6 w-full min-w-0">
<div className="space-y-4 w-full min-w-0">
<SiteSelector
options={siteOptions}
selectedValue={selectedSiteId!}
onChange={handleSiteChange}
/>
<SiteStatus
selectedSite={selectedProject.project_name || selectedProject.name}
siteId={selectedProject.name}
location={currentSiteDetails.location}
inverterProvider={currentSiteDetails.inverterProvider}
emergencyContact={currentSiteDetails.emergencyContact}
lastSyncTimestamp={currentSiteDetails.lastSyncTimestamp}
/>
</div>
</div>
{/* Small dark yellow banner when there is ZERO historical data */}
{!hasAnyData && (
<div className="rounded-lg border border-amber-400/40 bg-rtyellow-300/20 px-4 py-3 text-amber-600 dark:text-amber-100 flex flex-wrap items-center gap-3">
<span className="font-semibold text-black/85 dark:text-white/85">No data yet.</span>
<span className="opacity-95">Enter the meter number and click <span className="font-semibold text-black/85 dark:text-white/85">Start</span> to begin streaming.
</span>
{startError && <div className="basis-full text-sm text-red-300">{startError}</div>}
</div>
)}
<div ref={loggingRef}>
<LoggingControlCard
siteId={selectedProject.name}
projectLabel={selectedProject.project_name || selectedProject.name}
className="w-full"
/>
</div>
{/* Render the rest only if there is *any* data */}
{hasAnyData && (
<>
{/* Tiny banner if today is empty but historical exists */}
{!hasTodayData && (
<div className="rounded-lg border border-amber-300/50 bg-amber-50 dark:bg-amber-900/20 px-4 py-2 text-amber-800 dark:text-amber-200">
No data yet today charts may be blank until new points arrive.
</div>
)}
{/* TOP 3 CARDS */}
<div className="space-y-4">
<KpiTop
yieldKwh={yieldKwh}
consumptionKwh={consumptionKwh}
gridDrawKwh={gridDrawKwh}
/>
</div>
<div ref={energyChartRef} className="pb-5">
<EnergyLineChart siteId={selectedProject.name} />
</div>
{/* BOTTOM 3 PANELS */}
<KpiBottom
efficiencyPct={efficiencyPct}
powerFactor={powerFactor}
loadFactor={loadFactor}
middle={
<div ref={monthlyChartRef} className="transform scale-90 origin-top">
<MonthlyBarChart siteId={selectedProject.name} />
</div>
}
right={
<div className="flex items-center justify-center w-full px-3 text-center">
<div className="text-3xl font-semibold">
{(kpi?.peak_demand_kw ?? 0).toFixed(2)} kW
</div>
</div>
}
/>
<div className="flex flex-col md:flex-row gap-4 justify-center">
<button onClick={handlePDFExport} className="text-sm lg:text-lg btn-primary">
Export Chart Images to PDF
</button>
<button
onClick={() => setIsDownloadOpen(true)}
className="text-sm lg:text-lg btn-primary"
>
Download Excel Log
</button>
</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>
</DashboardLayout>
);
};
export default AdminDashboard;