Merge pull request 'feature/syasya/testlayout' (#6) from feature/syasya/testlayout into features/syasya
Reviewed-on: #6
This commit is contained in:
commit
f1836c4247
37
.gitea/workflows/pr-build-check.yml
Normal file
37
.gitea/workflows/pr-build-check.yml
Normal 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'
|
@ -1,129 +1,231 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||||
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
||||||
import SiteSelector from '@/components/dashboards/SiteSelector';
|
import SiteSelector from '@/components/dashboards/SiteSelector';
|
||||||
import SiteStatus from '@/components/dashboards/SiteStatus';
|
import SiteStatus from '@/components/dashboards/SiteStatus';
|
||||||
import KPI_Table from '@/components/dashboards/KPIStatus';
|
|
||||||
import DashboardLayout from './dashlayout';
|
import DashboardLayout from './dashlayout';
|
||||||
import html2canvas from 'html2canvas';
|
import html2canvas from 'html2canvas';
|
||||||
import jsPDF from 'jspdf';
|
import jsPDF from 'jspdf';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import { fetchPowerTimeseries } from '@/app/utils/api';
|
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'), {
|
const EnergyLineChart = dynamic(() => import('@/components/dashboards/EnergyLineChart'), { ssr: false });
|
||||||
ssr: false,
|
const MonthlyBarChart = dynamic(() => import('@/components/dashboards/MonthlyBarChart'), { ssr: false });
|
||||||
});
|
|
||||||
|
|
||||||
const MonthlyBarChart = dynamic(() => import('@/components/dashboards/MonthlyBarChart'), {
|
type MonthlyKPI = {
|
||||||
ssr: false,
|
site: string; month: string;
|
||||||
});
|
yield_kwh: number | null; consumption_kwh: number | null; grid_draw_kwh: number | null;
|
||||||
|
efficiency: number | null; peak_demand_kw: number | null;
|
||||||
|
avg_power_factor: number | null; load_factor: number | null;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
import { SiteName, SiteDetails, mockSiteData } from '@/types/SiteData';
|
type CrmProject = {
|
||||||
|
name: string; // e.g. PROJ-0008 <-- use as siteId
|
||||||
|
project_name: string;
|
||||||
|
status?: string;
|
||||||
|
percent_complete?: number | null;
|
||||||
|
owner?: string | null;
|
||||||
|
modified?: string | null;
|
||||||
|
customer?: string | null;
|
||||||
|
project_type?: string | null;
|
||||||
|
custom_address?: string | null;
|
||||||
|
custom_email?: string | null;
|
||||||
|
custom_mobile_phone_no?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000';
|
||||||
|
|
||||||
|
// Adjust this to your FastAPI route
|
||||||
|
const START_LOGGING_ENDPOINT = (siteId: string) =>
|
||||||
|
`${API}/logging/start?site=${encodeURIComponent(siteId)}`;
|
||||||
|
|
||||||
|
// helper to build ISO strings with +08:00
|
||||||
|
const withTZ = (d: Date) => {
|
||||||
|
const yyyyMMdd = d.toISOString().split('T')[0];
|
||||||
|
return {
|
||||||
|
start: `${yyyyMMdd}T00:00:00+08:00`,
|
||||||
|
end: `${yyyyMMdd}T23:59:59+08:00`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const AdminDashboard = () => {
|
const AdminDashboard = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const siteIdMap: Record<SiteName, string> = {
|
|
||||||
'Site A': 'site_01',
|
|
||||||
'Site B': 'site_02',
|
|
||||||
'Site C': 'site_03',
|
|
||||||
};
|
|
||||||
|
|
||||||
const siteParam = searchParams?.get('site');
|
// --- load CRM projects dynamically ---
|
||||||
const validSiteNames: SiteName[] = ['Site A', 'Site B', 'Site C'];
|
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);
|
||||||
|
|
||||||
const [selectedSite, setSelectedSite] = useState<SiteName>(() => {
|
|
||||||
if (siteParam && validSiteNames.includes(siteParam as SiteName)) {
|
|
||||||
return siteParam as SiteName;
|
|
||||||
}
|
|
||||||
return 'Site A';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Keep siteParam and selectedSite in sync
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
setSitesLoading(true);
|
||||||
siteParam &&
|
fetch(`${API}/crm/projects?limit=0`)
|
||||||
validSiteNames.includes(siteParam as SiteName) &&
|
.then(r => r.json())
|
||||||
siteParam !== selectedSite
|
.then(json => setSites(json?.data ?? []))
|
||||||
) {
|
.catch(setSitesError)
|
||||||
setSelectedSite(siteParam as SiteName);
|
.finally(() => setSitesLoading(false));
|
||||||
}
|
}, []);
|
||||||
}, [siteParam, selectedSite]);
|
|
||||||
|
|
||||||
|
// 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 it’s used
|
||||||
|
const currentMonth = useMemo(() => new Date().toISOString().slice(0, 7), []);
|
||||||
|
|
||||||
|
// --- Time-series state ---
|
||||||
const [timeSeriesData, setTimeSeriesData] = useState<{
|
const [timeSeriesData, setTimeSeriesData] = useState<{
|
||||||
consumption: { time: string; value: number }[];
|
consumption: { time: string; value: number }[];
|
||||||
generation: { time: string; value: number }[];
|
generation: { time: string; value: number }[];
|
||||||
}>({ consumption: [], generation: [] });
|
}>({ consumption: [], generation: [] });
|
||||||
|
|
||||||
|
// 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 today’s timeseries for selected siteId
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
if (!selectedSiteId) return;
|
||||||
|
|
||||||
const siteId = siteIdMap[selectedSite];
|
|
||||||
const today = new Date();
|
|
||||||
|
|
||||||
// Format to YYYY-MM-DD
|
|
||||||
const yyyyMMdd = today.toISOString().split('T')[0];
|
|
||||||
|
|
||||||
// Append Malaysia's +08:00 time zone manually
|
|
||||||
const start = `${yyyyMMdd}T00:00:00+08:00`;
|
|
||||||
const end = `${yyyyMMdd}T23:59:59+08:00`;
|
|
||||||
|
|
||||||
|
const fetchToday = async () => {
|
||||||
|
const { start, end } = withTZ(new Date());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const raw = await fetchPowerTimeseries(siteId, start, end);
|
const raw = await fetchPowerTimeseries(selectedSiteId, start, end);
|
||||||
|
const consumption = raw.consumption.map((d: any) => ({ time: d.time, value: d.value }));
|
||||||
const consumption = raw.consumption.map(d => ({
|
const generation = raw.generation.map((d: any) => ({ time: d.time, value: d.value }));
|
||||||
time: d.time,
|
|
||||||
value: d.value,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const generation = raw.generation.map(d => ({
|
|
||||||
time: d.time,
|
|
||||||
value: d.value,
|
|
||||||
}));
|
|
||||||
|
|
||||||
setTimeSeriesData({ consumption, generation });
|
setTimeSeriesData({ consumption, generation });
|
||||||
|
|
||||||
|
const anyToday = (consumption?.length ?? 0) > 0 || (generation?.length ?? 0) > 0;
|
||||||
|
setHasTodayData(anyToday);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch power time series:', error);
|
console.error('Failed to fetch power time series:', error);
|
||||||
|
setHasTodayData(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchData();
|
fetchToday();
|
||||||
}, [selectedSite]);
|
}, [selectedSiteId]);
|
||||||
|
|
||||||
// Update query string when site is changed manually
|
// Check historical data (last 30 days) → controls empty state
|
||||||
const handleSiteChange = (newSite: SiteName) => {
|
useEffect(() => {
|
||||||
setSelectedSite(newSite);
|
if (!selectedSiteId) return;
|
||||||
const newUrl = `${pathname}?site=${encodeURIComponent(newSite)}`;
|
|
||||||
|
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);
|
router.push(newUrl);
|
||||||
|
// reset flags when switching
|
||||||
|
setHasAnyData(false);
|
||||||
|
setHasTodayData(false);
|
||||||
|
setIsLogging(false);
|
||||||
|
setStartError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentSiteDetails: SiteDetails = mockSiteData[selectedSite] || {
|
const locationFormatted = useMemo(() => {
|
||||||
location: 'N/A',
|
const raw = selectedProject?.custom_address ?? '';
|
||||||
inverterProvider: 'N/A',
|
if (!raw) return 'N/A';
|
||||||
emergencyContact: 'N/A',
|
return formatAddress(raw).multiLine;
|
||||||
lastSyncTimestamp: 'N/A',
|
}, [selectedProject?.custom_address]);
|
||||||
consumptionData: [],
|
|
||||||
generationData: [],
|
const lastSyncFormatted = useMemo(
|
||||||
systemStatus: 'N/A',
|
() => formatCrmTimestamp(selectedProject?.modified, { includeSeconds: true }),
|
||||||
temperature: 'N/A',
|
[selectedProject?.modified]
|
||||||
solarPower: 0,
|
);
|
||||||
realTimePower: 0,
|
|
||||||
installedPower: 0,
|
// Adapt CRM project -> SiteStatus props
|
||||||
|
const currentSiteDetails = {
|
||||||
|
location: locationFormatted,
|
||||||
|
inverterProvider: selectedProject?.project_type || 'N/A',
|
||||||
|
emergencyContact:
|
||||||
|
selectedProject?.custom_mobile_phone_no ||
|
||||||
|
selectedProject?.custom_email ||
|
||||||
|
selectedProject?.customer ||
|
||||||
|
'N/A',
|
||||||
|
lastSyncTimestamp: lastSyncFormatted || 'N/A',
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCSVExport = () => {
|
const energyChartRef = useRef<HTMLDivElement | null>(null);
|
||||||
alert('Exported raw data to CSV (mock)');
|
const monthlyChartRef = useRef<HTMLDivElement | null>(null);
|
||||||
};
|
|
||||||
|
|
||||||
const energyChartRef = useRef(null);
|
|
||||||
const monthlyChartRef = useRef(null);
|
|
||||||
|
|
||||||
const handlePDFExport = async () => {
|
const handlePDFExport = async () => {
|
||||||
const doc = new jsPDF('p', 'mm', 'a4'); // portrait, millimeters, A4
|
const doc = new jsPDF('p', 'mm', 'a4');
|
||||||
const chartRefs = [
|
const chartRefs = [
|
||||||
{ ref: energyChartRef, title: 'Energy Line Chart' },
|
{ ref: energyChartRef, title: 'Energy Line Chart' },
|
||||||
{ ref: monthlyChartRef, title: 'Monthly Energy Yield' }
|
{ ref: monthlyChartRef, title: 'Monthly Energy Yield' }
|
||||||
@ -133,82 +235,200 @@ const AdminDashboard = () => {
|
|||||||
|
|
||||||
for (const chart of chartRefs) {
|
for (const chart of chartRefs) {
|
||||||
if (!chart.ref.current) continue;
|
if (!chart.ref.current) continue;
|
||||||
|
const canvas = await html2canvas(chart.ref.current, { scale: 2 });
|
||||||
// Capture chart as image
|
|
||||||
const canvas = await html2canvas(chart.ref.current, {
|
|
||||||
scale: 2, // Higher scale for better resolution
|
|
||||||
});
|
|
||||||
|
|
||||||
const imgData = canvas.toDataURL('image/png');
|
const imgData = canvas.toDataURL('image/png');
|
||||||
const imgProps = doc.getImageProperties(imgData);
|
const imgProps = doc.getImageProperties(imgData);
|
||||||
|
const pdfWidth = doc.internal.pageSize.getWidth() - 20;
|
||||||
const pdfWidth = doc.internal.pageSize.getWidth() - 20; // 10 margin each side
|
|
||||||
const imgHeight = (imgProps.height * pdfWidth) / imgProps.width;
|
const imgHeight = (imgProps.height * pdfWidth) / imgProps.width;
|
||||||
|
|
||||||
// Add title and image
|
|
||||||
doc.setFontSize(14);
|
doc.setFontSize(14);
|
||||||
doc.text(chart.title, 10, yOffset);
|
doc.text(chart.title, 10, yOffset);
|
||||||
yOffset += 6; // Space between title and chart
|
yOffset += 6;
|
||||||
|
|
||||||
// If content will overflow page, add a new page
|
|
||||||
if (yOffset + imgHeight > doc.internal.pageSize.getHeight()) {
|
if (yOffset + imgHeight > doc.internal.pageSize.getHeight()) {
|
||||||
doc.addPage();
|
doc.addPage();
|
||||||
yOffset = 10;
|
yOffset = 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
doc.addImage(imgData, 'PNG', 10, yOffset, pdfWidth, imgHeight);
|
doc.addImage(imgData, 'PNG', 10, yOffset, pdfWidth, imgHeight);
|
||||||
yOffset += imgHeight + 10; // Update offset for next chart
|
yOffset += imgHeight + 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
doc.save('dashboard_charts.pdf');
|
doc.save('dashboard_charts.pdf');
|
||||||
};
|
};
|
||||||
const currentMonth = new Date().toISOString().slice(0, 7); // "YYYY-MM"
|
|
||||||
|
// 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 (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<div className="px-6 space-y-6">
|
<div className="px-3 space-y-6 w-full max-w-screen-3xl mx-auto">
|
||||||
<h1 className="text-lg font-semibold dark:text-white">Admin Dashboard</h1>
|
<h1 className="text-lg font-semibold dark:text-white">Admin Dashboard</h1>
|
||||||
|
|
||||||
<div className="grid md:grid-cols-2 gap-6">
|
{/* Selector + status */}
|
||||||
<div className="space-y-4">
|
<div className="grid grid-cols-1 gap-6 w-full min-w-0">
|
||||||
|
<div className="space-y-4 w-full min-w-0">
|
||||||
<SiteSelector
|
<SiteSelector
|
||||||
selectedSite={selectedSite}
|
options={siteOptions}
|
||||||
setSelectedSite={handleSiteChange}
|
selectedValue={selectedSiteId!}
|
||||||
|
onChange={handleSiteChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SiteStatus
|
<SiteStatus
|
||||||
selectedSite={selectedSite}
|
selectedSite={selectedProject.project_name || selectedProject.name}
|
||||||
|
siteId={selectedProject.name}
|
||||||
location={currentSiteDetails.location}
|
location={currentSiteDetails.location}
|
||||||
inverterProvider={currentSiteDetails.inverterProvider}
|
inverterProvider={currentSiteDetails.inverterProvider}
|
||||||
emergencyContact={currentSiteDetails.emergencyContact}
|
emergencyContact={currentSiteDetails.emergencyContact}
|
||||||
lastSyncTimestamp={currentSiteDetails.lastSyncTimestamp}
|
lastSyncTimestamp={currentSiteDetails.lastSyncTimestamp}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<KPI_Table siteId={siteIdMap[selectedSite]} month={currentMonth} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid md:grid-cols-2 gap-6 lg:flex-col justify-center">
|
{/* 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">
|
<div ref={energyChartRef} className="pb-5">
|
||||||
<EnergyLineChart siteId={siteIdMap[selectedSite]} />
|
<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>
|
</div>
|
||||||
<div ref={monthlyChartRef} className="pb-5">
|
}
|
||||||
<MonthlyBarChart siteId={siteIdMap[selectedSite]} />
|
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>
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex flex-col md:flex-row gap-4 justify-center">
|
<div className="flex flex-col md:flex-row gap-4 justify-center">
|
||||||
<button onClick={handlePDFExport} className="text-sm lg:text-lg btn-primary">
|
<button onClick={handlePDFExport} className="text-sm lg:text-lg btn-primary">
|
||||||
Export Chart Images to PDF
|
Export Chart Images to PDF
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AdminDashboard;
|
export default AdminDashboard;
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,46 +1,187 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import DashboardLayout from '../adminDashboard/dashlayout';
|
import DashboardLayout from '../adminDashboard/dashlayout';
|
||||||
import SiteCard from '@/components/dashboards/SiteCard'; // Import the new SiteCard component
|
import SiteCard from '@/components/dashboards/SiteCard';
|
||||||
import { mockSiteData, SiteName } from '@/types/SiteData'; // Import your mock data and SiteName type
|
|
||||||
|
type CrmProject = {
|
||||||
|
name: string; // e.g. PROJ-0008 (siteId)
|
||||||
|
project_name: string;
|
||||||
|
status?: string | null;
|
||||||
|
modified?: string | null;
|
||||||
|
customer?: string | null;
|
||||||
|
project_type?: string | null;
|
||||||
|
custom_address?: string | null;
|
||||||
|
custom_email?: string | null;
|
||||||
|
custom_mobile_phone_no?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000';
|
||||||
|
|
||||||
const SitesPage = () => {
|
const SitesPage = () => {
|
||||||
// Helper function to determine status (can be externalized if used elsewhere)
|
const [projects, setProjects] = useState<CrmProject[]>([]);
|
||||||
const getSiteStatus = (siteName: SiteName): string => {
|
const [loading, setLoading] = useState(true);
|
||||||
const statusMap: Record<SiteName, string> = {
|
const [err, setErr] = useState<string | null>(null);
|
||||||
'Site A': 'Active',
|
const [q, setQ] = useState(''); // search filter
|
||||||
'Site B': 'Inactive',
|
|
||||||
'Site C': 'Faulty',
|
// pagination
|
||||||
};
|
const [page, setPage] = useState(1);
|
||||||
return statusMap[siteName];
|
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 (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
<h1 className="text-2xl font-bold mb-6 dark:text-white-light">All Sites Overview</h1>
|
<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="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="flex items-center gap-3">
|
||||||
{/* Iterate over the keys of mockSiteData (which are your SiteNames) */}
|
<input
|
||||||
{Object.keys(mockSiteData).map((siteNameKey) => {
|
value={q}
|
||||||
const siteName = siteNameKey as SiteName; // Cast to SiteName type
|
onChange={e => setQ(e.target.value)}
|
||||||
const siteDetails = mockSiteData[siteName];
|
placeholder="Search by name / ID / customer"
|
||||||
const siteStatus = getSiteStatus(siteName);
|
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"
|
||||||
|
|
||||||
return (
|
|
||||||
<SiteCard
|
|
||||||
key={siteName} // Important for React list rendering
|
|
||||||
siteName={siteName}
|
|
||||||
details={siteDetails}
|
|
||||||
status={siteStatus}
|
|
||||||
/>
|
/>
|
||||||
);
|
<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>
|
||||||
</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>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SitesPage;
|
export default SitesPage;
|
||||||
|
|
||||||
|
@ -1,20 +0,0 @@
|
|||||||
// app/api/sites/route.ts
|
|
||||||
import { NextResponse } from 'next/server';
|
|
||||||
import prisma from '@/lib/prisma';
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
const sites = await prisma.site.findMany({
|
|
||||||
include: {
|
|
||||||
consumptionData: true,
|
|
||||||
generationData: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
console.log('✅ Sites:', sites);
|
|
||||||
return NextResponse.json(sites);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Error fetching sites:', error);
|
|
||||||
return new NextResponse('Failed to fetch sites', { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
20
app/hooks/useCrmProjects.ts
Normal file
20
app/hooks/useCrmProjects.ts
Normal 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 };
|
||||||
|
}
|
@ -9,6 +9,18 @@ export interface TimeSeriesResponse {
|
|||||||
generation: 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(
|
export async function fetchPowerTimeseries(
|
||||||
site: string,
|
site: string,
|
||||||
start: string,
|
start: string,
|
||||||
@ -48,3 +60,29 @@ export async function fetchForecast(
|
|||||||
return res.json();
|
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
37
app/utils/datetime.ts
Normal 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);
|
||||||
|
}
|
35
app/utils/formatAddress.ts
Normal file
35
app/utils/formatAddress.ts
Normal 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 (& → &), 2) <br> → \n, 3) tidy whitespace
|
||||||
|
const text = decode(raw)
|
||||||
|
.replace(/<br\s*\/?>/gi, "\n")
|
||||||
|
.replace(/\u00A0/g, " ") //
|
||||||
|
.replace(/[ \t]{2,}/g, " ") // collapse spaces
|
||||||
|
.replace(/\n{2,}/g, "\n") // collapse blank lines
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// split to lines, strip empties
|
||||||
|
const lines = text.split("\n").map(s => s.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
// If postcode is alone (e.g., "40150") before the city line, merge: "40150 Shah Alam"
|
||||||
|
const merged: string[] = [];
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const cur = lines[i];
|
||||||
|
const next = lines[i + 1];
|
||||||
|
if (/^\d{5}$/.test(cur) && next) {
|
||||||
|
merged.push(`${cur} ${next}`);
|
||||||
|
i++; // skip the city line, already merged
|
||||||
|
} else {
|
||||||
|
merged.push(cur);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
parts: merged, // array of lines
|
||||||
|
multiLine: merged.join("\n"), // lines with \n
|
||||||
|
singleLine: merged.join(", "), // one-liner
|
||||||
|
};
|
||||||
|
}
|
@ -2,53 +2,69 @@
|
|||||||
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(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/login`, {
|
const res = await axios.post('/api/login', { email, password });
|
||||||
email,
|
toast.success(res.data?.message || 'Login successful!');
|
||||||
password,
|
router.push('/adminDashboard');
|
||||||
})
|
router.refresh();
|
||||||
|
// token cookie is already set by the server:
|
||||||
localStorage.setItem("token", res.data.token)
|
|
||||||
|
|
||||||
toast.success("Login successful!")
|
|
||||||
router.push("/")
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("Login error:", err)
|
console.error('Login error:', err);
|
||||||
toast.error(err.response?.data?.error || "Invalid credentials")
|
const msg =
|
||||||
|
err?.response?.data?.message ||
|
||||||
|
err?.message ||
|
||||||
|
'Invalid credentials';
|
||||||
|
toast.error(msg);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="space-y-3 dark:text-white" onSubmit={submitForm}>
|
<form className="space-y-3 dark:text-white" onSubmit={submitForm}>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="Email" className='text-yellow-400 text-left'>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
|
||||||
|
id="Email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="Enter Email"
|
||||||
|
className="form-input ps-10 placeholder:text-white-dark"
|
||||||
|
required
|
||||||
|
/>
|
||||||
<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} />
|
<IconMail fill={true} />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="pb-2">
|
<div className="pb-2">
|
||||||
<label htmlFor="Password" className='text-yellow-400 text-left'>Password</label>
|
<label htmlFor="Password" className="text-yellow-400 text-left">Password</label>
|
||||||
<div className="relative text-white-dark">
|
<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" />
|
<input
|
||||||
|
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">
|
<span className="absolute start-4 top-1/2 -translate-y-1/2">
|
||||||
<IconLockDots fill={true} />
|
<IconLockDots fill={true} />
|
||||||
</span>
|
</span>
|
||||||
@ -59,10 +75,11 @@ const ComponentsAuthLoginForm = () => {
|
|||||||
disabled={loading}
|
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"
|
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"}
|
{loading ? 'Logging in...' : 'Sign In'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ComponentsAuthLoginForm;
|
export default ComponentsAuthLoginForm;
|
||||||
|
|
||||||
|
@ -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)
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<form className="space-y-5 dark:text-white" onSubmit={submitForm}>
|
<form onSubmit={onSubmit} className="space-y-4 text-left">
|
||||||
{/* <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>
|
<div>
|
||||||
<label htmlFor="Email" className='text-yellow-400 text-left'>Email</label>
|
<label htmlFor="email" className="mb-1 block text-sm text-gray-300">
|
||||||
<div className="relative text-white-dark">
|
Email
|
||||||
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Enter Email" className="form-input ps-10 placeholder:text-white-dark" />
|
</label>
|
||||||
<span className="absolute start-4 top-1/2 -translate-y-1/2">
|
<input
|
||||||
<IconMail fill={true} />
|
id="email"
|
||||||
</span>
|
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>
|
||||||
|
|
||||||
|
<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>
|
||||||
<div className= "pb-2">
|
|
||||||
<label htmlFor="Password" className='text-yellow-400 text-left'>Password</label>
|
<div>
|
||||||
<div className="relative text-white-dark">
|
<label htmlFor="confirm" className="mb-1 block text-sm text-gray-300">
|
||||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required placeholder="Enter Password" className="form-input ps-10 placeholder:text-white-dark" />
|
Confirm Password
|
||||||
<span className="absolute start-4 top-1/2 -translate-y-1/2">
|
</label>
|
||||||
<IconLockDots fill={true} />
|
<input
|
||||||
</span>
|
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>
|
</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">
|
{error && (
|
||||||
{loading ? "Creating account..." : "Register"}
|
<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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default ComponentsAuthRegisterForm;
|
|
||||||
|
@ -19,6 +19,7 @@ import DatePicker from 'react-datepicker';
|
|||||||
import 'react-datepicker/dist/react-datepicker.css';
|
import 'react-datepicker/dist/react-datepicker.css';
|
||||||
import './datepicker-dark.css'; // custom dark mode styles
|
import './datepicker-dark.css'; // custom dark mode styles
|
||||||
|
|
||||||
|
|
||||||
ChartJS.register(zoomPlugin);
|
ChartJS.register(zoomPlugin);
|
||||||
|
|
||||||
interface TimeSeriesEntry {
|
interface TimeSeriesEntry {
|
||||||
@ -30,9 +31,48 @@ interface EnergyLineChartProps {
|
|||||||
siteId: string;
|
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(
|
function groupTimeSeries(
|
||||||
data: TimeSeriesEntry[],
|
data: TimeSeriesEntry[],
|
||||||
mode: 'day' | 'daily' | 'weekly' | 'monthly' | 'yearly'
|
mode: 'day' | 'daily' | 'weekly' | 'monthly' | 'yearly',
|
||||||
|
agg: 'mean' | 'max' | 'sum' = 'mean'
|
||||||
): TimeSeriesEntry[] {
|
): TimeSeriesEntry[] {
|
||||||
const groupMap = new Map<string, number[]>();
|
const groupMap = new Map<string, number[]>();
|
||||||
|
|
||||||
@ -41,19 +81,22 @@ function groupTimeSeries(
|
|||||||
let key = '';
|
let key = '';
|
||||||
|
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case 'day':
|
case 'day': {
|
||||||
const local = new Date(date.toLocaleString('en-US', { timeZone: 'Asia/Kuala_Lumpur' }));
|
const local = new Date(
|
||||||
const hour = local.getHours();
|
date.toLocaleString('en-US', { timeZone: 'Asia/Kuala_Lumpur' })
|
||||||
const minute = local.getMinutes() < 30 ? '00' : '30';
|
);
|
||||||
const adjusted = new Date(local.setMinutes(minute === '00' ? 0 : 30, 0)); // zero seconds
|
const minute = local.getMinutes() < 30 ? 0 : 30;
|
||||||
key = adjusted.toISOString(); // ✅ full timestamp key
|
local.setMinutes(minute, 0, 0);
|
||||||
|
key = local.toISOString();
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case 'daily':
|
case 'daily':
|
||||||
key = date.toLocaleDateString('en-MY', {
|
key = date.toLocaleDateString('en-MY', {
|
||||||
timeZone: 'Asia/Kuala_Lumpur',
|
timeZone: 'Asia/Kuala_Lumpur',
|
||||||
weekday: 'short',
|
weekday: 'short',
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case 'weekly':
|
case 'weekly':
|
||||||
@ -71,11 +114,18 @@ function groupTimeSeries(
|
|||||||
groupMap.get(key)!.push(entry.value);
|
groupMap.get(key)!.push(entry.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(groupMap.entries()).map(([time, values]) => ({
|
return Array.from(groupMap.entries()).map(([time, values]) => {
|
||||||
time,
|
if (agg === 'sum') {
|
||||||
value: values.reduce((sum, v) => sum + v, 0),
|
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 EnergyLineChart = ({ siteId }: EnergyLineChartProps) => {
|
||||||
const chartRef = useRef<any>(null);
|
const chartRef = useRef<any>(null);
|
||||||
@ -85,6 +135,94 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => {
|
|||||||
const [selectedDate, setSelectedDate] = useState(new Date());
|
const [selectedDate, setSelectedDate] = useState(new Date());
|
||||||
const [forecast, setForecast] = useState<TimeSeriesEntry[]>([]);
|
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() {
|
function useIsDarkMode() {
|
||||||
const [isDark, setIsDark] = useState(() =>
|
const [isDark, setIsDark] = useState(() =>
|
||||||
typeof document !== 'undefined'
|
typeof document !== 'undefined'
|
||||||
@ -140,7 +278,7 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => {
|
|||||||
setGeneration(res.generation);
|
setGeneration(res.generation);
|
||||||
|
|
||||||
// ⬇️ ADD THIS here — fetch forecast
|
// ⬇️ ADD THIS here — fetch forecast
|
||||||
const forecastData = await fetchForecast(3.15, 101.7, 37, 0, 5.67);
|
const forecastData = await fetchForecast(3.15, 101.7, 37, 0, 30.67);
|
||||||
const selectedDateStr = selectedDate.toISOString().split('T')[0];
|
const selectedDateStr = selectedDate.toISOString().split('T')[0];
|
||||||
|
|
||||||
setForecast(
|
setForecast(
|
||||||
@ -160,9 +298,40 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, [siteId, viewMode, selectedDate]);
|
}, [siteId, viewMode, selectedDate]);
|
||||||
|
|
||||||
const groupedConsumption = groupTimeSeries(consumption, viewMode);
|
const isEnergyView = viewMode !== 'day';
|
||||||
const groupedGeneration = groupTimeSeries(generation, viewMode);
|
|
||||||
const groupedForecast = groupTimeSeries(forecast, viewMode);
|
// 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 forecastMap = Object.fromEntries(groupedForecast.map(d => [d.time, d.value]));
|
||||||
|
|
||||||
|
|
||||||
@ -224,6 +393,22 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => {
|
|||||||
|
|
||||||
const axisColor = isDark ? '#fff' : '#222';
|
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 = {
|
const data = {
|
||||||
labels: filteredLabels.map(formatLabel),
|
labels: filteredLabels.map(formatLabel),
|
||||||
@ -231,26 +416,29 @@ const axisColor = isDark ? '#fff' : '#222';
|
|||||||
{
|
{
|
||||||
label: 'Consumption',
|
label: 'Consumption',
|
||||||
data: filteredConsumption,
|
data: filteredConsumption,
|
||||||
borderColor: '#8884d8',
|
borderColor: consumptionColor,
|
||||||
|
backgroundColor: (ctx: any) => areaGradient(ctx, consumptionColor),
|
||||||
|
fill: true, // <-- fill under line
|
||||||
tension: 0.4,
|
tension: 0.4,
|
||||||
fill: false,
|
|
||||||
spanGaps: true,
|
spanGaps: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Generation',
|
label: 'Generation',
|
||||||
data: filteredGeneration,
|
data: filteredGeneration,
|
||||||
borderColor: '#82ca9d',
|
borderColor: generationColor,
|
||||||
|
backgroundColor: (ctx: any) => areaGradient(ctx, generationColor),
|
||||||
|
fill: true, // <-- fill under line
|
||||||
tension: 0.4,
|
tension: 0.4,
|
||||||
fill: false,
|
|
||||||
spanGaps: true,
|
spanGaps: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Forecasted Solar',
|
label: 'Forecasted Solar',
|
||||||
data: filteredForecast,
|
data: filteredForecast,
|
||||||
borderColor: '#ffa500', // orange
|
borderColor: '#fcd913', // orange
|
||||||
|
backgroundColor: (ctx: any) => areaGradient(ctx, '#fcd913', 0.18, 0.03),
|
||||||
tension: 0.4,
|
tension: 0.4,
|
||||||
borderDash: [5, 5], // dashed line to distinguish forecast
|
borderDash: [5, 5], // dashed line to distinguish forecast
|
||||||
fill: false,
|
fill: true,
|
||||||
spanGaps: true,
|
spanGaps: true,
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@ -283,6 +471,13 @@ const axisColor = isDark ? '#fff' : '#222';
|
|||||||
bodyColor: axisColor,
|
bodyColor: axisColor,
|
||||||
borderColor: isDark ? '#444' : '#ccc',
|
borderColor: isDark ? '#444' : '#ccc',
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
|
callbacks: {
|
||||||
|
label: (ctx: any) => {
|
||||||
|
const dsLabel = ctx.dataset.label || '';
|
||||||
|
const val = ctx.parsed.y;
|
||||||
|
return `${dsLabel}: ${val?.toFixed(2)} ${yUnit}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
scales: {
|
scales: {
|
||||||
@ -309,12 +504,7 @@ const axisColor = isDark ? '#fff' : '#222';
|
|||||||
y: {
|
y: {
|
||||||
beginAtZero: true,
|
beginAtZero: true,
|
||||||
suggestedMax: yAxisSuggestedMax,
|
suggestedMax: yAxisSuggestedMax,
|
||||||
title: {
|
title: { display: true, text: yTitle, color: axisColor, font: { weight: 'normal' as const } },
|
||||||
display: true,
|
|
||||||
text: 'Power (kW)',
|
|
||||||
color: axisColor,
|
|
||||||
font: { weight: 'normal' as const },
|
|
||||||
},
|
|
||||||
ticks: {
|
ticks: {
|
||||||
color: axisColor,
|
color: axisColor,
|
||||||
},
|
},
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from "react";
|
||||||
|
|
||||||
interface KPI_TableProps {
|
interface KPI_TableProps {
|
||||||
siteId: string;
|
siteId: string;
|
||||||
@ -12,8 +12,8 @@ interface MonthlyKPI {
|
|||||||
consumption_kwh: number | null;
|
consumption_kwh: number | null;
|
||||||
grid_draw_kwh: number | null;
|
grid_draw_kwh: number | null;
|
||||||
efficiency: number | null;
|
efficiency: number | null;
|
||||||
peak_demand_kw: number | null; // ✅ new
|
peak_demand_kw: number | null;
|
||||||
avg_power_factor: number | null; // ✅ new
|
avg_power_factor: number | null;
|
||||||
load_factor: number | null;
|
load_factor: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -22,83 +22,66 @@ const KPI_Table: React.FC<KPI_TableProps> = ({ siteId, month }) => {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!siteId || !month) return;
|
||||||
|
|
||||||
const fetchKPI = async () => {
|
const fetchKPI = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`http://localhost:8000/kpi/monthly?site=${siteId}&month=${month}`);
|
const res = await fetch(
|
||||||
const data = await res.json();
|
`http://localhost:8000/kpi/monthly?site=${siteId}&month=${month}`
|
||||||
setKpiData(data);
|
);
|
||||||
|
setKpiData(await res.json());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch KPI:', err);
|
console.error("Failed to fetch KPI:", err);
|
||||||
setKpiData(null); // fallback
|
setKpiData(null);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (siteId && month) fetchKPI();
|
fetchKPI();
|
||||||
}, [siteId, month]);
|
}, [siteId, month]);
|
||||||
|
|
||||||
if (!siteId) {
|
const formatValue = (value: number | null, unit = "", decimals = 2) =>
|
||||||
return (
|
value != null ? `${value.toFixed(decimals)}${unit}` : "—";
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-bold mb-2">Monthly KPI</h2>
|
|
||||||
<div className="min-h-[275px] w-full flex items-center justify-center border">
|
|
||||||
<p>No site selected</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
const rows = [
|
||||||
return (
|
{ label: "Monthly Yield", value: formatValue(kpiData?.yield_kwh ?? null, " kWh", 0) },
|
||||||
<div>
|
{ label: "Monthly Consumption", value: formatValue(kpiData?.consumption_kwh ?? null, " kWh", 0) },
|
||||||
<h2 className="text-lg font-bold mb-2">Monthly KPI</h2>
|
{ label: "Monthly Grid Draw", value: formatValue(kpiData?.grid_draw_kwh ?? null, " kWh", 0) },
|
||||||
<div className="min-h-[275px] w-full flex items-center justify-center border">
|
{ label: "Efficiency", value: formatValue(kpiData?.efficiency ?? null, "%", 1) },
|
||||||
<p>Loading...</p>
|
{ label: "Peak Demand", value: formatValue(kpiData?.peak_demand_kw ?? null, " kW") },
|
||||||
</div>
|
{ label: "Power Factor", value: formatValue(kpiData?.avg_power_factor ?? null) },
|
||||||
</div>
|
{ label: "Load Factor", value: formatValue(kpiData?.load_factor ?? null) },
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use optional chaining and nullish coalescing to safely default values to 0
|
|
||||||
const yield_kwh = kpiData?.yield_kwh ?? 0;
|
|
||||||
const consumption_kwh = kpiData?.consumption_kwh ?? 0;
|
|
||||||
const grid_draw_kwh = kpiData?.grid_draw_kwh ?? 0;
|
|
||||||
const efficiency = kpiData?.efficiency ?? 0;
|
|
||||||
const peak_demand_kw = kpiData?.peak_demand_kw ?? 0;
|
|
||||||
const power_factor = kpiData?.avg_power_factor ?? 0;
|
|
||||||
const load_factor = kpiData?.load_factor ?? 0;
|
|
||||||
|
|
||||||
const data = [
|
|
||||||
{ kpi: 'Monthly Yield', value: `${yield_kwh.toFixed(0)} kWh` },
|
|
||||||
{ kpi: 'Monthly Consumption', value: `${consumption_kwh.toFixed(0)} kWh` },
|
|
||||||
{ kpi: 'Monthly Grid Draw', value: `${grid_draw_kwh.toFixed(0)} kWh` },
|
|
||||||
{ kpi: 'Efficiency', value: `${efficiency.toFixed(1)}%` },
|
|
||||||
{ kpi: 'Peak Demand', value: `${peak_demand_kw.toFixed(2)} kW` }, // ✅ added
|
|
||||||
{ kpi: 'Power Factor', value: `${power_factor.toFixed(2)} kW` }, // ✅ added
|
|
||||||
{ kpi: 'Load Factor', value: `${load_factor.toFixed(2)} kW` }, // ✅ added
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-bold mb-2 dark:text-white">Monthly KPI</h2>
|
<h2 className="text-lg font-bold mb-2 dark:text-white">Monthly KPI</h2>
|
||||||
<table className="min-h-[275px] w-full border-collapse border border-gray-300 dark:border-rtgray-700 text-black dark:text-white bg-white dark:bg-rtgray-700">
|
<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>
|
<thead>
|
||||||
<tr className="bg-rtgray-100 dark:bg-rtgray-800 text-black dark:text-white">
|
<tr className="bg-gray-100 dark:bg-rtgray-800">
|
||||||
<th className="border border-rtgray-300 dark:border-rtgray-700 p-3 text-left dark:bg-rtgray-900">KPI</th>
|
<th className="border p-3 text-left dark:text-white">KPI</th>
|
||||||
<th className="border border-rtgray-300 dark:border-rtgray-700 p-3 text-left dark:bg-rtgray-900">Value</th>
|
<th className="border p-3 text-left dark:text-white">Value</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{data.map((row) => (
|
{rows.map((row) => (
|
||||||
<tr key={row.kpi} className="even:bg-rtgray-50 dark:even:bg-rtgray-800">
|
<tr key={row.label} className="even:bg-gray-50 dark:even:bg-rtgray-800">
|
||||||
<td className="border border-rtgray-300 dark:border-rtgray-700 p-2.5">{row.kpi}</td>
|
<td className="border p-2.5 dark:text-white">{row.label}</td>
|
||||||
<td className="border border-rtgray-300 dark:border-rtgray-700 p-2.5">{row.value}</td>
|
<td className="border p-2.5 dark:text-white">{row.value}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -106,3 +89,4 @@ const data = [
|
|||||||
export default KPI_Table;
|
export default KPI_Table;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
223
components/dashboards/LoggingControl.tsx
Normal file
223
components/dashboards/LoggingControl.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
BarChart,
|
BarChart,
|
||||||
Bar,
|
Bar,
|
||||||
@ -9,45 +9,24 @@ import {
|
|||||||
Legend,
|
Legend,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { fetchPowerTimeseries } from '@/app/utils/api';
|
import { fetchMonthlyKpi, type MonthlyKPI } from '@/app/utils/api';
|
||||||
|
|
||||||
|
|
||||||
interface MonthlyBarChartProps {
|
interface MonthlyBarChartProps {
|
||||||
siteId: string;
|
siteId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TimeSeriesEntry {
|
const getLastNMonthKeys = (n: number): string[] => {
|
||||||
time: string;
|
const out: string[] = [];
|
||||||
value: number;
|
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;
|
||||||
const groupTimeSeries = (
|
|
||||||
data: TimeSeriesEntry[],
|
|
||||||
mode: 'monthly'
|
|
||||||
): TimeSeriesEntry[] => {
|
|
||||||
const groupMap = new Map<string, number[]>();
|
|
||||||
|
|
||||||
for (const entry of data) {
|
|
||||||
const date = new Date(entry.time);
|
|
||||||
const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
|
||||||
if (!groupMap.has(key)) groupMap.set(key, []);
|
|
||||||
groupMap.get(key)!.push(entry.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(groupMap.entries()).map(([time, values]) => ({
|
|
||||||
time,
|
|
||||||
value: values.reduce((sum, v) => sum + v, 0),
|
|
||||||
}));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const MonthlyBarChart: React.FC<MonthlyBarChartProps> = ({ siteId }) => {
|
|
||||||
const [chartData, setChartData] = useState<
|
|
||||||
{ month: string; consumption: number; generation: number }[]
|
|
||||||
>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
function useIsDarkMode() {
|
function useIsDarkMode() {
|
||||||
const [isDark, setIsDark] = useState(() =>
|
const [isDark, setIsDark] = useState(() =>
|
||||||
typeof document !== 'undefined'
|
typeof document !== 'undefined'
|
||||||
@ -58,79 +37,82 @@ const MonthlyBarChart: React.FC<MonthlyBarChartProps> = ({ siteId }) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const check = () => setIsDark(document.body.classList.contains('dark'));
|
const check = () => setIsDark(document.body.classList.contains('dark'));
|
||||||
check();
|
check();
|
||||||
|
|
||||||
// Listen for class changes on <body>
|
|
||||||
const observer = new MutationObserver(check);
|
const observer = new MutationObserver(check);
|
||||||
observer.observe(document.body, { attributes: true, attributeFilter: ['class'] });
|
observer.observe(document.body, { attributes: true, attributeFilter: ['class'] });
|
||||||
|
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return isDark;
|
return isDark;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDark = useIsDarkMode();
|
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 consumptionColor = isDark ? '#ba8e23' : '#003049';
|
||||||
const generationColor = isDark ? '#fcd913' : '#669bbc';
|
const generationColor = isDark ? '#fcd913' : '#669bbc';
|
||||||
|
|
||||||
|
const monthKeys = useMemo(() => getLastNMonthKeys(6), []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!siteId) return;
|
if (!siteId) return;
|
||||||
|
|
||||||
const fetchMonthlyData = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const start = '2025-01-01T00:00:00+08:00';
|
|
||||||
const end = '2025-12-31T23:59:59+08:00';
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetchPowerTimeseries(siteId, start, end);
|
// Fetch all 6 months in parallel
|
||||||
|
const results: MonthlyKPI[] = await Promise.all(
|
||||||
|
monthKeys.map((month) =>
|
||||||
|
fetchMonthlyKpi({
|
||||||
|
site: siteId,
|
||||||
|
month,
|
||||||
|
// consumption_topic: '...', // optional if your API needs it
|
||||||
|
// generation_topic: '...', // optional if your API needs it
|
||||||
|
}).catch((e) => {
|
||||||
|
// normalize failures to an error-shaped record so the chart can still render other months
|
||||||
|
return {
|
||||||
|
site: siteId,
|
||||||
|
month,
|
||||||
|
yield_kwh: null,
|
||||||
|
consumption_kwh: null,
|
||||||
|
grid_draw_kwh: null,
|
||||||
|
efficiency: null,
|
||||||
|
peak_demand_kw: null,
|
||||||
|
avg_power_factor: null,
|
||||||
|
load_factor: null,
|
||||||
|
error: String(e),
|
||||||
|
} as MonthlyKPI;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
const groupedConsumption = groupTimeSeries(res.consumption, 'monthly');
|
// Map to chart rows; default nulls to 0 for stacking/tooltip friendliness
|
||||||
const groupedGeneration = groupTimeSeries(res.generation, 'monthly');
|
const rows = results.map((kpi) => {
|
||||||
|
const monthLabel = format(new Date(`${kpi.month}-01`), 'MMM');
|
||||||
|
return {
|
||||||
|
month: monthLabel,
|
||||||
|
consumption: kpi.consumption_kwh ?? 0,
|
||||||
|
generation: kpi.yield_kwh ?? 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const monthMap = new Map<string, { consumption: number; generation: number }>();
|
setChartData(rows);
|
||||||
|
|
||||||
for (const entry of groupedConsumption) {
|
|
||||||
if (!monthMap.has(entry.time)) {
|
|
||||||
monthMap.set(entry.time, { consumption: 0, generation: 0 });
|
|
||||||
}
|
|
||||||
monthMap.get(entry.time)!.consumption = entry.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const entry of groupedGeneration) {
|
|
||||||
if (!monthMap.has(entry.time)) {
|
|
||||||
monthMap.set(entry.time, { consumption: 0, generation: 0 });
|
|
||||||
}
|
|
||||||
monthMap.get(entry.time)!.generation = entry.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatted = Array.from(monthMap.entries())
|
|
||||||
.sort(([a], [b]) => a.localeCompare(b))
|
|
||||||
.map(([key, val]) => ({
|
|
||||||
month: format(new Date(`${key}-01`), 'MMM'),
|
|
||||||
consumption: val.consumption,
|
|
||||||
generation: val.generation,
|
|
||||||
}));
|
|
||||||
|
|
||||||
setChartData(formatted.slice(-6)); // last 6 months
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch monthly power data:', error);
|
|
||||||
setChartData([]);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchMonthlyData();
|
load();
|
||||||
}, [siteId]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [siteId]); // monthKeys are stable via useMemo
|
||||||
|
|
||||||
if (loading || !siteId || chartData.length === 0) {
|
if (loading || !siteId || chartData.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white p-4 rounded-lg shadow-md dark:bg-rtgray-800 dark:text-white-light">
|
<div className="bg-white p-3 rounded-lg shadow-md dark:bg-rtgray-800 dark:text-white-light">
|
||||||
<div className="flex justify-between items-center mb-2">
|
<div className="h-[200px] w-full flex items-center justify-center">
|
||||||
<h2 className="text-lg font-bold pb-3">Monthly Energy Yield</h2>
|
|
||||||
</div>
|
|
||||||
<div className="h-96 w-full flex items-center justify-center">
|
|
||||||
<p className="text-white/70">
|
<p className="text-white/70">
|
||||||
{loading ? 'Loading data...' : 'No data available for chart.'}
|
{loading ? 'Loading data...' : 'No data available for chart.'}
|
||||||
</p>
|
</p>
|
||||||
@ -140,12 +122,8 @@ const generationColor = isDark ? '#fcd913' : '#669bbc';
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white p-4 rounded-lg shadow-md dark:bg-rtgray-800 dark:text-white-light">
|
<div className="bg-white p-3 rounded-lg dark:bg-rtgray-800 dark:text-white-light">
|
||||||
<div className="flex justify-between items-center mb-2">
|
<div className="h-[200px] w-full">
|
||||||
<h2 className="text-lg font-bold pb-3">Monthly Energy Yield</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="lg:h-[22.6vw] h-[290px] w-full pt-10">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={chartData}>
|
<BarChart data={chartData}>
|
||||||
<XAxis
|
<XAxis
|
||||||
@ -158,6 +136,16 @@ const generationColor = isDark ? '#fcd913' : '#669bbc';
|
|||||||
tick={{ fontSize: 10, fill: isDark ? '#fff' : '#222' }}
|
tick={{ fontSize: 10, fill: isDark ? '#fff' : '#222' }}
|
||||||
axisLine={{ stroke: isDark ? '#fff' : '#222' }}
|
axisLine={{ stroke: isDark ? '#fff' : '#222' }}
|
||||||
tickLine={{ 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
|
<Tooltip
|
||||||
formatter={(value: number) => [`${value.toFixed(2)} kWh`]}
|
formatter={(value: number) => [`${value.toFixed(2)} kWh`]}
|
||||||
@ -171,15 +159,11 @@ const generationColor = isDark ? '#fcd913' : '#669bbc';
|
|||||||
color: isDark ? '#fff' : '#222',
|
color: isDark ? '#fff' : '#222',
|
||||||
}}
|
}}
|
||||||
cursor={{
|
cursor={{
|
||||||
fill: isDark ? '#808080' : '#e0e7ef', // dark mode bg, light mode bg
|
fill: isDark ? '#808080' : '#e0e7ef',
|
||||||
fillOpacity: isDark ? 0.6 : 0.3, // adjust opacity as you like
|
fillOpacity: isDark ? 0.6 : 0.3,
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Legend
|
|
||||||
wrapperStyle={{
|
|
||||||
color: isDark ? '#fff' : '#222',
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Legend wrapperStyle={{ color: isDark ? '#fff' : '#222' }} />
|
||||||
<Bar dataKey="consumption" fill={consumptionColor} name="Consumption (kWh)" />
|
<Bar dataKey="consumption" fill={consumptionColor} name="Consumption (kWh)" />
|
||||||
<Bar dataKey="generation" fill={generationColor} name="Generation (kWh)" />
|
<Bar dataKey="generation" fill={generationColor} name="Generation (kWh)" />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
@ -191,3 +175,4 @@ const generationColor = isDark ? '#fcd913' : '#669bbc';
|
|||||||
|
|
||||||
export default MonthlyBarChart;
|
export default MonthlyBarChart;
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,26 +1,111 @@
|
|||||||
// components/dashboards/SiteCard.tsx
|
// components/dashboards/SiteCard.tsx
|
||||||
import React from 'react';
|
'use client';
|
||||||
import Link from 'next/link'; // Import Link from Next.js
|
|
||||||
import { SiteName, SiteDetails } from '@/types/SiteData'; // Adjust path as necessary
|
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 {
|
interface SiteCardProps {
|
||||||
siteName: SiteName;
|
siteId: string; // CRM Project "name" (canonical id)
|
||||||
details: SiteDetails;
|
className?: string; // optional styling hook
|
||||||
status: string;
|
fallbackStatus?: string; // optional backup status if CRM is missing it
|
||||||
}
|
}
|
||||||
|
|
||||||
const SiteCard: React.FC<SiteCardProps> = ({ siteName, details, status }) => {
|
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 =
|
const statusColorClass =
|
||||||
status === 'Active' ? 'text-green-500' :
|
status === 'Active' ? 'text-green-500' :
|
||||||
status === 'Inactive' ? 'text-orange-500' :
|
status === 'Inactive' ? 'text-orange-500' :
|
||||||
'text-red-500';
|
'text-red-500';
|
||||||
|
|
||||||
|
const niceAddress = useMemo(() => {
|
||||||
|
if (!project?.custom_address) return 'N/A';
|
||||||
|
return formatAddress(project.custom_address).multiLine;
|
||||||
|
}, [project?.custom_address]);
|
||||||
|
|
||||||
|
const lastSync = useMemo(() => {
|
||||||
|
return formatCrmTimestamp(project?.modified, { includeSeconds: true }) || 'N/A';
|
||||||
|
}, [project?.modified]);
|
||||||
|
|
||||||
|
const inverterProvider = project?.project_type || 'N/A';
|
||||||
|
const emergencyContact =
|
||||||
|
project?.custom_mobile_phone_no ||
|
||||||
|
project?.custom_email ||
|
||||||
|
project?.customer ||
|
||||||
|
'N/A';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white p-4 rounded-lg shadow-md dark:bg-gray-800 dark:text-white-light flex flex-col space-y-2">
|
<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">
|
<h3 className="text-xl font-bold text-primary-600 dark:text-primary-400 border-b pb-2 mb-2">
|
||||||
{siteName}
|
{project?.project_name || siteId}
|
||||||
</h3>
|
</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">
|
<div className="flex justify-between items-center">
|
||||||
<p className="text-gray-600 dark:text-gray-400 font-medium">Status:</p>
|
<p className="text-gray-600 dark:text-gray-400 font-medium">Status:</p>
|
||||||
<p className={`font-semibold ${statusColorClass}`}>{status}</p>
|
<p className={`font-semibold ${statusColorClass}`}>{status}</p>
|
||||||
@ -28,31 +113,29 @@ const SiteCard: React.FC<SiteCardProps> = ({ siteName, details, status }) => {
|
|||||||
|
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<p className="text-gray-600 dark:text-gray-400 font-medium">Location:</p>
|
<p className="text-gray-600 dark:text-gray-400 font-medium">Location:</p>
|
||||||
<p className="font-semibold">{details.location}</p>
|
<p className="font-medium whitespace-pre-line text-right">{niceAddress}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<p className="text-gray-600 dark:text-gray-400 font-medium">Inverter Provider:</p>
|
<p className="text-gray-600 dark:text-gray-400 font-medium">Inverter Provider:</p>
|
||||||
<p className="font-semibold">{details.inverterProvider}</p>
|
<p className="font-medium">{inverterProvider}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<p className="text-gray-600 dark:text-gray-400 font-medium">Emergency Contact:</p>
|
<p className="text-gray-600 dark:text-gray-400 font-medium">Emergency Contact:</p>
|
||||||
<p className="font-semibold">{details.emergencyContact}</p>
|
<p className="font-medium text-right">{emergencyContact}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<p className="text-gray-600 dark:text-gray-400 font-medium">Last Sync:</p>
|
<p className="text-gray-600 dark:text-gray-400 font-medium">Last Sync:</p>
|
||||||
<p className="font-semibold">{details.lastSyncTimestamp}</p>
|
<p className="font-medium">{lastSync}</p>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* New: View Dashboard Button */}
|
|
||||||
<Link
|
<Link
|
||||||
href={{
|
href={{ pathname: '/adminDashboard', query: { site: siteId } }}
|
||||||
pathname: '/adminDashboard', // Path to your AdminDashboard page
|
className="mt-4 w-full text-center text-sm btn-primary"
|
||||||
query: { site: siteName }, // Pass the siteName as a query parameter
|
|
||||||
}}
|
|
||||||
className="mt-4 w-full text-center text-sm btn-primary" // Tailwind classes for basic button styling
|
|
||||||
>
|
>
|
||||||
View Dashboard
|
View Dashboard
|
||||||
</Link>
|
</Link>
|
||||||
|
@ -1,26 +1,51 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import type { SiteName } from '@/components/dashboards/SiteStatus';
|
type Option = { label: string; value: string };
|
||||||
|
|
||||||
type SiteSelectorProps = {
|
type SiteSelectorProps = {
|
||||||
selectedSite: SiteName;
|
options: Option[]; // e.g. [{label: 'Timo… (Installation)', value: 'PROJ-0008'}, …]
|
||||||
setSelectedSite: (site: SiteName) => void;
|
selectedValue: string | null; // the selected project "name" (siteId) or null
|
||||||
|
onChange: (value: string) => void; // called with the selected value
|
||||||
|
label?: string;
|
||||||
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
const SiteSelector = ({ selectedSite, setSelectedSite }: SiteSelectorProps) => {
|
|
||||||
|
const SiteSelector = ({
|
||||||
|
options,
|
||||||
|
selectedValue,
|
||||||
|
onChange,
|
||||||
|
label = 'Select Site:',
|
||||||
|
disabled = false,
|
||||||
|
}: SiteSelectorProps) => {
|
||||||
|
const isEmpty = !options || options.length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<label htmlFor="site" className="font-semibold text-lg dark:text-white">Select Site:</label>
|
<label htmlFor="site" className="font-semibold text-lg dark:text-white">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
|
||||||
<select
|
<select
|
||||||
id="site"
|
id="site"
|
||||||
className="border p-2 rounded dark:text-white dark:bg-rtgray-800 dark:border-rtgray-700"
|
className="border p-2 rounded dark:text-white dark:bg-rtgray-800 dark:border-rtgray-700"
|
||||||
value={selectedSite}
|
value={selectedValue ?? ''} // keep controlled even when null
|
||||||
onChange={(e) => setSelectedSite(e.target.value as SiteName)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
disabled={disabled || isEmpty}
|
||||||
>
|
>
|
||||||
<option>Site A</option>
|
{/* Placeholder when nothing selected */}
|
||||||
<option>Site B</option>
|
<option value="" disabled>
|
||||||
<option>Site C</option>
|
{isEmpty ? 'No sites available' : 'Choose a site…'}
|
||||||
|
</option>
|
||||||
|
|
||||||
|
{options.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SiteSelector;
|
export default SiteSelector;
|
||||||
|
|
||||||
|
@ -1,82 +1,80 @@
|
|||||||
import axios from "axios";
|
'use client';
|
||||||
import React, { useState, useEffect } from "react";
|
|
||||||
|
|
||||||
export type SiteName = 'Site A' | 'Site B' | 'Site C';
|
import axios from "axios";
|
||||||
|
import React, { useState, useEffect, useMemo } from "react";
|
||||||
|
|
||||||
|
export type SiteName = string;
|
||||||
|
|
||||||
interface SiteStatusProps {
|
interface SiteStatusProps {
|
||||||
selectedSite: SiteName;
|
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;
|
location: string;
|
||||||
inverterProvider: string;
|
inverterProvider: string;
|
||||||
emergencyContact: string;
|
emergencyContact: string;
|
||||||
lastSyncTimestamp: 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 = ({
|
const SiteStatus = ({
|
||||||
selectedSite,
|
selectedSite,
|
||||||
|
siteId,
|
||||||
|
status,
|
||||||
location,
|
location,
|
||||||
inverterProvider,
|
inverterProvider,
|
||||||
emergencyContact,
|
emergencyContact,
|
||||||
lastSyncTimestamp,
|
lastSyncTimestamp,
|
||||||
}: SiteStatusProps) => {
|
}: SiteStatusProps) => {
|
||||||
|
|
||||||
|
// --- WebSocket to receive MQTT-forwarded messages ---
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ws = new WebSocket("ws://localhost:8000/ws");
|
const ws = new WebSocket(WS_URL);
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
|
||||||
const data = event.data;
|
|
||||||
alert(`MQTT: ${data}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onopen = () => console.log("WebSocket connected");
|
ws.onopen = () => console.log("WebSocket connected");
|
||||||
ws.onclose = () => console.log("WebSocket disconnected");
|
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();
|
return () => ws.close();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [deviceId, setDeviceId] = useState("");
|
const [deviceId, setDeviceId] = useState("");
|
||||||
const [functionType, setFunctionType] = useState("Grid");
|
const [functionType, setFunctionType] = useState<"Grid" | "Solar">("Grid");
|
||||||
|
|
||||||
// Map site names to site IDs
|
// Track devices connected per siteId (dynamic)
|
||||||
const siteIdMap: Record<SiteName, string> = {
|
const [loggedDevices, setLoggedDevices] = useState<Record<string, string[]>>({});
|
||||||
"Site A": "site_01",
|
const devicesAtSite = loggedDevices[siteId] ?? [];
|
||||||
"Site B": "site_02",
|
|
||||||
"Site C": "site_03",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Track devices connected per site
|
const handleStartLogging = () => setShowModal(true);
|
||||||
const [loggedDevices, setLoggedDevices] = useState<Record<string, string[]>>({
|
|
||||||
site_01: [],
|
|
||||||
site_02: [],
|
|
||||||
site_03: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
const siteId = siteIdMap[selectedSite];
|
|
||||||
const devicesAtSite = loggedDevices[siteId] || [];
|
|
||||||
|
|
||||||
const handleStartLogging = () => {
|
|
||||||
setShowModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirm = async () => {
|
const handleConfirm = async () => {
|
||||||
const siteId = siteIdMap[selectedSite];
|
const id = deviceId.trim();
|
||||||
const topic = `ADW300/${siteId}/${deviceId}/${functionType.toLowerCase()}`;
|
if (!id) return;
|
||||||
|
|
||||||
|
const topic = `ADW300/${siteId}/${id}/${functionType.toLowerCase()}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.post("http://localhost:8000/start-logging", {
|
const response = await axios.post(`${API_URL}/start-logging`, { topics: [topic] });
|
||||||
topics: [topic],
|
|
||||||
});
|
|
||||||
console.log("Started logging:", response.data);
|
console.log("Started logging:", response.data);
|
||||||
|
|
||||||
// Add device to list
|
setLoggedDevices(prev => ({
|
||||||
setLoggedDevices((prev) => ({
|
|
||||||
...prev,
|
...prev,
|
||||||
[siteId]: [...(prev[siteId] || []), deviceId],
|
[siteId]: [...(prev[siteId] ?? []), id],
|
||||||
}));
|
}));
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
|
setDeviceId("");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to start logging:", error);
|
console.error("Failed to start logging:", error);
|
||||||
}
|
}
|
||||||
@ -84,40 +82,37 @@ const SiteStatus = ({
|
|||||||
|
|
||||||
const handleStopLogging = async () => {
|
const handleStopLogging = async () => {
|
||||||
try {
|
try {
|
||||||
await axios.post("http://localhost:8000/stop-logging");
|
// Stop only this site's topics (both function types for each device)
|
||||||
|
const topics = (loggedDevices[siteId] ?? []).flatMap(did => [
|
||||||
// Clear all devices for the site (or modify to remove only specific one)
|
`ADW300/${siteId}/${did}/grid`,
|
||||||
setLoggedDevices((prev) => ({
|
`ADW300/${siteId}/${did}/solar`,
|
||||||
...prev,
|
]);
|
||||||
[siteId]: [],
|
await axios.post(`${API_URL}/stop-logging`, topics.length ? { topics } : {});
|
||||||
}));
|
|
||||||
|
|
||||||
|
setLoggedDevices(prev => ({ ...prev, [siteId]: [] }));
|
||||||
console.log("Stopped logging for", siteId);
|
console.log("Stopped logging for", siteId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to stop logging:", error);
|
console.error("Failed to stop logging:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusMap: Record<SiteName, string> = {
|
const statusClass = useMemo(() => {
|
||||||
'Site A': 'Active',
|
const s = (status ?? "").toLowerCase();
|
||||||
'Site B': 'Inactive',
|
if (s === "open" || s === "active") return "text-green-500";
|
||||||
'Site C': 'Faulty',
|
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 (
|
return (
|
||||||
<div className="bg-white p-4 rounded-lg shadow-md space-y-2 dark:bg-rtgray-800 dark:text-white-light">
|
<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>
|
<h2 className="text-xl font-semibold mb-3">Site Details</h2>
|
||||||
|
|
||||||
{/* Status */}
|
{/* Status (from CRM) */}
|
||||||
<div className="flex justify-between items-center text-base">
|
<div className="flex justify-between items-center text-base">
|
||||||
<p className="text-gray-600 dark:text-white/85 font-medium">Status:</p>
|
<p className="text-gray-600 dark:text-white/85 font-medium">Status:</p>
|
||||||
<p className={`font-semibold ${
|
<p className={`font-semibold ${statusClass}`}>{status ?? "—"}</p>
|
||||||
statusMap[selectedSite] === 'Active' ? 'text-green-500' :
|
|
||||||
statusMap[selectedSite] === 'Inactive' ? 'text-orange-500' :
|
|
||||||
'text-red-500'
|
|
||||||
}`}>
|
|
||||||
{statusMap[selectedSite]}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Site ID */}
|
{/* Site ID */}
|
||||||
@ -149,69 +144,9 @@ const SiteStatus = ({
|
|||||||
<p className="text-gray-600 dark:text-white/85 font-medium">Last Sync:</p>
|
<p className="text-gray-600 dark:text-white/85 font-medium">Last Sync:</p>
|
||||||
<p className="font-medium">{lastSyncTimestamp}</p>
|
<p className="font-medium">{lastSyncTimestamp}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Start Logging Button */}
|
|
||||||
<div className="flex justify-between items-center text-base space-x-2">
|
|
||||||
{devicesAtSite.length > 0 ? (
|
|
||||||
<button
|
|
||||||
onClick={handleStopLogging}
|
|
||||||
className="text-sm lg:text-md bg-red-500 hover:bg-red-600 text-white font-medium px-3 py-2 rounded"
|
|
||||||
>
|
|
||||||
Stop Logging
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={handleStartLogging}
|
|
||||||
className="text-sm lg:text-md btn-primary px-3 py-2"
|
|
||||||
>
|
|
||||||
Start Logging
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
{/* Modal */}
|
|
||||||
{showModal && (
|
|
||||||
<div className="fixed inset-0 z-50 bg-black bg-opacity-50 flex items-center justify-center">
|
|
||||||
<div className="bg-white rounded-lg p-6 w-[90%] max-w-md shadow-lg">
|
|
||||||
<h2 className="text-lg font-semibold mb-4">Enter Device Info</h2>
|
|
||||||
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Device ID (e.g. device_01)"
|
|
||||||
className="w-full p-2 mb-4 border rounded"
|
|
||||||
value={deviceId}
|
|
||||||
onChange={(e) => setDeviceId(e.target.value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<select
|
|
||||||
className="w-full p-2 mb-4 border rounded"
|
|
||||||
value={functionType}
|
|
||||||
onChange={(e) => setFunctionType(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="Grid">Grid</option>
|
|
||||||
<option value="Solar">Solar</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<div className="flex justify-end space-x-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowModal(false)}
|
|
||||||
className="btn-primary bg-white border-2 border-black hover:bg-rtgray-200 px-4 py-2"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleConfirm}
|
|
||||||
className="btn-primary px-4 py-2"
|
|
||||||
>
|
|
||||||
Confirm
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SiteStatus;
|
export default SiteStatus;
|
||||||
|
|
||||||
|
54
components/dashboards/kpibottom.tsx
Normal file
54
components/dashboards/kpibottom.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
74
components/dashboards/kpitop.tsx
Normal file
74
components/dashboards/kpitop.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@ -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) => {
|
|
||||||
if (referenceRef.current.contains(event.target) || popperRef.current.contains(event.target)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setVisibility(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.addEventListener('mousedown', handleDocumentClick);
|
const onDoc = (e: MouseEvent) => {
|
||||||
return () => {
|
if (!referenceRef.current || !popperRef.current) return;
|
||||||
document.removeEventListener('mousedown', handleDocumentClick);
|
if (referenceRef.current.contains(e.target as Node)) return;
|
||||||
|
if (popperRef.current.contains(e.target as Node)) return;
|
||||||
|
setVisible(false);
|
||||||
};
|
};
|
||||||
|
document.addEventListener('mousedown', onDoc);
|
||||||
|
return () => document.removeEventListener('mousedown', onDoc);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useImperativeHandle(forwardedRef, () => ({
|
useImperativeHandle(forwardedRef, () => ({ close: () => setVisible(false) }));
|
||||||
close() {
|
|
||||||
setVisibility(false);
|
const defaultButton = (
|
||||||
},
|
<span className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-gray-200 dark:bg-rtgray-700" />
|
||||||
}));
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button ref={referenceRef} type="button" className={props.btnClassName} onClick={() => setVisibility(!visibility)}>
|
<button
|
||||||
{props.button}
|
ref={referenceRef}
|
||||||
|
type="button"
|
||||||
|
className={props.btnClassName}
|
||||||
|
onClick={() => setVisible((v) => !v)}
|
||||||
|
>
|
||||||
|
{props.button ?? defaultButton} {/* 👈 fallback */}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div ref={popperRef} style={styles.popper} {...attributes.popper} className="z-50" onClick={() => setVisibility(!visibility)}>
|
<div
|
||||||
{visibility && props.children}
|
ref={popperRef}
|
||||||
|
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>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default forwardRef(Dropdown);
|
export default forwardRef(Dropdown);
|
||||||
|
|
||||||
|
|
||||||
|
@ -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 };
|
||||||
|
|
||||||
|
export default function Header() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t, i18n } = getTranslation();
|
const themeConfig = useSelector((state: IRootState) => state.themeConfig);
|
||||||
|
const isRtl = themeConfig.rtlClass === 'rtl';
|
||||||
|
|
||||||
|
const [user, setUser] = useState<UserData | null>(null);
|
||||||
|
const [loadingUser, setLoadingUser] = useState(true);
|
||||||
|
|
||||||
|
// Highlight active menu (your original effect)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const selector = document.querySelector('ul.horizontal-menu a[href="' + window.location.pathname + '"]');
|
const selector = document.querySelector(
|
||||||
|
'ul.horizontal-menu a[href="' + window.location.pathname + '"]'
|
||||||
|
);
|
||||||
if (selector) {
|
if (selector) {
|
||||||
const all: any = document.querySelectorAll('ul.horizontal-menu .nav-link.active');
|
document
|
||||||
for (let i = 0; i < all.length; i++) {
|
.querySelectorAll('ul.horizontal-menu .nav-link.active')
|
||||||
all[0]?.classList.remove('active');
|
.forEach((el) => el.classList.remove('active'));
|
||||||
}
|
document
|
||||||
|
.querySelectorAll('ul.horizontal-menu a.active')
|
||||||
let allLinks = document.querySelectorAll('ul.horizontal-menu a.active');
|
.forEach((el) => el.classList.remove('active'));
|
||||||
for (let i = 0; i < allLinks.length; i++) {
|
selector.classList.add('active');
|
||||||
const element = allLinks[i];
|
|
||||||
element?.classList.remove('active');
|
|
||||||
}
|
|
||||||
selector?.classList.add('active');
|
|
||||||
|
|
||||||
const ul: any = selector.closest('ul.sub-menu');
|
const ul: any = selector.closest('ul.sub-menu');
|
||||||
if (ul) {
|
if (ul) {
|
||||||
let ele: any = ul.closest('li.menu').querySelectorAll('.nav-link');
|
const ele: any = ul.closest('li.menu')?.querySelector('.nav-link');
|
||||||
if (ele) {
|
setTimeout(() => ele?.classList.add('active'));
|
||||||
ele = ele[0];
|
|
||||||
setTimeout(() => {
|
|
||||||
ele?.classList.add('active');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
|
|
||||||
const isRtl = useSelector((state: IRootState) => state.themeConfig.rtlClass) === 'rtl';
|
async function loadUser() {
|
||||||
|
try {
|
||||||
const themeConfig = useSelector((state: IRootState) => state.themeConfig);
|
const res = await fetch('/api/auth/me', {
|
||||||
const setLocale = (flag: string) => {
|
method: 'GET',
|
||||||
if (flag.toLowerCase() === 'ae') {
|
credentials: 'include', // send cookie
|
||||||
dispatch(toggleRTL('rtl'));
|
cache: 'no-store', // avoid stale cached responses
|
||||||
} else {
|
});
|
||||||
dispatch(toggleRTL('ltr'));
|
if (!res.ok) throw new Error();
|
||||||
|
const data = await res.json();
|
||||||
|
setUser(data.user);
|
||||||
|
} catch {
|
||||||
|
setUser(null);
|
||||||
|
} finally {
|
||||||
|
setLoadingUser(false);
|
||||||
}
|
}
|
||||||
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) => {
|
useEffect(() => {
|
||||||
setMessages(messages.filter((user) => user.id !== value));
|
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
|
||||||
};
|
};
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<header className={`z-40 ${themeConfig.semidark && themeConfig.menu === 'horizontal' ? 'dark' : ''}`}>
|
<header className={`z-40 ${themeConfig.semidark && themeConfig.menu === 'horizontal' ? 'dark' : ''}`}>
|
||||||
<div className="shadow-sm">
|
<div className="shadow-sm">
|
||||||
<div className="relative flex w-full items-center bg-white px-5 py-2.5 dark:bg-black">
|
<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="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">
|
<div className="relative h-10 w-32 sm:h-11 sm:w-36 md:h-12 md:w-27 shrink-0 max-h-12">
|
||||||
<img className="inline w-8 ltr:-ml-1 rtl:-mr-1" src="/assets/images/newfulllogo.png" alt="logo" />
|
<Image
|
||||||
<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>
|
src="/assets/images/newfulllogo.png"
|
||||||
</Link>
|
alt="logo"
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
priority
|
||||||
|
sizes="(max-width: 640px) 8rem, (max-width: 768px) 9rem, (max-width: 1024px) 10rem, 10rem"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="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())}
|
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-5 w-5" />
|
<IconMenu className="h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 ------------------- */}
|
{/* Right-side actions */}
|
||||||
<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] lg:space-x-2">
|
||||||
|
{/* Theme toggle */}
|
||||||
{themeConfig.theme === 'light' ? (
|
{themeConfig.theme === 'light' ? (
|
||||||
<button
|
<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'))}
|
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 />
|
<IconSun />
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
''
|
|
||||||
)}
|
|
||||||
{themeConfig.theme === 'dark' && (
|
|
||||||
<button
|
<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'))}
|
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 />
|
<IconMoon />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
{/* ------------------- End Theme Switch ------------------- */}
|
|
||||||
|
|
||||||
|
|
||||||
|
{/* User dropdown */}
|
||||||
<div className="dropdown flex shrink-0 ">
|
<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
|
<Dropdown
|
||||||
offset={[0, 8]}
|
placement={isRtl ? 'bottom-start' : 'bottom-end'}
|
||||||
placement={`${isRtl ? 'bottom-start' : 'bottom-end'}`}
|
|
||||||
btnClassName="relative group block"
|
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" />}
|
panelClassName="rounded-lg shadow-lg border border-white/10 bg-rtgray-100 dark:bg-rtgray-800 p-2" // ✅
|
||||||
>
|
button={
|
||||||
<ul className="w-[230px] !py-0 font-semibold text-dark dark:text-white-dark dark:text-white-light/90">
|
<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">
|
||||||
<li>
|
<IconUser className="h-5 w-5 text-gray-600 dark:text-gray-300" />
|
||||||
<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>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link href="/users/profile" className="dark:hover:text-white">
|
<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" />
|
<IconUser className="h-4.5 w-4.5 mr-2" /> Profile
|
||||||
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>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link href="/auth/boxed-lockscreen" className="dark:hover:text-white">
|
<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" />
|
<IconLockDots className="h-4.5 w-4.5 mr-2" /> Lock Screen
|
||||||
Lock Screen
|
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
<li className="border-t border-white-light dark:border-white-light/10">
|
<li className="border-t border-white-light dark:border-white-light/10">
|
||||||
<Link href="/auth/boxed-signin" className="!py-3 text-danger">
|
<button onClick={handleLogout} className="flex w-full items-center py-3 text-danger">
|
||||||
<IconLogout className="h-4.5 w-4.5 shrink-0 rotate-90 ltr:mr-2 rtl:ml-2" />
|
<IconLogout className="h-4.5 w-4.5 mr-2 rotate-90" /> Sign Out
|
||||||
Sign Out
|
</button>
|
||||||
</Link>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</Dropdown>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default Header;
|
|
||||||
|
@ -67,16 +67,15 @@ const Sidebar = () => {
|
|||||||
<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/newlogo.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" />
|
||||||
@ -84,7 +83,7 @@ const Sidebar = () => {
|
|||||||
</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-md">
|
<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] dark:text-white dark:active:text-white">
|
<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>
|
||||||
@ -156,6 +155,7 @@ const Sidebar = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</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">
|
<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" />
|
<IconMinus className="hidden h-5 w-4 flex-none" />
|
||||||
<span>Providers</span>
|
<span>Providers</span>
|
||||||
@ -239,7 +239,7 @@ const Sidebar = () => {
|
|||||||
</ul>
|
</ul>
|
||||||
</AnimateHeight>
|
</AnimateHeight>
|
||||||
</li>
|
</li>
|
||||||
|
*/}
|
||||||
|
|
||||||
</ul>
|
</ul>
|
||||||
</PerfectScrollbar>
|
</PerfectScrollbar>
|
||||||
|
5920
package-lock.json
generated
5920
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -11,6 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.10.6",
|
"@emotion/react": "^11.10.6",
|
||||||
"@headlessui/react": "^1.7.8",
|
"@headlessui/react": "^1.7.8",
|
||||||
|
"@heroui/react": "^2.8.2",
|
||||||
"@prisma/client": "^6.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",
|
||||||
@ -27,6 +28,8 @@
|
|||||||
"date-fns": "^4.1.0",
|
"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",
|
"html2canvas": "^1.4.1",
|
||||||
"i18next": "^22.4.10",
|
"i18next": "^22.4.10",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
@ -51,6 +54,7 @@
|
|||||||
"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/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",
|
||||||
|
@ -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;
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
function readCookieToken(req: NextApiRequest) {
|
||||||
const authHeader = req.headers.authorization;
|
const cookie = req.headers.cookie || "";
|
||||||
|
const match = cookie.split("; ").find((c) => c.startsWith("token="));
|
||||||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
return match?.split("=")[1];
|
||||||
return res.status(401).json({ message: "Unauthorized" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = authHeader.split(" ")[1]; // Extract token
|
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) {
|
||||||
|
if (req.method !== "GET") return res.status(405).json({ message: "Method not allowed" });
|
||||||
|
|
||||||
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 });
|
||||||
|
} catch {
|
||||||
|
return res.status(401).json({ message: "Invalid token" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.json({ user });
|
|
||||||
} catch (error) {
|
|
||||||
res.status(401).json({ message: "Invalid token" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -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
20
pages/api/logout.ts
Normal 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" });
|
||||||
|
}
|
@ -1,16 +1,19 @@
|
|||||||
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" });
|
||||||
@ -18,10 +21,22 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
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" });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Binary file not shown.
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 91 KiB |
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 84 KiB |
@ -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;
|
||||||
|
14
types/crm.ts
Normal file
14
types/crm.ts
Normal 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;
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user