integrate with crm

This commit is contained in:
Syasya 2025-08-13 12:30:11 +08:00
parent 9ab01d2655
commit e47951fb7e
8 changed files with 338 additions and 146 deletions

View File

@ -1,10 +1,9 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useMemo, useRef } from 'react';
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import SiteSelector from '@/components/dashboards/SiteSelector';
import SiteStatus from '@/components/dashboards/SiteStatus';
import KPI_Table from '@/components/dashboards/KPIStatus';
import DashboardLayout from './dashlayout';
import html2canvas from 'html2canvas';
import jsPDF from 'jspdf';
@ -12,14 +11,12 @@ import dynamic from 'next/dynamic';
import { fetchPowerTimeseries } from '@/app/utils/api';
import KpiTop from '@/components/dashboards/kpitop';
import KpiBottom from '@/components/dashboards/kpibottom';
import { formatAddress } from '@/app/utils/formatAddress';
import { formatCrmTimestamp } from '@/app/utils/datetime';
const EnergyLineChart = dynamic(() => import('@/components/dashboards/EnergyLineChart'), {
ssr: false,
});
const MonthlyBarChart = dynamic(() => import('@/components/dashboards/MonthlyBarChart'), {
ssr: false,
});
const EnergyLineChart = dynamic(() => import('@/components/dashboards/EnergyLineChart'), { ssr: false });
const MonthlyBarChart = dynamic(() => import('@/components/dashboards/MonthlyBarChart'), { ssr: false });
type MonthlyKPI = {
site: string; month: string;
@ -29,132 +26,150 @@ type MonthlyKPI = {
error?: string;
};
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000';
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;
};
import { SiteName, SiteDetails, mockSiteData } from '@/types/SiteData';
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000';
const AdminDashboard = () => {
const router = useRouter();
const pathname = usePathname();
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');
const validSiteNames: SiteName[] = ['Site A', 'Site B', 'Site C'];
// --- NEW: load CRM projects dynamically ---
const [sites, setSites] = useState<CrmProject[]>([]);
const [sitesLoading, setSitesLoading] = useState(true);
const [sitesError, setSitesError] = useState<unknown>(null);
const [kpi, setKpi] = useState<MonthlyKPI | 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(() => {
if (
siteParam &&
validSiteNames.includes(siteParam as SiteName) &&
siteParam !== selectedSite
) {
setSelectedSite(siteParam as SiteName);
}
}, [siteParam, selectedSite]);
setSitesLoading(true);
fetch(`${API}/crm/projects?limit=0`)
.then(r => r.json())
.then(json => setSites(json?.data ?? []))
.catch(setSitesError)
.finally(() => setSitesLoading(false));
}, []);
// The canonical siteId is the CRM Project "name" (e.g., PROJ-0008)
const siteParam = searchParams?.get('site') || null;
const [selectedSiteId, setSelectedSiteId] = useState<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]
);
// --- FIX: declare currentMonth BEFORE its used ---
const currentMonth = useMemo(() => new Date().toISOString().slice(0, 7), []);
// --- Time-series state (unchanged) ---
const [timeSeriesData, setTimeSeriesData] = useState<{
consumption: { time: string; value: number }[];
generation: { time: string; value: number }[];
}>({ consumption: [], generation: [] });
// Fetch todays timeseries for selected siteId (from CRM)
useEffect(() => {
if (!selectedSiteId) return;
const fetchData = async () => {
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`;
try {
const raw = await fetchPowerTimeseries(siteId, start, end);
const consumption = raw.consumption.map(d => ({
time: d.time,
value: d.value,
}));
const generation = raw.generation.map(d => ({
time: d.time,
value: d.value,
}));
const raw = await fetchPowerTimeseries(selectedSiteId, start, end);
const consumption = raw.consumption.map((d: any) => ({ time: d.time, value: d.value }));
const generation = raw.generation.map((d: any) => ({ time: d.time, value: d.value }));
setTimeSeriesData({ consumption, generation });
} catch (error) {
console.error('Failed to fetch power time series:', error);
}
};
fetchData();
}, [selectedSite]);
}, [selectedSiteId]);
// --- KPI monthly (uses your FastAPI) ---
const [kpi, setKpi] = useState<MonthlyKPI | null>(null);
// fetch KPI monthly (uses your FastAPI)
useEffect(() => {
const siteId = siteIdMap[selectedSite];
const url = `${API}/kpi/monthly?site=${encodeURIComponent(siteId)}&month=${currentMonth}`;
if (!selectedSiteId) return;
const url = `${API}/kpi/monthly?site=${encodeURIComponent(selectedSiteId)}&month=${currentMonth}`;
fetch(url).then(r => r.json()).then(setKpi).catch(console.error);
}, [selectedSite]);
}, [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);
// ...your existing code above return()
// Update query string when site is changed manually
const handleSiteChange = (newSite: SiteName) => {
setSelectedSite(newSite);
const newUrl = `${pathname}?site=${encodeURIComponent(newSite)}`;
// Update URL when site is changed manually (now expects a siteId/Project.name)
const handleSiteChange = (newSiteId: string) => {
setSelectedSiteId(newSiteId);
const newUrl = `${pathname}?site=${encodeURIComponent(newSiteId)}`;
router.push(newUrl);
};
const currentSiteDetails: SiteDetails = mockSiteData[selectedSite] || {
location: 'N/A',
inverterProvider: 'N/A',
emergencyContact: 'N/A',
lastSyncTimestamp: 'N/A',
consumptionData: [],
generationData: [],
systemStatus: 'N/A',
temperature: 'N/A',
solarPower: 0,
realTimePower: 0,
installedPower: 0,
const locationFormatted = useMemo(() => {
const raw = selectedProject?.custom_address ?? '';
if (!raw) return 'N/A';
return formatAddress(raw).multiLine; // pretty, multi-line version
}, [selectedProject?.custom_address]);
const lastSyncFormatted = useMemo(
() => formatCrmTimestamp(selectedProject?.modified, { includeSeconds: true }),
[selectedProject?.modified]
);
// Adapt CRM project -> SiteStatus props
const currentSiteDetails = {
location: locationFormatted, // <- formatted!
inverterProvider: selectedProject?.project_type || 'N/A',
emergencyContact:
selectedProject?.custom_mobile_phone_no ||
selectedProject?.custom_email ||
selectedProject?.customer ||
'N/A',
lastSyncTimestamp: lastSyncFormatted || 'N/A',
};
const handleCSVExport = () => {
alert('Exported raw data to CSV (mock)');
};
const energyChartRef = useRef(null);
const monthlyChartRef = useRef(null);
const energyChartRef = useRef<HTMLDivElement | null>(null);
const monthlyChartRef = useRef<HTMLDivElement | null>(null);
const handlePDFExport = async () => {
const doc = new jsPDF('p', 'mm', 'a4'); // portrait, millimeters, A4
const doc = new jsPDF('p', 'mm', 'a4');
const chartRefs = [
{ ref: energyChartRef, title: 'Energy Line Chart' },
{ ref: monthlyChartRef, title: 'Monthly Energy Yield' }
@ -164,36 +179,55 @@ const AdminDashboard = () => {
for (const chart of chartRefs) {
if (!chart.ref.current) continue;
// Capture chart as image
const canvas = await html2canvas(chart.ref.current, {
scale: 2, // Higher scale for better resolution
});
const canvas = await html2canvas(chart.ref.current, { scale: 2 });
const imgData = canvas.toDataURL('image/png');
const imgProps = doc.getImageProperties(imgData);
const pdfWidth = doc.internal.pageSize.getWidth() - 20; // 10 margin each side
const pdfWidth = doc.internal.pageSize.getWidth() - 20;
const imgHeight = (imgProps.height * pdfWidth) / imgProps.width;
// Add title and image
doc.setFontSize(14);
doc.text(chart.title, 10, yOffset);
yOffset += 6; // Space between title and chart
yOffset += 6;
// If content will overflow page, add a new page
if (yOffset + imgHeight > doc.internal.pageSize.getHeight()) {
doc.addPage();
yOffset = 10;
}
doc.addImage(imgData, 'PNG', 10, yOffset, pdfWidth, imgHeight);
yOffset += imgHeight + 10; // Update offset for next chart
yOffset += imgHeight + 10;
}
doc.save('dashboard_charts.pdf');
};
const currentMonth = new Date().toISOString().slice(0, 7); // "YYYY-MM"
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, // nice display
value: s.name, // siteId used everywhere
}));
return (
<DashboardLayout>
@ -202,12 +236,17 @@ const AdminDashboard = () => {
<div className="grid gap-6">
<div className="space-y-4">
{/* UPDATE SiteSelector to accept these props */}
<SiteSelector
selectedSite={selectedSite}
setSelectedSite={handleSiteChange}
options={siteOptions}
selectedValue={selectedSiteId!}
onChange={handleSiteChange}
/>
{/* UPDATE SiteStatus to accept siteId & dynamic fields */}
<SiteStatus
selectedSite={selectedSite}
selectedSite={selectedProject.project_name || selectedProject.name}
siteId={selectedProject.name} // <-- use for MQTT topics inside SiteStatus
location={currentSiteDetails.location}
inverterProvider={currentSiteDetails.inverterProvider}
emergencyContact={currentSiteDetails.emergencyContact}
@ -215,6 +254,7 @@ const AdminDashboard = () => {
/>
</div>
</div>
{/* TOP 3 CARDS */}
<div className="space-y-4">
<KpiTop
@ -225,8 +265,9 @@ const AdminDashboard = () => {
</div>
<div ref={energyChartRef} className="pb-5">
<EnergyLineChart siteId={siteIdMap[selectedSite]} />
<EnergyLineChart siteId={selectedProject.name} />
</div>
{/* BOTTOM 3 PANELS */}
<KpiBottom
efficiencyPct={efficiencyPct}
@ -234,7 +275,7 @@ const AdminDashboard = () => {
loadFactor={loadFactor}
middle={
<div ref={monthlyChartRef} className="transform scale-90 origin-top">
<MonthlyBarChart siteId={siteIdMap[selectedSite]} />
<MonthlyBarChart siteId={selectedProject.name} />
</div>
}
right={
@ -245,6 +286,7 @@ const AdminDashboard = () => {
</div>
}
/>
<div className="flex flex-col md:flex-row gap-4 justify-center">
<button onClick={handlePDFExport} className="text-sm lg:text-lg btn-primary">
Export Chart Images to PDF
@ -258,3 +300,4 @@ const AdminDashboard = () => {
export default AdminDashboard;

View File

@ -0,0 +1,20 @@
// src/hooks/useCrmProjects.ts
import { useEffect, useState } from "react";
import { crmapi } from "../utils/api";
import { CrmProject } from "@/types/crm";
export function useCrmProjects() {
const [data, setData] = useState<CrmProject[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<unknown>(null);
useEffect(() => {
setLoading(true);
crmapi.getProjects()
.then(res => setData(res.data?.data ?? []))
.catch(setError)
.finally(() => setLoading(false));
}, []);
return { data, loading, error };
}

View File

@ -9,6 +9,18 @@ export interface TimeSeriesResponse {
generation: TimeSeriesEntry[];
}
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://127.0.0.1:8000";
export const crmapi = {
getProjects: async () => {
const res = await fetch(`${API_BASE_URL}/crm/projects`, {
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
},
};
export async function fetchPowerTimeseries(
site: string,
start: string,

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

@ -0,0 +1,37 @@
// app/utils/datetime.ts
export function formatCrmTimestamp(
input: string | null | undefined,
opts?: { locale?: string; timeZone?: string; includeSeconds?: boolean }
): string {
if (!input) return 'N/A';
// Accept: 2025-06-30 10:04:58.387651 (also with 'T', with/without fraction)
const m = String(input).trim().match(
/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,6}))?$/
);
if (!m) return input; // fallback: show as-is
const [, y, mo, d, hh, mm, ss, frac = ''] = m;
const ms = Number((frac + '000').slice(0, 3)); // micro→millis
const dt = new Date(
Number(y),
Number(mo) - 1,
Number(d),
Number(hh),
Number(mm),
Number(ss),
ms
);
const locale = opts?.locale ?? 'en-MY';
const timeZone = opts?.timeZone ?? 'Asia/Kuala_Lumpur';
const timeStyle = opts?.includeSeconds ? 'medium' : 'short';
return new Intl.DateTimeFormat(locale, {
dateStyle: 'medium',
timeStyle, // 'short'=no seconds, 'medium'=with seconds
timeZone,
hour12: true,
}).format(dt);
}

View File

@ -0,0 +1,35 @@
// utils/formatAddress.ts
// npm i he (for robust HTML entity decoding)
import { decode } from "he";
export function formatAddress(raw: string) {
// 1) decode entities (&amp; → &), 2) <br> → \n, 3) tidy whitespace
const text = decode(raw)
.replace(/<br\s*\/?>/gi, "\n")
.replace(/\u00A0/g, " ") // &nbsp;
.replace(/[ \t]{2,}/g, " ") // collapse spaces
.replace(/\n{2,}/g, "\n") // collapse blank lines
.trim();
// split to lines, strip empties
const lines = text.split("\n").map(s => s.trim()).filter(Boolean);
// If postcode is alone (e.g., "40150") before the city line, merge: "40150 Shah Alam"
const merged: string[] = [];
for (let i = 0; i < lines.length; i++) {
const cur = lines[i];
const next = lines[i + 1];
if (/^\d{5}$/.test(cur) && next) {
merged.push(`${cur} ${next}`);
i++; // skip the city line, already merged
} else {
merged.push(cur);
}
}
return {
parts: merged, // array of lines
multiLine: merged.join("\n"), // lines with \n
singleLine: merged.join(", "), // one-liner
};
}

29
package-lock.json generated
View File

@ -26,6 +26,7 @@
"date-fns": "^4.1.0",
"eslint": "8.32.0",
"eslint-config-next": "13.1.2",
"he": "^1.2.0",
"html2canvas": "^1.4.1",
"i18next": "^22.4.10",
"jsonwebtoken": "^9.0.2",
@ -50,6 +51,7 @@
"devDependencies": {
"@tailwindcss/forms": "^0.5.3",
"@tailwindcss/typography": "^0.5.8",
"@types/he": "^1.2.3",
"@types/jsonwebtoken": "^9.0.9",
"@types/lodash": "^4.14.191",
"@types/react-redux": "^7.1.32",
@ -931,6 +933,13 @@
"integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==",
"license": "MIT"
},
"node_modules/@types/he": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@types/he/-/he-1.2.3.tgz",
"integrity": "sha512-q67/qwlxblDzEDvzHhVkwc1gzVWxaNxeyHUBF4xElrvjL11O+Ytze+1fGpBHlr/H9myiBUaUXNnNPmBHxxfAcA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/hoist-non-react-statics": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz",
@ -3403,6 +3412,15 @@
"node": ">= 0.4"
}
},
"node_modules/he": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
"license": "MIT",
"bin": {
"he": "bin/he"
}
},
"node_modules/hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
@ -6997,6 +7015,12 @@
"resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz",
"integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw=="
},
"@types/he": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@types/he/-/he-1.2.3.tgz",
"integrity": "sha512-q67/qwlxblDzEDvzHhVkwc1gzVWxaNxeyHUBF4xElrvjL11O+Ytze+1fGpBHlr/H9myiBUaUXNnNPmBHxxfAcA==",
"dev": true
},
"@types/hoist-non-react-statics": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz",
@ -8756,6 +8780,11 @@
"function-bind": "^1.1.2"
}
},
"he": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="
},
"hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",

View File

@ -27,6 +27,7 @@
"date-fns": "^4.1.0",
"eslint": "8.32.0",
"eslint-config-next": "13.1.2",
"he": "^1.2.0",
"html2canvas": "^1.4.1",
"i18next": "^22.4.10",
"jsonwebtoken": "^9.0.2",
@ -51,6 +52,7 @@
"devDependencies": {
"@tailwindcss/forms": "^0.5.3",
"@tailwindcss/typography": "^0.5.8",
"@types/he": "^1.2.3",
"@types/jsonwebtoken": "^9.0.9",
"@types/lodash": "^4.14.191",
"@types/react-redux": "^7.1.32",

14
types/crm.ts Normal file
View File

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