integrate with crm
This commit is contained in:
parent
9ab01d2655
commit
e47951fb7e
@ -1,10 +1,9 @@
|
|||||||
'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';
|
||||||
@ -12,14 +11,12 @@ import dynamic from 'next/dynamic';
|
|||||||
import { fetchPowerTimeseries } from '@/app/utils/api';
|
import { fetchPowerTimeseries } from '@/app/utils/api';
|
||||||
import KpiTop from '@/components/dashboards/kpitop';
|
import KpiTop from '@/components/dashboards/kpitop';
|
||||||
import KpiBottom from '@/components/dashboards/kpibottom';
|
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'), {
|
const EnergyLineChart = dynamic(() => import('@/components/dashboards/EnergyLineChart'), { ssr: false });
|
||||||
ssr: false,
|
const MonthlyBarChart = dynamic(() => import('@/components/dashboards/MonthlyBarChart'), { ssr: false });
|
||||||
});
|
|
||||||
|
|
||||||
type MonthlyKPI = {
|
type MonthlyKPI = {
|
||||||
site: string; month: string;
|
site: string; month: string;
|
||||||
@ -29,171 +26,208 @@ type MonthlyKPI = {
|
|||||||
error?: string;
|
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 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');
|
// --- NEW: 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);
|
||||||
|
|
||||||
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(() => {
|
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]
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- FIX: declare currentMonth BEFORE it’s used ---
|
||||||
|
const currentMonth = useMemo(() => new Date().toISOString().slice(0, 7), []);
|
||||||
|
|
||||||
|
// --- Time-series state (unchanged) ---
|
||||||
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: [] });
|
||||||
|
|
||||||
|
// Fetch today’s timeseries for selected siteId (from CRM)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
if (!selectedSiteId) return;
|
||||||
|
|
||||||
const siteId = siteIdMap[selectedSite];
|
const fetchData = async () => {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
|
const yyyyMMdd = today.toISOString().split('T')[0];
|
||||||
|
const start = `${yyyyMMdd}T00:00:00+08:00`;
|
||||||
|
const end = `${yyyyMMdd}T23:59:59+08:00`;
|
||||||
|
|
||||||
// Format to YYYY-MM-DD
|
try {
|
||||||
const yyyyMMdd = today.toISOString().split('T')[0];
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Append Malaysia's +08:00 time zone manually
|
fetchData();
|
||||||
const start = `${yyyyMMdd}T00:00:00+08:00`;
|
}, [selectedSiteId]);
|
||||||
const end = `${yyyyMMdd}T23:59:59+08:00`;
|
|
||||||
|
|
||||||
|
|
||||||
try {
|
// --- KPI monthly (uses your FastAPI) ---
|
||||||
const raw = await fetchPowerTimeseries(siteId, start, end);
|
const [kpi, setKpi] = useState<MonthlyKPI | null>(null);
|
||||||
|
|
||||||
const consumption = raw.consumption.map(d => ({
|
|
||||||
time: d.time,
|
|
||||||
value: d.value,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const generation = raw.generation.map(d => ({
|
|
||||||
time: d.time,
|
|
||||||
value: d.value,
|
|
||||||
}));
|
|
||||||
|
|
||||||
setTimeSeriesData({ consumption, generation });
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch power time series:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchData();
|
|
||||||
}, [selectedSite]);
|
|
||||||
|
|
||||||
// fetch KPI monthly (uses your FastAPI)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const siteId = siteIdMap[selectedSite];
|
if (!selectedSiteId) return;
|
||||||
const url = `${API}/kpi/monthly?site=${encodeURIComponent(siteId)}&month=${currentMonth}`;
|
const url = `${API}/kpi/monthly?site=${encodeURIComponent(selectedSiteId)}&month=${currentMonth}`;
|
||||||
fetch(url).then(r => r.json()).then(setKpi).catch(console.error);
|
fetch(url).then(r => r.json()).then(setKpi).catch(console.error);
|
||||||
}, [selectedSite]);
|
}, [selectedSiteId, currentMonth]);
|
||||||
|
|
||||||
// derived values with safe fallbacks
|
// derived values with safe fallbacks
|
||||||
const yieldKwh = kpi?.yield_kwh ?? 0;
|
const yieldKwh = kpi?.yield_kwh ?? 0;
|
||||||
const consumptionKwh = kpi?.consumption_kwh ?? 0;
|
const consumptionKwh = kpi?.consumption_kwh ?? 0;
|
||||||
const gridDrawKwh = kpi?.grid_draw_kwh ?? Math.max(0, consumptionKwh - yieldKwh);
|
const gridDrawKwh = kpi?.grid_draw_kwh ?? Math.max(0, consumptionKwh - yieldKwh);
|
||||||
|
|
||||||
const efficiencyPct = (kpi?.efficiency ?? 0) * 100;
|
const efficiencyPct = (kpi?.efficiency ?? 0) * 100;
|
||||||
const powerFactor = kpi?.avg_power_factor ?? 0;
|
const powerFactor = kpi?.avg_power_factor ?? 0;
|
||||||
const loadFactor = (kpi?.load_factor ?? 0);
|
const loadFactor = (kpi?.load_factor ?? 0);
|
||||||
|
|
||||||
// ...your existing code above return()
|
// Update URL when site is changed manually (now expects a siteId/Project.name)
|
||||||
// Update query string when site is changed manually
|
const handleSiteChange = (newSiteId: string) => {
|
||||||
const handleSiteChange = (newSite: SiteName) => {
|
setSelectedSiteId(newSiteId);
|
||||||
setSelectedSite(newSite);
|
const newUrl = `${pathname}?site=${encodeURIComponent(newSiteId)}`;
|
||||||
const newUrl = `${pathname}?site=${encodeURIComponent(newSite)}`;
|
|
||||||
router.push(newUrl);
|
router.push(newUrl);
|
||||||
};
|
};
|
||||||
|
|
||||||
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; // pretty, multi-line version
|
||||||
lastSyncTimestamp: 'N/A',
|
}, [selectedProject?.custom_address]);
|
||||||
consumptionData: [],
|
|
||||||
generationData: [],
|
|
||||||
systemStatus: 'N/A',
|
|
||||||
temperature: 'N/A',
|
|
||||||
solarPower: 0,
|
|
||||||
realTimePower: 0,
|
|
||||||
installedPower: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCSVExport = () => {
|
const lastSyncFormatted = useMemo(
|
||||||
alert('Exported raw data to CSV (mock)');
|
() => formatCrmTimestamp(selectedProject?.modified, { includeSeconds: true }),
|
||||||
};
|
[selectedProject?.modified]
|
||||||
|
);
|
||||||
|
|
||||||
const energyChartRef = useRef(null);
|
// Adapt CRM project -> SiteStatus props
|
||||||
const monthlyChartRef = useRef(null);
|
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 energyChartRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const monthlyChartRef = useRef<HTMLDivElement | null>(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' }
|
||||||
];
|
];
|
||||||
|
|
||||||
let yOffset = 10;
|
let yOffset = 10;
|
||||||
|
|
||||||
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 });
|
||||||
|
const imgData = canvas.toDataURL('image/png');
|
||||||
|
const imgProps = doc.getImageProperties(imgData);
|
||||||
|
const pdfWidth = doc.internal.pageSize.getWidth() - 20;
|
||||||
|
const imgHeight = (imgProps.height * pdfWidth) / imgProps.width;
|
||||||
|
|
||||||
// Capture chart as image
|
doc.setFontSize(14);
|
||||||
const canvas = await html2canvas(chart.ref.current, {
|
doc.text(chart.title, 10, yOffset);
|
||||||
scale: 2, // Higher scale for better resolution
|
yOffset += 6;
|
||||||
});
|
|
||||||
|
|
||||||
const imgData = canvas.toDataURL('image/png');
|
if (yOffset + imgHeight > doc.internal.pageSize.getHeight()) {
|
||||||
const imgProps = doc.getImageProperties(imgData);
|
doc.addPage();
|
||||||
|
yOffset = 10;
|
||||||
|
}
|
||||||
|
|
||||||
const pdfWidth = doc.internal.pageSize.getWidth() - 20; // 10 margin each side
|
doc.addImage(imgData, 'PNG', 10, yOffset, pdfWidth, imgHeight);
|
||||||
const imgHeight = (imgProps.height * pdfWidth) / imgProps.width;
|
yOffset += imgHeight + 10;
|
||||||
|
|
||||||
// Add title and image
|
|
||||||
doc.setFontSize(14);
|
|
||||||
doc.text(chart.title, 10, yOffset);
|
|
||||||
yOffset += 6; // Space between title and chart
|
|
||||||
|
|
||||||
// If content will overflow page, add a new page
|
|
||||||
if (yOffset + imgHeight > doc.internal.pageSize.getHeight()) {
|
|
||||||
doc.addPage();
|
|
||||||
yOffset = 10;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
doc.addImage(imgData, 'PNG', 10, yOffset, pdfWidth, imgHeight);
|
doc.save('dashboard_charts.pdf');
|
||||||
yOffset += imgHeight + 10; // Update offset for next chart
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
doc.save('dashboard_charts.pdf');
|
// Build selector options from CRM
|
||||||
};
|
const siteOptions = sites.map(s => ({
|
||||||
const currentMonth = new Date().toISOString().slice(0, 7); // "YYYY-MM"
|
label: s.project_name || s.name, // nice display
|
||||||
|
value: s.name, // siteId used everywhere
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
@ -202,12 +236,17 @@ const AdminDashboard = () => {
|
|||||||
|
|
||||||
<div className="grid gap-6">
|
<div className="grid gap-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{/* UPDATE SiteSelector to accept these props */}
|
||||||
<SiteSelector
|
<SiteSelector
|
||||||
selectedSite={selectedSite}
|
options={siteOptions}
|
||||||
setSelectedSite={handleSiteChange}
|
selectedValue={selectedSiteId!}
|
||||||
|
onChange={handleSiteChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* UPDATE SiteStatus to accept siteId & dynamic fields */}
|
||||||
<SiteStatus
|
<SiteStatus
|
||||||
selectedSite={selectedSite}
|
selectedSite={selectedProject.project_name || selectedProject.name}
|
||||||
|
siteId={selectedProject.name} // <-- use for MQTT topics inside SiteStatus
|
||||||
location={currentSiteDetails.location}
|
location={currentSiteDetails.location}
|
||||||
inverterProvider={currentSiteDetails.inverterProvider}
|
inverterProvider={currentSiteDetails.inverterProvider}
|
||||||
emergencyContact={currentSiteDetails.emergencyContact}
|
emergencyContact={currentSiteDetails.emergencyContact}
|
||||||
@ -215,36 +254,39 @@ const AdminDashboard = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* TOP 3 CARDS */}
|
|
||||||
<div className="space-y-4">
|
{/* TOP 3 CARDS */}
|
||||||
<KpiTop
|
<div className="space-y-4">
|
||||||
yieldKwh={yieldKwh}
|
<KpiTop
|
||||||
consumptionKwh={consumptionKwh}
|
yieldKwh={yieldKwh}
|
||||||
gridDrawKwh={gridDrawKwh}
|
consumptionKwh={consumptionKwh}
|
||||||
/>
|
gridDrawKwh={gridDrawKwh}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
<div ref={energyChartRef} className="pb-5">
|
|
||||||
<EnergyLineChart siteId={siteIdMap[selectedSite]} />
|
|
||||||
</div>
|
</div>
|
||||||
{/* BOTTOM 3 PANELS */}
|
|
||||||
|
<div ref={energyChartRef} className="pb-5">
|
||||||
|
<EnergyLineChart siteId={selectedProject.name} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* BOTTOM 3 PANELS */}
|
||||||
<KpiBottom
|
<KpiBottom
|
||||||
efficiencyPct={efficiencyPct}
|
efficiencyPct={efficiencyPct}
|
||||||
powerFactor={powerFactor}
|
powerFactor={powerFactor}
|
||||||
loadFactor={loadFactor}
|
loadFactor={loadFactor}
|
||||||
middle={
|
middle={
|
||||||
<div ref={monthlyChartRef} className="transform scale-90 origin-top">
|
<div ref={monthlyChartRef} className="transform scale-90 origin-top">
|
||||||
<MonthlyBarChart siteId={siteIdMap[selectedSite]} />
|
<MonthlyBarChart siteId={selectedProject.name} />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
right={
|
right={
|
||||||
<div className="flex items-center justify-center w-full px-3 text-center">
|
<div className="flex items-center justify-center w-full px-3 text-center">
|
||||||
<div className="text-3xl font-semibold">
|
<div className="text-3xl font-semibold">
|
||||||
{(kpi?.peak_demand_kw ?? 0).toFixed(2)} kW
|
{(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
|
||||||
@ -258,3 +300,4 @@ const AdminDashboard = () => {
|
|||||||
export default AdminDashboard;
|
export default AdminDashboard;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
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,
|
||||||
|
|||||||
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
|
||||||
|
};
|
||||||
|
}
|
||||||
29
package-lock.json
generated
29
package-lock.json
generated
@ -26,6 +26,7 @@
|
|||||||
"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",
|
||||||
|
"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",
|
||||||
@ -50,6 +51,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",
|
||||||
@ -931,6 +933,13 @@
|
|||||||
"integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==",
|
"integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/hoist-non-react-statics": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz",
|
"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": ">= 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": {
|
"node_modules/hoist-non-react-statics": {
|
||||||
"version": "3.3.2",
|
"version": "3.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz",
|
||||||
"integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw=="
|
"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": {
|
"@types/hoist-non-react-statics": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz",
|
"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"
|
"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": {
|
"hoist-non-react-statics": {
|
||||||
"version": "3.3.2",
|
"version": "3.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
|
||||||
|
|||||||
@ -27,6 +27,7 @@
|
|||||||
"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",
|
||||||
|
"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 +52,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",
|
||||||
|
|||||||
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