feature/syasya/testlayout #7
@ -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 it’s 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 today’s 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;
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										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[];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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
									
								
							
							
						
						
									
										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",
 | 
			
		||||
                "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",
 | 
			
		||||
 | 
			
		||||
@ -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
									
								
							
							
						
						
									
										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