feature/syasya/testlayout #7
@ -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