Skip to content

Commit f46a014

Browse files
Merge pull request #4011 from OneCommunityGlobal/saikrishna_donutChartForPlannedCostBreakdown_frontend
SaiKrishna_adding donut chart for planned cost breakdown_frontend
2 parents ceab8ea + 894f640 commit f46a014

5 files changed

Lines changed: 985 additions & 2 deletions

File tree

Lines changed: 352 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,352 @@
1+
import React, { useState, useEffect, useRef } from 'react';
2+
import { useSelector } from 'react-redux';
3+
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts';
4+
import { Alert, Spinner } from 'reactstrap';
5+
import { fetchProjects, fetchPlannedCostBreakdown } from './plannedCostService';
6+
import styles from './PlannedCostDonutChart.module.css';
7+
8+
const COLORS = {
9+
Plumbing: '#FF6384',
10+
Electrical: '#36A2EB',
11+
Structural: '#FFCE56',
12+
Mechanical: '#4BC0C0',
13+
};
14+
15+
const RADIAN = Math.PI / 180;
16+
17+
const CustomTooltip = ({ active, payload, totalCost }) => {
18+
if (active && payload && payload.length) {
19+
const data = payload[0];
20+
const percentage = ((data.value / totalCost) * 100).toFixed(1);
21+
22+
return (
23+
<div className={styles['planned-cost-tooltip']}>
24+
<p className={styles['tooltip-category']}>{data.name}</p>
25+
<p className={styles['tooltip-cost']}>Planned Cost: ${data.value.toLocaleString()}</p>
26+
<p className={styles['tooltip-percentage']}>Percentage: {percentage}%</p>
27+
</div>
28+
);
29+
}
30+
return null;
31+
};
32+
33+
const PlannedCostDonutChart = () => {
34+
const [selectedProject, setSelectedProject] = useState('');
35+
const [selectedProjectName, setSelectedProjectName] = useState('');
36+
const [chartData, setChartData] = useState([]);
37+
const [totalCost, setTotalCost] = useState(0);
38+
const [loading, setLoading] = useState(false);
39+
const [error, setError] = useState(null);
40+
const [projects, setProjects] = useState([]);
41+
const [dropdownOpen, setDropdownOpen] = useState(false);
42+
const [searchTerm, setSearchTerm] = useState('');
43+
44+
const darkMode = useSelector(state => state.theme?.darkMode);
45+
const dropdownRef = useRef(null);
46+
47+
const filteredProjects = projects.filter(project => {
48+
const name = project.name || project.projectName || '';
49+
return name.toLowerCase().includes(searchTerm.toLowerCase());
50+
});
51+
52+
useEffect(() => {
53+
const handleClickOutside = event => {
54+
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
55+
setDropdownOpen(false);
56+
}
57+
};
58+
document.addEventListener('mousedown', handleClickOutside);
59+
return () => document.removeEventListener('mousedown', handleClickOutside);
60+
}, []);
61+
62+
// Fetch projects list
63+
useEffect(() => {
64+
const getProjects = async () => {
65+
try {
66+
const projectsData = await fetchProjects();
67+
setProjects(projectsData);
68+
} catch (err) {
69+
setError(err.message);
70+
}
71+
};
72+
73+
getProjects();
74+
}, []);
75+
76+
// Fetch planned cost breakdown when project changes
77+
useEffect(() => {
78+
if (!selectedProject) {
79+
setChartData([]);
80+
setTotalCost(0);
81+
return;
82+
}
83+
84+
const getPlannedCostBreakdown = async () => {
85+
setLoading(true);
86+
setError(null);
87+
88+
try {
89+
const breakdown = await fetchPlannedCostBreakdown(selectedProject);
90+
91+
// Transform data for the chart - handle both array and object formats
92+
let transformedData = [];
93+
let total = 0;
94+
95+
if (Array.isArray(breakdown)) {
96+
const categoryMap = {};
97+
breakdown.forEach(item => {
98+
if (item.category && typeof item.plannedCost === 'number') {
99+
categoryMap[item.category] = item.plannedCost;
100+
}
101+
});
102+
103+
transformedData = [
104+
{ name: 'Plumbing', value: categoryMap.Plumbing || 0 },
105+
{ name: 'Electrical', value: categoryMap.Electrical || 0 },
106+
{ name: 'Structural', value: categoryMap.Structural || 0 },
107+
{ name: 'Mechanical', value: categoryMap.Mechanical || 0 },
108+
];
109+
110+
total = Object.values(categoryMap).reduce((sum, cost) => sum + cost, 0);
111+
} else {
112+
if (breakdown.total && breakdown.breakdown) {
113+
const breakdownData = breakdown.breakdown;
114+
115+
const hasValidData = Object.values(breakdownData).some(
116+
value => typeof value === 'number' && value > 0,
117+
);
118+
119+
if (hasValidData) {
120+
transformedData = [
121+
{ name: 'Plumbing', value: breakdownData.Plumbing || breakdownData.plumbing || 0 },
122+
{
123+
name: 'Electrical',
124+
value: breakdownData.Electrical || breakdownData.electrical || 0,
125+
},
126+
{
127+
name: 'Structural',
128+
value: breakdownData.Structural || breakdownData.structural || 0,
129+
},
130+
{
131+
name: 'Mechanical',
132+
value: breakdownData.Mechanical || breakdownData.mechanical || 0,
133+
},
134+
];
135+
136+
total = breakdown.total;
137+
} else {
138+
transformedData = [];
139+
total = 0;
140+
}
141+
} else {
142+
transformedData = [
143+
{ name: 'Plumbing', value: breakdown.plumbing || 0 },
144+
{ name: 'Electrical', value: breakdown.electrical || 0 },
145+
{ name: 'Structural', value: breakdown.structural || 0 },
146+
{ name: 'Mechanical', value: breakdown.mechanical || 0 },
147+
];
148+
149+
total = Object.values(breakdown).reduce((sum, cost) => sum + (cost || 0), 0);
150+
}
151+
}
152+
153+
setChartData(transformedData);
154+
setTotalCost(total);
155+
} catch (err) {
156+
setError(err.message);
157+
setChartData([]);
158+
setTotalCost(0);
159+
} finally {
160+
setLoading(false);
161+
}
162+
};
163+
164+
getPlannedCostBreakdown();
165+
}, [selectedProject]);
166+
167+
const renderCustomizedLabel = ({
168+
cx,
169+
cy,
170+
midAngle,
171+
innerRadius,
172+
outerRadius,
173+
percent,
174+
value,
175+
}) => {
176+
if (value === 0) return null;
177+
178+
const radius = innerRadius + (outerRadius - innerRadius) * 0.5;
179+
const x = cx + radius * Math.cos(-midAngle * RADIAN);
180+
const y = cy + radius * Math.sin(-midAngle * RADIAN);
181+
182+
return (
183+
<text
184+
x={x}
185+
y={y}
186+
fill="white"
187+
textAnchor="middle"
188+
dominantBaseline="central"
189+
fontSize="12"
190+
fontWeight="bold"
191+
>
192+
{percent > 0.05 ? `${parseFloat((percent * 100).toFixed(1))}%` : ''}
193+
</text>
194+
);
195+
};
196+
197+
if (error && !selectedProject) {
198+
return (
199+
<div className={styles['planned-cost-container']}>
200+
<Alert color="danger">{error}</Alert>
201+
</div>
202+
);
203+
}
204+
205+
return (
206+
<div className={`${styles['planned-cost-container']} ${darkMode ? styles['dark-mode'] : ''}`}>
207+
<h2 className={styles['chart-title']}>Planned Cost Breakdown by Type of Expenditure</h2>
208+
209+
{/* Project Filter */}
210+
<div className={styles['filter-section']}>
211+
<div className={styles['filter-col']}>
212+
<label htmlFor="project-search" className={styles['filter-label']}>
213+
Select Project
214+
</label>
215+
<div className={styles['searchable-dropdown']} ref={dropdownRef}>
216+
<input
217+
id="project-search"
218+
type="text"
219+
className={styles['dropdown-search-input']}
220+
placeholder="Choose a project..."
221+
value={dropdownOpen ? searchTerm : selectedProjectName}
222+
onChange={e => {
223+
setSearchTerm(e.target.value);
224+
if (!dropdownOpen) setDropdownOpen(true);
225+
}}
226+
onFocus={() => {
227+
setDropdownOpen(true);
228+
setSearchTerm('');
229+
}}
230+
/>
231+
<span className={`${styles['dropdown-arrow']} ${dropdownOpen ? styles.open : ''}`}>
232+
&#9662;
233+
</span>
234+
{dropdownOpen && (
235+
<div className={styles['dropdown-options']}>
236+
{filteredProjects.length > 0 ? (
237+
filteredProjects.map(project => (
238+
<div
239+
key={project._id}
240+
role="option"
241+
tabIndex={0}
242+
aria-selected={selectedProject === project._id}
243+
className={`${styles['dropdown-option']} ${
244+
selectedProject === project._id ? styles.selected : ''
245+
}`}
246+
onClick={() => {
247+
setSelectedProject(project._id);
248+
setSelectedProjectName(project.name || project.projectName);
249+
setDropdownOpen(false);
250+
setSearchTerm('');
251+
}}
252+
onKeyDown={e => {
253+
if (e.key === 'Enter' || e.key === ' ') {
254+
e.preventDefault();
255+
setSelectedProject(project._id);
256+
setSelectedProjectName(project.name || project.projectName);
257+
setDropdownOpen(false);
258+
setSearchTerm('');
259+
}
260+
}}
261+
>
262+
{project.name || project.projectName}
263+
</div>
264+
))
265+
) : (
266+
<div className={styles['dropdown-no-results']}>No projects found</div>
267+
)}
268+
</div>
269+
)}
270+
</div>
271+
</div>
272+
</div>
273+
274+
{/* Chart Area */}
275+
<div className={styles['chart-area']}>
276+
{loading ? (
277+
<div className={styles['loading-container']}>
278+
<Spinner color="primary" />
279+
<p>Loading planned cost data...</p>
280+
</div>
281+
) : selectedProject && chartData.length > 0 && totalCost > 0 ? (
282+
<>
283+
<ResponsiveContainer width="100%" height={400}>
284+
<PieChart>
285+
<Pie
286+
data={chartData}
287+
cx="50%"
288+
cy="50%"
289+
innerRadius={80}
290+
outerRadius={150}
291+
paddingAngle={2}
292+
dataKey="value"
293+
label={renderCustomizedLabel}
294+
labelLine={false}
295+
>
296+
{chartData.map((entry, index) => (
297+
<Cell key={`cell-${index}`} fill={COLORS[entry.name]} />
298+
))}
299+
</Pie>
300+
301+
<Tooltip content={<CustomTooltip totalCost={totalCost} />} />
302+
</PieChart>
303+
</ResponsiveContainer>
304+
305+
{/* Center display showing total */}
306+
<div className={styles['chart-center-info']}>
307+
<div className={styles['total-cost-display']}>
308+
<span className={styles['total-label']}>Total Planned Cost</span>
309+
<span className={styles['total-amount']}>${totalCost.toLocaleString()}</span>
310+
</div>
311+
</div>
312+
313+
{/* Legend */}
314+
<div className={styles['legend-container']}>
315+
<div className={styles['legend-items']}>
316+
{chartData.map((entry, index) => (
317+
<div key={entry.name} className={styles['legend-item']}>
318+
<div
319+
className={styles['legend-color']}
320+
style={{ backgroundColor: COLORS[entry.name] }}
321+
/>
322+
<span className={styles['legend-label']}>{entry.name}</span>
323+
<span className={styles['legend-value']}>
324+
${entry.value.toLocaleString()} (
325+
{((entry.value / totalCost) * 100).toFixed(1)}%)
326+
</span>
327+
</div>
328+
))}
329+
</div>
330+
</div>
331+
</>
332+
) : selectedProject ? (
333+
<div className={styles['no-data-container']}>
334+
<p>No planned cost data available for this project.</p>
335+
</div>
336+
) : (
337+
<div className={styles['select-project-container']}>
338+
<p>Please select a project to view the planned cost breakdown.</p>
339+
</div>
340+
)}
341+
</div>
342+
343+
{error && (
344+
<Alert color="danger" className="mt-3">
345+
{error}
346+
</Alert>
347+
)}
348+
</div>
349+
);
350+
};
351+
352+
export default PlannedCostDonutChart;

0 commit comments

Comments
 (0)