new layout

This commit is contained in:
Syasya 2025-08-15 09:19:49 +08:00
parent 401a89dd7a
commit 0467034acb
4 changed files with 430 additions and 137 deletions

View File

@ -13,7 +13,7 @@ import KpiTop from '@/components/dashboards/kpitop';
import KpiBottom from '@/components/dashboards/kpibottom'; 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';
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 });
@ -42,15 +42,31 @@ type CrmProject = {
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000'; 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 AdminDashboard = () => {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
// --- NEW: load CRM projects dynamically --- // --- load CRM projects dynamically ---
const [sites, setSites] = useState<CrmProject[]>([]); const [sites, setSites] = useState<CrmProject[]>([]);
const [sitesLoading, setSitesLoading] = useState(true); const [sitesLoading, setSitesLoading] = useState(true);
const [sitesError, setSitesError] = useState<unknown>(null); const [sitesError, setSitesError] = useState<unknown>(null);
// near other refs
const loggingRef = useRef<HTMLDivElement | null>(null);
useEffect(() => { useEffect(() => {
setSitesLoading(true); setSitesLoading(true);
@ -82,43 +98,78 @@ const AdminDashboard = () => {
// Current selected CRM project // Current selected CRM project
const selectedProject: CrmProject | null = useMemo( const selectedProject: CrmProject | null = useMemo(
() => sites.find(s => s.name === selectedSiteId) ?? null, () => sites.find(s => s.name === selectedSiteId) ?? null,
[sites, selectedSiteId] [sites, selectedSiteId]
); );
// --- FIX: declare currentMonth BEFORE its used --- // declare currentMonth BEFORE its used
const currentMonth = useMemo(() => new Date().toISOString().slice(0, 7), []); const currentMonth = useMemo(() => new Date().toISOString().slice(0, 7), []);
// --- Time-series state (unchanged) --- // --- Time-series state ---
const [timeSeriesData, setTimeSeriesData] = useState<{ const [timeSeriesData, setTimeSeriesData] = useState<{
consumption: { time: string; value: number }[]; consumption: { time: string; value: number }[];
generation: { time: string; value: number }[]; generation: { time: string; value: number }[];
}>({ consumption: [], generation: [] }); }>({ consumption: [], generation: [] });
// Fetch todays 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 todays timeseries for selected siteId
useEffect(() => { useEffect(() => {
if (!selectedSiteId) return; if (!selectedSiteId) return;
const fetchData = async () => { const fetchToday = async () => {
const today = new Date(); const { start, end } = withTZ(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`;
try { try {
const raw = await fetchPowerTimeseries(selectedSiteId, start, end); const raw = await fetchPowerTimeseries(selectedSiteId, start, end);
const consumption = raw.consumption.map((d: any) => ({ time: d.time, value: d.value })); 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 })); const generation = raw.generation.map((d: any) => ({ time: d.time, value: d.value }));
setTimeSeriesData({ consumption, generation }); setTimeSeriesData({ consumption, generation });
const anyToday = (consumption?.length ?? 0) > 0 || (generation?.length ?? 0) > 0;
setHasTodayData(anyToday);
} catch (error) { } catch (error) {
console.error('Failed to fetch power time series:', error); console.error('Failed to fetch power time series:', error);
setHasTodayData(false);
} }
}; };
fetchData(); fetchToday();
}, [selectedSiteId]); }, [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); const [kpi, setKpi] = useState<MonthlyKPI | null>(null);
useEffect(() => { useEffect(() => {
@ -135,35 +186,40 @@ const AdminDashboard = () => {
const powerFactor = kpi?.avg_power_factor ?? 0; const powerFactor = kpi?.avg_power_factor ?? 0;
const loadFactor = (kpi?.load_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) => { const handleSiteChange = (newSiteId: string) => {
setSelectedSiteId(newSiteId); setSelectedSiteId(newSiteId);
const newUrl = `${pathname}?site=${encodeURIComponent(newSiteId)}`; const newUrl = `${pathname}?site=${encodeURIComponent(newSiteId)}`;
router.push(newUrl); router.push(newUrl);
// reset flags when switching
setHasAnyData(false);
setHasTodayData(false);
setIsLogging(false);
setStartError(null);
}; };
const locationFormatted = useMemo(() => { const locationFormatted = useMemo(() => {
const raw = selectedProject?.custom_address ?? ''; const raw = selectedProject?.custom_address ?? '';
if (!raw) return 'N/A'; if (!raw) return 'N/A';
return formatAddress(raw).multiLine; // pretty, multi-line version return formatAddress(raw).multiLine;
}, [selectedProject?.custom_address]); }, [selectedProject?.custom_address]);
const lastSyncFormatted = useMemo( const lastSyncFormatted = useMemo(
() => formatCrmTimestamp(selectedProject?.modified, { includeSeconds: true }), () => formatCrmTimestamp(selectedProject?.modified, { includeSeconds: true }),
[selectedProject?.modified] [selectedProject?.modified]
); );
// Adapt CRM project -> SiteStatus props // Adapt CRM project -> SiteStatus props
const currentSiteDetails = { const currentSiteDetails = {
location: locationFormatted, // <- formatted! location: locationFormatted,
inverterProvider: selectedProject?.project_type || 'N/A', inverterProvider: selectedProject?.project_type || 'N/A',
emergencyContact: emergencyContact:
selectedProject?.custom_mobile_phone_no || selectedProject?.custom_mobile_phone_no ||
selectedProject?.custom_email || selectedProject?.custom_email ||
selectedProject?.customer || selectedProject?.customer ||
'N/A', 'N/A',
lastSyncTimestamp: lastSyncFormatted || 'N/A', lastSyncTimestamp: lastSyncFormatted || 'N/A',
}; };
const energyChartRef = useRef<HTMLDivElement | null>(null); const energyChartRef = useRef<HTMLDivElement | null>(null);
const monthlyChartRef = useRef<HTMLDivElement | null>(null); const monthlyChartRef = useRef<HTMLDivElement | null>(null);
@ -201,6 +257,49 @@ const currentSiteDetails = {
doc.save('dashboard_charts.pdf'); 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) { if (sitesLoading) {
return ( return (
<DashboardLayout> <DashboardLayout>
@ -225,79 +324,111 @@ const currentSiteDetails = {
// Build selector options from CRM // Build selector options from CRM
const siteOptions = sites.map(s => ({ const siteOptions = sites.map(s => ({
label: s.project_name || s.name, // nice display label: s.project_name || s.name,
value: s.name, // siteId used everywhere value: s.name,
})); }));
return ( return (
<DashboardLayout> <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> <h1 className="text-lg font-semibold dark:text-white">Admin Dashboard</h1>
<div className="grid gap-6"> {/* Selector + status */}
<div className="space-y-4"> <div className="grid grid-cols-1 gap-6 w-full min-w-0">
{/* UPDATE SiteSelector to accept these props */} <div className="space-y-4 w-full min-w-0">
<SiteSelector <SiteSelector
options={siteOptions} options={siteOptions}
selectedValue={selectedSiteId!} selectedValue={selectedSiteId!}
onChange={handleSiteChange} onChange={handleSiteChange}
/> />
{/* UPDATE SiteStatus to accept siteId & dynamic fields */}
<SiteStatus <SiteStatus
selectedSite={selectedProject.project_name || selectedProject.name} selectedSite={selectedProject.project_name || selectedProject.name}
siteId={selectedProject.name} // <-- use for MQTT topics inside SiteStatus siteId={selectedProject.name}
location={currentSiteDetails.location} location={currentSiteDetails.location}
inverterProvider={currentSiteDetails.inverterProvider} inverterProvider={currentSiteDetails.inverterProvider}
emergencyContact={currentSiteDetails.emergencyContact} emergencyContact={currentSiteDetails.emergencyContact}
lastSyncTimestamp={currentSiteDetails.lastSyncTimestamp} lastSyncTimestamp={currentSiteDetails.lastSyncTimestamp}
/> />
</div> </div>
</div> </div>
{/* TOP 3 CARDS */} {/* Small dark yellow banner when there is ZERO historical data */}
<div className="space-y-4"> {!hasAnyData && (
<KpiTop <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">
yieldKwh={yieldKwh} <span className="font-semibold text-black/85 dark:text-white/85">No data yet.</span>
consumptionKwh={consumptionKwh} <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.
gridDrawKwh={gridDrawKwh} </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> </div>
<div ref={energyChartRef} className="pb-5"> {/* Render the rest only if there is *any* data */}
<EnergyLineChart siteId={selectedProject.name} /> {hasAnyData && (
</div> <>
{/* Tiny banner if today is empty but historical exists */}
{/* BOTTOM 3 PANELS */} {!hasTodayData && (
<KpiBottom <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">
efficiencyPct={efficiencyPct} No data yet today charts may be blank until new points arrive.
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> )}
}
/>
{/* 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>
</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>
</div>
</div> </div>
</DashboardLayout> </DashboardLayout>
); );
}; };
export default AdminDashboard; export default AdminDashboard;

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

View File

@ -144,67 +144,6 @@ const SiteStatus = ({
<p className="text-gray-600 dark:text-white/85 font-medium">Last Sync:</p> <p className="text-gray-600 dark:text-white/85 font-medium">Last Sync:</p>
<p className="font-medium">{lastSyncTimestamp}</p> <p className="font-medium">{lastSyncTimestamp}</p>
</div> </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> </div>
); );
}; };

View File

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 84 KiB