integrate with crm

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

View File

@ -1,10 +1,9 @@
'use client'; '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 its 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 todays 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;

View File

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

View File

@ -9,6 +9,18 @@ export interface TimeSeriesResponse {
generation: TimeSeriesEntry[]; 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
View File

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

View File

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

29
package-lock.json generated
View File

@ -26,6 +26,7 @@
"date-fns": "^4.1.0", "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",

View File

@ -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
View File

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