Compare commits
No commits in common. "44bb94ded851f7eed2dbf2d6bd63e3e1e8bd9b05" and "401a89dd7a4de937219a66fff1b99849d4cf6707" have entirely different histories.
44bb94ded8
...
401a89dd7a
@ -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,31 +42,15 @@ 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();
|
||||||
|
|
||||||
// --- load CRM projects dynamically ---
|
// --- NEW: 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);
|
||||||
@ -102,74 +86,39 @@ const AdminDashboard = () => {
|
|||||||
[sites, selectedSiteId]
|
[sites, selectedSiteId]
|
||||||
);
|
);
|
||||||
|
|
||||||
// declare currentMonth BEFORE it’s used
|
// --- FIX: declare currentMonth BEFORE it’s used ---
|
||||||
const currentMonth = useMemo(() => new Date().toISOString().slice(0, 7), []);
|
const currentMonth = useMemo(() => new Date().toISOString().slice(0, 7), []);
|
||||||
|
|
||||||
// --- Time-series state ---
|
// --- Time-series state (unchanged) ---
|
||||||
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: [] });
|
||||||
|
|
||||||
// data-availability flags
|
// Fetch today’s timeseries for selected siteId (from CRM)
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
if (!selectedSiteId) return;
|
if (!selectedSiteId) return;
|
||||||
|
|
||||||
const fetchToday = async () => {
|
const fetchData = async () => {
|
||||||
const { start, end } = withTZ(new Date());
|
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`;
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchToday();
|
fetchData();
|
||||||
}, [selectedSiteId]);
|
}, [selectedSiteId]);
|
||||||
|
|
||||||
// Check historical data (last 30 days) → controls empty state
|
// --- KPI monthly (uses your FastAPI) ---
|
||||||
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(() => {
|
||||||
@ -186,22 +135,17 @@ 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 (expects a siteId/Project.name)
|
// Update URL when site is changed manually (now 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;
|
return formatAddress(raw).multiLine; // pretty, multi-line version
|
||||||
}, [selectedProject?.custom_address]);
|
}, [selectedProject?.custom_address]);
|
||||||
|
|
||||||
const lastSyncFormatted = useMemo(
|
const lastSyncFormatted = useMemo(
|
||||||
@ -211,7 +155,7 @@ const AdminDashboard = () => {
|
|||||||
|
|
||||||
// Adapt CRM project -> SiteStatus props
|
// Adapt CRM project -> SiteStatus props
|
||||||
const currentSiteDetails = {
|
const currentSiteDetails = {
|
||||||
location: locationFormatted,
|
location: locationFormatted, // <- formatted!
|
||||||
inverterProvider: selectedProject?.project_type || 'N/A',
|
inverterProvider: selectedProject?.project_type || 'N/A',
|
||||||
emergencyContact:
|
emergencyContact:
|
||||||
selectedProject?.custom_mobile_phone_no ||
|
selectedProject?.custom_mobile_phone_no ||
|
||||||
@ -257,49 +201,6 @@ const AdminDashboard = () => {
|
|||||||
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>
|
||||||
@ -324,67 +225,36 @@ const AdminDashboard = () => {
|
|||||||
|
|
||||||
// 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,
|
label: s.project_name || s.name, // nice display
|
||||||
value: s.name,
|
value: s.name, // siteId used everywhere
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<div className="px-3 space-y-6 w-full max-w-screen-3xl mx-auto">
|
<div className="px-6 space-y-6">
|
||||||
<h1 className="text-lg font-semibold dark:text-white">Admin Dashboard</h1>
|
<h1 className="text-lg font-semibold dark:text-white">Admin Dashboard</h1>
|
||||||
|
|
||||||
{/* Selector + status */}
|
<div className="grid gap-6">
|
||||||
<div className="grid grid-cols-1 gap-6 w-full min-w-0">
|
<div className="space-y-4">
|
||||||
<div className="space-y-4 w-full min-w-0">
|
{/* UPDATE SiteSelector to accept these props */}
|
||||||
<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}
|
siteId={selectedProject.name} // <-- use for MQTT topics inside SiteStatus
|
||||||
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>
|
||||||
|
|
||||||
{/* 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 */}
|
{/* TOP 3 CARDS */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<KpiTop
|
<KpiTop
|
||||||
@ -422,13 +292,12 @@ const AdminDashboard = () => {
|
|||||||
Export Chart Images to PDF
|
Export Chart Images to PDF
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AdminDashboard;
|
export default AdminDashboard;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,187 +1,46 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React from 'react';
|
||||||
import DashboardLayout from '../adminDashboard/dashlayout';
|
import DashboardLayout from '../adminDashboard/dashlayout';
|
||||||
import SiteCard from '@/components/dashboards/SiteCard';
|
import SiteCard from '@/components/dashboards/SiteCard'; // Import the new SiteCard component
|
||||||
|
import { mockSiteData, SiteName } from '@/types/SiteData'; // Import your mock data and SiteName type
|
||||||
type CrmProject = {
|
|
||||||
name: string; // e.g. PROJ-0008 (siteId)
|
|
||||||
project_name: string;
|
|
||||||
status?: 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_API_URL ?? 'http://localhost:8000';
|
|
||||||
|
|
||||||
const SitesPage = () => {
|
const SitesPage = () => {
|
||||||
const [projects, setProjects] = useState<CrmProject[]>([]);
|
// Helper function to determine status (can be externalized if used elsewhere)
|
||||||
const [loading, setLoading] = useState(true);
|
const getSiteStatus = (siteName: SiteName): string => {
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const statusMap: Record<SiteName, string> = {
|
||||||
const [q, setQ] = useState(''); // search filter
|
'Site A': 'Active',
|
||||||
|
'Site B': 'Inactive',
|
||||||
// pagination
|
'Site C': 'Faulty',
|
||||||
const [page, setPage] = useState(1);
|
};
|
||||||
const [pageSize, setPageSize] = useState(6); // tweak as you like
|
return statusMap[siteName];
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
const run = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setErr(null);
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API}/crm/projects?limit=0`);
|
|
||||||
if (!res.ok) throw new Error(await res.text());
|
|
||||||
const json = await res.json();
|
|
||||||
const data: CrmProject[] = json?.data ?? [];
|
|
||||||
if (!cancelled) setProjects(data);
|
|
||||||
} catch (e: any) {
|
|
||||||
if (!cancelled) setErr(e?.message ?? 'Failed to load CRM projects');
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
run();
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Reset to first page whenever search or pageSize changes
|
|
||||||
useEffect(() => {
|
|
||||||
setPage(1);
|
|
||||||
}, [q, pageSize]);
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
if (!q.trim()) return projects;
|
|
||||||
const needle = q.toLowerCase();
|
|
||||||
return projects.filter(p =>
|
|
||||||
(p.project_name || '').toLowerCase().includes(needle) ||
|
|
||||||
(p.name || '').toLowerCase().includes(needle) ||
|
|
||||||
(p.customer || '').toLowerCase().includes(needle)
|
|
||||||
);
|
|
||||||
}, [projects, q]);
|
|
||||||
|
|
||||||
const total = filtered.length;
|
|
||||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
|
||||||
const safePage = Math.min(page, totalPages);
|
|
||||||
const startIdx = (safePage - 1) * pageSize;
|
|
||||||
const endIdx = Math.min(startIdx + pageSize, total);
|
|
||||||
const pageItems = filtered.slice(startIdx, endIdx);
|
|
||||||
|
|
||||||
const goPrev = () => setPage(p => Math.max(1, p - 1));
|
|
||||||
const goNext = () => setPage(p => Math.min(totalPages, p + 1));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
|
<h1 className="text-2xl font-bold mb-6 dark:text-white-light">All Sites Overview</h1>
|
||||||
<h1 className="text-2xl font-bold dark:text-white-light">All Sites Overview</h1>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<input
|
|
||||||
value={q}
|
|
||||||
onChange={e => setQ(e.target.value)}
|
|
||||||
placeholder="Search by name / ID / customer"
|
|
||||||
className="w-64 max-w-full px-3 py-2 rounded-md border dark:border-gray-700 bg-white dark:bg-gray-900 dark:text-white"
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
value={pageSize}
|
|
||||||
onChange={e => setPageSize(Number(e.target.value))}
|
|
||||||
className="px-3 py-2 rounded-md border dark:border-gray-700 bg-white dark:bg-gray-900 dark:text-white"
|
|
||||||
aria-label="Items per page"
|
|
||||||
>
|
|
||||||
<option value={6}>6 / page</option>
|
|
||||||
<option value={9}>9 / page</option>
|
|
||||||
<option value={12}>12 / page</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading && (
|
|
||||||
<div className="text-gray-600 dark:text-gray-400">Loading CRM projects…</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{err && (
|
|
||||||
<div className="text-red-600">Error: {err}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && !err && total === 0 && (
|
|
||||||
<div className="text-amber-600">No sites found.</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && !err && total > 0 && (
|
|
||||||
<>
|
|
||||||
{/* Pagination header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
Showing <span className="font-semibold">{startIdx + 1}</span>–<span className="font-semibold">{endIdx}</span> of <span className="font-semibold">{total}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 dark:text-white">
|
|
||||||
<button
|
|
||||||
onClick={goPrev}
|
|
||||||
disabled={safePage <= 1}
|
|
||||||
className={`px-3 py-1.5 rounded-md border dark:border-gray-700 ${safePage <= 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-50 dark:hover:bg-gray-800'}`}
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</button>
|
|
||||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
Page <span className="font-semibold">{safePage}</span> / {totalPages}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={goNext}
|
|
||||||
disabled={safePage >= totalPages}
|
|
||||||
className={`px-3 py-1.5 rounded-md border dark:border-gray-700 ${safePage >= totalPages ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-50 dark:hover:bg-gray-800 '}`}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Cards */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{pageItems.map(p => (
|
{/* Iterate over the keys of mockSiteData (which are your SiteNames) */}
|
||||||
<SiteCard
|
{Object.keys(mockSiteData).map((siteNameKey) => {
|
||||||
key={p.name}
|
const siteName = siteNameKey as SiteName; // Cast to SiteName type
|
||||||
siteId={p.name} // SiteCard self-fetches details
|
const siteDetails = mockSiteData[siteName];
|
||||||
fallbackStatus={p.status ?? undefined}
|
const siteStatus = getSiteStatus(siteName);
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pagination footer mirrors header for convenience */}
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<SiteCard
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
key={siteName} // Important for React list rendering
|
||||||
Showing <span className="font-semibold">{startIdx + 1}</span>–<span className="font-semibold">{endIdx}</span> of <span className="font-semibold">{total}</span>
|
siteName={siteName}
|
||||||
|
details={siteDetails}
|
||||||
|
status={siteStatus}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 dark:text-white">
|
|
||||||
<button
|
|
||||||
onClick={goPrev}
|
|
||||||
disabled={safePage <= 1}
|
|
||||||
className={`px-3 py-1.5 rounded-md border dark:border-gray-700 ${safePage <= 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-50 dark:hover:bg-gray-800'}`}
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</button>
|
|
||||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
Page <span className="font-semibold">{safePage}</span> / {totalPages}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={goNext}
|
|
||||||
disabled={safePage >= totalPages}
|
|
||||||
className={`px-3 py-1.5 rounded-md border dark:border-gray-700 ${safePage >= totalPages ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-50 dark:hover:bg-gray-800'}`}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SitesPage;
|
export default SitesPage;
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 84 KiB |
@ -1,223 +0,0 @@
|
|||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,111 +1,26 @@
|
|||||||
// components/dashboards/SiteCard.tsx
|
// components/dashboards/SiteCard.tsx
|
||||||
'use client';
|
import React from 'react';
|
||||||
|
import Link from 'next/link'; // Import Link from Next.js
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import { SiteName, SiteDetails } from '@/types/SiteData'; // Adjust path as necessary
|
||||||
import Link from 'next/link';
|
|
||||||
import { formatAddress } from '@/app/utils/formatAddress';
|
|
||||||
import { formatCrmTimestamp } from '@/app/utils/datetime';
|
|
||||||
|
|
||||||
type CrmProject = {
|
|
||||||
name: string; // e.g. PROJ-0008 (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;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface SiteCardProps {
|
interface SiteCardProps {
|
||||||
siteId: string; // CRM Project "name" (canonical id)
|
siteName: SiteName;
|
||||||
className?: string; // optional styling hook
|
details: SiteDetails;
|
||||||
fallbackStatus?: string; // optional backup status if CRM is missing it
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000';
|
const SiteCard: React.FC<SiteCardProps> = ({ siteName, details, status }) => {
|
||||||
|
|
||||||
const SiteCard: React.FC<SiteCardProps> = ({ siteId, className = '', fallbackStatus }) => {
|
|
||||||
const [project, setProject] = useState<CrmProject | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [err, setErr] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
const fetchProject = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setErr(null);
|
|
||||||
try {
|
|
||||||
// ---- Try a single-project endpoint first (best) ----
|
|
||||||
// e.g. GET /crm/projects/PROJ-0008
|
|
||||||
const single = await fetch(`${API}/crm/projects/${encodeURIComponent(siteId)}`);
|
|
||||||
if (single.ok) {
|
|
||||||
const pj = await single.json();
|
|
||||||
if (!cancelled) setProject(pj?.data ?? pj ?? null);
|
|
||||||
} else {
|
|
||||||
// ---- Fallback: fetch all and find by name (works with your existing API) ----
|
|
||||||
const list = await fetch(`${API}/crm/projects?limit=0`);
|
|
||||||
if (!list.ok) throw new Error(await list.text());
|
|
||||||
const json = await list.json();
|
|
||||||
const found = (json?.data ?? []).find((p: CrmProject) => p.name === siteId) ?? null;
|
|
||||||
if (!cancelled) setProject(found);
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
if (!cancelled) setErr(e?.message ?? 'Failed to load CRM project');
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchProject();
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, [siteId]);
|
|
||||||
|
|
||||||
const status = project?.status || fallbackStatus || 'Unknown';
|
|
||||||
const statusColorClass =
|
const statusColorClass =
|
||||||
status === 'Active' ? 'text-green-500' :
|
status === 'Active' ? 'text-green-500' :
|
||||||
status === 'Inactive' ? 'text-orange-500' :
|
status === 'Inactive' ? 'text-orange-500' :
|
||||||
'text-red-500';
|
'text-red-500';
|
||||||
|
|
||||||
const niceAddress = useMemo(() => {
|
|
||||||
if (!project?.custom_address) return 'N/A';
|
|
||||||
return formatAddress(project.custom_address).multiLine;
|
|
||||||
}, [project?.custom_address]);
|
|
||||||
|
|
||||||
const lastSync = useMemo(() => {
|
|
||||||
return formatCrmTimestamp(project?.modified, { includeSeconds: true }) || 'N/A';
|
|
||||||
}, [project?.modified]);
|
|
||||||
|
|
||||||
const inverterProvider = project?.project_type || 'N/A';
|
|
||||||
const emergencyContact =
|
|
||||||
project?.custom_mobile_phone_no ||
|
|
||||||
project?.custom_email ||
|
|
||||||
project?.customer ||
|
|
||||||
'N/A';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`bg-white p-4 rounded-lg shadow-md dark:bg-gray-800 dark:text-white-light flex flex-col space-y-2 ${className}`}>
|
<div className="bg-white p-4 rounded-lg shadow-md dark:bg-gray-800 dark:text-white-light flex flex-col space-y-2">
|
||||||
<h3 className="text-xl font-bold text-primary-600 dark:text-primary-400 border-b pb-2 mb-2">
|
<h3 className="text-xl font-bold text-primary-600 dark:text-primary-400 border-b pb-2 mb-2">
|
||||||
{project?.project_name || siteId}
|
{siteName}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="animate-pulse space-y-2">
|
|
||||||
<div className="h-4 w-24 bg-gray-200 dark:bg-gray-700 rounded" />
|
|
||||||
<div className="h-4 w-48 bg-gray-200 dark:bg-gray-700 rounded" />
|
|
||||||
<div className="h-4 w-40 bg-gray-200 dark:bg-gray-700 rounded" />
|
|
||||||
<div className="h-4 w-36 bg-gray-200 dark:bg-gray-700 rounded" />
|
|
||||||
</div>
|
|
||||||
) : err ? (
|
|
||||||
<div className="text-red-500 text-sm">Failed to load CRM: {err}</div>
|
|
||||||
) : !project ? (
|
|
||||||
<div className="text-amber-500 text-sm">No CRM project found for <span className="font-semibold">{siteId}</span>.</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<p className="text-gray-600 dark:text-gray-400 font-medium">Status:</p>
|
<p className="text-gray-600 dark:text-gray-400 font-medium">Status:</p>
|
||||||
<p className={`font-semibold ${statusColorClass}`}>{status}</p>
|
<p className={`font-semibold ${statusColorClass}`}>{status}</p>
|
||||||
@ -113,29 +28,31 @@ const SiteCard: React.FC<SiteCardProps> = ({ siteId, className = '', fallbackSta
|
|||||||
|
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<p className="text-gray-600 dark:text-gray-400 font-medium">Location:</p>
|
<p className="text-gray-600 dark:text-gray-400 font-medium">Location:</p>
|
||||||
<p className="font-medium whitespace-pre-line text-right">{niceAddress}</p>
|
<p className="font-semibold">{details.location}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<p className="text-gray-600 dark:text-gray-400 font-medium">Inverter Provider:</p>
|
<p className="text-gray-600 dark:text-gray-400 font-medium">Inverter Provider:</p>
|
||||||
<p className="font-medium">{inverterProvider}</p>
|
<p className="font-semibold">{details.inverterProvider}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<p className="text-gray-600 dark:text-gray-400 font-medium">Emergency Contact:</p>
|
<p className="text-gray-600 dark:text-gray-400 font-medium">Emergency Contact:</p>
|
||||||
<p className="font-medium text-right">{emergencyContact}</p>
|
<p className="font-semibold">{details.emergencyContact}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<p className="text-gray-600 dark:text-gray-400 font-medium">Last Sync:</p>
|
<p className="text-gray-600 dark:text-gray-400 font-medium">Last Sync:</p>
|
||||||
<p className="font-medium">{lastSync}</p>
|
<p className="font-semibold">{details.lastSyncTimestamp}</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
|
{/* New: View Dashboard Button */}
|
||||||
<Link
|
<Link
|
||||||
href={{ pathname: '/adminDashboard', query: { site: siteId } }}
|
href={{
|
||||||
className="mt-4 w-full text-center text-sm btn-primary"
|
pathname: '/adminDashboard', // Path to your AdminDashboard page
|
||||||
|
query: { site: siteName }, // Pass the siteName as a query parameter
|
||||||
|
}}
|
||||||
|
className="mt-4 w-full text-center text-sm btn-primary" // Tailwind classes for basic button styling
|
||||||
>
|
>
|
||||||
View Dashboard
|
View Dashboard
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@ -144,6 +144,67 @@ 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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user