feature/syasya/testlayout #7
@ -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<CrmProject[]>([]);
 | 
			
		||||
  const [sitesLoading, setSitesLoading] = useState(true);
 | 
			
		||||
  const [sitesError, setSitesError] = useState<unknown>(null);
 | 
			
		||||
  // near other refs
 | 
			
		||||
  const loggingRef = useRef<HTMLDivElement | null>(null);
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    setSitesLoading(true);
 | 
			
		||||
@ -86,39 +102,74 @@ const AdminDashboard = () => {
 | 
			
		||||
    [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<string | null>(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<MonthlyKPI | null>(null);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
@ -135,17 +186,22 @@ 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
 | 
			
		||||
    return formatAddress(raw).multiLine;
 | 
			
		||||
  }, [selectedProject?.custom_address]);
 | 
			
		||||
 | 
			
		||||
  const lastSyncFormatted = useMemo(
 | 
			
		||||
@ -155,7 +211,7 @@ const lastSyncFormatted = useMemo(
 | 
			
		||||
 | 
			
		||||
  // Adapt CRM project -> SiteStatus props
 | 
			
		||||
  const currentSiteDetails = {
 | 
			
		||||
  location: locationFormatted,                    // <- formatted!
 | 
			
		||||
    location: locationFormatted,
 | 
			
		||||
    inverterProvider: selectedProject?.project_type || 'N/A',
 | 
			
		||||
    emergencyContact:
 | 
			
		||||
      selectedProject?.custom_mobile_phone_no ||
 | 
			
		||||
@ -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 (
 | 
			
		||||
      <DashboardLayout>
 | 
			
		||||
@ -225,36 +324,67 @@ 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 (
 | 
			
		||||
    <DashboardLayout>
 | 
			
		||||
      <div className="px-6 space-y-6">
 | 
			
		||||
      <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>
 | 
			
		||||
 | 
			
		||||
        <div className="grid gap-6">
 | 
			
		||||
          <div className="space-y-4">
 | 
			
		||||
            {/* UPDATE SiteSelector to accept these props */}
 | 
			
		||||
        {/* 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}
 | 
			
		||||
            />
 | 
			
		||||
 | 
			
		||||
            {/* UPDATE SiteStatus to accept siteId & dynamic fields */}
 | 
			
		||||
            <SiteStatus
 | 
			
		||||
              selectedSite={selectedProject.project_name || selectedProject.name}
 | 
			
		||||
              siteId={selectedProject.name}  // <-- use for MQTT topics inside SiteStatus
 | 
			
		||||
              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
 | 
			
		||||
@ -292,12 +422,13 @@ const currentSiteDetails = {
 | 
			
		||||
                Export Chart Images to PDF
 | 
			
		||||
              </button>
 | 
			
		||||
            </div>
 | 
			
		||||
          </>
 | 
			
		||||
        )}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
      </div>
 | 
			
		||||
    </DashboardLayout>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default AdminDashboard;
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										223
									
								
								components/dashboards/LoggingControl.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										223
									
								
								components/dashboards/LoggingControl.tsx
									
									
									
									
									
										Normal file
									
								
							@ -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<FnState>(emptyFnState);
 | 
			
		||||
  const [solar, setSolar] = useState<FnState>(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<React.SetStateAction<FnState>>
 | 
			
		||||
  ) => (
 | 
			
		||||
    <div className="space-y-2">
 | 
			
		||||
      <div className={label}>
 | 
			
		||||
        <span>{labelText}</span>
 | 
			
		||||
        {state.isLogging && (
 | 
			
		||||
          <span
 | 
			
		||||
            className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] sm:text-xs font-semibold
 | 
			
		||||
                       bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-300"
 | 
			
		||||
            aria-live="polite"
 | 
			
		||||
          >
 | 
			
		||||
            Logging
 | 
			
		||||
          </span>
 | 
			
		||||
        )}
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      {/* Input + Button: stack on mobile, row on ≥sm */}
 | 
			
		||||
      <div className="flex flex-col sm:flex-row gap-2 sm:gap-3">
 | 
			
		||||
        <input
 | 
			
		||||
          type="text"
 | 
			
		||||
          autoComplete="off"
 | 
			
		||||
          inputMode="text"
 | 
			
		||||
          placeholder="Meter serial number"
 | 
			
		||||
          className={`${field} flex-1`}
 | 
			
		||||
          value={state.serial}
 | 
			
		||||
          onChange={(e) => setState((s) => ({ ...s, serial: e.target.value }))}
 | 
			
		||||
          disabled={state.isLogging || state.isBusy}
 | 
			
		||||
          aria-label={`${labelText} serial number`}
 | 
			
		||||
        />
 | 
			
		||||
 | 
			
		||||
        {!state.isLogging ? (
 | 
			
		||||
          <button
 | 
			
		||||
            onClick={() => start(fn)}
 | 
			
		||||
            disabled={state.isBusy || !state.serial.trim()}
 | 
			
		||||
            className={`h-10 sm:h-11 rounded-full font-medium transition
 | 
			
		||||
              w-full sm:w-auto sm:min-w-28 mt-2 lg:mt-0
 | 
			
		||||
              ${state.isBusy || !state.serial.trim()
 | 
			
		||||
                ? 'bg-gray-400 cursor-not-allowed text-black/70'
 | 
			
		||||
                : 'bg-rtyellow-200 hover:bg-rtyellow-300 text-black'}`}
 | 
			
		||||
            aria-disabled={state.isBusy || !state.serial.trim()}
 | 
			
		||||
          >
 | 
			
		||||
            {state.isBusy ? 'Starting…' : 'Start'}
 | 
			
		||||
          </button>
 | 
			
		||||
        ) : (
 | 
			
		||||
          <button
 | 
			
		||||
            onClick={() => stop(fn)}
 | 
			
		||||
            disabled={state.isBusy}
 | 
			
		||||
            className={`h-10 sm:h-11 rounded-full font-medium transition
 | 
			
		||||
              w-full sm:w-auto sm:min-w-28 mt-2 lg:mt-0
 | 
			
		||||
              ${state.isBusy ? 'bg-gray-400 cursor-not-allowed' : 'bg-red-500 hover:bg-red-600'} text-white`}
 | 
			
		||||
            aria-disabled={state.isBusy}
 | 
			
		||||
          >
 | 
			
		||||
            {state.isBusy ? 'Stopping…' : 'Stop'}
 | 
			
		||||
          </button>
 | 
			
		||||
        )}
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      {!!state.error && <div className="text-sm sm:text-[15px] text-red-600">{state.error}</div>}
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      className={`bg-white p-4 sm:p-5 md:p-6 rounded-xl md:rounded-2xl shadow-md space-y-4 md:space-y-5
 | 
			
		||||
                  dark:bg-rtgray-800 dark:text-white-light w-full  ${className}`}
 | 
			
		||||
    >
 | 
			
		||||
      <h2 className="text-lg sm:text-xl md:text-2xl font-semibold truncate" title={title}>
 | 
			
		||||
        {title}
 | 
			
		||||
      </h2>
 | 
			
		||||
 | 
			
		||||
      {section('grid', 'Grid Meter', grid, setGrid)}
 | 
			
		||||
      <div className="border-t dark:border-rtgray-700" />
 | 
			
		||||
      {section('solar', 'Solar Meter', solar, setSolar)}
 | 
			
		||||
 | 
			
		||||
      <div className="text-[11px] sm:text-xs text-gray-500 dark:text-gray-400 pt-2 leading-relaxed break-words">
 | 
			
		||||
        • Inputs lock while logging is active. Stop to edit the serial.
 | 
			
		||||
        <br />
 | 
			
		||||
        • Topics follow{' '}
 | 
			
		||||
        <code className="break-all">
 | 
			
		||||
          ADW300/{'{'}siteId{'}'}/{'{'}serial{'}'}/(grid|solar)
 | 
			
		||||
        </code>
 | 
			
		||||
        .
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -144,67 +144,6 @@ const SiteStatus = ({
 | 
			
		||||
        <p className="text-gray-600 dark:text-white/85 font-medium">Last Sync:</p>
 | 
			
		||||
        <p className="font-medium">{lastSyncTimestamp}</p>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      {/* Start/Stop */}
 | 
			
		||||
      <div className="flex justify-between items-center text-base space-x-2">
 | 
			
		||||
        {devicesAtSite.length > 0 ? (
 | 
			
		||||
          <button
 | 
			
		||||
            onClick={handleStopLogging}
 | 
			
		||||
            className="text-sm lg:text-md bg-red-500 hover:bg-red-600 text-white font-medium px-3 py-2 rounded"
 | 
			
		||||
          >
 | 
			
		||||
            Stop Logging
 | 
			
		||||
          </button>
 | 
			
		||||
        ) : (
 | 
			
		||||
          <button
 | 
			
		||||
            onClick={handleStartLogging}
 | 
			
		||||
            className="text-sm lg:text-md btn-primary px-3 py-2"
 | 
			
		||||
          >
 | 
			
		||||
            Start Logging
 | 
			
		||||
          </button>
 | 
			
		||||
        )}
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      {/* Modal */}
 | 
			
		||||
      {showModal && (
 | 
			
		||||
        <div className="fixed inset-0 z-50 bg-black bg-opacity-50 flex items-center justify-center">
 | 
			
		||||
          <div className="bg-white dark:bg-rtgray-800 rounded-lg p-6 w-[90%] max-w-md shadow-lg">
 | 
			
		||||
            <h2 className="text-lg font-semibold mb-4">Enter Device Info</h2>
 | 
			
		||||
 | 
			
		||||
            <input
 | 
			
		||||
              type="text"
 | 
			
		||||
              placeholder="Device ID (e.g. device_01)"
 | 
			
		||||
              className="w-full p-2 mb-4 border rounded dark:border-rtgray-800 dark:bg-rtgray-700 dark:text-white"
 | 
			
		||||
              value={deviceId}
 | 
			
		||||
              onChange={(e) => setDeviceId(e.target.value)}
 | 
			
		||||
            />
 | 
			
		||||
 | 
			
		||||
            <select
 | 
			
		||||
              className="w-full p-2 mb-4 border rounded dark:border-rtgray-800 dark:bg-rtgray-700 dark:text-white"
 | 
			
		||||
              value={functionType}
 | 
			
		||||
              onChange={(e) => setFunctionType(e.target.value as "Grid" | "Solar")}
 | 
			
		||||
            >
 | 
			
		||||
              <option value="Grid">Grid</option>
 | 
			
		||||
              <option value="Solar">Solar</option>
 | 
			
		||||
            </select>
 | 
			
		||||
 | 
			
		||||
            <div className="flex justify-end space-x-2">
 | 
			
		||||
              <button
 | 
			
		||||
                onClick={() => setShowModal(false)}
 | 
			
		||||
                className="btn-primary bg-white border-2 border-black hover:bg-rtgray-200 px-4 py-2"
 | 
			
		||||
              >
 | 
			
		||||
                Cancel
 | 
			
		||||
              </button>
 | 
			
		||||
              <button
 | 
			
		||||
                onClick={handleConfirm}
 | 
			
		||||
                className="btn-primary px-4 py-2"
 | 
			
		||||
                disabled={!deviceId.trim()}
 | 
			
		||||
              >
 | 
			
		||||
                Confirm
 | 
			
		||||
              </button>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      )}
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 84 KiB  | 
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user