feature/syasya/testlayout #7

Merged
Syasya merged 27 commits from feature/syasya/testlayout into master 2025-08-20 02:14:09 +00:00
57 changed files with 10709 additions and 724 deletions

View File

@ -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'

View File

@ -32,7 +32,7 @@ function App({ children }: PropsWithChildren) {
<div <div
className={`${(themeConfig.sidebar && 'toggle-sidebar') || ''} ${themeConfig.menu} ${themeConfig.layout} ${ className={`${(themeConfig.sidebar && 'toggle-sidebar') || ''} ${themeConfig.menu} ${themeConfig.layout} ${
themeConfig.rtlClass themeConfig.rtlClass
} main-section relative font-nunito text-sm font-normal antialiased`} } main-section relative font-exo2 text-sm font-normal antialiased`}
> >
{isLoading ? <Loading /> : children} {isLoading ? <Loading /> : children}
<Toaster /> <Toaster />

View File

@ -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 (
<div className={`${semidark ? 'dark' : ''} ${themeConfig.sidebar ? 'toggle-sidebar' : ''}`}>
{/* Only render the single, consolidated Header component */}
<Header/>
<Sidebar />
<div className="main-content">
{/* This is where your page content will be injected */}
<div className="p-6 space-y-4 min-h-screen dark:bg-rtgray-900">
{children}
</div>
</div>
<Footer/>
</div>
);
};
export default DashboardLayout;

View File

@ -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<CrmProject[]>([]);
const [sitesLoading, setSitesLoading] = useState(true);
const [sitesError, setSitesError] = useState<unknown>(null);
// near other refs
const loggingRef = useRef<HTMLDivElement | null>(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<string | null>(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 its 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<string | null>(null);
// Fetch todays 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<MonthlyKPI | null>(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<HTMLDivElement | null>(null);
const monthlyChartRef = useRef<HTMLDivElement | null>(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 (
<DashboardLayout>
<div className="px-6">Loading sites</div>
</DashboardLayout>
);
}
if (sitesError) {
return (
<DashboardLayout>
<div className="px-6 text-red-600">Failed to load sites from CRM.</div>
</DashboardLayout>
);
}
if (!selectedProject) {
return (
<DashboardLayout>
<div className="px-6">No site selected.</div>
</DashboardLayout>
);
}
// Build selector options from CRM
const siteOptions = sites.map(s => ({
label: s.project_name || s.name,
value: s.name,
}));
return (
<DashboardLayout>
<div className="px-3 space-y-6 w-full max-w-screen-3xl mx-auto">
<h1 className="text-lg font-semibold dark:text-white">Admin Dashboard</h1>
{/* Selector + status */}
<div className="grid grid-cols-1 gap-6 w-full min-w-0">
<div className="space-y-4 w-full min-w-0">
<SiteSelector
options={siteOptions}
selectedValue={selectedSiteId!}
onChange={handleSiteChange}
/>
<SiteStatus
selectedSite={selectedProject.project_name || selectedProject.name}
siteId={selectedProject.name}
location={currentSiteDetails.location}
inverterProvider={currentSiteDetails.inverterProvider}
emergencyContact={currentSiteDetails.emergencyContact}
lastSyncTimestamp={currentSiteDetails.lastSyncTimestamp}
/>
</div>
</div>
{/* Small dark yellow banner when there is ZERO historical data */}
{!hasAnyData && (
<div className="rounded-lg border border-amber-400/40 bg-rtyellow-300/20 px-4 py-3 text-amber-600 dark:text-amber-100 flex flex-wrap items-center gap-3">
<span className="font-semibold text-black/85 dark:text-white/85">No data yet.</span>
<span className="opacity-95">Enter the meter number and click <span className="font-semibold text-black/85 dark:text-white/85">Start</span> to begin streaming.
</span>
{startError && <div className="basis-full text-sm text-red-300">{startError}</div>}
</div>
)}
<div ref={loggingRef}>
<LoggingControlCard
siteId={selectedProject.name}
projectLabel={selectedProject.project_name || selectedProject.name}
className="w-full"
/>
</div>
{/* Render the rest only if there is *any* data */}
{hasAnyData && (
<>
{/* Tiny banner if today is empty but historical exists */}
{!hasTodayData && (
<div className="rounded-lg border border-amber-300/50 bg-amber-50 dark:bg-amber-900/20 px-4 py-2 text-amber-800 dark:text-amber-200">
No data yet today charts may be blank until new points arrive.
</div>
)}
{/* TOP 3 CARDS */}
<div className="space-y-4">
<KpiTop
yieldKwh={yieldKwh}
consumptionKwh={consumptionKwh}
gridDrawKwh={gridDrawKwh}
/>
</div>
<div ref={energyChartRef} className="pb-5">
<EnergyLineChart siteId={selectedProject.name} />
</div>
{/* BOTTOM 3 PANELS */}
<KpiBottom
efficiencyPct={efficiencyPct}
powerFactor={powerFactor}
loadFactor={loadFactor}
middle={
<div ref={monthlyChartRef} className="transform scale-90 origin-top">
<MonthlyBarChart siteId={selectedProject.name} />
</div>
}
right={
<div className="flex items-center justify-center w-full px-3 text-center">
<div className="text-3xl font-semibold">
{(kpi?.peak_demand_kw ?? 0).toFixed(2)} kW
</div>
</div>
}
/>
<div className="flex flex-col md:flex-row gap-4 justify-center">
<button onClick={handlePDFExport} className="text-sm lg:text-lg btn-primary">
Export Chart Images to PDF
</button>
</div>
</>
)}
</div>
</DashboardLayout>
);
};
export default AdminDashboard;

187
app/(admin)/sites/page.tsx Normal file
View File

@ -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<CrmProject[]>([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(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 (
<DashboardLayout>
<div className="p-6 space-y-6">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
<h1 className="text-2xl font-bold dark:text-white-light">All Sites Overview</h1>
<div className="flex items-center gap-3">
<input
value={q}
onChange={e => setQ(e.target.value)}
placeholder="Search by name / ID / customer"
className="w-64 max-w-full px-3 py-2 rounded-md border dark:border-gray-700 bg-white dark:bg-gray-900 dark:text-white"
/>
<select
value={pageSize}
onChange={e => setPageSize(Number(e.target.value))}
className="px-3 py-2 rounded-md border dark:border-gray-700 bg-white dark:bg-gray-900 dark:text-white"
aria-label="Items per page"
>
<option value={6}>6 / page</option>
<option value={9}>9 / page</option>
<option value={12}>12 / page</option>
</select>
</div>
</div>
{loading && (
<div className="text-gray-600 dark:text-gray-400">Loading CRM projects</div>
)}
{err && (
<div className="text-red-600">Error: {err}</div>
)}
{!loading && !err && total === 0 && (
<div className="text-amber-600">No sites found.</div>
)}
{!loading && !err && total > 0 && (
<>
{/* Pagination header */}
<div className="flex items-center justify-between">
<div className="text-sm text-gray-600 dark:text-gray-400">
Showing <span className="font-semibold">{startIdx + 1}</span><span className="font-semibold">{endIdx}</span> of <span className="font-semibold">{total}</span>
</div>
<div className="flex items-center gap-2 dark:text-white">
<button
onClick={goPrev}
disabled={safePage <= 1}
className={`px-3 py-1.5 rounded-md border dark:border-gray-700 ${safePage <= 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-50 dark:hover:bg-gray-800'}`}
>
Previous
</button>
<span className="text-sm text-gray-600 dark:text-gray-400">
Page <span className="font-semibold">{safePage}</span> / {totalPages}
</span>
<button
onClick={goNext}
disabled={safePage >= totalPages}
className={`px-3 py-1.5 rounded-md border dark:border-gray-700 ${safePage >= totalPages ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-50 dark:hover:bg-gray-800 '}`}
>
Next
</button>
</div>
</div>
{/* Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{pageItems.map(p => (
<SiteCard
key={p.name}
siteId={p.name} // SiteCard self-fetches details
fallbackStatus={p.status ?? undefined}
/>
))}
</div>
{/* Pagination footer mirrors header for convenience */}
<div className="flex items-center justify-between">
<div className="text-sm text-gray-600 dark:text-gray-400">
Showing <span className="font-semibold">{startIdx + 1}</span><span className="font-semibold">{endIdx}</span> of <span className="font-semibold">{total}</span>
</div>
<div className="flex items-center gap-2 dark:text-white">
<button
onClick={goPrev}
disabled={safePage <= 1}
className={`px-3 py-1.5 rounded-md border dark:border-gray-700 ${safePage <= 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-50 dark:hover:bg-gray-800'}`}
>
Previous
</button>
<span className="text-sm text-gray-600 dark:text-gray-400">
Page <span className="font-semibold">{safePage}</span> / {totalPages}
</span>
<button
onClick={goNext}
disabled={safePage >= totalPages}
className={`px-3 py-1.5 rounded-md border dark:border-gray-700 ${safePage >= totalPages ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-50 dark:hover:bg-gray-800'}`}
>
Next
</button>
</div>
</div>
</>
)}
</div>
</DashboardLayout>
);
};
export default SitesPage;

View File

@ -7,28 +7,57 @@ type Props = {}
const LoginPage = (props: Props) => { const LoginPage = (props: Props) => {
return ( return (
<div> <div className="relative min-h-screen overflow-hidden bg-[#060818] text-white">
<div className="absolute inset-0"> {/* Background gradient layer */}
<img src="/assets/images/auth/bg-gradient.png" alt="image" className="h-full w-full object-cover" /> <div className="absolute inset-0 -z-10">
<img
src="/assets/images/auth/bg-gradient.png"
alt="background gradient"
className="h-full w-full object-cover"
/>
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
</div> </div>
<div className="relative flex min-h-screen items-center justify-center bg-[url(/assets/images/auth/map.png)] bg-cover bg-center bg-no-repeat px-6 py-10 dark:bg-[#060818] sm:px-16"> {/* Background decorative objects */}
<img src="/assets/images/auth/coming-soon-object1.png" alt="image" className="absolute left-0 top-1/2 h-full max-h-[893px] -translate-y-1/2" /> <img
<img src="/assets/images/auth/coming-soon-object2.png" alt="image" className="absolute left-24 top-0 h-40 md:left-[30%]" /> src="/assets/images/auth/coming-soon-object1.png"
<img src="/assets/images/auth/coming-soon-object3.png" alt="image" className="absolute right-0 top-0 h-[300px]" /> alt="left decor"
<img src="/assets/images/auth/polygon-object.svg" alt="image" className="absolute bottom-0 end-[28%]" /> className="absolute left-0 top-1/2 hidden h-full max-h-[893px] -translate-y-1/2 brightness-125 md:block"
<div className="relative w-full max-w-[870px] rounded-md bg-[linear-gradient(45deg,#fff9f9_0%,rgba(255,255,255,0)_25%,rgba(255,255,255,0)_75%,_#fff9f9_100%)] p-2 dark:bg-[linear-gradient(52.22deg,#0E1726_0%,rgba(14,23,38,0)_18.66%,rgba(14,23,38,0)_51.04%,rgba(14,23,38,0)_80.07%,#0E1726_100%)]"> />
<div className="relative flex flex-col justify-center rounded-md bg-white/60 px-6 py-20 backdrop-blur-lg dark:bg-black/50 lg:min-h-[758px]"> <img
<div className="mx-auto w-full max-w-[440px]"> src="/assets/images/auth/coming-soon-object3.png"
<div className="mb-10"> alt="right decor"
<h1 className="text-3xl font-extrabold uppercase !leading-snug text-primary md:text-4xl">Sign in</h1> className="absolute right-0 top-0 hidden h-[300px] brightness-125 md:block"
<p className="text-base font-bold leading-normal text-white-dark">Enter your email and password to login</p> />
</div>
{/* Centered card wrapper */}
<div className="relative flex min-h-screen items-center justify-center px-6 py-10 sm:px-16">
<div
className="relative w-full max-w-[870px] rounded-2xl p-1
bg-[linear-gradient(45deg,#fffbe6_0%,rgba(255,251,230,0)_25%,rgba(255,251,230,0)_75%,#fffbe6_100%)]
dark:bg-[linear-gradient(52.22deg,#facc15_0%,rgba(250,204,21,0)_20%,rgba(250,204,21,0)_80%,#facc15_100%)]"
>
{/* Inner card (glassmorphic effect) */}
<div className="relative z-10 rounded-2xl bg-white/10 px-8 py-16 backdrop-blur-lg dark:bg-white/10 lg:min-h-[600px]">
<div className="mx-auto w-full max-w-[440px] text-center">
{/* Header */}
<h1 className="text-4xl font-extrabold uppercase tracking-wide text-yellow-400 mb-2">
Sign In
</h1>
<p className="text-base font-medium text-gray-200 dark:text-gray-300 mb-8">
Enter your email and password to access your account.
</p>
{/* Login form */}
<ComponentsAuthLoginForm /> <ComponentsAuthLoginForm />
<div className="text-center dark:text-white"> {/* Footer link */}
Don&apos;t have an account ?&nbsp; <div className="mt-6 text-sm text-gray-200 dark:text-gray-300">
<Link href="/register" className="uppercase text-primary underline transition hover:text-black dark:hover:text-white"> Dont have an account?{" "}
<Link
href="/register"
className="text-yellow-400 font-semibold underline transition hover:text-white"
>
SIGN UP SIGN UP
</Link> </Link>
</div> </div>
@ -37,7 +66,8 @@ const LoginPage = (props: Props) => {
</div> </div>
</div> </div>
</div> </div>
) );
} };
export default LoginPage export default LoginPage

View File

@ -6,30 +6,58 @@ import React from 'react';
type Props = {} type Props = {}
const RegisterPage = (props: Props) => { const RegisterPage = (props: Props) => {
return ( return (
<div> <div className="relative min-h-screen overflow-hidden bg-[#060818] text-white">
<div className="absolute inset-0"> {/* Background gradient layer */}
<img src="/assets/images/auth/bg-gradient.png" alt="image" className="h-full w-full object-cover" /> <div className="absolute inset-0 -z-10">
<img
src="/assets/images/auth/bg-gradient.png"
alt="background gradient"
className="h-full w-full object-cover"
/>
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
</div> </div>
<div className="relative flex min-h-screen items-center justify-center bg-[url(/assets/images/auth/map.png)] bg-cover bg-center bg-no-repeat px-6 py-10 dark:bg-[#060818] sm:px-16"> {/* Background decorative objects */}
<img src="/assets/images/auth/coming-soon-object1.png" alt="image" className="absolute left-0 top-1/2 h-full max-h-[893px] -translate-y-1/2" /> <img
<img src="/assets/images/auth/coming-soon-object2.png" alt="image" className="absolute left-24 top-0 h-40 md:left-[30%]" /> src="/assets/images/auth/coming-soon-object1.png"
<img src="/assets/images/auth/coming-soon-object3.png" alt="image" className="absolute right-0 top-0 h-[300px]" /> alt="left decor"
<img src="/assets/images/auth/polygon-object.svg" alt="image" className="absolute bottom-0 end-[28%]" /> className="absolute left-0 top-1/2 hidden h-full max-h-[893px] -translate-y-1/2 brightness-125 md:block"
<div className="relative w-full max-w-[870px] rounded-md bg-[linear-gradient(45deg,#fff9f9_0%,rgba(255,255,255,0)_25%,rgba(255,255,255,0)_75%,_#fff9f9_100%)] p-2 dark:bg-[linear-gradient(52.22deg,#0E1726_0%,rgba(14,23,38,0)_18.66%,rgba(14,23,38,0)_51.04%,rgba(14,23,38,0)_80.07%,#0E1726_100%)]"> />
<div className="relative flex flex-col justify-center rounded-md bg-white/60 px-6 py-20 backdrop-blur-lg dark:bg-black/50 lg:min-h-[758px]"> <img
src="/assets/images/auth/coming-soon-object3.png"
alt="right decor"
className="absolute right-0 top-0 hidden h-[300px] brightness-125 md:block"
/>
<div className="mx-auto w-full max-w-[440px]"> {/* Centered card wrapper */}
<div className="mb-10"> <div className="relative flex min-h-screen items-center justify-center px-6 py-10 sm:px-16">
<h1 className="text-3xl font-extrabold uppercase !leading-snug text-primary md:text-4xl">Sign Up</h1> <div
<p className="text-base font-bold leading-normal text-white-dark">Enter your email and password to register</p> className="relative w-full max-w-[870px] rounded-2xl p-1
</div> bg-[linear-gradient(45deg,#fffbe6_0%,rgba(255,251,230,0)_25%,rgba(255,251,230,0)_75%,#fffbe6_100%)]
dark:bg-[linear-gradient(52.22deg,#facc15_0%,rgba(250,204,21,0)_20%,rgba(250,204,21,0)_80%,#facc15_100%)]"
>
{/* Inner card (glassmorphic effect) */}
<div className="relative z-10 rounded-2xl bg-white/10 px-8 py-16 backdrop-blur-lg dark:bg-white/10 lg:min-h-[600px]">
<div className="mx-auto w-full max-w-[440px] text-center">
{/* Header */}
<h1 className="text-4xl font-extrabold uppercase tracking-wide text-yellow-400 mb-2">
Sign Up
</h1>
<p className="text-base font-medium text-gray-200 dark:text-gray-300 mb-8">
Enter your email and password to register
</p>
{/* Login form */}
<ComponentsAuthRegisterForm /> <ComponentsAuthRegisterForm />
<div className="text-center dark:text-white"> {/* Footer link */}
Already have an account ?&nbsp; <div className="mt-6 text-sm text-gray-200 dark:text-gray-300">
<Link href="/login" className="uppercase text-primary underline transition hover:text-black dark:hover:text-white"> Already have an account ?{" "}
<Link
href="/register"
className="text-yellow-400 font-semibold underline transition hover:text-white"
>
SIGN IN SIGN IN
</Link> </Link>
</div> </div>
@ -38,7 +66,7 @@ const RegisterPage = (props: Props) => {
</div> </div>
</div> </div>
</div> </div>
) );
} }
export default RegisterPage export default RegisterPage

View File

@ -25,6 +25,9 @@ const InverterViewPage = (props: Props) => {
const fetchData = async () => { const fetchData = async () => {
try { 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()}`, { const res = await axios.get(`https://api-a.fomware.com.cn/asset/v1/list?type=2&key=${params.id.toString()}`, {
headers: { headers: {
"Authorization": "Bearer " + process.env.NEXT_PUBLIC_CHINT_TOKEN "Authorization": "Bearer " + process.env.NEXT_PUBLIC_CHINT_TOKEN
@ -45,7 +48,7 @@ const InverterViewPage = (props: Props) => {
{loading ? <p>Loading...</p> : ( {loading ? <p>Loading...</p> : (
<> <>
<PanelCodeHighlight title={params.id.toString() || ""}> <PanelCodeHighlight title={params?.id?.toString() || ""}>
<div className="mb-5"> <div className="mb-5">
{isMounted && ( {isMounted && (
<Tab.Group> <Tab.Group>

View File

@ -6,43 +6,33 @@ import Header from '@/components/layouts/header';
import MainContainer from '@/components/layouts/main-container'; import MainContainer from '@/components/layouts/main-container';
import Overlay from '@/components/layouts/overlay'; import Overlay from '@/components/layouts/overlay';
import ScrollToTop from '@/components/layouts/scroll-to-top'; import ScrollToTop from '@/components/layouts/scroll-to-top';
import Setting from '@/components/layouts/setting';
import Sidebar from '@/components/layouts/sidebar'; import Sidebar from '@/components/layouts/sidebar';
import Portals from '@/components/portals'; import Portals from '@/components/portals';
import withAuth from '@/hoc/withAuth'; import withAuth from '@/hoc/withAuth'; // make sure this matches your export style
import { FC } from 'react'; import { FC, ReactNode } from 'react';
const DefaultLayout: FC<{ children: React.ReactNode }> = ({ children }) => { interface DefaultLayoutProps {
children: ReactNode;
return (
<>
{/* BEGIN MAIN CONTAINER */}
<div className="relative">
<Overlay />
<ScrollToTop />
<MainContainer>
{/* BEGIN SIDEBAR */}
<Sidebar />
{/* END SIDEBAR */}
<div className="main-content flex min-h-screen flex-col">
{/* BEGIN TOP NAVBAR */}
<Header />
{/* END TOP NAVBAR */}
{/* BEGIN CONTENT AREA */}
<ContentAnimation>{children}</ContentAnimation>
{/* END CONTENT AREA */}
{/* BEGIN FOOTER */}
<Footer />
{/* END FOOTER */}
<Portals />
</div>
</MainContainer>
</div>
</>
);
} }
const DefaultLayout: FC<DefaultLayoutProps> = ({ children }) => {
return (
<div className="relative">
<Overlay />
<ScrollToTop />
<MainContainer>
<Sidebar />
<div className="main-content flex min-h-screen flex-col">
<Header />
<ContentAnimation>{children}</ContentAnimation>
<Footer />
<Portals />
</div>
</MainContainer>
</div>
);
};
export default withAuth(DefaultLayout); export default withAuth(DefaultLayout);

View File

@ -1,11 +1,59 @@
import { Metadata } from 'next'; 'use client';
import React from 'react'; import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
export const metadata: Metadata = {
};
const Sales = () => { const Sales = () => {
return <div>starter page</div>; 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 (
<div className="min-h-screen flex flex-col items-center justify-center p-4 bg-gray-50">
<h1 className="text-3xl font-bold mb-4 text-gray-800">
Welcome to Rooftop Dashboard!
</h1>
<h2 className="text-2xl font-bold mb-4 text-gray-800">
Select a site to get started.
</h2>
<div className="w-full max-w-sm">
<label className="block text-gray-700 mb-2">Select Site:</label>
<select
value={selectedSite}
onChange={(e) => setSelectedSite(e.target.value)}
className="w-full p-2 border-2 border-yellow-300 rounded-md"
>
<option value="" disabled>
-- Choose a site --
</option>
{sites.map((site) => (
<option key={site} value={site}>
{site}
</option>
))}
</select>
{selectedSite && (
<div className="flex flex-col space-y-2">
<p className="mt-4 text-green-700">You selected: {selectedSite}</p>
<button
onClick={handleGoToDashboard}
className="btn-primary"
>
Go to Dashboard
</button>
</div>
)}
</div>
</div>
);
}; };
export default Sales; export default Sales;

View File

@ -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<CrmProject[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<unknown>(null);
useEffect(() => {
setLoading(true);
crmapi.getProjects()
.then(res => setData(res.data?.data ?? []))
.catch(setError)
.finally(() => setLoading(false));
}, []);
return { data, loading, error };
}

View File

@ -1,15 +1,17 @@
'use client';
import ProviderComponent from '@/components/layouts/provider-component'; import ProviderComponent from '@/components/layouts/provider-component';
import 'react-perfect-scrollbar/dist/css/styles.css'; import 'react-perfect-scrollbar/dist/css/styles.css';
import '../styles/tailwind.css'; import '../styles/tailwind.css';
import { Metadata } from 'next'; import { Metadata } from 'next';
import { Nunito } from 'next/font/google'; 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({ const nunito = Nunito({
weight: ['400', '500', '600', '700', '800'], weight: ['400', '500', '600', '700', '800'],
subsets: ['latin'], subsets: ['latin'],
@ -20,7 +22,7 @@ const nunito = Nunito({
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="en"> <html lang="en">
<body className={nunito.variable}> <body className={exo2.variable}>
<ProviderComponent>{children}</ProviderComponent> <ProviderComponent>{children}</ProviderComponent>
</body> </body>
</html> </html>

88
app/utils/api.ts Normal file
View File

@ -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<TimeSeriesResponse> { // <-- 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<MonthlyKPI> {
const qs = new URLSearchParams(params as Record<string, string>);
const res = await fetch(`${API}/kpi/monthly?${qs.toString()}`, { cache: "no-store" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}

37
app/utils/datetime.ts Normal file
View File

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

View File

@ -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 (&amp; → &), 2) <br> → \n, 3) tidy whitespace
const text = decode(raw)
.replace(/<br\s*\/?>/gi, "\n")
.replace(/\u00A0/g, " ") // &nbsp;
.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
};
}

View File

@ -2,63 +2,84 @@
import IconLockDots from '@/components/icon/icon-lock-dots'; import IconLockDots from '@/components/icon/icon-lock-dots';
import IconMail from '@/components/icon/icon-mail'; import IconMail from '@/components/icon/icon-mail';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useState } from "react"; import { useState } from 'react';
import axios from "axios"; import axios from 'axios';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
const ComponentsAuthLoginForm = () => { const ComponentsAuthLoginForm = () => {
const [email, setEmail] = useState("") const [email, setEmail] = useState('');
const [password, setPassword] = useState("") const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false);
const router = useRouter() const router = useRouter();
const submitForm = async (e: React.FormEvent) => { const submitForm = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault();
setLoading(true);
setLoading(true) try {
try { const res = await axios.post('/api/login', { email, password });
const res = await axios.post(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/login`, { toast.success(res.data?.message || 'Login successful!');
email, router.push('/adminDashboard');
password, router.refresh();
}) // token cookie is already set by the server:
} catch (err: any) {
localStorage.setItem("token", res.data.token) console.error('Login error:', err);
const msg =
toast.success("Login successful!") err?.response?.data?.message ||
router.push("/") err?.message ||
} catch (err: any) { 'Invalid credentials';
console.error("Login error:", err) toast.error(msg);
toast.error(err.response?.data?.error || "Invalid credentials") } finally {
} finally { setLoading(false);
setLoading(false)
}
} }
};
return ( return (
<form className="space-y-5 dark:text-white" onSubmit={submitForm}> <form className="space-y-3 dark:text-white" onSubmit={submitForm}>
<div> <div>
<label htmlFor="Email">Email</label> <label htmlFor="Email" className="text-yellow-400 text-left">Email</label>
<div className="relative text-white-dark"> <div className="relative text-white-dark">
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Enter Email" className="form-input ps-10 placeholder:text-white-dark" /> <input
<span className="absolute start-4 top-1/2 -translate-y-1/2"> id="Email"
<IconMail fill={true} /> type="email"
</span> value={email}
</div> onChange={(e) => setEmail(e.target.value)}
</div> placeholder="Enter Email"
<div> className="form-input ps-10 placeholder:text-white-dark"
<label htmlFor="Password">Password</label> required
<div className="relative text-white-dark"> />
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required placeholder="Enter Password" className="form-input ps-10 placeholder:text-white-dark" /> <span className="absolute start-4 top-1/2 -translate-y-1/2">
<span className="absolute start-4 top-1/2 -translate-y-1/2"> <IconMail fill={true} />
<IconLockDots fill={true} /> </span>
</span> </div>
</div> </div>
</div> <div className="pb-2">
<button type="submit" disabled={loading} className="btn btn-gradient !mt-6 w-full border-0 uppercase shadow-[0_10px_20px_-10px_rgba(67,97,238,0.44)]"> <label htmlFor="Password" className="text-yellow-400 text-left">Password</label>
{loading ? "Logging in..." : "Sign In"} <div className="relative text-white-dark">
</button> <input
</form> id="Password"
); type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
placeholder="Enter Password"
className="form-input ps-10 placeholder:text-white-dark"
minLength={8}
/>
<span className="absolute start-4 top-1/2 -translate-y-1/2">
<IconLockDots fill={true} />
</span>
</div>
</div>
<button
type="submit"
disabled={loading}
className="w-full uppercase border-0 rounded-md bg-[#fcd913] text-black font-semibold py-2 hover:bg-[#E6C812] transition disabled:opacity-70"
>
{loading ? 'Logging in...' : 'Sign In'}
</button>
</form>
);
}; };
export default ComponentsAuthLoginForm; export default ComponentsAuthLoginForm;

View File

@ -1,74 +1,131 @@
'use client'; // components/auth/components-auth-register-form.tsx
import IconLockDots from '@/components/icon/icon-lock-dots'; "use client";
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';
const ComponentsAuthRegisterForm = () => { import * as React from "react";
const [email, setEmail] = useState("") import { useRouter } from "next/navigation";
const [password, setPassword] = useState("")
const [loading, setLoading] = useState(false)
const router = useRouter()
const submitForm = async(e: any) => { type Props = {
e.preventDefault() redirectTo?: string; // optional override
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 (
<form className="space-y-5 dark:text-white" onSubmit={submitForm}>
{/* <div>
<label htmlFor="Name">Name</label>
<div className="relative text-white-dark">
<input id="Name" type="text" placeholder="Enter Name" className="form-input ps-10 placeholder:text-white-dark" />
<span className="absolute start-4 top-1/2 -translate-y-1/2">
<IconUser fill={true} />
</span>
</div>
</div> */}
<div>
<label htmlFor="Email">Email</label>
<div className="relative text-white-dark">
<input value={email} onChange={(e) => setEmail(e.target.value)} type="email" placeholder="Enter Email" className="form-input ps-10 placeholder:text-white-dark" />
<span className="absolute start-4 top-1/2 -translate-y-1/2">
<IconMail fill={true} />
</span>
</div>
</div>
<div>
<label htmlFor="Password">Password</label>
<div className="relative text-white-dark">
<input value={password} onChange={(e) => setPassword(e.target.value)} type="password" placeholder="Enter Password" className="form-input ps-10 placeholder:text-white-dark" />
<span className="absolute start-4 top-1/2 -translate-y-1/2">
<IconLockDots fill={true} />
</span>
</div>
</div>
<button type="submit" disabled={loading} className="btn btn-gradient !mt-6 w-full border-0 uppercase shadow-[0_10px_20px_-10px_rgba(67,97,238,0.44)]">
{loading ? "Creating account..." : "Register"}
</button>
</form>
);
}; };
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<string | null>(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 (
<form onSubmit={onSubmit} className="space-y-4 text-left">
<div>
<label htmlFor="email" className="mb-1 block text-sm text-gray-300">
Email
</label>
<input
id="email"
type="email"
autoComplete="email"
className="w-full rounded-xl border border-white/10 bg-white/10 px-4 py-3 text-white placeholder-gray-400 outline-none focus:border-yellow-400"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={loading}
required
/>
</div>
<div>
<label htmlFor="password" className="mb-1 block text-sm text-gray-300">
Password
</label>
<input
id="password"
type="password"
autoComplete="new-password"
className="w-full rounded-xl border border-white/10 bg-white/10 px-4 py-3 text-white placeholder-gray-400 outline-none focus:border-yellow-400"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading}
required
minLength={8}
/>
</div>
<div>
<label htmlFor="confirm" className="mb-1 block text-sm text-gray-300">
Confirm Password
</label>
<input
id="confirm"
type="password"
autoComplete="new-password"
className="w-full rounded-xl border border-white/10 bg-white/10 px-4 py-3 text-white placeholder-gray-400 outline-none focus:border-yellow-400"
placeholder="••••••••"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
disabled={loading}
required
minLength={8}
/>
</div>
{error && (
<p className="rounded-lg bg-red-500/10 px-3 py-2 text-sm text-red-300">
{error}
</p>
)}
<button
type="submit"
disabled={loading}
className="inline-flex w-full items-center justify-center rounded-xl bg-yellow-400 px-4 py-3 font-semibold text-black hover:brightness-90 disabled:opacity-60"
>
{loading ? "Creating account…" : "Create account"}
</button>
</form>
);
}

View File

@ -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<string, number[]>();
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<any>(null);
const [viewMode, setViewMode] = useState<'day' | 'daily' | 'weekly' | 'monthly' | 'yearly'>('day');
const [consumption, setConsumption] = useState<TimeSeriesEntry[]>([]);
const [generation, setGeneration] = useState<TimeSeriesEntry[]>([]);
const [selectedDate, setSelectedDate] = useState(new Date());
const [forecast, setForecast] = useState<TimeSeriesEntry[]>([]);
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 (
<div className="bg-white p-4 rounded-lg shadow-md dark:bg-rtgray-800 dark:text-white-light">
<div className="h-98 w-full">
<div className="flex justify-between items-center mb-2">
<h2 className="text-lg font-bold dark:text-white-light">Energy Consumption & Generation</h2>
<button onClick={handleResetZoom} className="btn-primary px-8 py-2 text-sm">
Reset
</button>
</div>
<div className="mb-4 flex gap-4 items-center dark:text-white">
{viewMode === 'day' && (
<label className="font-medium">
Date:{' '}
<DatePicker
selected={selectedDate}
onChange={(date) => setSelectedDate(date!)}
dateFormat="dd/MM/yyyy" // ✅ sets correct format
className="dark:bg-rtgray-700 dark:text-white bg-white border border-rounded dark:border-rtgray-700 text-black p-1 rounded"
/>
</label>
)}
<label className="font-medium ">
From:{' '}
<select
value={startIndex}
onChange={(e) => {
const val = Number(e.target.value);
setStartIndex(val <= endIndex ? val : endIndex);
}}
className="dark:bg-rtgray-700 border dark:border-rtgray-700 rounded p-1"
>
{allTimes.map((label, idx) => (
<option key={idx} value={idx}>{formatLabel(label)}</option>
))}
</select>
</label>
<label className="font-medium ">
To:{' '}
<select
value={endIndex}
onChange={(e) => {
const val = Number(e.target.value);
setEndIndex(val >= startIndex ? val : startIndex);
}}
className="dark:bg-rtgray-700 border dark:border-rtgray-700 rounded p-1"
>
{allTimes.map((label, idx) => (
<option key={idx} value={idx}>{formatLabel(label)}</option>
))}
</select>
</label>
<label className="font-medium">
View:{' '}
<select
value={viewMode}
onChange={(e) => setViewMode(e.target.value as typeof viewMode)}
className="dark:bg-rtgray-700 border dark:border-rtgray-700 rounded p-1"
>
<option value="day">Day</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
<option value="yearly">Yearly</option>
</select>
</label>
</div>
<div className="h-96 w-full">
<Line ref={chartRef} data={data} options={options} />
</div>
</div>
</div>
);
};
export default EnergyLineChart;

View File

@ -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<KPI_TableProps> = ({ siteId, month }) => {
const [kpiData, setKpiData] = useState<MonthlyKPI | null>(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 (
<div>
<h2 className="text-lg font-bold mb-2 dark:text-white">Monthly KPI</h2>
<div className="min-h-[275px] border rounded">
{!siteId ? (
<p className="text-center py-10">No site selected</p>
) : loading ? (
<p className="text-center py-10">Loading...</p>
) : (
<table className="w-full border-collapse">
<thead>
<tr className="bg-gray-100 dark:bg-rtgray-800">
<th className="border p-3 text-left dark:text-white">KPI</th>
<th className="border p-3 text-left dark:text-white">Value</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.label} className="even:bg-gray-50 dark:even:bg-rtgray-800">
<td className="border p-2.5 dark:text-white">{row.label}</td>
<td className="border p-2.5 dark:text-white">{row.value}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
};
export default KPI_Table;

View File

@ -0,0 +1,223 @@
'use client';
import React, { useEffect, useMemo, useState } from 'react';
import axios from 'axios';
type FnType = 'grid' | 'solar';
interface LoggingControlCardProps {
siteId: string;
projectLabel?: string; // nice display (e.g., CRM project_name)
className?: string;
}
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000';
type FnState = {
serial: string;
isLogging: boolean;
isBusy: boolean; // to block double clicks while calling API
error?: string | null;
};
const emptyFnState: FnState = { serial: '', isLogging: false, isBusy: false, error: null };
const storageKey = (siteId: string) => `logging_control_${siteId}`;
export default function LoggingControlCard({
siteId,
projectLabel,
className = '',
}: LoggingControlCardProps) {
const [grid, setGrid] = useState<FnState>(emptyFnState);
const [solar, setSolar] = useState<FnState>(emptyFnState);
// Load persisted state (if any)
useEffect(() => {
try {
const raw = localStorage.getItem(storageKey(siteId));
if (raw) {
const parsed = JSON.parse(raw);
setGrid({ ...emptyFnState, ...(parsed.grid ?? {}) });
setSolar({ ...emptyFnState, ...(parsed.solar ?? {}) });
} else {
setGrid(emptyFnState);
setSolar(emptyFnState);
}
} catch {
setGrid(emptyFnState);
setSolar(emptyFnState);
}
}, [siteId]);
// Persist on any change
useEffect(() => {
const data = { grid, solar };
try {
localStorage.setItem(storageKey(siteId), JSON.stringify(data));
} catch {
// ignore storage errors
}
}, [siteId, grid, solar]);
const title = useMemo(
() => `Logging Control${projectLabel ? `${projectLabel}` : ''}`,
[projectLabel]
);
const topicsFor = (fn: FnType, serial: string) => {
return [`ADW300/${siteId}/${serial}/${fn}`];
};
const start = async (fn: FnType) => {
const state = fn === 'grid' ? grid : solar;
const setState = fn === 'grid' ? setGrid : setSolar;
if (!state.serial.trim()) {
setState((s) => ({ ...s, error: 'Please enter a meter serial number.' }));
return;
}
setState((s) => ({ ...s, isBusy: true, error: null }));
try {
const topics = topicsFor(fn, state.serial.trim());
const res = await axios.post(`${API_URL}/start-logging`, { topics });
console.log('Start logging:', res.data);
setState((s) => ({ ...s, isLogging: true, isBusy: false }));
} catch (e: any) {
console.error('Failed to start logging', e);
setState((s) => ({
...s,
isBusy: false,
error: e?.response?.data?.detail || e?.message || 'Failed to start logging',
}));
}
};
const stop = async (fn: FnType) => {
const state = fn === 'grid' ? grid : solar;
const setState = fn === 'grid' ? setGrid : setSolar;
if (!state.isLogging) return;
const confirmed = window.confirm(
`Stop logging for ${fn.toUpperCase()} meter "${state.serial}" at site ${siteId}?`
);
if (!confirmed) return;
setState((s) => ({ ...s, isBusy: true, error: null }));
try {
const topics = topicsFor(fn, state.serial.trim());
const res = await axios.post(`${API_URL}/stop-logging`, { topics });
console.log('Stop logging:', res.data);
setState((s) => ({ ...s, isLogging: false, isBusy: false }));
} catch (e: any) {
console.error('Failed to stop logging', e);
setState((s) => ({
...s,
isBusy: false,
error: e?.response?.data?.detail || e?.message || 'Failed to stop logging',
}));
}
};
// Responsive utility classes
const field =
'w-full px-3 py-2 sm:py-2.5 border rounded-md text-sm sm:text-base placeholder:text-gray-400 dark:border-rtgray-700 dark:bg-rtgray-700 dark:text-white';
const label =
'text-gray-600 dark:text-white/85 font-medium text-sm sm:text-base mb-1 flex items-center justify-between mr-2.5';
const section = (
fn: FnType,
labelText: string,
state: FnState,
setState: React.Dispatch<React.SetStateAction<FnState>>
) => (
<div className="space-y-2">
<div className={label}>
<span>{labelText}</span>
{state.isLogging && (
<span
className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] sm:text-xs font-semibold
bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-300"
aria-live="polite"
>
Logging
</span>
)}
</div>
{/* Input + Button: stack on mobile, row on ≥sm */}
<div className="flex flex-col sm:flex-row gap-2 sm:gap-3">
<input
type="text"
autoComplete="off"
inputMode="text"
placeholder="Meter serial number"
className={`${field} flex-1`}
value={state.serial}
onChange={(e) => setState((s) => ({ ...s, serial: e.target.value }))}
disabled={state.isLogging || state.isBusy}
aria-label={`${labelText} serial number`}
/>
{!state.isLogging ? (
<button
onClick={() => start(fn)}
disabled={state.isBusy || !state.serial.trim()}
className={`h-10 sm:h-11 rounded-full font-medium transition
w-full sm:w-auto sm:min-w-28 mt-2 lg:mt-0
${state.isBusy || !state.serial.trim()
? 'bg-gray-400 cursor-not-allowed text-black/70'
: 'bg-rtyellow-200 hover:bg-rtyellow-300 text-black'}`}
aria-disabled={state.isBusy || !state.serial.trim()}
>
{state.isBusy ? 'Starting…' : 'Start'}
</button>
) : (
<button
onClick={() => stop(fn)}
disabled={state.isBusy}
className={`h-10 sm:h-11 rounded-full font-medium transition
w-full sm:w-auto sm:min-w-28 mt-2 lg:mt-0
${state.isBusy ? 'bg-gray-400 cursor-not-allowed' : 'bg-red-500 hover:bg-red-600'} text-white`}
aria-disabled={state.isBusy}
>
{state.isBusy ? 'Stopping…' : 'Stop'}
</button>
)}
</div>
{!!state.error && <div className="text-sm sm:text-[15px] text-red-600">{state.error}</div>}
</div>
);
return (
<div
className={`bg-white p-4 sm:p-5 md:p-6 rounded-xl md:rounded-2xl shadow-md space-y-4 md:space-y-5
dark:bg-rtgray-800 dark:text-white-light w-full ${className}`}
>
<h2 className="text-lg sm:text-xl md:text-2xl font-semibold truncate" title={title}>
{title}
</h2>
{section('grid', 'Grid Meter', grid, setGrid)}
<div className="border-t dark:border-rtgray-700" />
{section('solar', 'Solar Meter', solar, setSolar)}
<div className="text-[11px] sm:text-xs text-gray-500 dark:text-gray-400 pt-2 leading-relaxed break-words">
Inputs lock while logging is active. Stop to edit the serial.
<br />
Topics follow{' '}
<code className="break-all">
ADW300/{'{'}siteId{'}'}/{'{'}serial{'}'}/(grid|solar)
</code>
.
</div>
</div>
);
}

View File

@ -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<MonthlyBarChartProps> = ({ 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 (
<div className="bg-white p-3 rounded-lg shadow-md dark:bg-rtgray-800 dark:text-white-light">
<div className="h-[200px] w-full flex items-center justify-center">
<p className="text-white/70">
{loading ? 'Loading data...' : 'No data available for chart.'}
</p>
</div>
</div>
);
}
return (
<div className="bg-white p-3 rounded-lg dark:bg-rtgray-800 dark:text-white-light">
<div className="h-[200px] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData}>
<XAxis
dataKey="month"
tick={{ fontSize: 10, fill: isDark ? '#fff' : '#222' }}
axisLine={{ stroke: isDark ? '#fff' : '#222' }}
tickLine={{ stroke: isDark ? '#fff' : '#222' }}
/>
<YAxis
tick={{ fontSize: 10, fill: isDark ? '#fff' : '#222' }}
axisLine={{ stroke: isDark ? '#fff' : '#222' }}
tickLine={{ stroke: isDark ? '#fff' : '#222' }}
label={{
value: 'Energy (kWh)', // fixed: units are kWh
angle: -90,
position: 'insideLeft',
style: {
textAnchor: 'middle',
fill: isDark ? '#fff' : '#222',
fontSize: 12,
},
}}
/>
<Tooltip
formatter={(value: number) => [`${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,
}}
/>
<Legend wrapperStyle={{ color: isDark ? '#fff' : '#222' }} />
<Bar dataKey="consumption" fill={consumptionColor} name="Consumption (kWh)" />
<Bar dataKey="generation" fill={generationColor} name="Generation (kWh)" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
};
export default MonthlyBarChart;

View File

@ -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<SiteCardProps> = ({ siteId, className = '', fallbackStatus }) => {
const [project, setProject] = useState<CrmProject | null>(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
const fetchProject = async () => {
setLoading(true);
setErr(null);
try {
// ---- Try a single-project endpoint first (best) ----
// e.g. GET /crm/projects/PROJ-0008
const single = await fetch(`${API}/crm/projects/${encodeURIComponent(siteId)}`);
if (single.ok) {
const pj = await single.json();
if (!cancelled) setProject(pj?.data ?? pj ?? null);
} else {
// ---- Fallback: fetch all and find by name (works with your existing API) ----
const list = await fetch(`${API}/crm/projects?limit=0`);
if (!list.ok) throw new Error(await list.text());
const json = await list.json();
const found = (json?.data ?? []).find((p: CrmProject) => p.name === siteId) ?? null;
if (!cancelled) setProject(found);
}
} catch (e: any) {
if (!cancelled) setErr(e?.message ?? 'Failed to load CRM project');
} finally {
if (!cancelled) setLoading(false);
}
};
fetchProject();
return () => { cancelled = true; };
}, [siteId]);
const status = project?.status || fallbackStatus || 'Unknown';
const statusColorClass =
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 (
<div className={`bg-white p-4 rounded-lg shadow-md dark:bg-rtgray-800 dark:text-white-light flex flex-col space-y-2 ${className}`}>
<h3 className="text-xl font-bold text-primary-600 dark:text-primary-400 border-b pb-2 mb-2">
{project?.project_name || siteId}
</h3>
{loading ? (
<div className="animate-pulse space-y-2">
<div className="h-4 w-24 bg-rtgray-200 dark:bg-rtgray-700 rounded" />
<div className="h-4 w-48 bg-rtgray-200 dark:bg-rtgray-700 rounded" />
<div className="h-4 w-40 bg-rtgray-200 dark:bg-rtgray-700 rounded" />
<div className="h-4 w-36 bg-rtgray-200 dark:bg-rtgray-700 rounded" />
</div>
) : err ? (
<div className="text-red-500 text-sm">Failed to load CRM: {err}</div>
) : !project ? (
<div className="text-amber-500 text-sm">No CRM project found for <span className="font-semibold">{siteId}</span>.</div>
) : (
<>
<div className="flex justify-between items-center">
<p className="text-gray-600 dark:text-gray-400 font-medium">Status:</p>
<p className={`font-semibold ${statusColorClass}`}>{status}</p>
</div>
<div className="flex justify-between items-center">
<p className="text-gray-600 dark:text-gray-400 font-medium">Location:</p>
<p className="font-medium whitespace-pre-line text-right">{niceAddress}</p>
</div>
<div className="flex justify-between items-center">
<p className="text-gray-600 dark:text-gray-400 font-medium">Inverter Provider:</p>
<p className="font-medium">{inverterProvider}</p>
</div>
<div className="flex justify-between items-center">
<p className="text-gray-600 dark:text-gray-400 font-medium">Emergency Contact:</p>
<p className="font-medium text-right">{emergencyContact}</p>
</div>
<div className="flex justify-between items-center">
<p className="text-gray-600 dark:text-gray-400 font-medium">Last Sync:</p>
<p className="font-medium">{lastSync}</p>
</div>
</>
)}
<Link
href={{ pathname: '/adminDashboard', query: { site: siteId } }}
className="mt-4 w-full text-center text-sm btn-primary"
>
View Dashboard
</Link>
</div>
);
};
export default SiteCard;

View File

@ -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 (
<div className="flex flex-col">
<label htmlFor="site" className="font-semibold text-lg dark:text-white">
{label}
</label>
<select
id="site"
className="border p-2 rounded dark:text-white dark:bg-rtgray-800 dark:border-rtgray-700"
value={selectedValue ?? ''} // keep controlled even when null
onChange={(e) => onChange(e.target.value)}
disabled={disabled || isEmpty}
>
{/* Placeholder when nothing selected */}
<option value="" disabled>
{isEmpty ? 'No sites available' : 'Choose a site…'}
</option>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
);
};
export default SiteSelector;

View File

@ -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<Record<string, string[]>>({});
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 (
<div className="bg-white p-4 rounded-lg shadow-md space-y-2 dark:bg-rtgray-800 dark:text-white-light">
<h2 className="text-xl font-semibold mb-3">Site Details</h2>
{/* Status (from CRM) */}
<div className="flex justify-between items-center text-base">
<p className="text-gray-600 dark:text-white/85 font-medium">Status:</p>
<p className={`font-semibold ${statusClass}`}>{status ?? "—"}</p>
</div>
{/* Site ID */}
<div className="flex justify-between items-center text-base">
<p className="text-gray-600 dark:text-white/85 font-medium">Site ID:</p>
<p className="font-medium">{siteId}</p>
</div>
{/* Location */}
<div className="flex justify-between items-center text-base">
<p className="text-gray-600 dark:text-white/85 font-medium">Location:</p>
<p className="font-medium">{location}</p>
</div>
{/* Inverter Provider */}
<div className="flex justify-between items-center text-base">
<p className="text-gray-600 dark:text-white/85 font-medium">Inverter Provider:</p>
<p className="font-medium">{inverterProvider}</p>
</div>
{/* Emergency Contact */}
<div className="flex justify-between items-center text-base">
<p className="text-gray-600 dark:text-white/85 font-medium">Emergency Contact:</p>
<p className="font-medium">{emergencyContact}</p>
</div>
{/* Last Sync */}
<div className="flex justify-between items-center text-base">
<p className="text-gray-600 dark:text-white/85 font-medium">Last Sync:</p>
<p className="font-medium">{lastSyncTimestamp}</p>
</div>
</div>
);
};
export default SiteStatus;

View File

@ -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;
}

View File

@ -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 }) => (
<div className="rounded-2xl p-5 shadow-md bg-white dark:bg-rtgray-800 text-white min-h-[260px] flex flex-col">
<div className="text-lg font-bold opacity-80 mb-3">{title}</div>
<div className="flex-1 grid place-items-center">{children}</div>
</div>
);
const Stat = ({ value, label, accent = false }: { value: ReactNode; label: string; accent?: boolean }) => (
<div className="flex flex-col items-center gap-1">
<div className={`text-3xl font-semibold ${accent ? 'text-[#fcd913]' : 'text-white'}`}>{value}</div>
<div className="text-xs text-[#9aa4b2]">{label}</div>
</div>
);
export default function KpiBottom({
efficiencyPct, powerFactor, loadFactor, middle, right,
}: Props) {
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<Panel title="Measurements">
<div className="grid grid-cols-3 gap-3 w-full">
<Stat value={`${efficiencyPct.toFixed(1)}%`} label="Efficiency" />
<Stat value={powerFactor.toFixed(2)} label="Power Factor" />
<Stat value={loadFactor.toFixed(2)} label="Load Factor" />
</div>
</Panel>
<Panel title="Yield Bar Chart">
<div className="w-full h-48">
{middle}
</div>
</Panel>
<Panel title="Peak Power Demand">
<div className="text-3xl font-semibold">
{right}
</div>
</Panel>
</div>
);
}

View File

@ -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,
}) => (
<div
className={`rounded-xl p-4 md:p-5 shadow-sm
${accent
? "bg-[#fcd913] text-black"
: "bg-white text-gray-900 dark:bg-rtgray-800 dark:text-white"}`}
>
<div className="flex items-center gap-3">
<div className="shrink-0 text-black dark:text-white">
{icon}
</div>
<div className="flex-1">
<div className="text-lg font-bold opacity-80">{title}</div>
<div className="text-2xl font-semibold">{value}</div>
</div>
</div>
</div>
);
export default function KpiTop({ month, yieldKwh, consumptionKwh, gridDrawKwh }: Props) {
return (
<section aria-label="Top KPIs" className="space-y-3">
{month && <div className="text-xs dark:text-[#9aa4b2]">{month}</div>}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<Card
title="Monthly Yield"
value={`${yieldKwh.toLocaleString()} kWh`}
icon={
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="4" stroke="#fcd913" strokeWidth="2" />
<path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1" stroke="#fcd913" strokeWidth="2" />
</svg>
}
/>
<Card
title="Monthly Consumption"
value={`${consumptionKwh.toLocaleString()} kWh`}
icon={
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
<rect x="8" y="3" width="8" height="12" rx="2" stroke="currentColor" strokeWidth="2" />
<path d="M12 15v6" stroke="#fcd913" strokeWidth="2" />
</svg>
}
/>
<Card
title="Monthly Grid Draw"
value={`${gridDrawKwh.toLocaleString()} kWh`}
icon={
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
<path d="M5 21h14M7 21l5-18 5 18" stroke="currentColor" strokeWidth="2" />
<path d="M14 8l2 2" stroke="#fcd913" strokeWidth="2" />
</svg>
}
/>
</div>
</section>
);
}

View File

@ -1,57 +1,73 @@
// Dropdown.tsx
'use client'; 'use client';
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'; import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
import { usePopper } from 'react-popper'; import { usePopper } from 'react-popper';
import type { ReactNode } from 'react';
const Dropdown = (props: any, forwardedRef: any) => { type DropdownProps = {
const [visibility, setVisibility] = useState<any>(false); button?: ReactNode; // 👈 make optional
children: ReactNode;
btnClassName?: string;
placement?: any;
offset?: [number, number];
panelClassName?: string;
closeOnItemClick?: boolean;
};
const referenceRef = useRef<any>(); const Dropdown = (props: DropdownProps, forwardedRef: any) => {
const popperRef = useRef<any>(); const [visible, setVisible] = useState(false);
const referenceRef = useRef<HTMLButtonElement | null>(null);
const popperRef = useRef<HTMLDivElement | null>(null);
const { styles, attributes } = usePopper(referenceRef.current, popperRef.current, { const { styles, attributes } = usePopper(referenceRef.current, popperRef.current, {
placement: props.placement || 'bottom-end', placement: props.placement || 'bottom-end',
modifiers: [ modifiers: [{ name: 'offset', options: { offset: props.offset ?? [0, 8] } }],
{ });
name: 'offset',
options: {
offset: props.offset || [0],
},
},
],
});
const handleDocumentClick = (event: any) => { useEffect(() => {
if (referenceRef.current.contains(event.target) || popperRef.current.contains(event.target)) { const onDoc = (e: MouseEvent) => {
return; if (!referenceRef.current || !popperRef.current) return;
} if (referenceRef.current.contains(e.target as Node)) return;
if (popperRef.current.contains(e.target as Node)) return;
setVisibility(false); setVisible(false);
}; };
document.addEventListener('mousedown', onDoc);
return () => document.removeEventListener('mousedown', onDoc);
}, []);
useEffect(() => { useImperativeHandle(forwardedRef, () => ({ close: () => setVisible(false) }));
document.addEventListener('mousedown', handleDocumentClick);
return () => {
document.removeEventListener('mousedown', handleDocumentClick);
};
}, []);
useImperativeHandle(forwardedRef, () => ({ const defaultButton = (
close() { <span className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-gray-200 dark:bg-rtgray-700" />
setVisibility(false); );
},
}));
return ( return (
<> <>
<button ref={referenceRef} type="button" className={props.btnClassName} onClick={() => setVisibility(!visibility)}> <button
{props.button} ref={referenceRef}
</button> type="button"
className={props.btnClassName}
onClick={() => setVisible((v) => !v)}
>
{props.button ?? defaultButton} {/* 👈 fallback */}
</button>
<div ref={popperRef} style={styles.popper} {...attributes.popper} className="z-50" onClick={() => setVisibility(!visibility)}> <div
{visibility && props.children} ref={popperRef}
</div> style={styles.popper}
</> {...attributes.popper}
); className="z-50"
>
{visible && (
<div className={props.panelClassName ?? 'rounded-lg bg-white dark:bg-rtgray-700 shadow-lg ring-1 ring-black/5'}>
{props.children}
</div>
)}
</div>
</>
);
}; };
export default forwardRef(Dropdown); export default forwardRef(Dropdown);

View File

@ -1,6 +1,54 @@
import React from 'react';
const Footer = () => { 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 ( return (
<div className="p-6 pt-0 mt-auto text-center dark:text-white-dark ltr:sm:text-left rtl:sm:text-right">© {new Date().getFullYear()}. Rooftop Energy All rights reserved.</div> <footer className="bg-rtyellow-500 text-black p-6 scale-100">
{/* Social Links */}
<div className="flex justify-center space-x-6 mb-6">
{Object.entries(socialLinks).map(([platform, url]) => (
<a
key={platform}
href={url}
target="_blank"
rel="noopener noreferrer"
className="w-10 h-10 bg-rtyellow-600 flex items-center justify-center rounded-full transition-colors duration-300 hover:bg-white"
>
<img
src={platform === 'email' ? '/mailicon.svg' : `/${platform}.svg`}
alt={platform.charAt(0).toUpperCase() + platform.slice(1)}
className="w-5 h-5"
/>
</a>
))}
</div>
{/* Divider */}
<div className="w-3/4 mx-auto border-t-2 border-rtyellow-600 my-6"></div>
{/* Contact Info */}
<div className="flex flex-col items-center">
<div className="text-left w-full max-w-md mx-auto mb-7">
<p className="text-[17px] text-rtbrown-800">Rooftop Energy Tech Sdn Bhd</p>
<p className="text-[17px] text-rtbrown-800">202501013544 (1613958-P)</p>
<p className="text-[17px] text-rtbrown-800 md:whitespace-nowrap">
3-5, Block D2, Dataran Prima,<br className="md:hidden" />
47301 Petaling Jaya,<br className="md:hidden" />
Selangor, Malaysia
</p>
</div>
</div>
{/* Copyright */}
<p className="text-black text-s mt-6 text-center">Rooftop Energy © 2025</p>
</footer>
); );
}; };

View File

@ -4,253 +4,175 @@ import { useDispatch, useSelector } from 'react-redux';
import Link from 'next/link'; import Link from 'next/link';
import { IRootState } from '@/store'; import { IRootState } from '@/store';
import { toggleTheme, toggleSidebar, toggleRTL } from '@/store/themeConfigSlice'; import { toggleTheme, toggleSidebar, toggleRTL } from '@/store/themeConfigSlice';
import Image from 'next/image';
import Dropdown from '@/components/dropdown'; import Dropdown from '@/components/dropdown';
import IconMenu from '@/components/icon/icon-menu'; 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 IconSun from '@/components/icon/icon-sun';
import IconMoon from '@/components/icon/icon-moon'; 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 IconUser from '@/components/icon/icon-user';
import IconMail from '@/components/icon/icon-mail'; import IconMail from '@/components/icon/icon-mail';
import IconLockDots from '@/components/icon/icon-lock-dots'; import IconLockDots from '@/components/icon/icon-lock-dots';
import IconLogout from '@/components/icon/icon-logout'; 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 { usePathname, useRouter } from 'next/navigation';
import { getTranslation } from '@/i18n';
const Header = () => { type UserData = { id: string; email: string; createdAt: string };
const pathname = usePathname();
const dispatch = useDispatch();
const router = useRouter();
const { t, i18n } = getTranslation();
useEffect(() => { export default function Header() {
const selector = document.querySelector('ul.horizontal-menu a[href="' + window.location.pathname + '"]'); const pathname = usePathname();
if (selector) { const dispatch = useDispatch();
const all: any = document.querySelectorAll('ul.horizontal-menu .nav-link.active'); const router = useRouter();
for (let i = 0; i < all.length; i++) { const themeConfig = useSelector((state: IRootState) => state.themeConfig);
all[0]?.classList.remove('active'); const isRtl = themeConfig.rtlClass === 'rtl';
}
let allLinks = document.querySelectorAll('ul.horizontal-menu a.active'); const [user, setUser] = useState<UserData | null>(null);
for (let i = 0; i < allLinks.length; i++) { const [loadingUser, setLoadingUser] = useState(true);
const element = allLinks[i];
element?.classList.remove('active');
}
selector?.classList.add('active');
const ul: any = selector.closest('ul.sub-menu'); // Highlight active menu (your original effect)
if (ul) { useEffect(() => {
let ele: any = ul.closest('li.menu').querySelectorAll('.nav-link'); const selector = document.querySelector(
if (ele) { 'ul.horizontal-menu a[href="' + window.location.pathname + '"]'
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: '<span class="grid place-content-center w-9 h-9 rounded-full bg-success-light dark:bg-success text-success dark:text-success-light"><svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path></svg></span>',
title: 'Congratulations!',
message: 'Your OS has been updated.',
time: '1hr',
},
{
id: 2,
image: '<span class="grid place-content-center w-9 h-9 rounded-full bg-info-light dark:bg-info text-info dark:text-info-light"><svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg></span>',
title: 'Did you know?',
message: 'You can switch between artboards.',
time: '2hr',
},
{
id: 3,
image: '<span class="grid place-content-center w-9 h-9 rounded-full bg-danger-light dark:bg-danger text-danger dark:text-danger-light"> <svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></span>',
title: 'Something went wrong!',
message: 'Send Reposrt',
time: '2days',
},
{
id: 4,
image: '<span class="grid place-content-center w-9 h-9 rounded-full bg-warning-light dark:bg-warning text-warning dark:text-warning-light"><svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> <circle cx="12" cy="12" r="10"></circle> <line x1="12" y1="8" x2="12" y2="12"></line> <line x1="12" y1="16" x2="12.01" y2="16"></line></svg></span>',
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: '<strong class="text-sm mr-1">John Doe</strong>invite you to <strong>Prototyping</strong>',
time: '45 min ago',
},
{
id: 2,
profile: 'profile-34.jpeg',
message: '<strong class="text-sm mr-1">Adam Nolan</strong>mentioned you to <strong>UX Basics</strong>',
time: '9h Ago',
},
{
id: 3,
profile: 'profile-16.jpeg',
message: '<strong class="text-sm mr-1">Anna Morgan</strong>Upload a file',
time: '9h Ago',
},
]);
const removeNotification = (value: number) => {
setNotifications(notifications.filter((user) => user.id !== value));
};
const [search, setSearch] = useState(false);
return (
<header className={`z-40 ${themeConfig.semidark && themeConfig.menu === 'horizontal' ? 'dark' : ''}`}>
<div className="shadow-sm">
<div className="relative flex w-full items-center bg-white px-5 py-2.5 dark:bg-black">
<div className="horizontal-logo flex items-center justify-between ltr:mr-2 rtl:ml-2 lg:hidden">
<Link href="/" className="main-logo flex shrink-0 items-center">
<img className="inline w-8 ltr:-ml-1 rtl:-mr-1" src="/assets/images/logo.png" alt="logo" />
<span className="hidden align-middle text-2xl font-semibold transition-all duration-300 ltr:ml-1.5 rtl:mr-1.5 dark:text-white-light md:inline">Rooftop Energy</span>
</Link>
<button
type="button"
className="collapse-icon flex flex-none rounded-full bg-white-light/40 p-2 hover:bg-white-light/90 hover:text-primary ltr:ml-2 rtl:mr-2 dark:bg-dark/40 dark:text-[#d0d2d6] dark:hover:bg-dark/60 dark:hover:text-primary lg:hidden"
onClick={() => dispatch(toggleSidebar())}
>
<IconMenu className="h-5 w-5" />
</button>
</div>
<div className="flex items-center justify-end space-x-1.5 ltr:ml-auto rtl:mr-auto rtl:space-x-reverse dark:text-[#d0d2d6] sm:flex-1 ltr:sm:ml-0 sm:rtl:mr-0 lg:space-x-2">
{/* ------------------- Start Theme Switch ------------------- */}
<div>
{themeConfig.theme === 'light' ? (
<button
className={`${
themeConfig.theme === 'light' &&
'flex items-center rounded-full bg-white-light/40 p-2 hover:bg-white-light/90 hover:text-primary dark:bg-dark/40 dark:hover:bg-dark/60'
}`}
onClick={() => dispatch(toggleTheme('dark'))}
>
<IconSun />
</button>
) : (
''
)}
{themeConfig.theme === 'dark' && (
<button
className={`${
themeConfig.theme === 'dark' &&
'flex items-center rounded-full bg-white-light/40 p-2 hover:bg-white-light/90 hover:text-primary dark:bg-dark/40 dark:hover:bg-dark/60'
}`}
onClick={() => dispatch(toggleTheme('light'))}
>
<IconMoon />
</button>
)}
</div>
{/* ------------------- End Theme Switch ------------------- */}
<div className="dropdown flex shrink-0">
<Dropdown
offset={[0, 8]}
placement={`${isRtl ? 'bottom-start' : 'bottom-end'}`}
btnClassName="relative group block"
button={<img className="h-9 w-9 rounded-full object-cover saturate-50 group-hover:saturate-100" src="/assets/images/user-profile.jpeg" alt="userProfile" />}
>
<ul className="w-[230px] !py-0 font-semibold text-dark dark:text-white-dark dark:text-white-light/90">
<li>
<div className="flex items-center px-4 py-4">
<img className="h-10 w-10 rounded-md object-cover" src="/assets/images/user-profile.jpeg" alt="userProfile" />
<div className="truncate ltr:pl-4 rtl:pr-4">
<h4 className="text-base">
John Doe
<span className="rounded bg-success-light px-1 text-xs text-success ltr:ml-2 rtl:ml-2">Pro</span>
</h4>
<button type="button" className="text-black/60 hover:text-primary dark:text-dark-light/60 dark:hover:text-white">
johndoe@gmail.com
</button>
</div>
</div>
</li>
<li>
<Link href="/users/profile" className="dark:hover:text-white">
<IconUser className="h-4.5 w-4.5 shrink-0 ltr:mr-2 rtl:ml-2" />
Profile
</Link>
</li>
<li>
<Link href="/apps/mailbox" className="dark:hover:text-white">
<IconMail className="h-4.5 w-4.5 shrink-0 ltr:mr-2 rtl:ml-2" />
Inbox
</Link>
</li>
<li>
<Link href="/auth/boxed-lockscreen" className="dark:hover:text-white">
<IconLockDots className="h-4.5 w-4.5 shrink-0 ltr:mr-2 rtl:ml-2" />
Lock Screen
</Link>
</li>
<li className="border-t border-white-light dark:border-white-light/10">
<Link href="/auth/boxed-signin" className="!py-3 text-danger">
<IconLogout className="h-4.5 w-4.5 shrink-0 rotate-90 ltr:mr-2 rtl:ml-2" />
Sign Out
</Link>
</li>
</ul>
</Dropdown>
</div>
</div>
</div>
</div>
</header>
); );
}; 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 (
<header className={`z-40 ${themeConfig.semidark && themeConfig.menu === 'horizontal' ? 'dark' : ''}`}>
<div className="shadow-sm">
<div className="relative flex w-full items-center bg-white px-5 py-2.5 dark:bg-rtgray-900">
{/* Logo */}
<div className="horizontal-logo flex items-center justify-between ltr:mr-2 rtl:ml-2 lg:hidden">
<div className="relative h-10 w-32 sm:h-11 sm:w-36 md:h-12 md:w-27 shrink-0 max-h-12">
<Image
src="/assets/images/newfulllogo.png"
alt="logo"
fill
className="object-cover"
priority
sizes="(max-width: 640px) 8rem, (max-width: 768px) 9rem, (max-width: 1024px) 10rem, 10rem"
/>
</div>
<button
type="button"
onClick={() => dispatch(toggleSidebar())}
className="collapse-icon flex p-2 rounded-full hover:bg-rtgray-200 dark:text-white dark:hover:bg-rtgray-700"
>
<IconMenu className="h-6 w-6" />
</button>
</div>
{/* Right-side actions */}
<div className="flex items-center justify-end space-x-1.5 ltr:ml-auto rtl:mr-auto rtl:space-x-reverse dark:text-[#d0d2d6] lg:space-x-2">
{/* Theme toggle */}
{themeConfig.theme === 'light' ? (
<button
onClick={() => dispatch(toggleTheme('dark'))}
className="flex items-center p-2 rounded-full bg-white-light/40 hover:bg-white-light/90 dark:bg-rtgray-800 dark:hover:bg-rtgray-700"
>
<IconSun />
</button>
) : (
<button
onClick={() => dispatch(toggleTheme('light'))}
className="flex items-center p-2 rounded-full bg-white-light/40 hover:bg-white-light/90 dark:bg-rtgray-800 dark:hover:bg-rtgray-700"
>
<IconMoon />
</button>
)}
{/* User dropdown */}
<div className="dropdown flex shrink-0 ">
{loadingUser ? (
<div className="h-9 w-9 rounded-full animate-pulse bg-gray-300 dark:bg-rtgray-800" />
) : user ? (
<Dropdown
placement={isRtl ? 'bottom-start' : 'bottom-end'}
btnClassName="relative group block"
panelClassName="rounded-lg shadow-lg border border-white/10 bg-rtgray-100 dark:bg-rtgray-800 p-2" // ✅
button={
<div className="h-9 w-9 rounded-full bg-rtgray-200 dark:bg-rtgray-800 flex items-center justify-center group-hover:bg-rtgray-300 dark:group-hover:bg-rtgray-700">
<IconUser className="h-5 w-5 text-gray-600 dark:text-gray-300" />
</div>
}
>
<ul className="w-[230px] font-semibold text-dark"> {/* make sure this stays transparent */}
<li className="px-4 py-4 flex items-center">
<div className="truncate ltr:pl-1.5 rtl:pr-4">
<h4 className="text-sm text-left">{user.email}</h4>
</div>
</li>
<li>
<Link href="/users/profile" className="dark:hover:text-white">
<IconUser className="h-4.5 w-4.5 mr-2" /> Profile
</Link>
</li>
<li>
<Link href="/auth/boxed-lockscreen" className="dark:hover:text-white">
<IconLockDots className="h-4.5 w-4.5 mr-2" /> Lock Screen
</Link>
</li>
<li className="border-t border-white-light dark:border-white-light/10">
<button onClick={handleLogout} className="flex w-full items-center py-3 text-danger">
<IconLogout className="h-4.5 w-4.5 mr-2 rotate-90" /> Sign Out
</button>
</li>
</ul>
</Dropdown>
) : (
<Link
href="/login"
className="rounded-md bg-yellow-400 px-3 py-1.5 text-black font-semibold hover:brightness-95"
>
Sign In
</Link>
)}
</div>
</div>
</div>
</div>
</header>
);
}

View File

@ -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 (
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-full transition duration-300 hover:bg-gray-500/10 dark:text-white-light dark:hover:bg-dark-light/10"
onClick={() => dispatch(toggleSidebar())}
// Optional: You might want to hide this button if the sidebar is already open
// or show a different icon depending on the sidebar state.
// For simplicity, it just toggles.
>
{/* You can use a generic menu icon here, or condition it based on themeConfig.sidebar */}
</button>
);
};
export default SidebarToggleButton;

View File

@ -62,28 +62,28 @@ const Sidebar = () => {
}; };
return ( return (
<div className={semidark ? 'dark' : ''}> <div className={semidark ? 'dark' : ''}>
<nav <nav
className={`sidebar fixed bottom-0 top-0 z-50 h-full min-h-screen w-[260px] shadow-[5px_0_25px_0_rgba(94,92,154,0.1)] transition-all duration-300 ${semidark ? 'text-white-dark' : ''}`} className={`sidebar fixed bottom-0 top-0 z-50 h-full min-h-screen w-[260px] shadow-[5px_0_25px_0_rgba(94,92,154,0.1)] transition-all duration-300 ${semidark ? 'text-white-dark' : ''}`}
> >
<div className="h-full bg-white dark:bg-black"> <div className="h-full bg-[white] dark:bg-rtgray-900">
<div className="flex items-center justify-between px-4 py-3"> <div className="flex items-center justify-between px-4 pt-4">
<Link href="/" className="main-logo flex shrink-0 items-center"> <Link href="/" className="main-logo flex shrink-0 items-center">
<img className="ml-[5px] w-8 flex-none" src="/assets/images/logo.png" alt="logo" /> <img className="max-w-[180px] h-auto flex" src="/assets/images/newfulllogo.png" alt="logo" />
<span className="align-middle text-2xl font-semibold ltr:ml-1.5 rtl:mr-1.5 dark:text-white-light lg:inline">Rooftop Energy</span>
</Link> </Link>
<button <button
type="button" type="button"
className="collapse-icon flex h-8 w-8 items-center rounded-full transition duration-300 hover:bg-gray-500/10 rtl:rotate-180 dark:text-white-light dark:hover:bg-dark-light/10" className="collapse-icon flex h-8 w-8 items-center rounded-full transition duration-300 hover:bg-rtgray-500/10 rtl:rotate-180 dark:text-white-light dark:hover:bg-rtgray-900/10"
onClick={() => dispatch(toggleSidebar())} onClick={() => dispatch(toggleSidebar())}
> >
<IconCaretsDown className="m-auto rotate-90" /> <IconCaretsDown className="m-auto rotate-90" />
</button> </button>
</div> </div>
<PerfectScrollbar className="relative h-[calc(100vh-80px)]"> <PerfectScrollbar className="relative h-[calc(100vh-80px)] ">
<ul className="relative space-y-0.5 p-4 py-0 font-semibold"> <ul className="relative space-y-0.5 p-4 py-0 font-md">
<h2 className="-mx-4 mb-1 flex items-center bg-white-light/30 px-7 py-3 font-extrabold uppercase dark:bg-dark dark:bg-opacity-[0.08]"> <h2 className="-mx-4 mb-1 flex items-center bg-white-light/30 px-7 pb-3 pt-2 font-extrabold uppercase dark:bg-dark dark:bg-opacity-[0.08] dark:text-white dark:active:text-white">
<IconMinus className="hidden h-5 w-4 flex-none" /> <IconMinus className="hidden h-5 w-4 flex-none" />
<span>Customer</span> <span>Customer</span>
</h2> </h2>
@ -91,7 +91,7 @@ const Sidebar = () => {
<Link href="#" className="nav-link group"> <Link href="#" className="nav-link group">
<div className="flex items-center"> <div className="flex items-center">
<IconMenuComponents className="shrink-0 group-hover:!text-primary" /> <IconMenuComponents className="shrink-0 group-hover:!text-primary" />
<span className="text-black ltr:pl-3 rtl:pr-3 dark:text-[#506690] dark:group-hover:text-white-dark">Dashboard</span> <span className="text-black ltr:pl-3 rtl:pr-3 dark:text-white dark:group-hover:text-white dark:active:text-white">Dashboard</span>
</div> </div>
</Link> </Link>
</li> </li>
@ -99,15 +99,7 @@ const Sidebar = () => {
<Link href="#" className="nav-link group"> <Link href="#" className="nav-link group">
<div className="flex items-center"> <div className="flex items-center">
<IconMenuComponents className="shrink-0 group-hover:!text-primary" /> <IconMenuComponents className="shrink-0 group-hover:!text-primary" />
<span className="text-black ltr:pl-3 rtl:pr-3 dark:text-[#506690] dark:group-hover:text-white-dark">Sites</span> <span className="text-black ltr:pl-3 rtl:pr-3 dark:text-white dark:group-hover:text-white dark:active:text-white">Sites</span>
</div>
</Link>
</li>
<li className="menu nav-item">
<Link href="#" className="nav-link group">
<div className="flex items-center">
<IconMenuComponents className="shrink-0 group-hover:!text-primary" />
<span className="text-black ltr:pl-3 rtl:pr-3 dark:text-[#506690] dark:group-hover:text-white-dark">Inverters</span>
</div> </div>
</Link> </Link>
</li> </li>
@ -115,7 +107,7 @@ const Sidebar = () => {
<button type="button" className={`nav-link group w-full`} onClick={() => toggleMenu('setting')}> <button type="button" className={`nav-link group w-full`} onClick={() => toggleMenu('setting')}>
<div className="flex items-center"> <div className="flex items-center">
<IconMenuComponents className="shrink-0 group-hover:!text-primary" /> <IconMenuComponents className="shrink-0 group-hover:!text-primary" />
<span className="text-black ltr:pl-3 rtl:pr-3 dark:text-[#506690] dark:group-hover:text-white-dark">Setting</span> <span className="text-black ltr:pl-3 rtl:pr-3 dark:text-white dark:group-hover:text-white dark:active:text-white">Setting</span>
</div> </div>
<div className={currentMenu !== 'setting' ? '-rotate-90 rtl:rotate-90' : ''}> <div className={currentMenu !== 'setting' ? '-rotate-90 rtl:rotate-90' : ''}>
@ -135,15 +127,23 @@ const Sidebar = () => {
<h2 className="-mx-4 mb-1 flex items-center bg-white-light/30 px-7 py-3 font-extrabold uppercase dark:bg-dark dark:bg-opacity-[0.08]"> <h2 className="-mx-4 mb-1 flex items-center bg-white-light/30 px-7 py-3 font-extrabold uppercase dark:bg-dark dark:text-white dark:bg-opacity-[0.08] dark:active:text-white">
<IconMinus className="hidden h-5 w-4 flex-none" /> <IconMinus className="hidden h-5 w-4 flex-none" />
<span>Admin</span> <span>Admin</span>
</h2> </h2>
<li className="menu nav-item"> <li className="menu nav-item">
<Link href="#" className="nav-link group"> <Link href="/adminDashboard" className="nav-link group">
<div className="flex items-center"> <div className="flex items-center">
<IconMenuComponents className="shrink-0 group-hover:!text-primary" /> <IconMenuComponents className="shrink-0 group-hover:!text-primary" />
<span className="text-black ltr:pl-3 rtl:pr-3 dark:text-[#506690] dark:group-hover:text-white-dark">Solis</span> <span className="text-black ltr:pl-3 rtl:pr-3 dark:text-white dark:group-hover:text-white dark:active:text-white">Dashboard</span>
</div>
</Link>
</li>
<li className="menu nav-item">
<Link href="/sites" className="nav-link group">
<div className="flex items-center">
<IconMenuComponents className="shrink-0 group-hover:!text-primary" />
<span className="text-black ltr:pl-3 rtl:pr-3 dark:text-white dark:group-hover:text-white">Sites</span>
</div> </div>
</Link> </Link>
</li> </li>
@ -151,7 +151,28 @@ const Sidebar = () => {
<Link href="#" className="nav-link group"> <Link href="#" className="nav-link group">
<div className="flex items-center"> <div className="flex items-center">
<IconMenuComponents className="shrink-0 group-hover:!text-primary" /> <IconMenuComponents className="shrink-0 group-hover:!text-primary" />
<span className="text-black ltr:pl-3 rtl:pr-3 dark:text-[#506690] dark:group-hover:text-white-dark">Huawei</span> <span className="text-black ltr:pl-3 rtl:pr-3 dark:text-white dark:group-hover:text-white">Devices</span>
</div>
</Link>
</li>
{/*}
<h2 className="-mx-4 mb-1 flex items-center px-7 py-3 font-extrabold uppercase dark:bg-opacity-[0.08] dark:group-hover:text-white dark:text-white">
<IconMinus className="hidden h-5 w-4 flex-none" />
<span>Providers</span>
</h2>
<li className="menu nav-item">
<Link href="#" className="nav-link group">
<div className="flex items-center">
<IconMenuComponents className="shrink-0 group-hover:!text-primary" />
<span className="text-black ltr:pl-3 rtl:pr-3 dark:text-white dark:group-hover:text-white">Solis</span>
</div>
</Link>
</li>
<li className="menu nav-item">
<Link href="#" className="nav-link group">
<div className="flex items-center">
<IconMenuComponents className="shrink-0 group-hover:!text-primary" />
<span className="text-black ltr:pl-3 rtl:pr-3 dark:text-white dark:group-hover:text-white">Huawei</span>
</div> </div>
</Link> </Link>
</li> </li>
@ -160,7 +181,7 @@ const Sidebar = () => {
<button type="button" className={`${currentMenu === 'sungrow' ? 'active' : ''} nav-link group w-full`} onClick={() => toggleMenu('sungrow')}> <button type="button" className={`${currentMenu === 'sungrow' ? 'active' : ''} nav-link group w-full`} onClick={() => toggleMenu('sungrow')}>
<div className="flex items-center"> <div className="flex items-center">
<IconMenuComponents className="shrink-0 group-hover:!text-primary" /> <IconMenuComponents className="shrink-0 group-hover:!text-primary" />
<span className="text-black ltr:pl-3 rtl:pr-3 dark:text-[#506690] dark:group-hover:text-white-dark">Sungrow</span> <span className="text-black ltr:pl-3 rtl:pr-3 dark:text-white dark:group-hover:text-white">Sungrow</span>
</div> </div>
<div className={currentMenu !== 'component' ? '-rotate-90 rtl:rotate-90' : ''}> <div className={currentMenu !== 'component' ? '-rotate-90 rtl:rotate-90' : ''}>
@ -169,7 +190,7 @@ const Sidebar = () => {
</button> </button>
<AnimateHeight duration={300} height={currentMenu === 'sungrow' ? 'auto' : 0}> <AnimateHeight duration={300} height={currentMenu === 'sungrow' ? 'auto' : 0}>
<ul className="sub-menu text-gray-500"> <ul className="sub-menu text-gray-500 dark:text-white/90">
<li> <li>
<Link href="/sungrow/plant">Plant</Link> <Link href="/sungrow/plant">Plant</Link>
</li> </li>
@ -187,7 +208,7 @@ const Sidebar = () => {
<Link href="#" className="nav-link group"> <Link href="#" className="nav-link group">
<div className="flex items-center"> <div className="flex items-center">
<IconMenuComponents className="shrink-0 group-hover:!text-primary" /> <IconMenuComponents className="shrink-0 group-hover:!text-primary" />
<span className="text-black ltr:pl-3 rtl:pr-3 dark:text-[#506690] dark:group-hover:text-white-dark">CSI</span> <span className="text-black ltr:pl-3 rtl:pr-3 dark:text-white dark:group-hover:text-white">CSI</span>
</div> </div>
</Link> </Link>
</li> </li>
@ -196,7 +217,7 @@ const Sidebar = () => {
<button type="button" className={`${currentMenu === 'chint' ? 'active' : ''} nav-link group w-full`} onClick={() => toggleMenu('chint')}> <button type="button" className={`${currentMenu === 'chint' ? 'active' : ''} nav-link group w-full`} onClick={() => toggleMenu('chint')}>
<div className="flex items-center"> <div className="flex items-center">
<IconMenuComponents className="shrink-0 group-hover:!text-primary" /> <IconMenuComponents className="shrink-0 group-hover:!text-primary" />
<span className="text-black ltr:pl-3 rtl:pr-3 dark:text-[#506690] dark:group-hover:text-white-dark">Chint</span> <span className="text-black ltr:pl-3 rtl:pr-3 dark:text-white dark:group-hover:text-white">Chint</span>
</div> </div>
<div className={currentMenu !== 'component' ? '-rotate-90 rtl:rotate-90' : ''}> <div className={currentMenu !== 'component' ? '-rotate-90 rtl:rotate-90' : ''}>
@ -205,12 +226,12 @@ const Sidebar = () => {
</button> </button>
<AnimateHeight duration={300} height={currentMenu === 'chint' ? 'auto' : 0}> <AnimateHeight duration={300} height={currentMenu === 'chint' ? 'auto' : 0}>
<ul className="sub-menu text-gray-500"> <ul className="sub-menu text-gray-500 dark:text-white/90">
<li> <li>
<Link href="/chint/sites">Sites</Link> <Link href="/chint/sites">Sites</Link>
</li> </li>
<li> <li>
<Link href="/chint/gateway">Gateaway</Link> <Link href="/chint/gateway">Gateway</Link>
</li> </li>
<li> <li>
<Link href="/chint/inverters">Inverters</Link> <Link href="/chint/inverters">Inverters</Link>
@ -218,7 +239,7 @@ const Sidebar = () => {
</ul> </ul>
</AnimateHeight> </AnimateHeight>
</li> </li>
*/}
</ul> </ul>
</PerfectScrollbar> </PerfectScrollbar>

View File

@ -1,27 +1,31 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { ComponentType } from "react";
const withAuth = (WrappedComponent: React.FC) => { const withAuth = <P extends object>(WrappedComponent: ComponentType<P>) => {
return (props: any) => { const WithAuthComponent = (props: P) => {
const [isAuthenticated, setIsAuthenticated] = useState(false) const [isAuthenticated, setIsAuthenticated] = useState(false);
const router = useRouter() const router = useRouter();
useEffect(() => { useEffect(() => {
const token = localStorage.getItem("token") const token = localStorage.getItem("token");
if (!token) { if (!token) {
router.replace("/login") // Redirect to login if no token router.replace("/login");
} else { } else {
setIsAuthenticated(true) setIsAuthenticated(true);
} }
}, []) }, []);
if (!isAuthenticated) { if (!isAuthenticated) {
return null // Avoid rendering until auth check is done return null;
} }
return <WrappedComponent {...props} /> return <WrappedComponent {...props} />;
}; };
return WithAuthComponent;
}; };
export default withAuth export default withAuth;

6
lib/prisma.ts Normal file
View File

@ -0,0 +1,6 @@
// lib/prisma.ts (create this file to reuse Prisma client instance)
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export default prisma;

7176
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -11,37 +11,51 @@
"dependencies": { "dependencies": {
"@emotion/react": "^11.10.6", "@emotion/react": "^11.10.6",
"@headlessui/react": "^1.7.8", "@headlessui/react": "^1.7.8",
"@prisma/client": "^6.4.1", "@heroui/react": "^2.8.2",
"@prisma/client": "^6.8.2",
"@reduxjs/toolkit": "^1.9.1", "@reduxjs/toolkit": "^1.9.1",
"@tippyjs/react": "^4.2.6", "@tippyjs/react": "^4.2.6",
"@types/bcrypt": "^5.0.2",
"@types/node": "18.11.18", "@types/node": "18.11.18",
"@types/react": "18.0.27", "@types/react": "18.0.27",
"@types/react-dom": "18.0.10", "@types/react-dom": "18.0.10",
"apexcharts": "^4.5.0", "apexcharts": "^4.5.0",
"axios": "^1.7.9", "axios": "^1.7.9",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"chart.js": "^4.4.9",
"chartjs-plugin-zoom": "^2.2.0",
"cookie": "^1.0.2", "cookie": "^1.0.2",
"date-fns": "^4.1.0",
"eslint": "8.32.0", "eslint": "8.32.0",
"eslint-config-next": "13.1.2", "eslint-config-next": "13.1.2",
"framer-motion": "^12.23.12",
"he": "^1.2.0",
"html2canvas": "^1.4.1",
"i18next": "^22.4.10", "i18next": "^22.4.10",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"jspdf": "^3.0.1",
"next": "14.0.3", "next": "14.0.3",
"ni18n": "^1.0.5", "ni18n": "^1.0.5",
"react": "18.2.0", "react": "18.2.0",
"react-animate-height": "^3.1.0", "react-animate-height": "^3.1.0",
"react-apexcharts": "^1.7.0", "react-apexcharts": "^1.7.0",
"react-chartjs-2": "^5.3.0",
"react-datepicker": "^8.4.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-hot-toast": "^2.5.2", "react-hot-toast": "^2.5.2",
"react-i18next": "^12.1.5", "react-i18next": "^12.1.5",
"react-perfect-scrollbar": "^1.5.8", "react-perfect-scrollbar": "^1.5.8",
"react-popper": "^2.3.0", "react-popper": "^2.3.0",
"react-redux": "^8.1.3", "react-redux": "^8.1.3",
"recharts": "^2.15.3",
"universal-cookie": "^6.1.1", "universal-cookie": "^6.1.1",
"yup": "^0.32.11" "yup": "^0.32.11"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/forms": "^0.5.3", "@tailwindcss/forms": "^0.5.3",
"@tailwindcss/typography": "^0.5.8", "@tailwindcss/typography": "^0.5.8",
"@types/he": "^1.2.3",
"@types/jsonwebtoken": "^9.0.9",
"@types/lodash": "^4.14.191", "@types/lodash": "^4.14.191",
"@types/react-redux": "^7.1.32", "@types/react-redux": "^7.1.32",
"autoprefixer": "^10.4.17", "autoprefixer": "^10.4.17",

View File

@ -1,27 +1,46 @@
import { NextApiRequest, NextApiResponse } from "next"; // pages/api/auth/me.ts
import jwt from "jsonwebtoken"; import type { NextApiRequest, NextApiResponse } from "next";
import jwt, { JwtPayload } from "jsonwebtoken";
import { PrismaClient } from "@prisma/client"; import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient(); const prisma = new PrismaClient();
const SECRET_KEY = process.env.JWT_SECRET as string; const SECRET_KEY = process.env.JWT_SECRET as string;
function readCookieToken(req: NextApiRequest) {
const cookie = req.headers.cookie || "";
const match = cookie.split("; ").find((c) => c.startsWith("token="));
return match?.split("=")[1];
}
function readAuthBearer(req: NextApiRequest) {
const auth = req.headers.authorization;
if (!auth?.startsWith("Bearer ")) return undefined;
return auth.slice("Bearer ".length);
}
function hasEmail(payload: string | JwtPayload): payload is JwtPayload & { email: string } {
return typeof payload === "object" && payload !== null && typeof (payload as any).email === "string";
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) { export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const authHeader = req.headers.authorization; if (req.method !== "GET") return res.status(405).json({ message: "Method not allowed" });
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return res.status(401).json({ message: "Unauthorized" });
}
const token = authHeader.split(" ")[1]; // Extract token
try { try {
const decoded: any = jwt.verify(token, SECRET_KEY); const token = readAuthBearer(req) ?? readCookieToken(req);
const user = await prisma.user.findUnique({ where: { id: decoded.userId } }); if (!token) return res.status(401).json({ message: "Unauthorized" });
const decoded = jwt.verify(token, SECRET_KEY);
if (!hasEmail(decoded)) return res.status(401).json({ message: "Invalid token" });
const user = await prisma.user.findUnique({
where: { email: decoded.email },
select: { id: true, email: true, createdAt: true },
});
if (!user) return res.status(401).json({ message: "User not found" }); if (!user) return res.status(401).json({ message: "User not found" });
return res.status(200).json({ user });
res.json({ user }); } catch {
} catch (error) { return res.status(401).json({ message: "Invalid token" });
res.status(401).json({ message: "Invalid token" });
} }
} }

View File

@ -1,15 +1,18 @@
import { NextApiRequest, NextApiResponse } from "next"; // pages/api/login.ts
import type { NextApiRequest, NextApiResponse } from "next";
import { PrismaClient } from "@prisma/client"; import { PrismaClient } from "@prisma/client";
import bcrypt from "bcrypt"; import bcrypt from "bcrypt";
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
const prisma = new PrismaClient() const prisma = new PrismaClient();
const SECRET_KEY = process.env.JWT_SECRET as string; const SECRET_KEY = process.env.JWT_SECRET as string;
export default async function handler(req: NextApiRequest, res: NextApiResponse) { export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") return res.status(405).json({ message: "Method not allowed" }); if (req.method !== "POST") return res.status(405).json({ message: "Method not allowed" });
const { email, password } = req.body; try {
const { email, password } = req.body as { email?: string; password?: string };
if (!email || !password) return res.status(400).json({ message: "Email and password are required" });
const user = await prisma.user.findUnique({ where: { email } }); const user = await prisma.user.findUnique({ where: { email } });
if (!user) return res.status(401).json({ message: "Invalid credentials" }); if (!user) return res.status(401).json({ message: "Invalid credentials" });
@ -17,8 +20,23 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const isMatch = await bcrypt.compare(password, user.password); const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) return res.status(401).json({ message: "Invalid credentials" }); if (!isMatch) return res.status(401).json({ message: "Invalid credentials" });
const token = jwt.sign({ email: user.email }, SECRET_KEY, { expiresIn: "1d" }); const token = jwt.sign({ sub: String(user.id), email: user.email }, SECRET_KEY, { expiresIn: "1d" });
res.setHeader("Set-Cookie", `token=${token}; HttpOnly; Path=/; Secure`); const isProd = process.env.NODE_ENV === "production";
res.json({ token }); const cookie = [
`token=${token}`,
"HttpOnly",
"Path=/",
"SameSite=Strict",
`Max-Age=${60 * 60 * 24}`, // 1 day
isProd ? "Secure" : "", // only secure in prod
].filter(Boolean).join("; ");
res.setHeader("Set-Cookie", cookie);
return res.status(200).json({ message: "Login successful" });
} catch (e) {
console.error(e);
return res.status(500).json({ message: "Something went wrong" });
}
} }

20
pages/api/logout.ts Normal file
View File

@ -0,0 +1,20 @@
// pages/api/auth/logout.ts
import type { NextApiRequest, NextApiResponse } from "next";
export default function handler(req: NextApiRequest, res: NextApiResponse) {
const isProd = process.env.NODE_ENV === "production";
res.setHeader(
"Set-Cookie",
[
"token=", // empty token
"HttpOnly",
"Path=/",
"SameSite=Strict",
"Max-Age=0", // expire immediately
isProd ? "Secure" : "",
]
.filter(Boolean)
.join("; ")
);
return res.status(200).json({ message: "Logged out" });
}

View File

@ -1,27 +1,42 @@
import { NextApiRequest, NextApiResponse } from "next"; // pages/api/register.ts
import type { NextApiRequest, NextApiResponse } from "next";
import { PrismaClient } from "@prisma/client"; import { PrismaClient } from "@prisma/client";
import bcrypt from "bcrypt"; import bcrypt from "bcrypt";
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
const prisma = new PrismaClient() const prisma = new PrismaClient();
const SECRET_KEY = process.env.JWT_SECRET as string; const SECRET_KEY = process.env.JWT_SECRET as string;
export default async function handler(req: NextApiRequest, res: NextApiResponse) { export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") return res.status(405).json({ message: "Method not allowed" }); if (req.method !== "POST") return res.status(405).json({ message: "Method not allowed" });
const { email, password } = req.body; try {
const { email, password } = req.body as { email?: string; password?: string };
if (!email || !password) return res.status(400).json({ message: "Email and password are required" });
const existingUser = await prisma.user.findUnique({ where: { email } }); const existingUser = await prisma.user.findUnique({ where: { email } });
if (existingUser) return res.status(400).json({ message: "User already exists" }); if (existingUser) return res.status(400).json({ message: "User already exists" });
const hashedPassword = await bcrypt.hash(password, 10); const hashedPassword = await bcrypt.hash(password, 10);
const user = await prisma.user.create({ const user = await prisma.user.create({
data: { email, password: hashedPassword }, data: { email, password: hashedPassword },
select: { id: true, email: true, createdAt: true }, // do NOT expose password
}); });
const token = jwt.sign({ email: user.email }, SECRET_KEY, { expiresIn: "1d" }); const token = jwt.sign({ sub: String(user.id), email: user.email }, SECRET_KEY, { expiresIn: "1d" });
res.setHeader("Set-Cookie", `token=${token}; HttpOnly; Path=/; Secure`); // Set a secure, httpOnly cookie
res.status(201).json({ message: "User registered", user, token }); const maxAge = 60 * 60 * 24; // 1 day
res.setHeader(
"Set-Cookie",
`token=${token}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Strict; Secure`
);
return res.status(201).json({ message: "User registered", user });
} catch (err) {
console.error(err);
return res.status(500).json({ message: "Something went wrong" });
}
} }

View File

@ -0,0 +1,39 @@
-- CreateTable
CREATE TABLE "Site" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"location" TEXT NOT NULL,
"inverterProvider" TEXT NOT NULL,
"emergencyContact" TEXT NOT NULL,
"lastSyncTimestamp" TIMESTAMP(3) NOT NULL,
"theoreticalMaxGeneration_kWh" DOUBLE PRECISION NOT NULL,
CONSTRAINT "Site_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "EnergyData" (
"id" SERIAL NOT NULL,
"type" TEXT NOT NULL,
"timestamp" TIMESTAMP(3) NOT NULL,
"value" DOUBLE PRECISION NOT NULL,
"consumptionSiteId" INTEGER,
"generationSiteId" INTEGER,
CONSTRAINT "EnergyData_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Site_name_key" ON "Site"("name");
-- CreateIndex
CREATE INDEX "EnergyData_consumptionSiteId_idx" ON "EnergyData"("consumptionSiteId");
-- CreateIndex
CREATE INDEX "EnergyData_generationSiteId_idx" ON "EnergyData"("generationSiteId");
-- AddForeignKey
ALTER TABLE "EnergyData" ADD CONSTRAINT "EnergyData_consumptionSiteId_fkey" FOREIGN KEY ("consumptionSiteId") REFERENCES "Site"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EnergyData" ADD CONSTRAINT "EnergyData_generationSiteId_fkey" FOREIGN KEY ("generationSiteId") REFERENCES "Site"("id") ON DELETE SET NULL ON UPDATE CASCADE;

44
prisma/seed.js Normal file
View File

@ -0,0 +1,44 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function main() {
const site = await prisma.site.create({
data: {
name: 'Solar Rooftop 2',
location: 'Malaysia',
inverterProvider: 'Huawei',
emergencyContact: '012-3456789',
lastSyncTimestamp: new Date(),
theoreticalMaxGeneration_kWh: 5000,
consumptionData: {
create: [
{
type: 'consumption',
timestamp: new Date('2025-01-01T00:00:00Z'),
value: 120.5,
},
],
},
generationData: {
create: [
{
type: 'generation',
timestamp: new Date('2025-01-01T00:00:00Z'),
value: 200.75,
},
],
},
},
});
console.log('Seeded site:', site.name);
}
main()
.then(() => prisma.$disconnect())
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

3
public/facebook.svg Normal file
View File

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20.25 1.5H3.75C3.15326 1.5 2.58097 1.73705 2.15901 2.15901C1.73705 2.58097 1.5 3.15326 1.5 3.75L1.5 20.25C1.5 20.8467 1.73705 21.419 2.15901 21.841C2.58097 22.2629 3.15326 22.5 3.75 22.5H10.1836V15.3605H7.23047V12H10.1836V9.43875C10.1836 6.52547 11.918 4.91625 14.5744 4.91625C15.8466 4.91625 17.1769 5.14313 17.1769 5.14313V8.0025H15.7111C14.2669 8.0025 13.8164 8.89875 13.8164 9.81797V12H17.0405L16.5248 15.3605H13.8164V22.5H20.25C20.8467 22.5 21.419 22.2629 21.841 21.841C22.2629 21.419 22.5 20.8467 22.5 20.25V3.75C22.5 3.15326 22.2629 2.58097 21.841 2.15901C21.419 1.73705 20.8467 1.5 20.25 1.5Z" fill="#331E00"/>
</svg>

After

Width:  |  Height:  |  Size: 732 B

View File

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 84 KiB

BIN
public/images/building.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

BIN
public/images/gridtower.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

4
public/instagram.svg Normal file
View File

@ -0,0 +1,4 @@
<svg width="28" height="30" viewBox="0 0 28 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M24.4382 10.5347C24.4265 9.61957 24.2611 8.7135 23.9494 7.85706C23.6792 7.13466 23.2664 6.47859 22.7374 5.93077C22.2085 5.38296 21.5751 4.95543 20.8776 4.67552C20.0613 4.35816 19.199 4.18656 18.3272 4.16802C17.2049 4.11606 16.8491 4.10156 14.0001 4.10156C11.1511 4.10156 10.7859 4.10156 9.67175 4.16802C8.80043 4.18669 7.93849 4.35829 7.12258 4.67552C6.42498 4.95524 5.79144 5.38269 5.26249 5.93054C4.73353 6.47838 4.32082 7.13455 4.05075 7.85706C3.74372 8.70185 3.57839 9.59478 3.56192 10.4973C3.51175 11.6609 3.49658 12.0294 3.49658 14.9802C3.49658 17.9309 3.49658 18.3079 3.56192 19.4631C3.57942 20.3669 3.74392 21.2587 4.05075 22.1057C4.32127 22.828 4.73429 23.4839 5.26342 24.0315C5.79255 24.5791 6.42616 25.0064 7.12375 25.2861C7.93742 25.6162 8.79952 25.8001 9.67292 25.8298C10.7964 25.8818 11.1522 25.8975 14.0012 25.8975C16.8502 25.8975 17.2154 25.8975 18.3296 25.8298C19.2013 25.812 20.0637 25.6408 20.8799 25.3235C21.5772 25.0433 22.2105 24.6156 22.7394 24.0679C23.2683 23.5201 23.6812 22.8642 23.9517 22.142C24.2586 21.2961 24.4231 20.4044 24.4406 19.4994C24.4907 18.3369 24.5059 17.9684 24.5059 15.0164C24.5036 12.0657 24.5036 11.6911 24.4382 10.5347ZM13.9931 20.5603C11.0134 20.5603 8.59958 18.0602 8.59958 14.9741C8.59958 11.8881 11.0134 9.38802 13.9931 9.38802C15.4235 9.38802 16.7954 9.97656 17.8069 11.0242C18.8183 12.0718 19.3866 13.4926 19.3866 14.9741C19.3866 16.4557 18.8183 17.8765 17.8069 18.9241C16.7954 19.9717 15.4235 20.5603 13.9931 20.5603ZM19.6012 10.484C19.436 10.4841 19.2724 10.4506 19.1198 10.3851C18.9671 10.3197 18.8284 10.2238 18.7116 10.1028C18.5948 9.98183 18.5022 9.83817 18.439 9.68006C18.3759 9.52195 18.3434 9.3525 18.3436 9.1814C18.3436 9.01042 18.3761 8.84111 18.4393 8.68315C18.5024 8.52519 18.595 8.38166 18.7118 8.26076C18.8285 8.13986 18.9671 8.04395 19.1196 7.97852C19.2721 7.91309 19.4356 7.87942 19.6007 7.87942C19.7657 7.87942 19.9292 7.91309 20.0817 7.97852C20.2342 8.04395 20.3728 8.13986 20.4896 8.26076C20.6063 8.38166 20.6989 8.52519 20.7621 8.68315C20.8252 8.84111 20.8577 9.01042 20.8577 9.1814C20.8577 9.90156 20.2954 10.484 19.6012 10.484Z" fill="#331E00"/>
<path d="M13.9928 18.603C15.9277 18.603 17.4963 16.9784 17.4963 14.9743C17.4963 12.9703 15.9277 11.3457 13.9928 11.3457C12.0578 11.3457 10.4893 12.9703 10.4893 14.9743C10.4893 16.9784 12.0578 18.603 13.9928 18.603Z" fill="#331E00"/>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

3
public/linkedin.svg Normal file
View File

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 1.5H2.99531C2.17031 1.5 1.5 2.17969 1.5 3.01406V20.9859C1.5 21.8203 2.17031 22.5 2.99531 22.5H21C21.825 22.5 22.5 21.8203 22.5 20.9859V3.01406C22.5 2.17969 21.825 1.5 21 1.5ZM7.84687 19.5H4.73438V9.47812H7.85156V19.5H7.84687ZM6.29062 8.10938C5.29219 8.10938 4.48594 7.29844 4.48594 6.30469C4.48594 5.31094 5.29219 4.5 6.29062 4.5C7.28437 4.5 8.09531 5.31094 8.09531 6.30469C8.09531 7.30312 7.28906 8.10938 6.29062 8.10938ZM19.5141 19.5H16.4016V14.625C16.4016 13.4625 16.3781 11.9672 14.7844 11.9672C13.1625 11.9672 12.9141 13.2328 12.9141 14.5406V19.5H9.80156V9.47812H12.7875V10.8469H12.8297C13.2469 10.0594 14.2641 9.22969 15.7781 9.22969C18.9281 9.22969 19.5141 11.3062 19.5141 14.0062V19.5Z" fill="#331E00"/>
</svg>

After

Width:  |  Height:  |  Size: 828 B

4
public/mailicon.svg Normal file
View File

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 4H20C21.1 4 22 4.9 22 6V18C22 19.1 21.1 20 20 20H4C2.9 20 2 19.1 2 18V6C2 4.9 2.9 4 4 4Z" stroke="#331E00" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M22 6L12 13L2 6" stroke="#331E00" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 401 B

3
public/whatsapp.svg Normal file
View File

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19.3547 4.55156C17.3906 2.58281 14.775 1.5 11.9953 1.5C6.25781 1.5 1.58906 6.16875 1.58906 11.9062C1.58906 13.7391 2.06719 15.5297 2.97656 17.1094L1.5 22.5L7.01719 21.0516C8.53594 21.8813 10.2469 22.3172 11.9906 22.3172H11.9953C17.7281 22.3172 22.5 17.6484 22.5 11.9109C22.5 9.13125 21.3188 6.52031 19.3547 4.55156ZM11.9953 20.5641C10.4391 20.5641 8.91562 20.1469 7.58906 19.3594L7.275 19.1719L4.00313 20.0297L4.875 16.8375L4.66875 16.5094C3.80156 15.1313 3.34688 13.5422 3.34688 11.9062C3.34688 7.13906 7.22812 3.25781 12 3.25781C14.3109 3.25781 16.4812 4.15781 18.1125 5.79375C19.7437 7.42969 20.7469 9.6 20.7422 11.9109C20.7422 16.6828 16.7625 20.5641 11.9953 20.5641ZM16.7391 14.0859C16.4813 13.9547 15.2016 13.3266 14.9625 13.2422C14.7234 13.1531 14.55 13.1109 14.3766 13.3734C14.2031 13.6359 13.7063 14.2172 13.5516 14.3953C13.4016 14.5688 13.2469 14.5922 12.9891 14.4609C11.4609 13.6969 10.4578 13.0969 9.45 11.3672C9.18281 10.9078 9.71719 10.9406 10.2141 9.94687C10.2984 9.77344 10.2562 9.62344 10.1906 9.49219C10.125 9.36094 9.60469 8.08125 9.38906 7.56094C9.17813 7.05469 8.9625 7.125 8.80313 7.11563C8.65313 7.10625 8.47969 7.10625 8.30625 7.10625C8.13281 7.10625 7.85156 7.17188 7.6125 7.42969C7.37344 7.69219 6.70312 8.32031 6.70312 9.6C6.70312 10.8797 7.63594 12.1172 7.7625 12.2906C7.89375 12.4641 9.59531 15.0891 12.2062 16.2188C13.8562 16.9312 14.5031 16.9922 15.3281 16.8703C15.8297 16.7953 16.8656 16.2422 17.0812 15.6328C17.2969 15.0234 17.2969 14.5031 17.2313 14.3953C17.1703 14.2781 16.9969 14.2125 16.7391 14.0859Z" fill="#331E00"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -51,12 +51,12 @@
} }
.sidebar .nav-item > button.active, .sidebar .nav-item > button.active,
.sidebar .nav-item > a.active { .sidebar .nav-item > a.active {
@apply bg-[#000]/[0.08] text-black dark:bg-[#181f32] dark:text-white-dark; @apply bg-[#000]/[0.08] text-black dark:bg-[#181f32] dark:text-white;
} }
.sidebar .nav-item > button.active > div > span, .sidebar .nav-item > button.active > div > span,
.sidebar .nav-item > a.active > div > span { .sidebar .nav-item > a.active > div > span {
@apply dark:!text-white-dark; @apply dark:!text-white;
} }
.sidebar ul.sub-menu li button, .sidebar ul.sub-menu li button,
@ -237,7 +237,7 @@ hover:text-primary hover:before:!bg-primary ltr:before:mr-2 rtl:before:ml-2 dark
} }
.btn-primary { .btn-primary {
@apply border-primary bg-primary text-white shadow-primary/60; @apply rounded-3xl px-10 py-2.5 bg-rtyellow-200 text-black text-lg font-medium font-exo2 hover:bg-rtyellow-300;
} }
.btn-outline-primary { .btn-outline-primary {
@apply border-primary text-primary shadow-none hover:bg-primary hover:text-white; @apply border-primary text-primary shadow-none hover:bg-primary hover:text-white;
@ -449,6 +449,7 @@ hover:text-primary hover:before:!bg-primary ltr:before:mr-2 rtl:before:ml-2 dark
/* dropdown */ /* dropdown */
.dropdown { .dropdown {
@apply relative; @apply relative;
@apply z-50;
} }
.dropdown > button { .dropdown > button {
@apply flex; @apply flex;

View File

@ -7,6 +7,7 @@ const rotateX = plugin(function ({ addUtilities }) {
}, },
}); });
}); });
module.exports = { module.exports = {
content: ['./App.tsx', './app/**/*.{js,ts,jsx,tsx}', './pages/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}', './src/**/*.{js,ts,jsx,tsx}'], content: ['./App.tsx', './app/**/*.{js,ts,jsx,tsx}', './pages/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}', './src/**/*.{js,ts,jsx,tsx}'],
darkMode: 'class', darkMode: 'class',
@ -16,6 +17,29 @@ module.exports = {
}, },
extend: { extend: {
colors: { colors: {
rtgray: {
50: '#F9FAFB',
100: '#F3F4F6',
200: '#E6E6EA',
300: '#D3D4D9',
400: '#9EA1AD',
500: '#6D707E',
600: '#4D5261',
700: '#3A3F4E',
800: '#222634',
900: '#141624',
1000: '#080912',
},
rtyellow: {
200: '#FCD913',
300: '#E6C812',
500: '#F2BE03',
600: '#D9A003',
},
rtbrown: {
800: '#301E03',
},
rtwhite: '#FFFFFF',
primary: { primary: {
DEFAULT: '#fcd913', DEFAULT: '#fcd913',
light: '#eaf1ff', light: '#eaf1ff',
@ -61,10 +85,13 @@ module.exports = {
light: '#e0e6ed', light: '#e0e6ed',
dark: '#888ea8', dark: '#888ea8',
}, },
}, },
fontFamily: { fontFamily: {
nunito: ['var(--font-nunito)'], nunito: ['var(--font-nunito)'],
}, exo2: ['var(--font-exo2)'],
sans: ['var(--font-sans)'],
mono: ['var(--font-mono)'],
},
spacing: { spacing: {
4.5: '18px', 4.5: '18px',
}, },

136
types/SiteData.ts Normal file
View File

@ -0,0 +1,136 @@
// types/siteData.ts
const generateDailyData = (
type: 'consumption' | 'generation',
min: number,
max: number
): { time: string; value: number }[] => {
return Array.from({ length: 48 }, (_, i) => {
const hour = Math.floor(i / 2);
const minute = i % 2 === 0 ? '00' : '30';
const time = `${hour.toString().padStart(2, '0')}:${minute}`;
let value = Math.random() * (max - min) + min;
if (type === 'generation') {
if (hour < 8 || hour >= 18) {
value = 0;
} else {
const peakHour = 13;
const offset = Math.abs(hour + (i % 2 === 0 ? 0 : 0.5) - peakHour);
value = Math.max(0, max - offset * 5 + Math.random() * 5);
}
}
return { time, value: parseFloat(value.toFixed(2)) };
});
};
export type SiteName = 'Site A' | 'Site B' | 'Site C';
export interface SiteDetails {
location: string;
inverterProvider: string;
emergencyContact: string;
lastSyncTimestamp: string;
consumptionData: number[]; // e.g., Daily consumption in kWh
generationData: number[]; // e.g., Daily generation in kWh
connectedDevices: string[];
// Properties for SystemOverview
systemStatus: string; // e.g., "Normal", "Faulty"
temperature: string; // e.g., "35°C"
solarPower: number; // Power generated by solar (kW) - Real-time
realTimePower: number; // Real-time power used (kW)
installedPower: number; // Installed capacity (kWp)
dailyTimeSeriesData: {
consumption: { time: string; value: number }[];
generation: { time: string; value: number }[];
};
// For savings calculation:
gridImportPrice_RM_per_kWh: number; // Price paid for electricity from the grid
solarExportTariff_RM_per_kWh: number; // Price received for excess solar sent to grid (e.g., FiT)
theoreticalMaxGeneration_kWh?: number; // For efficiency calculation (e.g., based on installedPower * peak sun hours)
}
// Helper function to sum data for monthly totals (if consumptionData/generationData are daily)
const calculateMonthlyTotal = (dataArray: number[]): number => {
return dataArray.reduce((sum, value) => sum + value, 0);
};
const generateYearlyDataInRange = (min: number, max: number) =>
Array(365)
.fill(0)
.map(() => Math.floor(Math.random() * (max - min + 1)) + min);
export const mockSiteData: Record<SiteName, SiteDetails & {
dailyTimeSeriesData: {
consumption: { time: string; value: number }[];
generation: { time: string; value: number }[];
};
}> = {
'Site A': {
location: 'Petaling Jaya, Selangor',
inverterProvider: 'SolarEdge',
emergencyContact: '+60 12-345 6789',
lastSyncTimestamp: '2025-06-03 15:30:00',
consumptionData: generateYearlyDataInRange(80, 250),
generationData: generateYearlyDataInRange(80, 250),
systemStatus: 'Normal',
temperature: '35°C',
solarPower: 108.4,
realTimePower: 108.4,
installedPower: 174.9,
gridImportPrice_RM_per_kWh: 0.50,
solarExportTariff_RM_per_kWh: 0.30,
theoreticalMaxGeneration_kWh: 80000,
connectedDevices: [],
dailyTimeSeriesData: {
consumption: generateDailyData('consumption', 80, 250),
generation: generateDailyData('generation', 80, 100),
},
},
'Site B': {
location: 'Kuala Lumpur, Wilayah Persekutuan',
inverterProvider: 'Huawei',
emergencyContact: '+60 19-876 5432',
lastSyncTimestamp: '2025-06-02 10:15:00',
consumptionData: generateYearlyDataInRange(200, 450),
generationData: generateYearlyDataInRange(200, 450),
systemStatus: 'Normal',
temperature: '32°C',
solarPower: 95.2,
realTimePower: 95.2,
installedPower: 150.0,
gridImportPrice_RM_per_kWh: 0.52,
solarExportTariff_RM_per_kWh: 0.32,
theoreticalMaxGeneration_kWh: 190000,
connectedDevices: [],
dailyTimeSeriesData: {
consumption: generateDailyData('consumption', 150, 300),
generation: generateDailyData('generation', 0, 120),
},
},
'Site C': {
location: 'Johor Bahru, Johor',
inverterProvider: 'Enphase',
emergencyContact: '+60 13-555 1234',
lastSyncTimestamp: '2025-06-03 08:00:00',
consumptionData: generateYearlyDataInRange(400, 550),
generationData: generateYearlyDataInRange(400, 550),
systemStatus: 'Faulty',
temperature: '30°C',
solarPower: 25.0,
realTimePower: 70.0,
installedPower: 120.0,
gridImportPrice_RM_per_kWh: 0.48,
solarExportTariff_RM_per_kWh: 0.28,
theoreticalMaxGeneration_kWh: 180000,
connectedDevices: [],
dailyTimeSeriesData: {
consumption: generateDailyData('consumption', 100, 200),
generation: generateDailyData('generation', 0, 90),
},
},
};

14
types/crm.ts Normal file
View File

@ -0,0 +1,14 @@
// src/types/crm.ts
export interface CrmProject {
name: string; // e.g. PROJ-0008
project_name: string; // display title
status?: string; // "Open" | ...
percent_complete?: number;
owner?: string;
modified?: string; // ISO or "YYYY-MM-DD HH:mm:ss"
customer?: string;
project_type?: string;
custom_address?: string | null;
custom_email?: string | null;
custom_mobile_phone_no?: string | null;
}