diff --git a/.gitea/workflows/pr-build-check.yml b/.gitea/workflows/pr-build-check.yml new file mode 100644 index 0000000..7673ebe --- /dev/null +++ b/.gitea/workflows/pr-build-check.yml @@ -0,0 +1,37 @@ +name: PR Build Check + +on: + pull_request: + branches: + - '**' + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: '18' + + - name: Install dependencies + run: npm install --force + + - name: Generate Prisma Client + run: npx prisma generate + + - name: Build + run: npm run build + env: + NEXT_PUBLIC_URL: 'http://localhost:3000' + NEXT_PUBLIC_FORECAST_URL: 'http://localhost:3001' + DATABASE_URL: 'postgresql://dummy:dummy@localhost:5432/dummy' + SMTP_EMAIL: 'dummy@example.com' + SMTP_EMAIL_PASSWORD: 'dummy' + NEXT_PUBLIC_PLAUSIBLE_DOMAIN: 'localhost' + JWT_SECRET: 'dummy_secret' + JWT_REFRESH_SECRET: 'dummy_refresh_secret' \ No newline at end of file diff --git a/app/(admin)/adminDashboard/page.tsx b/app/(admin)/adminDashboard/page.tsx index e0c3ab7..bc97fce 100644 --- a/app/(admin)/adminDashboard/page.tsx +++ b/app/(admin)/adminDashboard/page.tsx @@ -1,214 +1,434 @@ 'use client'; -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useMemo, useRef } from 'react'; import { useRouter, usePathname, useSearchParams } from 'next/navigation'; import SiteSelector from '@/components/dashboards/SiteSelector'; import SiteStatus from '@/components/dashboards/SiteStatus'; -import KPI_Table from '@/components/dashboards/KPIStatus'; import DashboardLayout from './dashlayout'; import html2canvas from 'html2canvas'; import jsPDF from 'jspdf'; import dynamic from 'next/dynamic'; import { fetchPowerTimeseries } from '@/app/utils/api'; +import KpiTop from '@/components/dashboards/kpitop'; +import KpiBottom from '@/components/dashboards/kpibottom'; +import { formatAddress } from '@/app/utils/formatAddress'; +import { formatCrmTimestamp } from '@/app/utils/datetime'; +import LoggingControlCard from '@/components/dashboards/LoggingControl'; -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, -}); +type MonthlyKPI = { + site: string; month: string; + yield_kwh: number | null; consumption_kwh: number | null; grid_draw_kwh: number | null; + efficiency: number | null; peak_demand_kw: number | null; + avg_power_factor: number | null; load_factor: number | null; + error?: string; +}; -import { SiteName, SiteDetails, mockSiteData } from '@/types/SiteData'; +type CrmProject = { + name: string; // e.g. PROJ-0008 <-- use as siteId + project_name: string; + status?: string; + percent_complete?: number | null; + owner?: string | null; + modified?: string | null; + customer?: string | null; + project_type?: string | null; + custom_address?: string | null; + custom_email?: string | null; + custom_mobile_phone_no?: string | null; +}; + +const API = process.env.NEXT_PUBLIC_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(); - const siteIdMap: Record = { - 'Site A': 'site_01', - 'Site B': 'site_02', - 'Site C': 'site_03', -}; - const siteParam = searchParams?.get('site'); - const validSiteNames: SiteName[] = ['Site A', 'Site B', 'Site C']; + // --- load CRM projects dynamically --- + const [sites, setSites] = useState([]); + const [sitesLoading, setSitesLoading] = useState(true); + const [sitesError, setSitesError] = useState(null); + // near other refs + const loggingRef = useRef(null); - const [selectedSite, setSelectedSite] = useState(() => { - if (siteParam && validSiteNames.includes(siteParam as SiteName)) { - return siteParam as SiteName; - } - return 'Site A'; - }); - // Keep siteParam and selectedSite in sync useEffect(() => { - if ( - siteParam && - validSiteNames.includes(siteParam as SiteName) && - siteParam !== selectedSite - ) { - setSelectedSite(siteParam as SiteName); - } - }, [siteParam, selectedSite]); + setSitesLoading(true); + fetch(`${API}/crm/projects?limit=0`) + .then(r => r.json()) + .then(json => setSites(json?.data ?? [])) + .catch(setSitesError) + .finally(() => setSitesLoading(false)); + }, []); + // The canonical siteId is the CRM Project "name" (e.g., PROJ-0008) + const siteParam = searchParams?.get('site') || null; + const [selectedSiteId, setSelectedSiteId] = useState(siteParam); + + // Keep query param <-> state in sync + useEffect(() => { + if ((siteParam || null) !== selectedSiteId) { + setSelectedSiteId(siteParam); + } + }, [siteParam]); // eslint-disable-line + + // Default to the first site when loaded + useEffect(() => { + if (!selectedSiteId && sites.length) { + setSelectedSiteId(sites[0].name); + router.replace(`${pathname}?site=${encodeURIComponent(sites[0].name)}`); + } + }, [sites, selectedSiteId, pathname, router]); + + // Current selected CRM project + const selectedProject: CrmProject | null = useMemo( + () => sites.find(s => s.name === selectedSiteId) ?? null, + [sites, selectedSiteId] + ); + + // declare currentMonth BEFORE it’s used + const currentMonth = useMemo(() => new Date().toISOString().slice(0, 7), []); + + // --- Time-series state --- const [timeSeriesData, setTimeSeriesData] = useState<{ consumption: { time: string; value: number }[]; generation: { time: string; value: number }[]; }>({ consumption: [], generation: [] }); + // data-availability flags + const [hasAnyData, setHasAnyData] = useState(false); // historical window + const [hasTodayData, setHasTodayData] = useState(false); + const [isLogging, setIsLogging] = useState(false); + const [startError, setStartError] = useState(null); + + // Fetch today’s timeseries for selected siteId useEffect(() => { - const fetchData = async () => { + if (!selectedSiteId) return; - const siteId = siteIdMap[selectedSite]; - const today = new Date(); + const fetchToday = async () => { + const { start, end } = withTZ(new Date()); - // Format to YYYY-MM-DD - const yyyyMMdd = today.toISOString().split('T')[0]; + 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 }); - // Append Malaysia's +08:00 time zone manually - const start = `${yyyyMMdd}T00:00:00+08:00`; - const end = `${yyyyMMdd}T23:59:59+08:00`; - + 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); + } + }; - try { - const raw = await fetchPowerTimeseries(siteId, start, end); + fetchToday(); + }, [selectedSiteId]); - const consumption = raw.consumption.map(d => ({ - time: d.time, - value: d.value, - })); + // Check historical data (last 30 days) → controls empty state + useEffect(() => { + if (!selectedSiteId) return; - const generation = raw.generation.map(d => ({ - time: d.time, - value: d.value, - })); + const fetchHistorical = async () => { + try { + const endDate = new Date(); + const startDate = new Date(); + startDate.setDate(endDate.getDate() - 30); - setTimeSeriesData({ consumption, generation }); + const startISO = `${startDate.toISOString().split('T')[0]}T00:00:00+08:00`; + const endISO = `${endDate.toISOString().split('T')[0]}T23:59:59+08:00`; - } catch (error) { - console.error('Failed to fetch power time series:', error); - } - }; + const raw = await fetchPowerTimeseries(selectedSiteId, startISO, endISO); + const anyHistorical = + (raw?.consumption?.length ?? 0) > 0 || + (raw?.generation?.length ?? 0) > 0; - fetchData(); -}, [selectedSite]); + setHasAnyData(anyHistorical); + } catch (e) { + console.error('Failed to check historical data:', e); + setHasAnyData(false); + } + }; - // Update query string when site is changed manually - const handleSiteChange = (newSite: SiteName) => { - setSelectedSite(newSite); - const newUrl = `${pathname}?site=${encodeURIComponent(newSite)}`; + fetchHistorical(); + }, [selectedSiteId]); + + // --- KPI monthly --- + const [kpi, setKpi] = useState(null); + + useEffect(() => { + if (!selectedSiteId) return; + const url = `${API}/kpi/monthly?site=${encodeURIComponent(selectedSiteId)}&month=${currentMonth}`; + fetch(url).then(r => r.json()).then(setKpi).catch(console.error); + }, [selectedSiteId, currentMonth]); + + // derived values with safe fallbacks + const yieldKwh = kpi?.yield_kwh ?? 0; + const consumptionKwh = kpi?.consumption_kwh ?? 0; + const gridDrawKwh = kpi?.grid_draw_kwh ?? Math.max(0, consumptionKwh - yieldKwh); + const efficiencyPct = (kpi?.efficiency ?? 0) * 100; + const powerFactor = kpi?.avg_power_factor ?? 0; + const loadFactor = (kpi?.load_factor ?? 0); + + // Update URL when site is changed manually (expects a siteId/Project.name) + const handleSiteChange = (newSiteId: string) => { + setSelectedSiteId(newSiteId); + const newUrl = `${pathname}?site=${encodeURIComponent(newSiteId)}`; router.push(newUrl); + // reset flags when switching + setHasAnyData(false); + setHasTodayData(false); + setIsLogging(false); + setStartError(null); }; - const currentSiteDetails: SiteDetails = mockSiteData[selectedSite] || { - location: 'N/A', - inverterProvider: 'N/A', - emergencyContact: 'N/A', - lastSyncTimestamp: 'N/A', - consumptionData: [], - generationData: [], - systemStatus: 'N/A', - temperature: 'N/A', - solarPower: 0, - realTimePower: 0, - installedPower: 0, + const locationFormatted = useMemo(() => { + const raw = selectedProject?.custom_address ?? ''; + if (!raw) return 'N/A'; + return formatAddress(raw).multiLine; + }, [selectedProject?.custom_address]); + + const lastSyncFormatted = useMemo( + () => formatCrmTimestamp(selectedProject?.modified, { includeSeconds: true }), + [selectedProject?.modified] + ); + + // Adapt CRM project -> SiteStatus props + const currentSiteDetails = { + location: locationFormatted, + inverterProvider: selectedProject?.project_type || 'N/A', + emergencyContact: + selectedProject?.custom_mobile_phone_no || + selectedProject?.custom_email || + selectedProject?.customer || + 'N/A', + lastSyncTimestamp: lastSyncFormatted || 'N/A', }; - const handleCSVExport = () => { - alert('Exported raw data to CSV (mock)'); - }; - - const energyChartRef = useRef(null); - const monthlyChartRef = useRef(null); + const energyChartRef = useRef(null); + const monthlyChartRef = useRef(null); const handlePDFExport = async () => { - const doc = new jsPDF('p', 'mm', 'a4'); // portrait, millimeters, A4 - const chartRefs = [ - { ref: energyChartRef, title: 'Energy Line Chart' }, - { ref: monthlyChartRef, title: 'Monthly Energy Yield' } - ]; + const doc = new jsPDF('p', 'mm', 'a4'); + const chartRefs = [ + { ref: energyChartRef, title: 'Energy Line Chart' }, + { ref: monthlyChartRef, title: 'Monthly Energy Yield' } + ]; - let yOffset = 10; + let yOffset = 10; - for (const chart of chartRefs) { - if (!chart.ref.current) continue; + for (const chart of chartRefs) { + if (!chart.ref.current) continue; + const canvas = await html2canvas(chart.ref.current, { scale: 2 }); + const imgData = canvas.toDataURL('image/png'); + const imgProps = doc.getImageProperties(imgData); + const pdfWidth = doc.internal.pageSize.getWidth() - 20; + const imgHeight = (imgProps.height * pdfWidth) / imgProps.width; - // Capture chart as image - const canvas = await html2canvas(chart.ref.current, { - scale: 2, // Higher scale for better resolution - }); + doc.setFontSize(14); + doc.text(chart.title, 10, yOffset); + yOffset += 6; - const imgData = canvas.toDataURL('image/png'); - const imgProps = doc.getImageProperties(imgData); + if (yOffset + imgHeight > doc.internal.pageSize.getHeight()) { + doc.addPage(); + yOffset = 10; + } - const pdfWidth = doc.internal.pageSize.getWidth() - 20; // 10 margin each side - const imgHeight = (imgProps.height * pdfWidth) / imgProps.width; - - // Add title and image - doc.setFontSize(14); - doc.text(chart.title, 10, yOffset); - yOffset += 6; // Space between title and chart - - // If content will overflow page, add a new page - if (yOffset + imgHeight > doc.internal.pageSize.getHeight()) { - doc.addPage(); - yOffset = 10; + doc.addImage(imgData, 'PNG', 10, yOffset, pdfWidth, imgHeight); + yOffset += imgHeight + 10; } - doc.addImage(imgData, 'PNG', 10, yOffset, pdfWidth, imgHeight); - yOffset += imgHeight + 10; // Update offset for next chart + 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 ( + +
Loading sites…
+
+ ); + } + if (sitesError) { + return ( + +
Failed to load sites from CRM.
+
+ ); + } + if (!selectedProject) { + return ( + +
No site selected.
+
+ ); } - doc.save('dashboard_charts.pdf'); -}; - const currentMonth = new Date().toISOString().slice(0, 7); // "YYYY-MM" + // Build selector options from CRM + const siteOptions = sites.map(s => ({ + label: s.project_name || s.name, + value: s.name, + })); return ( -
+

Admin Dashboard

-
-
+ {/* Selector + status */} +
+
+ -
+ -
-
-
-
- + {/* Small dark yellow banner when there is ZERO historical data */} + {!hasAnyData && ( +
+ No data yet. + Enter the meter number and click Start to begin streaming. + + {startError &&
{startError}
}
-
- -
+ )} + +
+
-
- -
+ {/* Render the rest only if there is *any* data */} + {hasAnyData && ( + <> + {/* Tiny banner if today is empty but historical exists */} + {!hasTodayData && ( +
+ No data yet today — charts may be blank until new points arrive. +
+ )} + + + {/* TOP 3 CARDS */} +
+ +
+ +
+ +
+ + {/* BOTTOM 3 PANELS */} + + +
+ } + right={ +
+
+ {(kpi?.peak_demand_kw ?? 0).toFixed(2)} kW +
+
+ } + /> + +
+ +
+ + )} + +
); }; export default AdminDashboard; - - diff --git a/app/(admin)/sites/page.tsx b/app/(admin)/sites/page.tsx index 2f6d3de..03752fa 100644 --- a/app/(admin)/sites/page.tsx +++ b/app/(admin)/sites/page.tsx @@ -1,46 +1,187 @@ 'use client'; -import React from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import DashboardLayout from '../adminDashboard/dashlayout'; -import SiteCard from '@/components/dashboards/SiteCard'; // Import the new SiteCard component -import { mockSiteData, SiteName } from '@/types/SiteData'; // Import your mock data and SiteName type +import SiteCard from '@/components/dashboards/SiteCard'; -const SitesPage = () => { - // Helper function to determine status (can be externalized if used elsewhere) - const getSiteStatus = (siteName: SiteName): string => { - const statusMap: Record = { - 'Site A': 'Active', - 'Site B': 'Inactive', - 'Site C': 'Faulty', - }; - return statusMap[siteName]; - }; - - return ( - -
-

All Sites Overview

- -
- {/* Iterate over the keys of mockSiteData (which are your SiteNames) */} - {Object.keys(mockSiteData).map((siteNameKey) => { - const siteName = siteNameKey as SiteName; // Cast to SiteName type - const siteDetails = mockSiteData[siteName]; - const siteStatus = getSiteStatus(siteName); - - return ( - - ); - })} -
-
-
- ); +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; }; -export default SitesPage; \ No newline at end of file +const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000'; + +const SitesPage = () => { + const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(true); + const [err, setErr] = useState(null); + const [q, setQ] = useState(''); // search filter + + // pagination + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(6); // tweak as you like + + 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 ( + +
+
+

All Sites Overview

+ +
+ 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" + /> + +
+
+ + {loading && ( +
Loading CRM projects…
+ )} + + {err && ( +
Error: {err}
+ )} + + {!loading && !err && total === 0 && ( +
No sites found.
+ )} + + {!loading && !err && total > 0 && ( + <> + {/* Pagination header */} +
+
+ Showing {startIdx + 1}{endIdx} of {total} +
+
+ + + Page {safePage} / {totalPages} + + +
+
+ + {/* Cards */} +
+ {pageItems.map(p => ( + + ))} +
+ + {/* Pagination footer mirrors header for convenience */} +
+
+ Showing {startIdx + 1}{endIdx} of {total} +
+
+ + + Page {safePage} / {totalPages} + + +
+
+ + )} +
+
+ ); +}; + +export default SitesPage; + diff --git a/app/api/sites/route.ts b/app/api/sites/route.ts deleted file mode 100644 index 1e92b98..0000000 --- a/app/api/sites/route.ts +++ /dev/null @@ -1,20 +0,0 @@ -// app/api/sites/route.ts -import { NextResponse } from 'next/server'; -import prisma from '@/lib/prisma'; - -export async function GET() { - try { - const sites = await prisma.site.findMany({ - include: { - consumptionData: true, - generationData: true, - }, - }); - console.log('✅ Sites:', sites); - return NextResponse.json(sites); - } catch (error) { - console.error('❌ Error fetching sites:', error); - return new NextResponse('Failed to fetch sites', { status: 500 }); - } -} - diff --git a/app/hooks/useCrmProjects.ts b/app/hooks/useCrmProjects.ts new file mode 100644 index 0000000..a820dbf --- /dev/null +++ b/app/hooks/useCrmProjects.ts @@ -0,0 +1,20 @@ +// src/hooks/useCrmProjects.ts +import { useEffect, useState } from "react"; +import { crmapi } from "../utils/api"; +import { CrmProject } from "@/types/crm"; + +export function useCrmProjects() { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + setLoading(true); + crmapi.getProjects() + .then(res => setData(res.data?.data ?? [])) + .catch(setError) + .finally(() => setLoading(false)); + }, []); + + return { data, loading, error }; +} diff --git a/app/utils/api.ts b/app/utils/api.ts index 9cb282e..09773e1 100644 --- a/app/utils/api.ts +++ b/app/utils/api.ts @@ -9,6 +9,18 @@ export interface TimeSeriesResponse { generation: TimeSeriesEntry[]; } +const API_BASE_URL = + process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://127.0.0.1:8000"; + +export const crmapi = { + getProjects: async () => { + const res = await fetch(`${API_BASE_URL}/crm/projects`, { + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); + }, +}; + export async function fetchPowerTimeseries( site: string, start: string, @@ -48,3 +60,29 @@ export async function fetchForecast( return res.json(); } +export type MonthlyKPI = { + site: string; + month: string; // "YYYY-MM" + yield_kwh: number | null; + consumption_kwh: number | null; + grid_draw_kwh: number | null; + efficiency: number | null; // 0..1 (fraction) + peak_demand_kw: number | null; + avg_power_factor: number | null; // 0..1 + load_factor: number | null; // 0..1 + error?: string; +}; + +const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000"; + +export async function fetchMonthlyKpi(params: { + site: string; + month: string; // "YYYY-MM" + consumption_topic?: string; + generation_topic?: string; +}): Promise { + const qs = new URLSearchParams(params as Record); + const res = await fetch(`${API}/kpi/monthly?${qs.toString()}`, { cache: "no-store" }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); +} diff --git a/app/utils/datetime.ts b/app/utils/datetime.ts new file mode 100644 index 0000000..4ea171a --- /dev/null +++ b/app/utils/datetime.ts @@ -0,0 +1,37 @@ +// app/utils/datetime.ts +export function formatCrmTimestamp( + input: string | null | undefined, + opts?: { locale?: string; timeZone?: string; includeSeconds?: boolean } +): string { + if (!input) return 'N/A'; + + // Accept: 2025-06-30 10:04:58.387651 (also with 'T', with/without fraction) + const m = String(input).trim().match( + /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,6}))?$/ + ); + if (!m) return input; // fallback: show as-is + + const [, y, mo, d, hh, mm, ss, frac = ''] = m; + const ms = Number((frac + '000').slice(0, 3)); // micro→millis + + const dt = new Date( + Number(y), + Number(mo) - 1, + Number(d), + Number(hh), + Number(mm), + Number(ss), + ms + ); + + const locale = opts?.locale ?? 'en-MY'; + const timeZone = opts?.timeZone ?? 'Asia/Kuala_Lumpur'; + const timeStyle = opts?.includeSeconds ? 'medium' : 'short'; + + return new Intl.DateTimeFormat(locale, { + dateStyle: 'medium', + timeStyle, // 'short'=no seconds, 'medium'=with seconds + timeZone, + hour12: true, + }).format(dt); +} diff --git a/app/utils/formatAddress.ts b/app/utils/formatAddress.ts new file mode 100644 index 0000000..a822019 --- /dev/null +++ b/app/utils/formatAddress.ts @@ -0,0 +1,35 @@ +// utils/formatAddress.ts +// npm i he (for robust HTML entity decoding) +import { decode } from "he"; + +export function formatAddress(raw: string) { + // 1) decode entities (& → &), 2)
→ \n, 3) tidy whitespace + const text = decode(raw) + .replace(//gi, "\n") + .replace(/\u00A0/g, " ") //   + .replace(/[ \t]{2,}/g, " ") // collapse spaces + .replace(/\n{2,}/g, "\n") // collapse blank lines + .trim(); + + // split to lines, strip empties + const lines = text.split("\n").map(s => s.trim()).filter(Boolean); + + // If postcode is alone (e.g., "40150") before the city line, merge: "40150 Shah Alam" + const merged: string[] = []; + for (let i = 0; i < lines.length; i++) { + const cur = lines[i]; + const next = lines[i + 1]; + if (/^\d{5}$/.test(cur) && next) { + merged.push(`${cur} ${next}`); + i++; // skip the city line, already merged + } else { + merged.push(cur); + } + } + + return { + parts: merged, // array of lines + multiLine: merged.join("\n"), // lines with \n + singleLine: merged.join(", "), // one-liner + }; +} diff --git a/components/auth/components-auth-login-form.tsx b/components/auth/components-auth-login-form.tsx index 0013f4e..b478c44 100644 --- a/components/auth/components-auth-login-form.tsx +++ b/components/auth/components-auth-login-form.tsx @@ -2,67 +2,84 @@ import IconLockDots from '@/components/icon/icon-lock-dots'; import IconMail from '@/components/icon/icon-mail'; import { useRouter } from 'next/navigation'; -import { useState } from "react"; -import axios from "axios"; +import { useState } from 'react'; +import axios from 'axios'; import toast from 'react-hot-toast'; const ComponentsAuthLoginForm = () => { - const [email, setEmail] = useState("") - const [password, setPassword] = useState("") - const [loading, setLoading] = useState(false) - const router = useRouter() + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [loading, setLoading] = useState(false); + const router = useRouter(); - const submitForm = async (e: React.FormEvent) => { - e.preventDefault() - - setLoading(true) - try { - const res = await axios.post(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/login`, { - email, - password, - }) - - localStorage.setItem("token", res.data.token) - - toast.success("Login successful!") - router.push("/") - } catch (err: any) { - console.error("Login error:", err) - toast.error(err.response?.data?.error || "Invalid credentials") - } finally { - setLoading(false) - } + const submitForm = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + try { + const res = await axios.post('/api/login', { email, password }); + toast.success(res.data?.message || 'Login successful!'); + router.push('/adminDashboard'); + router.refresh(); + // token cookie is already set by the server: + } catch (err: any) { + console.error('Login error:', err); + const msg = + err?.response?.data?.message || + err?.message || + 'Invalid credentials'; + toast.error(msg); + } finally { + setLoading(false); } + }; - return ( -
-
- -
- setEmail(e.target.value)} placeholder="Enter Email" className="form-input ps-10 placeholder:text-white-dark" /> - - - -
-
-
- -
- setPassword(e.target.value)} required placeholder="Enter Password" className="form-input ps-10 placeholder:text-white-dark" /> - - - -
-
- -
- ); + return ( +
+
+ +
+ setEmail(e.target.value)} + placeholder="Enter Email" + className="form-input ps-10 placeholder:text-white-dark" + required + /> + + + +
+
+
+ +
+ setPassword(e.target.value)} + required + placeholder="Enter Password" + className="form-input ps-10 placeholder:text-white-dark" + minLength={8} + /> + + + +
+
+ +
+ ); }; export default ComponentsAuthLoginForm; + diff --git a/components/auth/components-auth-register-form.tsx b/components/auth/components-auth-register-form.tsx index f5c2313..c48cfce 100644 --- a/components/auth/components-auth-register-form.tsx +++ b/components/auth/components-auth-register-form.tsx @@ -1,74 +1,131 @@ -'use client'; -import IconLockDots from '@/components/icon/icon-lock-dots'; -import IconMail from '@/components/icon/icon-mail'; -import IconUser from '@/components/icon/icon-user'; -import axios from 'axios'; -import { useRouter } from 'next/navigation'; -import { useState } from "react"; -import React from 'react'; -import toast from 'react-hot-toast'; +// components/auth/components-auth-register-form.tsx +"use client"; -const ComponentsAuthRegisterForm = () => { - const [email, setEmail] = useState("") - const [password, setPassword] = useState("") - const [loading, setLoading] = useState(false) - const router = useRouter() +import * as React from "react"; +import { useRouter } from "next/navigation"; - const submitForm = async(e: any) => { - e.preventDefault() - - setLoading(true) - try { - const res = await axios.post(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/register`, { - email, - password, - }) - - localStorage.setItem("token", res.data.token) - - toast.success("Register successful!") - router.push("/") - } catch (err: any) { - console.error("Register error:", err) - toast.error(err.response?.data?.error || "Something went wrong") - } finally { - setLoading(false) - } - }; - return ( -
- {/*
- -
- - - - -
-
*/} -
- -
- setEmail(e.target.value)} placeholder="Enter Email" className="form-input ps-10 placeholder:text-white-dark" /> - - - -
-
-
- -
- setPassword(e.target.value)} required placeholder="Enter Password" className="form-input ps-10 placeholder:text-white-dark" /> - - - -
-
- -
- ); +type Props = { + redirectTo?: string; // optional override }; -export default ComponentsAuthRegisterForm; +export default function ComponentsAuthRegisterForm({ redirectTo = "/dashboard" }: Props) { + const router = useRouter(); + const [email, setEmail] = React.useState(""); + const [password, setPassword] = React.useState(""); + const [confirm, setConfirm] = React.useState(""); + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(null); + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + + if (!email.trim() || !password) { + setError("Please fill in all fields."); + return; + } + if (password.length < 8) { + setError("Password must be at least 8 characters."); + return; + } + if (password !== confirm) { + setError("Passwords do not match."); + return; + } + + try { + setLoading(true); + const res = await fetch("/api/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + + const data = await res.json(); + + if (!res.ok) { + setError(data?.message || "Registration failed."); + return; + } + + // Cookie is set by API; just route away + router.replace(redirectTo); + } catch (err) { + setError("Network error. Please try again."); + } finally { + setLoading(false); + } + } + + return ( +
+
+ + setEmail(e.target.value)} + disabled={loading} + required + /> +
+ +
+ + setPassword(e.target.value)} + disabled={loading} + required + minLength={8} + /> +
+ +
+ + setConfirm(e.target.value)} + disabled={loading} + required + minLength={8} + /> +
+ + {error && ( +

+ {error} +

+ )} + + +
+ ); +} + diff --git a/components/dashboards/EnergyLineChart.tsx b/components/dashboards/EnergyLineChart.tsx index e0ebbaf..e719d2b 100644 --- a/components/dashboards/EnergyLineChart.tsx +++ b/components/dashboards/EnergyLineChart.tsx @@ -19,6 +19,7 @@ import DatePicker from 'react-datepicker'; import 'react-datepicker/dist/react-datepicker.css'; import './datepicker-dark.css'; // custom dark mode styles + ChartJS.register(zoomPlugin); interface TimeSeriesEntry { @@ -30,9 +31,48 @@ interface EnergyLineChartProps { siteId: string; } +function powerSeriesToEnergySeries( + data: TimeSeriesEntry[], + guessMinutes = 30 +): TimeSeriesEntry[] { + if (!data?.length) return []; + + // Ensure ascending by time + const sorted = [...data].sort( + (a, b) => new Date(a.time).getTime() - new Date(b.time).getTime() + ); + + const out: TimeSeriesEntry[] = []; + let lastDeltaMs: number | null = null; + + for (let i = 0; i < sorted.length; i++) { + const t0 = new Date(sorted[i].time).getTime(); + const p0 = sorted[i].value; // kW + + let deltaMs: number; + if (i < sorted.length - 1) { + const t1 = new Date(sorted[i + 1].time).getTime(); + deltaMs = Math.max(0, t1 - t0); + if (deltaMs > 0) lastDeltaMs = deltaMs; + } else { + // For the last point, assume previous cadence or a guess + deltaMs = lastDeltaMs ?? guessMinutes * 60 * 1000; + } + + const hours = deltaMs / (1000 * 60 * 60); + const kwh = p0 * hours; // kW * h = kWh + + out.push({ time: sorted[i].time, value: kwh }); + } + + return out; +} + + function groupTimeSeries( data: TimeSeriesEntry[], - mode: 'day' | 'daily' | 'weekly' | 'monthly' | 'yearly' + mode: 'day' | 'daily' | 'weekly' | 'monthly' | 'yearly', + agg: 'mean' | 'max' | 'sum' = 'mean' ): TimeSeriesEntry[] { const groupMap = new Map(); @@ -41,19 +81,22 @@ function groupTimeSeries( let key = ''; switch (mode) { - case 'day': - const local = new Date(date.toLocaleString('en-US', { timeZone: 'Asia/Kuala_Lumpur' })); - const hour = local.getHours(); - const minute = local.getMinutes() < 30 ? '00' : '30'; - const adjusted = new Date(local.setMinutes(minute === '00' ? 0 : 30, 0)); // zero seconds - key = adjusted.toISOString(); // ✅ full timestamp key + case 'day': { + const local = new Date( + date.toLocaleString('en-US', { timeZone: 'Asia/Kuala_Lumpur' }) + ); + const minute = local.getMinutes() < 30 ? 0 : 30; + local.setMinutes(minute, 0, 0); + key = local.toISOString(); break; + } case 'daily': key = date.toLocaleDateString('en-MY', { timeZone: 'Asia/Kuala_Lumpur', weekday: 'short', day: '2-digit', month: 'short', + year: 'numeric', }); break; case 'weekly': @@ -71,12 +114,19 @@ function groupTimeSeries( groupMap.get(key)!.push(entry.value); } - return Array.from(groupMap.entries()).map(([time, values]) => ({ - time, - value: values.reduce((sum, v) => sum + v, 0), - })); + return Array.from(groupMap.entries()).map(([time, values]) => { + if (agg === 'sum') { + const sum = values.reduce((a, b) => a + b, 0); + return { time, value: sum }; + } + const mean = values.reduce((a, b) => a + b, 0) / values.length; + const max = values.reduce((a, b) => (b > a ? b : a), -Infinity); + return { time, value: agg === 'max' ? max : mean }; + }); } + + const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => { const chartRef = useRef(null); const [viewMode, setViewMode] = useState<'day' | 'daily' | 'weekly' | 'monthly' | 'yearly'>('day'); @@ -85,6 +135,94 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => { const [selectedDate, setSelectedDate] = useState(new Date()); const [forecast, setForecast] = useState([]); + const LIVE_REFRESH_MS = 300000; // 5min when viewing a single day + const SLOW_REFRESH_MS = 600000; // 10min for weekly/monthly/yearly + + + const fetchAndSet = React.useCallback(async () => { + const now = new Date(); + let start: Date; + let end: Date; + + switch (viewMode) { + case 'day': + start = startOfDay(selectedDate); + end = endOfDay(selectedDate); + break; + case 'daily': + start = startOfWeek(now, { weekStartsOn: 1 }); + end = endOfWeek(now, { weekStartsOn: 1 }); + break; + case 'weekly': + start = startOfMonth(now); + end = endOfMonth(now); + break; + case 'monthly': + start = startOfYear(now); + end = endOfYear(now); + break; + case 'yearly': + start = new Date('2020-01-01'); + end = now; + break; + } + + const isoStart = start.toISOString(); + const isoEnd = end.toISOString(); + + try { + const res = await fetchPowerTimeseries(siteId, isoStart, isoEnd); + setConsumption(res.consumption); + setGeneration(res.generation); + + // Forecast only needs updating for the selected day + const forecastData = await fetchForecast(3.15, 101.7, 37, 0, 25.67); + const selectedDateStr = selectedDate.toISOString().split('T')[0]; + setForecast( + forecastData + .filter(({ time }: any) => time.startsWith(selectedDateStr)) + .map(({ time, forecast }: any) => ({ time, value: forecast })) + ); + } catch (error) { + console.error('Failed to fetch energy timeseries:', error); + } + }, [siteId, viewMode, selectedDate]); + + // 3) Auto-refresh effect: initial load + interval (pauses when tab hidden) + useEffect(() => { + let timer: number | undefined; + + const tick = async () => { + // Avoid wasted calls when the tab is in the background + if (!document.hidden) { + await fetchAndSet(); + } + const ms = viewMode === 'day' ? LIVE_REFRESH_MS : SLOW_REFRESH_MS; + timer = window.setTimeout(tick, ms); + }; + + // initial load + fetchAndSet(); + + // schedule next cycles + timer = window.setTimeout(tick, viewMode === 'day' ? LIVE_REFRESH_MS : SLOW_REFRESH_MS); + + const onVis = () => { + if (!document.hidden) { + // kick immediately when user returns + clearTimeout(timer); + tick(); + } + }; + document.addEventListener('visibilitychange', onVis); + + return () => { + clearTimeout(timer); + document.removeEventListener('visibilitychange', onVis); + }; + }, [fetchAndSet, viewMode]); + + function useIsDarkMode() { const [isDark, setIsDark] = useState(() => typeof document !== 'undefined' @@ -140,7 +278,7 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => { setGeneration(res.generation); // ⬇️ ADD THIS here — fetch forecast - const forecastData = await fetchForecast(3.15, 101.7, 37, 0, 5.67); + const forecastData = await fetchForecast(3.15, 101.7, 37, 0, 30.67); const selectedDateStr = selectedDate.toISOString().split('T')[0]; setForecast( @@ -160,9 +298,40 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => { fetchData(); }, [siteId, viewMode, selectedDate]); - const groupedConsumption = groupTimeSeries(consumption, viewMode); - const groupedGeneration = groupTimeSeries(generation, viewMode); - const groupedForecast = groupTimeSeries(forecast, viewMode); + const isEnergyView = viewMode !== 'day'; + +// Convert to energy series for aggregated views +const consumptionForGrouping = isEnergyView + ? powerSeriesToEnergySeries(consumption, 30) + : consumption; + +const generationForGrouping = isEnergyView + ? powerSeriesToEnergySeries(generation, 30) + : generation; + +const forecastForGrouping = isEnergyView + ? powerSeriesToEnergySeries(forecast, 60) // if forecast is hourly, guess 60 + : forecast; + +// Group: sum for energy views, mean for day view +const groupedConsumption = groupTimeSeries( + consumptionForGrouping, + viewMode, + isEnergyView ? 'sum' : 'mean' +); + +const groupedGeneration = groupTimeSeries( + generationForGrouping, + viewMode, + isEnergyView ? 'sum' : 'mean' +); + +const groupedForecast = groupTimeSeries( + forecastForGrouping, + viewMode, + isEnergyView ? 'sum' : 'mean' +); + const forecastMap = Object.fromEntries(groupedForecast.map(d => [d.time, d.value])); @@ -224,6 +393,22 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => { const axisColor = isDark ? '#fff' : '#222'; +function areaGradient(ctx: any, hex: string, alphaTop = 0.22, alphaBottom = 0.02) { + const { ctx: g, chartArea } = ctx.chart; + if (!chartArea) return hex; // initial render fallback + const gradient = g.createLinearGradient(0, chartArea.top, 0, chartArea.bottom); + // top more opaque → bottom fades out + gradient.addColorStop(0, hex + Math.floor(alphaTop * 255).toString(16).padStart(2, '0')); + gradient.addColorStop(1, hex + Math.floor(alphaBottom * 255).toString(16).padStart(2, '0')); + return gradient; +} + +// Define colors for both light and dark modes +const consumptionColor = isDark ? '#B80F0A' : '#EF4444'; // Example: Brighter red for dark mode +const generationColor = isDark ? '#48A860' : '#22C55E'; // Example: Brighter green for dark mode +const forecastColor = '#fcd913'; // A golden yellow that works well in both modes +const yUnit = isEnergyView ? 'kWh' : 'kW'; +const yTitle = isEnergyView ? 'Energy (kWh)' : 'Power (kW)'; const data = { labels: filteredLabels.map(formatLabel), @@ -231,26 +416,29 @@ const axisColor = isDark ? '#fff' : '#222'; { label: 'Consumption', data: filteredConsumption, - borderColor: '#8884d8', + borderColor: consumptionColor, + backgroundColor: (ctx: any) => areaGradient(ctx, consumptionColor), + fill: true, // <-- fill under line tension: 0.4, - fill: false, spanGaps: true, }, { label: 'Generation', data: filteredGeneration, - borderColor: '#82ca9d', + borderColor: generationColor, + backgroundColor: (ctx: any) => areaGradient(ctx, generationColor), + fill: true, // <-- fill under line tension: 0.4, - fill: false, spanGaps: true, }, { label: 'Forecasted Solar', data: filteredForecast, - borderColor: '#ffa500', // orange + borderColor: '#fcd913', // orange + backgroundColor: (ctx: any) => areaGradient(ctx, '#fcd913', 0.18, 0.03), tension: 0.4, borderDash: [5, 5], // dashed line to distinguish forecast - fill: false, + fill: true, spanGaps: true, } ], @@ -283,6 +471,13 @@ const axisColor = isDark ? '#fff' : '#222'; bodyColor: axisColor, borderColor: isDark ? '#444' : '#ccc', borderWidth: 1, + callbacks: { + label: (ctx: any) => { + const dsLabel = ctx.dataset.label || ''; + const val = ctx.parsed.y; + return `${dsLabel}: ${val?.toFixed(2)} ${yUnit}`; + }, + }, }, }, scales: { @@ -309,12 +504,7 @@ const axisColor = isDark ? '#fff' : '#222'; y: { beginAtZero: true, suggestedMax: yAxisSuggestedMax, - title: { - display: true, - text: 'Power (kW)', - color: axisColor, - font: { weight: 'normal' as const }, - }, + title: { display: true, text: yTitle, color: axisColor, font: { weight: 'normal' as const } }, ticks: { color: axisColor, }, diff --git a/components/dashboards/KPIStatus.tsx b/components/dashboards/KPIStatus.tsx index 9dade91..d2bf736 100644 --- a/components/dashboards/KPIStatus.tsx +++ b/components/dashboards/KPIStatus.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState } from "react"; interface KPI_TableProps { siteId: string; @@ -12,8 +12,8 @@ interface MonthlyKPI { consumption_kwh: number | null; grid_draw_kwh: number | null; efficiency: number | null; - peak_demand_kw: number | null; // ✅ new - avg_power_factor: number | null; // ✅ new + peak_demand_kw: number | null; + avg_power_factor: number | null; load_factor: number | null; } @@ -22,83 +22,66 @@ const KPI_Table: React.FC = ({ siteId, month }) => { const [loading, setLoading] = useState(false); useEffect(() => { + if (!siteId || !month) return; + const fetchKPI = async () => { setLoading(true); try { - const res = await fetch(`http://localhost:8000/kpi/monthly?site=${siteId}&month=${month}`); - const data = await res.json(); - setKpiData(data); + const res = await fetch( + `http://localhost:8000/kpi/monthly?site=${siteId}&month=${month}` + ); + setKpiData(await res.json()); } catch (err) { - console.error('Failed to fetch KPI:', err); - setKpiData(null); // fallback + console.error("Failed to fetch KPI:", err); + setKpiData(null); } finally { setLoading(false); } }; - if (siteId && month) fetchKPI(); + fetchKPI(); }, [siteId, month]); - if (!siteId) { - return ( -
-

Monthly KPI

-
-

No site selected

-
-
- ); - } + const formatValue = (value: number | null, unit = "", decimals = 2) => + value != null ? `${value.toFixed(decimals)}${unit}` : "—"; - if (loading) { - return ( -
-

Monthly KPI

-
-

Loading...

-
-
- ); - } - - // Use optional chaining and nullish coalescing to safely default values to 0 - const yield_kwh = kpiData?.yield_kwh ?? 0; - const consumption_kwh = kpiData?.consumption_kwh ?? 0; - const grid_draw_kwh = kpiData?.grid_draw_kwh ?? 0; - const efficiency = kpiData?.efficiency ?? 0; - const peak_demand_kw = kpiData?.peak_demand_kw ?? 0; - const power_factor = kpiData?.avg_power_factor ?? 0; - const load_factor = kpiData?.load_factor ?? 0; - -const data = [ - { kpi: 'Monthly Yield', value: `${yield_kwh.toFixed(0)} kWh` }, - { kpi: 'Monthly Consumption', value: `${consumption_kwh.toFixed(0)} kWh` }, - { kpi: 'Monthly Grid Draw', value: `${grid_draw_kwh.toFixed(0)} kWh` }, - { kpi: 'Efficiency', value: `${efficiency.toFixed(1)}%` }, - { kpi: 'Peak Demand', value: `${peak_demand_kw.toFixed(2)} kW` }, // ✅ added - { kpi: 'Power Factor', value: `${power_factor.toFixed(2)} kW` }, // ✅ added - { kpi: 'Load Factor', value: `${load_factor.toFixed(2)} kW` }, // ✅ added -]; + const rows = [ + { label: "Monthly Yield", value: formatValue(kpiData?.yield_kwh ?? null, " kWh", 0) }, + { label: "Monthly Consumption", value: formatValue(kpiData?.consumption_kwh ?? null, " kWh", 0) }, + { label: "Monthly Grid Draw", value: formatValue(kpiData?.grid_draw_kwh ?? null, " kWh", 0) }, + { label: "Efficiency", value: formatValue(kpiData?.efficiency ?? null, "%", 1) }, + { label: "Peak Demand", value: formatValue(kpiData?.peak_demand_kw ?? null, " kW") }, + { label: "Power Factor", value: formatValue(kpiData?.avg_power_factor ?? null) }, + { label: "Load Factor", value: formatValue(kpiData?.load_factor ?? null) }, + ]; return (

Monthly KPI

- - - - - - - - - {data.map((row) => ( - - - - - ))} - -
KPIValue
{row.kpi}{row.value}
+
+ {!siteId ? ( +

No site selected

+ ) : loading ? ( +

Loading...

+ ) : ( + + + + + + + + + {rows.map((row) => ( + + + + + ))} + +
KPIValue
{row.label}{row.value}
+ )} +
); }; @@ -106,3 +89,4 @@ const data = [ export default KPI_Table; + diff --git a/components/dashboards/LoggingControl.tsx b/components/dashboards/LoggingControl.tsx new file mode 100644 index 0000000..1303678 --- /dev/null +++ b/components/dashboards/LoggingControl.tsx @@ -0,0 +1,223 @@ +'use client'; + +import React, { useEffect, useMemo, useState } from 'react'; +import axios from 'axios'; + +type FnType = 'grid' | 'solar'; + +interface LoggingControlCardProps { + siteId: string; + projectLabel?: string; // nice display (e.g., CRM project_name) + className?: string; +} + +const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000'; + +type FnState = { + serial: string; + isLogging: boolean; + isBusy: boolean; // to block double clicks while calling API + error?: string | null; +}; + +const emptyFnState: FnState = { serial: '', isLogging: false, isBusy: false, error: null }; + +const storageKey = (siteId: string) => `logging_control_${siteId}`; + +export default function LoggingControlCard({ + siteId, + projectLabel, + className = '', +}: LoggingControlCardProps) { + const [grid, setGrid] = useState(emptyFnState); + const [solar, setSolar] = useState(emptyFnState); + + // Load persisted state (if any) + useEffect(() => { + try { + const raw = localStorage.getItem(storageKey(siteId)); + if (raw) { + const parsed = JSON.parse(raw); + setGrid({ ...emptyFnState, ...(parsed.grid ?? {}) }); + setSolar({ ...emptyFnState, ...(parsed.solar ?? {}) }); + } else { + setGrid(emptyFnState); + setSolar(emptyFnState); + } + } catch { + setGrid(emptyFnState); + setSolar(emptyFnState); + } + }, [siteId]); + + // Persist on any change + useEffect(() => { + const data = { grid, solar }; + try { + localStorage.setItem(storageKey(siteId), JSON.stringify(data)); + } catch { + // ignore storage errors + } + }, [siteId, grid, solar]); + + const title = useMemo( + () => `Logging Control${projectLabel ? ` — ${projectLabel}` : ''}`, + [projectLabel] + ); + + const topicsFor = (fn: FnType, serial: string) => { + return [`ADW300/${siteId}/${serial}/${fn}`]; + }; + + const start = async (fn: FnType) => { + const state = fn === 'grid' ? grid : solar; + const setState = fn === 'grid' ? setGrid : setSolar; + + if (!state.serial.trim()) { + setState((s) => ({ ...s, error: 'Please enter a meter serial number.' })); + return; + } + + setState((s) => ({ ...s, isBusy: true, error: null })); + + try { + const topics = topicsFor(fn, state.serial.trim()); + const res = await axios.post(`${API_URL}/start-logging`, { topics }); + console.log('Start logging:', res.data); + setState((s) => ({ ...s, isLogging: true, isBusy: false })); + } catch (e: any) { + console.error('Failed to start logging', e); + setState((s) => ({ + ...s, + isBusy: false, + error: e?.response?.data?.detail || e?.message || 'Failed to start logging', + })); + } + }; + + const stop = async (fn: FnType) => { + const state = fn === 'grid' ? grid : solar; + const setState = fn === 'grid' ? setGrid : setSolar; + + if (!state.isLogging) return; + + const confirmed = window.confirm( + `Stop logging for ${fn.toUpperCase()} meter "${state.serial}" at site ${siteId}?` + ); + if (!confirmed) return; + + setState((s) => ({ ...s, isBusy: true, error: null })); + + try { + const topics = topicsFor(fn, state.serial.trim()); + const res = await axios.post(`${API_URL}/stop-logging`, { topics }); + console.log('Stop logging:', res.data); + setState((s) => ({ ...s, isLogging: false, isBusy: false })); + } catch (e: any) { + console.error('Failed to stop logging', e); + setState((s) => ({ + ...s, + isBusy: false, + error: e?.response?.data?.detail || e?.message || 'Failed to stop logging', + })); + } + }; + + // Responsive utility classes + const field = + 'w-full px-3 py-2 sm:py-2.5 border rounded-md text-sm sm:text-base placeholder:text-gray-400 dark:border-rtgray-700 dark:bg-rtgray-700 dark:text-white'; + + const label = + 'text-gray-600 dark:text-white/85 font-medium text-sm sm:text-base mb-1 flex items-center justify-between mr-2.5'; + + const section = ( + fn: FnType, + labelText: string, + state: FnState, + setState: React.Dispatch> + ) => ( +
+
+ {labelText} + {state.isLogging && ( + + Logging + + )} +
+ + {/* Input + Button: stack on mobile, row on ≥sm */} +
+ setState((s) => ({ ...s, serial: e.target.value }))} + disabled={state.isLogging || state.isBusy} + aria-label={`${labelText} serial number`} + /> + + {!state.isLogging ? ( + + ) : ( + + )} +
+ + {!!state.error &&
{state.error}
} +
+ ); + + return ( +
+

+ {title} +

+ + {section('grid', 'Grid Meter', grid, setGrid)} +
+ {section('solar', 'Solar Meter', solar, setSolar)} + +
+ • Inputs lock while logging is active. Stop to edit the serial. +
+ • Topics follow{' '} + + ADW300/{'{'}siteId{'}'}/{'{'}serial{'}'}/(grid|solar) + + . +
+
+ ); +} + diff --git a/components/dashboards/MonthlyBarChart.tsx b/components/dashboards/MonthlyBarChart.tsx index 7c23505..57e15e7 100644 --- a/components/dashboards/MonthlyBarChart.tsx +++ b/components/dashboards/MonthlyBarChart.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { BarChart, Bar, @@ -9,46 +9,25 @@ import { Legend, } from 'recharts'; import { format } from 'date-fns'; -import { fetchPowerTimeseries } from '@/app/utils/api'; - +import { fetchMonthlyKpi, type MonthlyKPI } from '@/app/utils/api'; interface MonthlyBarChartProps { siteId: string; } -interface TimeSeriesEntry { - time: string; - value: number; -} - -const groupTimeSeries = ( - data: TimeSeriesEntry[], - mode: 'monthly' -): TimeSeriesEntry[] => { - const groupMap = new Map(); - - for (const entry of data) { - const date = new Date(entry.time); - const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; - if (!groupMap.has(key)) groupMap.set(key, []); - groupMap.get(key)!.push(entry.value); +const getLastNMonthKeys = (n: number): string[] => { + const out: string[] = []; + const now = new Date(); + // include current month, go back n-1 months + for (let i = 0; i < n; i++) { + const d = new Date(now.getFullYear(), now.getMonth() - (n - 1 - i), 1); + const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; // YYYY-MM + out.push(key); } - - return Array.from(groupMap.entries()).map(([time, values]) => ({ - time, - value: values.reduce((sum, v) => sum + v, 0), - })); + return out; }; - - -const MonthlyBarChart: React.FC = ({ siteId }) => { - const [chartData, setChartData] = useState< - { month: string; consumption: number; generation: number }[] - >([]); - const [loading, setLoading] = useState(true); - - function useIsDarkMode() { +function useIsDarkMode() { const [isDark, setIsDark] = useState(() => typeof document !== 'undefined' ? document.body.classList.contains('dark') @@ -58,79 +37,82 @@ const MonthlyBarChart: React.FC = ({ siteId }) => { useEffect(() => { const check = () => setIsDark(document.body.classList.contains('dark')); check(); - - // Listen for class changes on const observer = new MutationObserver(check); observer.observe(document.body, { attributes: true, attributeFilter: ['class'] }); - return () => observer.disconnect(); }, []); return isDark; } -const isDark = useIsDarkMode(); +const MonthlyBarChart: React.FC = ({ siteId }) => { + const [chartData, setChartData] = useState< + { month: string; consumption: number; generation: number }[] + >([]); + const [loading, setLoading] = useState(true); -const consumptionColor = isDark ? '#ba8e23' : '#003049'; -const generationColor = isDark ? '#fcd913' : '#669bbc'; + const isDark = useIsDarkMode(); + const consumptionColor = isDark ? '#ba8e23' : '#003049'; + const generationColor = isDark ? '#fcd913' : '#669bbc'; + + const monthKeys = useMemo(() => getLastNMonthKeys(6), []); useEffect(() => { if (!siteId) return; - const fetchMonthlyData = async () => { + const load = async () => { setLoading(true); - const start = '2025-01-01T00:00:00+08:00'; - const end = '2025-12-31T23:59:59+08:00'; - try { - const res = await fetchPowerTimeseries(siteId, start, end); + // Fetch all 6 months in parallel + const results: MonthlyKPI[] = await Promise.all( + monthKeys.map((month) => + fetchMonthlyKpi({ + site: siteId, + month, + // consumption_topic: '...', // optional if your API needs it + // generation_topic: '...', // optional if your API needs it + }).catch((e) => { + // normalize failures to an error-shaped record so the chart can still render other months + return { + site: siteId, + month, + yield_kwh: null, + consumption_kwh: null, + grid_draw_kwh: null, + efficiency: null, + peak_demand_kw: null, + avg_power_factor: null, + load_factor: null, + error: String(e), + } as MonthlyKPI; + }) + ) + ); - const groupedConsumption = groupTimeSeries(res.consumption, 'monthly'); - const groupedGeneration = groupTimeSeries(res.generation, 'monthly'); + // Map to chart rows; default nulls to 0 for stacking/tooltip friendliness + const rows = results.map((kpi) => { + const monthLabel = format(new Date(`${kpi.month}-01`), 'MMM'); + return { + month: monthLabel, + consumption: kpi.consumption_kwh ?? 0, + generation: kpi.yield_kwh ?? 0, + }; + }); - const monthMap = new Map(); - - for (const entry of groupedConsumption) { - if (!monthMap.has(entry.time)) { - monthMap.set(entry.time, { consumption: 0, generation: 0 }); - } - monthMap.get(entry.time)!.consumption = entry.value; - } - - for (const entry of groupedGeneration) { - if (!monthMap.has(entry.time)) { - monthMap.set(entry.time, { consumption: 0, generation: 0 }); - } - monthMap.get(entry.time)!.generation = entry.value; - } - - const formatted = Array.from(monthMap.entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([key, val]) => ({ - month: format(new Date(`${key}-01`), 'MMM'), - consumption: val.consumption, - generation: val.generation, - })); - - setChartData(formatted.slice(-6)); // last 6 months - } catch (error) { - console.error('Failed to fetch monthly power data:', error); - setChartData([]); + setChartData(rows); } finally { setLoading(false); } }; - fetchMonthlyData(); - }, [siteId]); + load(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [siteId]); // monthKeys are stable via useMemo if (loading || !siteId || chartData.length === 0) { return ( -
-
-

Monthly Energy Yield

-
-
+
+

{loading ? 'Loading data...' : 'No data available for chart.'}

@@ -140,12 +122,8 @@ const generationColor = isDark ? '#fcd913' : '#669bbc'; } return ( -
-
-

Monthly Energy Yield

-
- -
+
+
[`${value.toFixed(2)} kWh`]} @@ -171,15 +159,11 @@ const generationColor = isDark ? '#fcd913' : '#669bbc'; color: isDark ? '#fff' : '#222', }} cursor={{ - fill: isDark ? '#808080' : '#e0e7ef', // dark mode bg, light mode bg - fillOpacity: isDark ? 0.6 : 0.3, // adjust opacity as you like - }} - /> - + @@ -191,3 +175,4 @@ const generationColor = isDark ? '#fcd913' : '#669bbc'; export default MonthlyBarChart; + diff --git a/components/dashboards/SiteCard.tsx b/components/dashboards/SiteCard.tsx index f38ec36..ff47cca 100644 --- a/components/dashboards/SiteCard.tsx +++ b/components/dashboards/SiteCard.tsx @@ -1,63 +1,146 @@ // components/dashboards/SiteCard.tsx -import React from 'react'; -import Link from 'next/link'; // Import Link from Next.js -import { SiteName, SiteDetails } from '@/types/SiteData'; // Adjust path as necessary +'use client'; -interface SiteCardProps { - siteName: SiteName; - details: SiteDetails; - status: string; -} +import React, { useEffect, useMemo, useState } from 'react'; +import Link from 'next/link'; +import { formatAddress } from '@/app/utils/formatAddress'; +import { formatCrmTimestamp } from '@/app/utils/datetime'; -const SiteCard: React.FC = ({ siteName, details, status }) => { - const statusColorClass = - status === 'Active' ? 'text-green-500' : - status === 'Inactive' ? 'text-orange-500' : - 'text-red-500'; - - return ( -
-

- {siteName} -

- -
-

Status:

-

{status}

-
- -
-

Location:

-

{details.location}

-
- -
-

Inverter Provider:

-

{details.inverterProvider}

-
- -
-

Emergency Contact:

-

{details.emergencyContact}

-
- -
-

Last Sync:

-

{details.lastSyncTimestamp}

-
- - {/* New: View Dashboard Button */} - - View Dashboard - -
- ); +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; }; -export default SiteCard; \ No newline at end of file +interface SiteCardProps { + siteId: string; // CRM Project "name" (canonical id) + className?: string; // optional styling hook + fallbackStatus?: string; // optional backup status if CRM is missing it +} + +const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000'; + +const SiteCard: React.FC = ({ siteId, className = '', fallbackStatus }) => { + const [project, setProject] = useState(null); + const [loading, setLoading] = useState(true); + const [err, setErr] = useState(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 = + status === 'Active' ? 'text-green-500' : + status === 'Inactive' ? 'text-orange-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 ( +
+

+ {project?.project_name || siteId} +

+ + {loading ? ( +
+
+
+
+
+
+ ) : err ? ( +
Failed to load CRM: {err}
+ ) : !project ? ( +
No CRM project found for {siteId}.
+ ) : ( + <> +
+

Status:

+

{status}

+
+ +
+

Location:

+

{niceAddress}

+
+ +
+

Inverter Provider:

+

{inverterProvider}

+
+ +
+

Emergency Contact:

+

{emergencyContact}

+
+ +
+

Last Sync:

+

{lastSync}

+
+ + )} + + + View Dashboard + +
+ ); +}; + +export default SiteCard; diff --git a/components/dashboards/SiteSelector.tsx b/components/dashboards/SiteSelector.tsx index a8f0544..616bdc8 100644 --- a/components/dashboards/SiteSelector.tsx +++ b/components/dashboards/SiteSelector.tsx @@ -1,26 +1,51 @@ +'use client'; -import type { SiteName } from '@/components/dashboards/SiteStatus'; +type Option = { label: string; value: string }; type SiteSelectorProps = { - selectedSite: SiteName; - setSelectedSite: (site: SiteName) => void; + options: Option[]; // e.g. [{label: 'Timo… (Installation)', value: 'PROJ-0008'}, …] + selectedValue: string | null; // the selected project "name" (siteId) or null + onChange: (value: string) => void; // called with the selected value + label?: string; + disabled?: boolean; }; -const SiteSelector = ({ selectedSite, setSelectedSite }: SiteSelectorProps) => { + +const SiteSelector = ({ + options, + selectedValue, + onChange, + label = 'Select Site:', + disabled = false, +}: SiteSelectorProps) => { + const isEmpty = !options || options.length === 0; + return ( -
- +
+ +
); }; export default SiteSelector; + diff --git a/components/dashboards/SiteStatus.tsx b/components/dashboards/SiteStatus.tsx index fb252a6..5c1ecf3 100644 --- a/components/dashboards/SiteStatus.tsx +++ b/components/dashboards/SiteStatus.tsx @@ -1,217 +1,152 @@ -import axios from "axios"; -import React, { useState, useEffect } from "react"; +'use client'; -export type SiteName = 'Site A' | 'Site B' | 'Site C'; +import axios from "axios"; +import React, { useState, useEffect, useMemo } from "react"; + +export type SiteName = string; interface SiteStatusProps { - selectedSite: SiteName; - location: string; - inverterProvider: string; - emergencyContact: string; - lastSyncTimestamp: string; + selectedSite: string; // display label (e.g., CRM project_name) + siteId: string; // canonical id (e.g., CRM Project.name like PROJ-0008) + status?: string; // CRM status (Open/Completed/On Hold/…) + location: string; + inverterProvider: string; + emergencyContact: string; + lastSyncTimestamp: string; } +const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000"; +const WS_URL = process.env.NEXT_PUBLIC_WS_URL ?? "ws://localhost:8000/ws"; + const SiteStatus = ({ - selectedSite, - location, - inverterProvider, - emergencyContact, - lastSyncTimestamp, + selectedSite, + siteId, + status, + location, + inverterProvider, + emergencyContact, + lastSyncTimestamp, }: SiteStatusProps) => { - useEffect(() => { - const ws = new WebSocket("ws://localhost:8000/ws"); - - ws.onmessage = (event) => { - const data = event.data; - alert(`MQTT: ${data}`); - }; + // --- WebSocket to receive MQTT-forwarded messages --- + useEffect(() => { + const ws = new WebSocket(WS_URL); ws.onopen = () => console.log("WebSocket connected"); ws.onclose = () => console.log("WebSocket disconnected"); + ws.onerror = (e) => console.error("WebSocket error:", e); + + ws.onmessage = (event) => { + // Tip: avoid alert storms; log or toast instead + try { + const data = JSON.parse(event.data); + console.log("WS:", data); + } catch { + console.log("WS raw:", event.data); + } + }; return () => ws.close(); -}, []); + }, []); + const [showModal, setShowModal] = useState(false); + const [deviceId, setDeviceId] = useState(""); + const [functionType, setFunctionType] = useState<"Grid" | "Solar">("Grid"); + // Track devices connected per siteId (dynamic) + const [loggedDevices, setLoggedDevices] = useState>({}); + const devicesAtSite = loggedDevices[siteId] ?? []; - const [showModal, setShowModal] = useState(false); - const [deviceId, setDeviceId] = useState(""); - const [functionType, setFunctionType] = useState("Grid"); + const handleStartLogging = () => setShowModal(true); - // Map site names to site IDs - const siteIdMap: Record = { - "Site A": "site_01", - "Site B": "site_02", - "Site C": "site_03", - }; + const handleConfirm = async () => { + const id = deviceId.trim(); + if (!id) return; - // Track devices connected per site - const [loggedDevices, setLoggedDevices] = useState>({ - site_01: [], - site_02: [], - site_03: [], - }); + const topic = `ADW300/${siteId}/${id}/${functionType.toLowerCase()}`; - const siteId = siteIdMap[selectedSite]; - const devicesAtSite = loggedDevices[siteId] || []; + try { + const response = await axios.post(`${API_URL}/start-logging`, { topics: [topic] }); + console.log("Started logging:", response.data); - const handleStartLogging = () => { - setShowModal(true); - }; + setLoggedDevices(prev => ({ + ...prev, + [siteId]: [...(prev[siteId] ?? []), id], + })); + setShowModal(false); + setDeviceId(""); + } catch (error) { + console.error("Failed to start logging:", error); + } + }; - const handleConfirm = async () => { - const siteId = siteIdMap[selectedSite]; - const topic = `ADW300/${siteId}/${deviceId}/${functionType.toLowerCase()}`; + const handleStopLogging = async () => { + try { + // Stop only this site's topics (both function types for each device) + const topics = (loggedDevices[siteId] ?? []).flatMap(did => [ + `ADW300/${siteId}/${did}/grid`, + `ADW300/${siteId}/${did}/solar`, + ]); + await axios.post(`${API_URL}/stop-logging`, topics.length ? { topics } : {}); - try { - const response = await axios.post("http://localhost:8000/start-logging", { - topics: [topic], - }); - console.log("Started logging:", response.data); + setLoggedDevices(prev => ({ ...prev, [siteId]: [] })); + console.log("Stopped logging for", siteId); + } catch (error) { + console.error("Failed to stop logging:", error); + } + }; - // Add device to list - setLoggedDevices((prev) => ({ - ...prev, - [siteId]: [...(prev[siteId] || []), deviceId], - })); - setShowModal(false); + const statusClass = useMemo(() => { + const s = (status ?? "").toLowerCase(); + if (s === "open" || s === "active") return "text-green-500"; + if (s === "completed" || s === "closed") return "text-blue-500"; + if (s === "inactive" || s === "on hold") return "text-orange-500"; + if (s === "faulty" || s === "cancelled") return "text-red-500"; + return "text-gray-500"; + }, [status]); - } catch (error) { - console.error("Failed to start logging:", error); - } - }; + return ( +
+

Site Details

- const handleStopLogging = async () => { - try { - await axios.post("http://localhost:8000/stop-logging"); + {/* Status (from CRM) */} +
+

Status:

+

{status ?? "—"}

+
- // Clear all devices for the site (or modify to remove only specific one) - setLoggedDevices((prev) => ({ - ...prev, - [siteId]: [], - })); + {/* Site ID */} +
+

Site ID:

+

{siteId}

+
- console.log("Stopped logging for", siteId); - } catch (error) { - console.error("Failed to stop logging:", error); - } - }; + {/* Location */} +
+

Location:

+

{location}

+
- const statusMap: Record = { - 'Site A': 'Active', - 'Site B': 'Inactive', - 'Site C': 'Faulty', - }; + {/* Inverter Provider */} +
+

Inverter Provider:

+

{inverterProvider}

+
- return ( -
-

Site Details

+ {/* Emergency Contact */} +
+

Emergency Contact:

+

{emergencyContact}

+
- {/* Status */} -
-

Status:

-

- {statusMap[selectedSite]} -

-
- - {/* Site ID */} -
-

Site ID:

-

{siteId}

-
- - {/* Location */} -
-

Location:

-

{location}

-
- - {/* Inverter Provider */} -
-

Inverter Provider:

-

{inverterProvider}

-
- - {/* Emergency Contact */} -
-

Emergency Contact:

-

{emergencyContact}

-
- - {/* Last Sync */} -
-

Last Sync:

-

{lastSyncTimestamp}

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

Enter Device Info

- - setDeviceId(e.target.value)} - /> - - - -
- - -
-
-
- )} -
- ); + {/* Last Sync */} +
+

Last Sync:

+

{lastSyncTimestamp}

+
+
+ ); }; export default SiteStatus; + diff --git a/components/dashboards/kpibottom.tsx b/components/dashboards/kpibottom.tsx new file mode 100644 index 0000000..d43553b --- /dev/null +++ b/components/dashboards/kpibottom.tsx @@ -0,0 +1,54 @@ +// components/dashboards/KpiBottom.tsx +'use client'; +import React, { ReactNode } from 'react'; + +type Props = { + efficiencyPct: number; // % value (0..100) + powerFactor: number; // 0..1 + loadFactor: number; // ratio, not % + middle?: ReactNode; + right?: ReactNode; +}; + +const Panel = ({ title, children }: { title: string; children: ReactNode }) => ( +
+
{title}
+
{children}
+
+); + +const Stat = ({ value, label, accent = false }: { value: ReactNode; label: string; accent?: boolean }) => ( +
+
{value}
+
{label}
+
+); + +export default function KpiBottom({ + efficiencyPct, powerFactor, loadFactor, middle, right, +}: Props) { + return ( +
+ +
+ + + +
+
+ + +
+ {middle} +
+
+ + +
+ {right} +
+
+
+ ); +} + diff --git a/components/dashboards/kpitop.tsx b/components/dashboards/kpitop.tsx new file mode 100644 index 0000000..8daac3d --- /dev/null +++ b/components/dashboards/kpitop.tsx @@ -0,0 +1,74 @@ +// components/KpiTop.tsx +import React from "react"; + +type Props = { + month?: string; + yieldKwh: number; + consumptionKwh: number; + gridDrawKwh: number; +}; + +const Card: React.FC<{ title: string; value: string; accent?: boolean; icon?: React.ReactNode }> = ({ + title, + value, + accent, + icon, +}) => ( +
+
+
+ {icon} +
+
+
{title}
+
{value}
+
+
+
+); + + +export default function KpiTop({ month, yieldKwh, consumptionKwh, gridDrawKwh }: Props) { + return ( +
+ {month &&
{month}
} +
+ + + + + } + /> + + + + + } + /> + + + + + } + /> +
+
+ ); +} diff --git a/components/dropdown.tsx b/components/dropdown.tsx index 30bb517..7fef922 100644 --- a/components/dropdown.tsx +++ b/components/dropdown.tsx @@ -1,57 +1,73 @@ +// Dropdown.tsx 'use client'; import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'; import { usePopper } from 'react-popper'; +import type { ReactNode } from 'react'; -const Dropdown = (props: any, forwardedRef: any) => { - const [visibility, setVisibility] = useState(false); +type DropdownProps = { + button?: ReactNode; // 👈 make optional + children: ReactNode; + btnClassName?: string; + placement?: any; + offset?: [number, number]; + panelClassName?: string; + closeOnItemClick?: boolean; +}; - const referenceRef = useRef(); - const popperRef = useRef(); +const Dropdown = (props: DropdownProps, forwardedRef: any) => { + const [visible, setVisible] = useState(false); + const referenceRef = useRef(null); + const popperRef = useRef(null); - const { styles, attributes } = usePopper(referenceRef.current, popperRef.current, { - placement: props.placement || 'bottom-end', - modifiers: [ - { - name: 'offset', - options: { - offset: props.offset || [0], - }, - }, - ], - }); + const { styles, attributes } = usePopper(referenceRef.current, popperRef.current, { + placement: props.placement || 'bottom-end', + modifiers: [{ name: 'offset', options: { offset: props.offset ?? [0, 8] } }], + }); - const handleDocumentClick = (event: any) => { - if (referenceRef.current.contains(event.target) || popperRef.current.contains(event.target)) { - return; - } - - setVisibility(false); + useEffect(() => { + const onDoc = (e: MouseEvent) => { + if (!referenceRef.current || !popperRef.current) return; + if (referenceRef.current.contains(e.target as Node)) return; + if (popperRef.current.contains(e.target as Node)) return; + setVisible(false); }; + document.addEventListener('mousedown', onDoc); + return () => document.removeEventListener('mousedown', onDoc); + }, []); - useEffect(() => { - document.addEventListener('mousedown', handleDocumentClick); - return () => { - document.removeEventListener('mousedown', handleDocumentClick); - }; - }, []); + useImperativeHandle(forwardedRef, () => ({ close: () => setVisible(false) })); - useImperativeHandle(forwardedRef, () => ({ - close() { - setVisibility(false); - }, - })); + const defaultButton = ( + + ); - return ( - <> - + return ( + <> + -
setVisibility(!visibility)}> - {visibility && props.children} -
- - ); +
+ {visible && ( +
+ {props.children} +
+ )} +
+ + ); }; export default forwardRef(Dropdown); + + diff --git a/components/layouts/header.tsx b/components/layouts/header.tsx index b0f2db0..19402f0 100644 --- a/components/layouts/header.tsx +++ b/components/layouts/header.tsx @@ -4,253 +4,175 @@ import { useDispatch, useSelector } from 'react-redux'; import Link from 'next/link'; import { IRootState } from '@/store'; import { toggleTheme, toggleSidebar, toggleRTL } from '@/store/themeConfigSlice'; +import Image from 'next/image'; import Dropdown from '@/components/dropdown'; import IconMenu from '@/components/icon/icon-menu'; -import IconCalendar from '@/components/icon/icon-calendar'; -import IconEdit from '@/components/icon/icon-edit'; -import IconChatNotification from '@/components/icon/icon-chat-notification'; -import IconSearch from '@/components/icon/icon-search'; -import IconXCircle from '@/components/icon/icon-x-circle'; import IconSun from '@/components/icon/icon-sun'; import IconMoon from '@/components/icon/icon-moon'; -import IconLaptop from '@/components/icon/icon-laptop'; -import IconMailDot from '@/components/icon/icon-mail-dot'; -import IconArrowLeft from '@/components/icon/icon-arrow-left'; -import IconInfoCircle from '@/components/icon/icon-info-circle'; -import IconBellBing from '@/components/icon/icon-bell-bing'; import IconUser from '@/components/icon/icon-user'; import IconMail from '@/components/icon/icon-mail'; import IconLockDots from '@/components/icon/icon-lock-dots'; import IconLogout from '@/components/icon/icon-logout'; -import IconMenuDashboard from '@/components/icon/menu/icon-menu-dashboard'; -import IconCaretDown from '@/components/icon/icon-caret-down'; -import IconMenuApps from '@/components/icon/menu/icon-menu-apps'; -import IconMenuComponents from '@/components/icon/menu/icon-menu-components'; -import IconMenuElements from '@/components/icon/menu/icon-menu-elements'; -import IconMenuDatatables from '@/components/icon/menu/icon-menu-datatables'; -import IconMenuForms from '@/components/icon/menu/icon-menu-forms'; -import IconMenuPages from '@/components/icon/menu/icon-menu-pages'; -import IconMenuMore from '@/components/icon/menu/icon-menu-more'; import { usePathname, useRouter } from 'next/navigation'; -import { getTranslation } from '@/i18n'; -const Header = () => { - const pathname = usePathname(); - const dispatch = useDispatch(); - const router = useRouter(); - const { t, i18n } = getTranslation(); +type UserData = { id: string; email: string; createdAt: string }; - useEffect(() => { - const selector = document.querySelector('ul.horizontal-menu a[href="' + window.location.pathname + '"]'); - if (selector) { - const all: any = document.querySelectorAll('ul.horizontal-menu .nav-link.active'); - for (let i = 0; i < all.length; i++) { - all[0]?.classList.remove('active'); - } +export default function Header() { + const pathname = usePathname(); + const dispatch = useDispatch(); + const router = useRouter(); + const themeConfig = useSelector((state: IRootState) => state.themeConfig); + const isRtl = themeConfig.rtlClass === 'rtl'; - let allLinks = document.querySelectorAll('ul.horizontal-menu a.active'); - for (let i = 0; i < allLinks.length; i++) { - const element = allLinks[i]; - element?.classList.remove('active'); - } - selector?.classList.add('active'); + const [user, setUser] = useState(null); + const [loadingUser, setLoadingUser] = useState(true); - const ul: any = selector.closest('ul.sub-menu'); - if (ul) { - let ele: any = ul.closest('li.menu').querySelectorAll('.nav-link'); - if (ele) { - ele = ele[0]; - setTimeout(() => { - ele?.classList.add('active'); - }); - } - } - } - }, [pathname]); - - const isRtl = useSelector((state: IRootState) => state.themeConfig.rtlClass) === 'rtl'; - - const themeConfig = useSelector((state: IRootState) => state.themeConfig); - const setLocale = (flag: string) => { - if (flag.toLowerCase() === 'ae') { - dispatch(toggleRTL('rtl')); - } else { - dispatch(toggleRTL('ltr')); - } - router.refresh(); - }; - - function createMarkup(messages: any) { - return { __html: messages }; - } - const [messages, setMessages] = useState([ - { - id: 1, - image: '', - title: 'Congratulations!', - message: 'Your OS has been updated.', - time: '1hr', - }, - { - id: 2, - image: '', - title: 'Did you know?', - message: 'You can switch between artboards.', - time: '2hr', - }, - { - id: 3, - image: ' ', - title: 'Something went wrong!', - message: 'Send Reposrt', - time: '2days', - }, - { - id: 4, - image: ' ', - title: 'Warning', - message: 'Your password strength is low.', - time: '5days', - }, - ]); - - const removeMessage = (value: number) => { - setMessages(messages.filter((user) => user.id !== value)); - }; - - const [notifications, setNotifications] = useState([ - { - id: 1, - profile: 'user-profile.jpeg', - message: 'John Doeinvite you to Prototyping', - time: '45 min ago', - }, - { - id: 2, - profile: 'profile-34.jpeg', - message: 'Adam Nolanmentioned you to UX Basics', - time: '9h Ago', - }, - { - id: 3, - profile: 'profile-16.jpeg', - message: 'Anna MorganUpload a file', - time: '9h Ago', - }, - ]); - - const removeNotification = (value: number) => { - setNotifications(notifications.filter((user) => user.id !== value)); - }; - - const [search, setSearch] = useState(false); - - return ( -
-
-
-
- - logo - Rooftop Energy - - -
- -
- - {/* ------------------- Start Theme Switch ------------------- */} -
- {themeConfig.theme === 'light' ? ( - - ) : ( - '' - )} - {themeConfig.theme === 'dark' && ( - - )} -
- {/* ------------------- End Theme Switch ------------------- */} - - -
- } - > -
    -
  • -
    - userProfile -
    -

    - John Doe - Pro -

    - -
    -
    -
  • -
  • - - - Profile - -
  • -
  • - - - Inbox - -
  • -
  • - - - Lock Screen - -
  • -
  • - - - Sign Out - -
  • -
-
-
-
-
- - -
-
+ // Highlight active menu (your original effect) + useEffect(() => { + const selector = document.querySelector( + 'ul.horizontal-menu a[href="' + window.location.pathname + '"]' ); -}; + if (selector) { + document + .querySelectorAll('ul.horizontal-menu .nav-link.active') + .forEach((el) => el.classList.remove('active')); + document + .querySelectorAll('ul.horizontal-menu a.active') + .forEach((el) => el.classList.remove('active')); + selector.classList.add('active'); + const ul: any = selector.closest('ul.sub-menu'); + if (ul) { + const ele: any = ul.closest('li.menu')?.querySelector('.nav-link'); + setTimeout(() => ele?.classList.add('active')); + } + } + }, [pathname]); -export default Header; + async function loadUser() { + try { + const res = await fetch('/api/auth/me', { + method: 'GET', + credentials: 'include', // send cookie + cache: 'no-store', // avoid stale cached responses + }); + if (!res.ok) throw new Error(); + const data = await res.json(); + setUser(data.user); + } catch { + setUser(null); + } finally { + setLoadingUser(false); + } +} + +useEffect(() => { + setLoadingUser(true); + loadUser(); + // eslint-disable-next-line react-hooks/exhaustive-deps +}, [pathname]); // re-fetch on route change (after login redirect) + + const handleLogout = async () => { + await fetch('/api/auth/logout', { method: 'POST' }); + setUser(null); + router.push('/login'); // go to login + }; + + return ( +
+
+
+ {/* Logo */} +
+
+ logo +
+ + +
+ + + {/* Right-side actions */} +
+ {/* Theme toggle */} + {themeConfig.theme === 'light' ? ( + + ) : ( + + )} + + {/* User dropdown */} +
+ {loadingUser ? ( +
+ ) : user ? ( + + +
+ } + > +
    {/* make sure this stays transparent */} +
  • +
    +

    {user.email}

    +
    +
  • +
  • + + Profile + +
  • +
  • + + Lock Screen + +
  • +
  • + +
  • +
+ + ) : ( + + Sign In + + )} +
+
+
+
+
+ ); +} diff --git a/components/layouts/sidebar.tsx b/components/layouts/sidebar.tsx index 162092d..b3f6326 100644 --- a/components/layouts/sidebar.tsx +++ b/components/layouts/sidebar.tsx @@ -67,16 +67,15 @@ const Sidebar = () => {