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.tsx b/App.tsx index aa626fc..a94c018 100644 --- a/App.tsx +++ b/App.tsx @@ -32,7 +32,7 @@ function App({ children }: PropsWithChildren) {
{isLoading ? : children} diff --git a/app/(admin)/adminDashboard/dashlayout.tsx b/app/(admin)/adminDashboard/dashlayout.tsx new file mode 100644 index 0000000..94655b5 --- /dev/null +++ b/app/(admin)/adminDashboard/dashlayout.tsx @@ -0,0 +1,32 @@ +// components/layouts/DashboardLayout.tsx +'use client'; + +import { useSelector } from 'react-redux'; +import { IRootState } from '@/store'; +import Sidebar from '@/components/layouts/sidebar'; +import Header from '@/components/layouts/header'; // Correctly import the consolidated Header +import Footer from '@/components/layouts/footer'; + +const DashboardLayout = ({ children }: { children: React.ReactNode }) => { + const themeConfig = useSelector((state: IRootState) => state.themeConfig); + const semidark = useSelector((state: IRootState) => state.themeConfig.semidark); + + return ( +
+ {/* Only render the single, consolidated Header component */} +
+ + +
+ {/* This is where your page content will be injected */} +
+ {children} +
+
+
+
+ + ); +}; + +export default DashboardLayout; \ No newline at end of file diff --git a/app/(admin)/adminDashboard/page.tsx b/app/(admin)/adminDashboard/page.tsx new file mode 100644 index 0000000..bc97fce --- /dev/null +++ b/app/(admin)/adminDashboard/page.tsx @@ -0,0 +1,434 @@ +'use client'; + +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 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 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; +}; + +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(); + + // --- load CRM projects dynamically --- + const [sites, setSites] = useState([]); + const [sitesLoading, setSitesLoading] = useState(true); + const [sitesError, setSitesError] = useState(null); + // near other refs + const loggingRef = useRef(null); + + + useEffect(() => { + setSitesLoading(true); + 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(() => { + if (!selectedSiteId) return; + + const fetchToday = async () => { + const { start, end } = withTZ(new Date()); + + try { + const raw = await fetchPowerTimeseries(selectedSiteId, start, end); + const consumption = raw.consumption.map((d: any) => ({ time: d.time, value: d.value })); + const generation = raw.generation.map((d: any) => ({ time: d.time, value: d.value })); + setTimeSeriesData({ consumption, generation }); + + const anyToday = (consumption?.length ?? 0) > 0 || (generation?.length ?? 0) > 0; + setHasTodayData(anyToday); + } catch (error) { + console.error('Failed to fetch power time series:', error); + setHasTodayData(false); + } + }; + + fetchToday(); + }, [selectedSiteId]); + + // Check historical data (last 30 days) → controls empty state + useEffect(() => { + if (!selectedSiteId) return; + + const fetchHistorical = async () => { + try { + const endDate = new Date(); + const startDate = new Date(); + startDate.setDate(endDate.getDate() - 30); + + const startISO = `${startDate.toISOString().split('T')[0]}T00:00:00+08:00`; + const endISO = `${endDate.toISOString().split('T')[0]}T23:59:59+08:00`; + + const raw = await fetchPowerTimeseries(selectedSiteId, startISO, endISO); + const anyHistorical = + (raw?.consumption?.length ?? 0) > 0 || + (raw?.generation?.length ?? 0) > 0; + + setHasAnyData(anyHistorical); + } catch (e) { + console.error('Failed to check historical data:', e); + setHasAnyData(false); + } + }; + + fetchHistorical(); + }, [selectedSiteId]); + + // --- KPI monthly --- + const [kpi, setKpi] = useState(null); + + useEffect(() => { + 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 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 energyChartRef = useRef(null); + const monthlyChartRef = useRef(null); + + const handlePDFExport = async () => { + const doc = new jsPDF('p', 'mm', 'a4'); + const chartRefs = [ + { ref: energyChartRef, title: 'Energy Line Chart' }, + { ref: monthlyChartRef, title: 'Monthly Energy Yield' } + ]; + + let yOffset = 10; + + 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; + + doc.setFontSize(14); + doc.text(chart.title, 10, yOffset); + yOffset += 6; + + if (yOffset + imgHeight > doc.internal.pageSize.getHeight()) { + doc.addPage(); + yOffset = 10; + } + + doc.addImage(imgData, 'PNG', 10, yOffset, pdfWidth, imgHeight); + yOffset += imgHeight + 10; + } + + 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.
+
+ ); + } + + // 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 new file mode 100644 index 0000000..03752fa --- /dev/null +++ b/app/(admin)/sites/page.tsx @@ -0,0 +1,187 @@ +'use client'; + +import React, { useEffect, useMemo, useState } from 'react'; +import DashboardLayout from '../adminDashboard/dashlayout'; +import SiteCard from '@/components/dashboards/SiteCard'; + +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 [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/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index d7d6ef1..ef76b0c 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -7,28 +7,57 @@ type Props = {} const LoginPage = (props: Props) => { return ( -
-
- image +
+ {/* Background gradient layer */} +
+ background gradient +
-
- image - image - image - image -
-
-
-
-

Sign in

-

Enter your email and password to login

-
+ {/* Background decorative objects */} + left decor + right decor + + {/* Centered card wrapper */} +
+
+ {/* Inner card (glassmorphic effect) */} +
+
+ {/* Header */} +

+ Sign In +

+

+ Enter your email and password to access your account. +

+ + {/* Login form */} -
- Don't have an account ?  - + {/* Footer link */} +
+ Don’t have an account?{" "} + SIGN UP
@@ -37,7 +66,8 @@ const LoginPage = (props: Props) => {
- ) -} + ); +}; + export default LoginPage diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx index 5b3a7b1..11b05b0 100644 --- a/app/(auth)/register/page.tsx +++ b/app/(auth)/register/page.tsx @@ -6,30 +6,58 @@ import React from 'react'; type Props = {} const RegisterPage = (props: Props) => { - return ( -
-
- image + return ( +
+ {/* Background gradient layer */} +
+ background gradient +
-
- image - image - image - image -
-
+ {/* Background decorative objects */} + left decor + right decor -
-
-

Sign Up

-

Enter your email and password to register

-
+ {/* Centered card wrapper */} +
+
+ {/* Inner card (glassmorphic effect) */} +
+
+ {/* Header */} +

+ Sign Up +

+

+ Enter your email and password to register +

+ + {/* Login form */} -
- Already have an account ?  - + {/* Footer link */} +
+ Already have an account ?{" "} + SIGN IN
@@ -38,7 +66,7 @@ const RegisterPage = (props: Props) => {
- ) + ); } export default RegisterPage diff --git a/app/(defaults)/chint/inverters/[id]/page.tsx b/app/(defaults)/chint/inverters/[id]/page.tsx index e34f686..4b64183 100644 --- a/app/(defaults)/chint/inverters/[id]/page.tsx +++ b/app/(defaults)/chint/inverters/[id]/page.tsx @@ -25,6 +25,9 @@ const InverterViewPage = (props: Props) => { const fetchData = async () => { try { + if (!params || !params.id) { + throw new Error("Invalid params or params.id is missing"); + } const res = await axios.get(`https://api-a.fomware.com.cn/asset/v1/list?type=2&key=${params.id.toString()}`, { headers: { "Authorization": "Bearer " + process.env.NEXT_PUBLIC_CHINT_TOKEN @@ -45,7 +48,7 @@ const InverterViewPage = (props: Props) => { {loading ?

Loading...

: ( <> - +
{isMounted && ( diff --git a/app/(defaults)/layout.tsx b/app/(defaults)/layout.tsx index 6c27841..579f997 100644 --- a/app/(defaults)/layout.tsx +++ b/app/(defaults)/layout.tsx @@ -6,43 +6,33 @@ import Header from '@/components/layouts/header'; import MainContainer from '@/components/layouts/main-container'; import Overlay from '@/components/layouts/overlay'; import ScrollToTop from '@/components/layouts/scroll-to-top'; -import Setting from '@/components/layouts/setting'; import Sidebar from '@/components/layouts/sidebar'; import Portals from '@/components/portals'; -import withAuth from '@/hoc/withAuth'; -import { FC } from 'react'; +import withAuth from '@/hoc/withAuth'; // make sure this matches your export style +import { FC, ReactNode } from 'react'; -const DefaultLayout: FC<{ children: React.ReactNode }> = ({ children }) => { - - return ( - <> - {/* BEGIN MAIN CONTAINER */} -
- - - - - {/* BEGIN SIDEBAR */} - - {/* END SIDEBAR */} -
- {/* BEGIN TOP NAVBAR */} -
- {/* END TOP NAVBAR */} - - {/* BEGIN CONTENT AREA */} - {children} - {/* END CONTENT AREA */} - - {/* BEGIN FOOTER */} -
- {/* END FOOTER */} - -
-
-
- - ); +interface DefaultLayoutProps { + children: ReactNode; } +const DefaultLayout: FC = ({ children }) => { + return ( +
+ + + + + +
+
+ {children} +
+ +
+
+
+ ); +}; + export default withAuth(DefaultLayout); + diff --git a/app/(defaults)/page.tsx b/app/(defaults)/page.tsx index 8d06fa8..97234e7 100644 --- a/app/(defaults)/page.tsx +++ b/app/(defaults)/page.tsx @@ -1,11 +1,59 @@ -import { Metadata } from 'next'; -import React from 'react'; - -export const metadata: Metadata = { -}; +'use client'; +import React, { useState } from 'react'; +import { useRouter } from 'next/navigation'; const Sales = () => { - return
starter page
; + const [selectedSite, setSelectedSite] = useState(''); + const sites = ['Site A', 'Site B', 'Site C']; + const router = useRouter(); + + const handleGoToDashboard = () => { + if (selectedSite) { + router.push(`/adminDashboard?site=${encodeURIComponent(selectedSite)}`); + } +}; + + return ( +
+

+ Welcome to Rooftop Dashboard! +

+

+ Select a site to get started. +

+
+ + + + {selectedSite && ( +
+

You selected: {selectedSite}

+ +
+ )} +
+
+ ); }; export default Sales; + + 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/layout.tsx b/app/layout.tsx index 8ba396c..ce1fc5c 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,15 +1,17 @@ +'use client'; import ProviderComponent from '@/components/layouts/provider-component'; import 'react-perfect-scrollbar/dist/css/styles.css'; import '../styles/tailwind.css'; import { Metadata } from 'next'; import { Nunito } from 'next/font/google'; +import { Exo_2 } from "next/font/google"; + +const exo2 = Exo_2({ + subsets: ["latin"], + variable: "--font-exo2", + weight: ["200", "400"], +}); -export const metadata: Metadata = { - title: { - template: '%s | Rooftop Energy - Admin', - default: 'Rooftop Energy - Admin', - }, -}; const nunito = Nunito({ weight: ['400', '500', '600', '700', '800'], subsets: ['latin'], @@ -20,7 +22,7 @@ const nunito = Nunito({ export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - + {children} diff --git a/app/utils/api.ts b/app/utils/api.ts new file mode 100644 index 0000000..09773e1 --- /dev/null +++ b/app/utils/api.ts @@ -0,0 +1,88 @@ +// app/utils/api.ts +export interface TimeSeriesEntry { + time: string; + value: number; +} + +export interface TimeSeriesResponse { + consumption: TimeSeriesEntry[]; + 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, + end: string +): Promise { // <-- Change here + const params = new URLSearchParams({ site, start, end }); + + const res = await fetch(`http://localhost:8000/power-timeseries?${params.toString()}`); + + if (!res.ok) { + throw new Error(`Failed to fetch data: ${res.status}`); + } + + const json = await res.json(); + console.log(`🔍 API response from /power-timeseries?${params.toString()}:`, json); // ✅ log here + return json; // <-- This is a single object, not an array +} + +export async function fetchForecast( + lat: number, + lon: number, + dec: number, + az: number, + kwp: number +): Promise<{ time: string; forecast: number }[]> { + const query = new URLSearchParams({ + lat: lat.toString(), + lon: lon.toString(), + dec: dec.toString(), + az: az.toString(), + kwp: kwp.toString(), + }).toString(); + + const res = await fetch(`http://localhost:8000/forecast?${query}`); + if (!res.ok) throw new Error("Failed to fetch forecast"); + + 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 77cf895..b478c44 100644 --- a/components/auth/components-auth-login-form.tsx +++ b/components/auth/components-auth-login-form.tsx @@ -2,63 +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 6db417a..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)} type="email" placeholder="Enter Email" className="form-input ps-10 placeholder:text-white-dark" /> - - - -
-
-
- -
- setPassword(e.target.value)} type="password" 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 new file mode 100644 index 0000000..e719d2b --- /dev/null +++ b/components/dashboards/EnergyLineChart.tsx @@ -0,0 +1,604 @@ +import React, { useRef, useEffect, useState } from 'react'; +import { Line } from 'react-chartjs-2'; +import ChartJS from 'chart.js/auto'; +import zoomPlugin from 'chartjs-plugin-zoom'; +import { + getISOWeek, + startOfDay, + endOfDay, + startOfWeek, + endOfWeek, + startOfMonth, + endOfMonth, + startOfYear, + endOfYear, +} from 'date-fns'; +import { fetchPowerTimeseries, fetchForecast } from '@/app/utils/api'; +import { color } from 'html2canvas/dist/types/css/types/color'; +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 { + time: string; + value: number; +} + +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', + agg: 'mean' | 'max' | 'sum' = 'mean' +): TimeSeriesEntry[] { + const groupMap = new Map(); + + for (const entry of data) { + const date = new Date(entry.time); + let key = ''; + + switch (mode) { + 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': + key = `${date.getFullYear()}-W${String(getISOWeek(date)).padStart(2, '0')}`; + break; + case 'monthly': + key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; + break; + case 'yearly': + key = date.getFullYear().toString(); + break; + } + + if (!groupMap.has(key)) groupMap.set(key, []); + groupMap.get(key)!.push(entry.value); + } + + 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'); + const [consumption, setConsumption] = useState([]); + const [generation, setGeneration] = useState([]); + 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' + ? document.body.classList.contains('dark') + : false + ); + + useEffect(() => { + const check = () => setIsDark(document.body.classList.contains('dark')); + const observer = new MutationObserver(check); + observer.observe(document.body, { attributes: true, attributeFilter: ['class'] }); + return () => observer.disconnect(); + }, []); + + return isDark; +} + + useEffect(() => { + 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(); + + const fetchData = async () => { + try { + const res = await fetchPowerTimeseries(siteId, isoStart, isoEnd); + setConsumption(res.consumption); + setGeneration(res.generation); + + // ⬇️ ADD THIS here — fetch forecast + const forecastData = await fetchForecast(3.15, 101.7, 37, 0, 30.67); + const selectedDateStr = selectedDate.toISOString().split('T')[0]; + + setForecast( + forecastData + .filter(({ time }) => time.startsWith(selectedDateStr)) // ✅ filter only selected date + .map(({ time, forecast }) => ({ + time, + value: forecast + })) + ); + + } catch (error) { + console.error('Failed to fetch energy timeseries:', error); + } + }; + + fetchData(); + }, [siteId, viewMode, selectedDate]); + + 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])); + + + const allTimes = Array.from(new Set([ + ...groupedConsumption.map(d => d.time), + ...groupedGeneration.map(d => d.time), + ...groupedForecast.map(d => d.time), +])).sort((a, b) => new Date(a).getTime() - new Date(b).getTime()); + + + + const consumptionMap = Object.fromEntries(groupedConsumption.map(d => [d.time, d.value])); + const generationMap = Object.fromEntries(groupedGeneration.map(d => [d.time, d.value])); + + const [startIndex, setStartIndex] = useState(0); + const [endIndex, setEndIndex] = useState(allTimes.length - 1); + + + useEffect(() => { + if (typeof window !== 'undefined') { + import('hammerjs'); + } + }, []); + + useEffect(() => { + setStartIndex(0); + setEndIndex(allTimes.length - 1); + }, [viewMode, allTimes.length]); + + const formatLabel = (key: string) => { + switch (viewMode) { + case 'day': + return new Date(key).toLocaleTimeString('en-GB', { + hour: '2-digit', + minute: '2-digit', + hour12: false, + timeZone: 'Asia/Kuala_Lumpur', + }); + case 'monthly': + return new Date(`${key}-01`).toLocaleString('en-GB', { month: 'short', year: 'numeric' }); + case 'weekly': + return key.replace('-', ' '); + default: + return key; + } + }; + + const filteredLabels = allTimes.slice(startIndex, endIndex + 1); + const filteredConsumption = filteredLabels.map(t => consumptionMap[t] ?? 0); + const filteredGeneration = filteredLabels.map(t => generationMap[t] ?? 0); + const filteredForecast = filteredLabels.map(t => forecastMap[t] ?? null); + + + const allValues = [...filteredConsumption, ...filteredGeneration].filter(v => v !== null) as number[]; + const maxValue = allValues.length > 0 ? Math.max(...allValues) : 0; + const yAxisSuggestedMax = maxValue * 1.15; + + const isDark = useIsDarkMode(); + +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), + datasets: [ + { + label: 'Consumption', + data: filteredConsumption, + borderColor: consumptionColor, + backgroundColor: (ctx: any) => areaGradient(ctx, consumptionColor), + fill: true, // <-- fill under line + tension: 0.4, + spanGaps: true, + }, + { + label: 'Generation', + data: filteredGeneration, + borderColor: generationColor, + backgroundColor: (ctx: any) => areaGradient(ctx, generationColor), + fill: true, // <-- fill under line + tension: 0.4, + spanGaps: true, + }, + { + label: 'Forecasted Solar', + data: filteredForecast, + 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: true, + spanGaps: true, + } + ], + }; + + const options = { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'top', + labels: { + color: axisColor, // legend text color + }, + }, + zoom: { + zoom: { + wheel: { enabled: true }, + pinch: { enabled: true }, + mode: 'x' as const, + }, + pan: { enabled: true, mode: 'x' as const }, + }, + tooltip: { + enabled: true, + mode: 'index', + intersect: false, + backgroundColor: isDark ? '#232b3e' : '#fff', + titleColor: axisColor, + 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: { + x: { + title: { + display: true, + color: axisColor, + text: + viewMode === 'day' + ? 'Time (HH:MM)' + : viewMode === 'daily' + ? 'Day' + : viewMode === 'weekly' + ? 'Week' + : viewMode === 'monthly' + ? 'Month' + : 'Year', + font: { weight: 'normal' as const }, + }, + ticks: { + color: axisColor, + }, + }, + y: { + beginAtZero: true, + suggestedMax: yAxisSuggestedMax, + title: { display: true, text: yTitle, color: axisColor, font: { weight: 'normal' as const } }, + ticks: { + color: axisColor, + }, + }, + + }, + } as const; + + const handleResetZoom = () => { + chartRef.current?.resetZoom(); + }; + + return ( +
+
+
+

Energy Consumption & Generation

+ +
+ +
+ {viewMode === 'day' && ( + + )} + + + + +
+ +
+ +
+
+
+ ); +}; + +export default EnergyLineChart; + + + + + + + diff --git a/components/dashboards/KPIStatus.tsx b/components/dashboards/KPIStatus.tsx new file mode 100644 index 0000000..d2bf736 --- /dev/null +++ b/components/dashboards/KPIStatus.tsx @@ -0,0 +1,92 @@ +import React, { useEffect, useState } from "react"; + +interface KPI_TableProps { + siteId: string; + month: string; // format: "YYYY-MM" +} + +interface 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; +} + +const KPI_Table: React.FC = ({ siteId, month }) => { + const [kpiData, setKpiData] = useState(null); + 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}` + ); + setKpiData(await res.json()); + } catch (err) { + console.error("Failed to fetch KPI:", err); + setKpiData(null); + } finally { + setLoading(false); + } + }; + + fetchKPI(); + }, [siteId, month]); + + const formatValue = (value: number | null, unit = "", decimals = 2) => + value != null ? `${value.toFixed(decimals)}${unit}` : "—"; + + 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

+
+ {!siteId ? ( +

No site selected

+ ) : loading ? ( +

Loading...

+ ) : ( + + + + + + + + + {rows.map((row) => ( + + + + + ))} + +
KPIValue
{row.label}{row.value}
+ )} +
+
+ ); +}; + +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 new file mode 100644 index 0000000..57e15e7 --- /dev/null +++ b/components/dashboards/MonthlyBarChart.tsx @@ -0,0 +1,178 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, + Legend, +} from 'recharts'; +import { format } from 'date-fns'; +import { fetchMonthlyKpi, type MonthlyKPI } from '@/app/utils/api'; + +interface MonthlyBarChartProps { + siteId: string; +} + +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 out; +}; + +function useIsDarkMode() { + const [isDark, setIsDark] = useState(() => + typeof document !== 'undefined' + ? document.body.classList.contains('dark') + : false + ); + + useEffect(() => { + const check = () => setIsDark(document.body.classList.contains('dark')); + check(); + const observer = new MutationObserver(check); + observer.observe(document.body, { attributes: true, attributeFilter: ['class'] }); + return () => observer.disconnect(); + }, []); + + return isDark; +} + +const MonthlyBarChart: React.FC = ({ siteId }) => { + const [chartData, setChartData] = useState< + { month: string; consumption: number; generation: number }[] + >([]); + const [loading, setLoading] = useState(true); + + const isDark = useIsDarkMode(); + const consumptionColor = isDark ? '#ba8e23' : '#003049'; + const generationColor = isDark ? '#fcd913' : '#669bbc'; + + const monthKeys = useMemo(() => getLastNMonthKeys(6), []); + + useEffect(() => { + if (!siteId) return; + + const load = async () => { + setLoading(true); + try { + // 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; + }) + ) + ); + + // 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, + }; + }); + + setChartData(rows); + } finally { + setLoading(false); + } + }; + + load(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [siteId]); // monthKeys are stable via useMemo + + if (loading || !siteId || chartData.length === 0) { + return ( +
+
+

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

+
+
+ ); + } + + return ( +
+
+ + + + + [`${value.toFixed(2)} kWh`]} + labelFormatter={(label) => `${label}`} + contentStyle={{ + background: isDark ? '#232b3e' : '#fff', + color: isDark ? '#fff' : '#222', + border: isDark ? '1px solid #444' : '1px solid #ccc', + }} + labelStyle={{ + color: isDark ? '#fff' : '#222', + }} + cursor={{ + fill: isDark ? '#808080' : '#e0e7ef', + fillOpacity: isDark ? 0.6 : 0.3, + }} + /> + + + + + +
+
+ ); +}; + +export default MonthlyBarChart; + + diff --git a/components/dashboards/SiteCard.tsx b/components/dashboards/SiteCard.tsx new file mode 100644 index 0000000..ff47cca --- /dev/null +++ b/components/dashboards/SiteCard.tsx @@ -0,0 +1,146 @@ +// components/dashboards/SiteCard.tsx +'use client'; + +import React, { useEffect, useMemo, useState } from 'react'; +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 { + 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 new file mode 100644 index 0000000..616bdc8 --- /dev/null +++ b/components/dashboards/SiteSelector.tsx @@ -0,0 +1,51 @@ +'use client'; + +type Option = { label: string; value: string }; + +type SiteSelectorProps = { + 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 = ({ + 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 new file mode 100644 index 0000000..5c1ecf3 --- /dev/null +++ b/components/dashboards/SiteStatus.tsx @@ -0,0 +1,152 @@ +'use client'; + +import axios from "axios"; +import React, { useState, useEffect, useMemo } from "react"; + +export type SiteName = string; + +interface SiteStatusProps { + 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, + siteId, + status, + location, + inverterProvider, + emergencyContact, + lastSyncTimestamp, +}: SiteStatusProps) => { + + // --- 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 handleStartLogging = () => setShowModal(true); + + const handleConfirm = async () => { + const id = deviceId.trim(); + if (!id) return; + + const topic = `ADW300/${siteId}/${id}/${functionType.toLowerCase()}`; + + try { + const response = await axios.post(`${API_URL}/start-logging`, { topics: [topic] }); + console.log("Started logging:", response.data); + + setLoggedDevices(prev => ({ + ...prev, + [siteId]: [...(prev[siteId] ?? []), id], + })); + setShowModal(false); + setDeviceId(""); + } catch (error) { + console.error("Failed to start logging:", error); + } + }; + + 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 } : {}); + + setLoggedDevices(prev => ({ ...prev, [siteId]: [] })); + console.log("Stopped logging for", siteId); + } catch (error) { + console.error("Failed to stop logging:", error); + } + }; + + 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]); + + return ( +
+

Site Details

+ + {/* Status (from CRM) */} +
+

Status:

+

{status ?? "—"}

+
+ + {/* Site ID */} +
+

Site ID:

+

{siteId}

+
+ + {/* Location */} +
+

Location:

+

{location}

+
+ + {/* Inverter Provider */} +
+

Inverter Provider:

+

{inverterProvider}

+
+ + {/* Emergency Contact */} +
+

Emergency Contact:

+

{emergencyContact}

+
+ + {/* Last Sync */} +
+

Last Sync:

+

{lastSyncTimestamp}

+
+
+ ); +}; + +export default SiteStatus; + diff --git a/components/dashboards/datepicker-dark.css b/components/dashboards/datepicker-dark.css new file mode 100644 index 0000000..3e4f21f --- /dev/null +++ b/components/dashboards/datepicker-dark.css @@ -0,0 +1,77 @@ +/* ========== LIGHT MODE (Default) ========== */ +.react-datepicker { + background-color: #ffffff; /* white bg */ + color: #111827; /* dark gray text */ + border: 1px solid #d1d5db; /* light gray border */ +} + +.react-datepicker__header { + background-color: #f3f4f6; + border-bottom: 1px solid #e5e7eb; + color: #111827; +} + +.react-datepicker__day, +.react-datepicker__day-name, +.react-datepicker__current-month { + color: #111827; +} + +.react-datepicker__navigation-icon::before { + border-color: #111827; +} + +.react-datepicker__day--selected, +.react-datepicker__day--keyboard-selected { + background-color: #3b82f6; /* blue highlight */ + color: #ffffff; +} + +.react-datepicker__day:hover { + background-color: #e5e7eb; + color: #111827; +} + +.react-datepicker__day--disabled { + color: #9ca3af; +} + +/* ========== DARK MODE (Wrap in `.dark`) ========== */ +.dark .react-datepicker { + background-color: #141624; + color: #ffffff; + border: 1px solid #374151; +} + +.dark .react-datepicker__header { + background-color: #080912; + border-bottom: 1px solid #4b5563; + color: #ffffff; +} + +.dark .react-datepicker__day, +.dark .react-datepicker__day-name, +.dark .react-datepicker__current-month { + color: #ffffff; +} + +.dark .react-datepicker__navigation-icon::before { + border-color: #ffffff; +} + +.dark .react-datepicker__day--selected, +.dark .react-datepicker__day--keyboard-selected { + background-color: #fcd913; + color: #000000; +} + +.dark .react-datepicker__day:hover { + background-color: #374151; + color: #ffffff; +} + +.dark .react-datepicker__day--disabled { + color: #555; +} + + 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/footer.tsx b/components/layouts/footer.tsx index ef0b262..ba31fcb 100644 --- a/components/layouts/footer.tsx +++ b/components/layouts/footer.tsx @@ -1,6 +1,54 @@ +import React from 'react'; + const Footer = () => { + const socialLinks = { + instagram: 'https://www.instagram.com/rooftop.my/', + linkedin: 'https://my.linkedin.com/company/rooftop-my?trk=public_jobs_topcard_logo', + facebook: 'https://www.facebook.com/profile.php?id=61572728757164', + whatsapp: 'https://wa.me/message/XFIYMAVF27EBE1', + email: 'mailto:sales@rooftop.my', + }; + return ( -
© {new Date().getFullYear()}. Rooftop Energy All rights reserved.
+
+ {/* Social Links */} +
+ {Object.entries(socialLinks).map(([platform, url]) => ( + + {platform.charAt(0).toUpperCase() + + ))} +
+ + {/* Divider */} +
+ + {/* Contact Info */} +
+
+

Rooftop Energy Tech Sdn Bhd

+

202501013544 (1613958-P)

+

+ 3-5, Block D2, Dataran Prima,
+ 47301 Petaling Jaya,
+ Selangor, Malaysia +

+
+
+ + {/* Copyright */} +

Rooftop Energy © 2025

+
); }; diff --git a/components/layouts/header.tsx b/components/layouts/header.tsx index 11958da..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-toggle.tsx b/components/layouts/sidebar-toggle.tsx new file mode 100644 index 0000000..2c95de8 --- /dev/null +++ b/components/layouts/sidebar-toggle.tsx @@ -0,0 +1,26 @@ +'use client'; + +import { useDispatch, useSelector } from 'react-redux'; +import { toggleSidebar } from '@/store/themeConfigSlice'; +import { IRootState } from '@/store'; + +const SidebarToggleButton = () => { + const dispatch = useDispatch(); + const themeConfig = useSelector((state: IRootState) => state.themeConfig); + + return ( + + ); +}; + +export default SidebarToggleButton; \ No newline at end of file diff --git a/components/layouts/sidebar.tsx b/components/layouts/sidebar.tsx index 86e07c8..b3f6326 100644 --- a/components/layouts/sidebar.tsx +++ b/components/layouts/sidebar.tsx @@ -62,28 +62,28 @@ const Sidebar = () => { }; return ( +