diff --git a/app/(admin)/adminDashboard/page.tsx b/app/(admin)/adminDashboard/page.tsx index 2f6fb88..bc97fce 100644 --- a/app/(admin)/adminDashboard/page.tsx +++ b/app/(admin)/adminDashboard/page.tsx @@ -13,7 +13,7 @@ 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'; const EnergyLineChart = dynamic(() => import('@/components/dashboards/EnergyLineChart'), { ssr: false }); const MonthlyBarChart = dynamic(() => import('@/components/dashboards/MonthlyBarChart'), { ssr: false }); @@ -42,15 +42,31 @@ type CrmProject = { const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000'; +// 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(); - // --- NEW: load CRM projects dynamically --- + // --- load CRM projects dynamically --- const [sites, setSites] = useState([]); const [sitesLoading, setSitesLoading] = useState(true); const [sitesError, setSitesError] = useState(null); + // near other refs + const loggingRef = useRef(null); + useEffect(() => { setSitesLoading(true); @@ -82,43 +98,78 @@ const AdminDashboard = () => { // Current selected CRM project const selectedProject: CrmProject | null = useMemo( - () => sites.find(s => s.name === selectedSiteId) ?? null, - [sites, selectedSiteId] + () => sites.find(s => s.name === selectedSiteId) ?? null, + [sites, selectedSiteId] ); - // --- FIX: declare currentMonth BEFORE it’s used --- + // declare currentMonth BEFORE it’s used const currentMonth = useMemo(() => new Date().toISOString().slice(0, 7), []); - // --- Time-series state (unchanged) --- + // --- Time-series state --- const [timeSeriesData, setTimeSeriesData] = useState<{ consumption: { time: string; value: number }[]; generation: { time: string; value: number }[]; }>({ consumption: [], generation: [] }); - // Fetch today’s timeseries for selected siteId (from CRM) + // data-availability flags + const [hasAnyData, setHasAnyData] = useState(false); // historical window + const [hasTodayData, setHasTodayData] = useState(false); + const [isLogging, setIsLogging] = useState(false); + const [startError, setStartError] = useState(null); + + // Fetch today’s timeseries for selected siteId useEffect(() => { if (!selectedSiteId) return; - const fetchData = async () => { - const today = new Date(); - const yyyyMMdd = today.toISOString().split('T')[0]; - const start = `${yyyyMMdd}T00:00:00+08:00`; - const end = `${yyyyMMdd}T23:59:59+08:00`; + 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); } }; - fetchData(); + fetchToday(); }, [selectedSiteId]); - // --- KPI monthly (uses your FastAPI) --- + // 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(null); useEffect(() => { @@ -135,35 +186,40 @@ const AdminDashboard = () => { const powerFactor = kpi?.avg_power_factor ?? 0; const loadFactor = (kpi?.load_factor ?? 0); - // Update URL when site is changed manually (now expects a siteId/Project.name) + // 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; // pretty, multi-line version -}, [selectedProject?.custom_address]); + 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] -); + const lastSyncFormatted = useMemo( + () => formatCrmTimestamp(selectedProject?.modified, { includeSeconds: true }), + [selectedProject?.modified] + ); // Adapt CRM project -> SiteStatus props -const currentSiteDetails = { - location: locationFormatted, // <- formatted! - inverterProvider: selectedProject?.project_type || 'N/A', - emergencyContact: - selectedProject?.custom_mobile_phone_no || - selectedProject?.custom_email || - selectedProject?.customer || - 'N/A', - lastSyncTimestamp: lastSyncFormatted || 'N/A', -}; + 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(null); const monthlyChartRef = useRef(null); @@ -201,6 +257,49 @@ const currentSiteDetails = { 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); + } + }; + + // ---------- RENDER ---------- if (sitesLoading) { return ( @@ -225,79 +324,111 @@ const currentSiteDetails = { // Build selector options from CRM const siteOptions = sites.map(s => ({ - label: s.project_name || s.name, // nice display - value: s.name, // siteId used everywhere + label: s.project_name || s.name, + value: s.name, })); return ( -
+

Admin Dashboard

-
-
- {/* UPDATE SiteSelector to accept these props */} + {/* Selector + status */} +
+
- {/* UPDATE SiteStatus to accept siteId & dynamic fields */} + +
- {/* TOP 3 CARDS */} -
- + No data yet. + Enter the meter number and click Start to begin streaming. + + + {startError &&
{startError}
} +
+ )} + +
+
-
- -
- - {/* BOTTOM 3 PANELS */} - - -
- } - right={ -
-
- {(kpi?.peak_demand_kw ?? 0).toFixed(2)} kW + {/* Render the rest only if there is *any* data */} + {hasAnyData && ( + <> + {/* Tiny banner if today is empty but historical exists */} + {!hasTodayData && ( +
+ No data yet today — charts may be blank until new points arrive.
-
- } - /> + )} + + + {/* TOP 3 CARDS */} +
+ +
+ +
+ +
+ + {/* BOTTOM 3 PANELS */} + + +
+ } + right={ +
+
+ {(kpi?.peak_demand_kw ?? 0).toFixed(2)} kW +
+
+ } + /> + +
+ +
+ + )} + -
- -
); }; export default AdminDashboard; - - - diff --git a/components/dashboards/LoggingControl.tsx b/components/dashboards/LoggingControl.tsx new file mode 100644 index 0000000..1303678 --- /dev/null +++ b/components/dashboards/LoggingControl.tsx @@ -0,0 +1,223 @@ +'use client'; + +import React, { useEffect, useMemo, useState } from 'react'; +import axios from 'axios'; + +type FnType = 'grid' | 'solar'; + +interface LoggingControlCardProps { + siteId: string; + projectLabel?: string; // nice display (e.g., CRM project_name) + className?: string; +} + +const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000'; + +type FnState = { + serial: string; + isLogging: boolean; + isBusy: boolean; // to block double clicks while calling API + error?: string | null; +}; + +const emptyFnState: FnState = { serial: '', isLogging: false, isBusy: false, error: null }; + +const storageKey = (siteId: string) => `logging_control_${siteId}`; + +export default function LoggingControlCard({ + siteId, + projectLabel, + className = '', +}: LoggingControlCardProps) { + const [grid, setGrid] = useState(emptyFnState); + const [solar, setSolar] = useState(emptyFnState); + + // Load persisted state (if any) + useEffect(() => { + try { + const raw = localStorage.getItem(storageKey(siteId)); + if (raw) { + const parsed = JSON.parse(raw); + setGrid({ ...emptyFnState, ...(parsed.grid ?? {}) }); + setSolar({ ...emptyFnState, ...(parsed.solar ?? {}) }); + } else { + setGrid(emptyFnState); + setSolar(emptyFnState); + } + } catch { + setGrid(emptyFnState); + setSolar(emptyFnState); + } + }, [siteId]); + + // Persist on any change + useEffect(() => { + const data = { grid, solar }; + try { + localStorage.setItem(storageKey(siteId), JSON.stringify(data)); + } catch { + // ignore storage errors + } + }, [siteId, grid, solar]); + + const title = useMemo( + () => `Logging Control${projectLabel ? ` — ${projectLabel}` : ''}`, + [projectLabel] + ); + + const topicsFor = (fn: FnType, serial: string) => { + return [`ADW300/${siteId}/${serial}/${fn}`]; + }; + + const start = async (fn: FnType) => { + const state = fn === 'grid' ? grid : solar; + const setState = fn === 'grid' ? setGrid : setSolar; + + if (!state.serial.trim()) { + setState((s) => ({ ...s, error: 'Please enter a meter serial number.' })); + return; + } + + setState((s) => ({ ...s, isBusy: true, error: null })); + + try { + const topics = topicsFor(fn, state.serial.trim()); + const res = await axios.post(`${API_URL}/start-logging`, { topics }); + console.log('Start logging:', res.data); + setState((s) => ({ ...s, isLogging: true, isBusy: false })); + } catch (e: any) { + console.error('Failed to start logging', e); + setState((s) => ({ + ...s, + isBusy: false, + error: e?.response?.data?.detail || e?.message || 'Failed to start logging', + })); + } + }; + + const stop = async (fn: FnType) => { + const state = fn === 'grid' ? grid : solar; + const setState = fn === 'grid' ? setGrid : setSolar; + + if (!state.isLogging) return; + + const confirmed = window.confirm( + `Stop logging for ${fn.toUpperCase()} meter "${state.serial}" at site ${siteId}?` + ); + if (!confirmed) return; + + setState((s) => ({ ...s, isBusy: true, error: null })); + + try { + const topics = topicsFor(fn, state.serial.trim()); + const res = await axios.post(`${API_URL}/stop-logging`, { topics }); + console.log('Stop logging:', res.data); + setState((s) => ({ ...s, isLogging: false, isBusy: false })); + } catch (e: any) { + console.error('Failed to stop logging', e); + setState((s) => ({ + ...s, + isBusy: false, + error: e?.response?.data?.detail || e?.message || 'Failed to stop logging', + })); + } + }; + + // Responsive utility classes + const field = + 'w-full px-3 py-2 sm:py-2.5 border rounded-md text-sm sm:text-base placeholder:text-gray-400 dark:border-rtgray-700 dark:bg-rtgray-700 dark:text-white'; + + const label = + 'text-gray-600 dark:text-white/85 font-medium text-sm sm:text-base mb-1 flex items-center justify-between mr-2.5'; + + const section = ( + fn: FnType, + labelText: string, + state: FnState, + setState: React.Dispatch> + ) => ( +
+
+ {labelText} + {state.isLogging && ( + + Logging + + )} +
+ + {/* Input + Button: stack on mobile, row on ≥sm */} +
+ setState((s) => ({ ...s, serial: e.target.value }))} + disabled={state.isLogging || state.isBusy} + aria-label={`${labelText} serial number`} + /> + + {!state.isLogging ? ( + + ) : ( + + )} +
+ + {!!state.error &&
{state.error}
} +
+ ); + + return ( +
+

+ {title} +

+ + {section('grid', 'Grid Meter', grid, setGrid)} +
+ {section('solar', 'Solar Meter', solar, setSolar)} + +
+ • Inputs lock while logging is active. Stop to edit the serial. +
+ • Topics follow{' '} + + ADW300/{'{'}siteId{'}'}/{'{'}serial{'}'}/(grid|solar) + + . +
+
+ ); +} + diff --git a/components/dashboards/SiteStatus.tsx b/components/dashboards/SiteStatus.tsx index 50aa9df..5c1ecf3 100644 --- a/components/dashboards/SiteStatus.tsx +++ b/components/dashboards/SiteStatus.tsx @@ -144,67 +144,6 @@ const SiteStatus = ({

Last Sync:

{lastSyncTimestamp}

- - {/* Start/Stop */} -
- {devicesAtSite.length > 0 ? ( - - ) : ( - - )} -
- - {/* Modal */} - {showModal && ( -
-
-

Enter Device Info

- - setDeviceId(e.target.value)} - /> - - - -
- - -
-
-
- )}
); }; diff --git a/app/icon.png b/public/icon.png similarity index 100% rename from app/icon.png rename to public/icon.png