All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m50s
707 lines
23 KiB
TypeScript
707 lines
23 KiB
TypeScript
'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 it’s 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 today’s 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/<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>
|
||
</DashboardLayout>
|
||
);
|
||
};
|
||
|
||
export default AdminDashboard;
|