integrate with crm
This commit is contained in:
parent
9ab01d2655
commit
e47951fb7e
@ -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,171 +26,208 @@ 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(() => {
|
||||
const fetchData = async () => {
|
||||
if (!selectedSiteId) return;
|
||||
|
||||
const siteId = siteIdMap[selectedSite];
|
||||
const today = new Date();
|
||||
const fetchData = async () => {
|
||||
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
|
||||
const yyyyMMdd = today.toISOString().split('T')[0];
|
||||
try {
|
||||
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
|
||||
const start = `${yyyyMMdd}T00:00:00+08:00`;
|
||||
const end = `${yyyyMMdd}T23:59:59+08:00`;
|
||||
fetchData();
|
||||
}, [selectedSiteId]);
|
||||
|
||||
// --- KPI monthly (uses your FastAPI) ---
|
||||
const [kpi, setKpi] = useState<MonthlyKPI | null>(null);
|
||||
|
||||
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,
|
||||
}));
|
||||
|
||||
setTimeSeriesData({ consumption, generation });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch power time series:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [selectedSite]);
|
||||
|
||||
// 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);
|
||||
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 handleCSVExport = () => {
|
||||
alert('Exported raw data to CSV (mock)');
|
||||
};
|
||||
const lastSyncFormatted = useMemo(
|
||||
() => formatCrmTimestamp(selectedProject?.modified, { includeSeconds: true }),
|
||||
[selectedProject?.modified]
|
||||
);
|
||||
|
||||
const energyChartRef = useRef(null);
|
||||
const monthlyChartRef = useRef(null);
|
||||
// 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 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 chartRefs = [
|
||||
{ ref: energyChartRef, title: 'Energy Line Chart' },
|
||||
{ ref: monthlyChartRef, title: 'Monthly Energy Yield' }
|
||||
];
|
||||
const doc = new jsPDF('p', 'mm', 'a4');
|
||||
const chartRefs = [
|
||||
{ ref: energyChartRef, title: 'Energy Line Chart' },
|
||||
{ ref: monthlyChartRef, title: 'Monthly Energy Yield' }
|
||||
];
|
||||
|
||||
let yOffset = 10;
|
||||
let yOffset = 10;
|
||||
|
||||
for (const chart of chartRefs) {
|
||||
if (!chart.ref.current) continue;
|
||||
for (const chart of chartRefs) {
|
||||
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
|
||||
const canvas = await html2canvas(chart.ref.current, {
|
||||
scale: 2, // Higher scale for better resolution
|
||||
});
|
||||
doc.setFontSize(14);
|
||||
doc.text(chart.title, 10, yOffset);
|
||||
yOffset += 6;
|
||||
|
||||
const imgData = canvas.toDataURL('image/png');
|
||||
const imgProps = doc.getImageProperties(imgData);
|
||||
if (yOffset + imgHeight > doc.internal.pageSize.getHeight()) {
|
||||
doc.addPage();
|
||||
yOffset = 10;
|
||||
}
|
||||
|
||||
const pdfWidth = doc.internal.pageSize.getWidth() - 20; // 10 margin each side
|
||||
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
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
doc.addImage(imgData, 'PNG', 10, yOffset, pdfWidth, imgHeight);
|
||||
yOffset += imgHeight + 10; // Update offset for next chart
|
||||
doc.save('dashboard_charts.pdf');
|
||||
};
|
||||
|
||||
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');
|
||||
};
|
||||
const currentMonth = new Date().toISOString().slice(0, 7); // "YYYY-MM"
|
||||
// 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,36 +254,39 @@ const AdminDashboard = () => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* TOP 3 CARDS */}
|
||||
<div className="space-y-4">
|
||||
<KpiTop
|
||||
yieldKwh={yieldKwh}
|
||||
consumptionKwh={consumptionKwh}
|
||||
gridDrawKwh={gridDrawKwh}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* TOP 3 CARDS */}
|
||||
<div className="space-y-4">
|
||||
<KpiTop
|
||||
yieldKwh={yieldKwh}
|
||||
consumptionKwh={consumptionKwh}
|
||||
gridDrawKwh={gridDrawKwh}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div ref={energyChartRef} className="pb-5">
|
||||
<EnergyLineChart siteId={siteIdMap[selectedSite]} />
|
||||
<EnergyLineChart siteId={selectedProject.name} />
|
||||
</div>
|
||||
{/* BOTTOM 3 PANELS */}
|
||||
|
||||
{/* BOTTOM 3 PANELS */}
|
||||
<KpiBottom
|
||||
efficiencyPct={efficiencyPct}
|
||||
powerFactor={powerFactor}
|
||||
loadFactor={loadFactor}
|
||||
middle={
|
||||
<div ref={monthlyChartRef} className="transform scale-90 origin-top">
|
||||
<MonthlyBarChart siteId={siteIdMap[selectedSite]} />
|
||||
</div>
|
||||
}
|
||||
<div ref={monthlyChartRef} className="transform scale-90 origin-top">
|
||||
<MonthlyBarChart siteId={selectedProject.name} />
|
||||
</div>
|
||||
}
|
||||
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">
|
||||
{(kpi?.peak_demand_kw ?? 0).toFixed(2)} kW
|
||||
</div>
|
||||
</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