diff --git a/src/components/PlannedCostDonutChart/PlannedCostDonutChart.jsx b/src/components/PlannedCostDonutChart/PlannedCostDonutChart.jsx new file mode 100644 index 0000000000..e225fc7fdc --- /dev/null +++ b/src/components/PlannedCostDonutChart/PlannedCostDonutChart.jsx @@ -0,0 +1,352 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { useSelector } from 'react-redux'; +import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts'; +import { Alert, Spinner } from 'reactstrap'; +import { fetchProjects, fetchPlannedCostBreakdown } from './plannedCostService'; +import styles from './PlannedCostDonutChart.module.css'; + +const COLORS = { + Plumbing: '#FF6384', + Electrical: '#36A2EB', + Structural: '#FFCE56', + Mechanical: '#4BC0C0', +}; + +const RADIAN = Math.PI / 180; + +const CustomTooltip = ({ active, payload, totalCost }) => { + if (active && payload && payload.length) { + const data = payload[0]; + const percentage = ((data.value / totalCost) * 100).toFixed(1); + + return ( +
+

{data.name}

+

Planned Cost: ${data.value.toLocaleString()}

+

Percentage: {percentage}%

+
+ ); + } + return null; +}; + +const PlannedCostDonutChart = () => { + const [selectedProject, setSelectedProject] = useState(''); + const [selectedProjectName, setSelectedProjectName] = useState(''); + const [chartData, setChartData] = useState([]); + const [totalCost, setTotalCost] = useState(0); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [projects, setProjects] = useState([]); + const [dropdownOpen, setDropdownOpen] = useState(false); + const [searchTerm, setSearchTerm] = useState(''); + + const darkMode = useSelector(state => state.theme?.darkMode); + const dropdownRef = useRef(null); + + const filteredProjects = projects.filter(project => { + const name = project.name || project.projectName || ''; + return name.toLowerCase().includes(searchTerm.toLowerCase()); + }); + + useEffect(() => { + const handleClickOutside = event => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target)) { + setDropdownOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + // Fetch projects list + useEffect(() => { + const getProjects = async () => { + try { + const projectsData = await fetchProjects(); + setProjects(projectsData); + } catch (err) { + setError(err.message); + } + }; + + getProjects(); + }, []); + + // Fetch planned cost breakdown when project changes + useEffect(() => { + if (!selectedProject) { + setChartData([]); + setTotalCost(0); + return; + } + + const getPlannedCostBreakdown = async () => { + setLoading(true); + setError(null); + + try { + const breakdown = await fetchPlannedCostBreakdown(selectedProject); + + // Transform data for the chart - handle both array and object formats + let transformedData = []; + let total = 0; + + if (Array.isArray(breakdown)) { + const categoryMap = {}; + breakdown.forEach(item => { + if (item.category && typeof item.plannedCost === 'number') { + categoryMap[item.category] = item.plannedCost; + } + }); + + transformedData = [ + { name: 'Plumbing', value: categoryMap.Plumbing || 0 }, + { name: 'Electrical', value: categoryMap.Electrical || 0 }, + { name: 'Structural', value: categoryMap.Structural || 0 }, + { name: 'Mechanical', value: categoryMap.Mechanical || 0 }, + ]; + + total = Object.values(categoryMap).reduce((sum, cost) => sum + cost, 0); + } else { + if (breakdown.total && breakdown.breakdown) { + const breakdownData = breakdown.breakdown; + + const hasValidData = Object.values(breakdownData).some( + value => typeof value === 'number' && value > 0, + ); + + if (hasValidData) { + transformedData = [ + { name: 'Plumbing', value: breakdownData.Plumbing || breakdownData.plumbing || 0 }, + { + name: 'Electrical', + value: breakdownData.Electrical || breakdownData.electrical || 0, + }, + { + name: 'Structural', + value: breakdownData.Structural || breakdownData.structural || 0, + }, + { + name: 'Mechanical', + value: breakdownData.Mechanical || breakdownData.mechanical || 0, + }, + ]; + + total = breakdown.total; + } else { + transformedData = []; + total = 0; + } + } else { + transformedData = [ + { name: 'Plumbing', value: breakdown.plumbing || 0 }, + { name: 'Electrical', value: breakdown.electrical || 0 }, + { name: 'Structural', value: breakdown.structural || 0 }, + { name: 'Mechanical', value: breakdown.mechanical || 0 }, + ]; + + total = Object.values(breakdown).reduce((sum, cost) => sum + (cost || 0), 0); + } + } + + setChartData(transformedData); + setTotalCost(total); + } catch (err) { + setError(err.message); + setChartData([]); + setTotalCost(0); + } finally { + setLoading(false); + } + }; + + getPlannedCostBreakdown(); + }, [selectedProject]); + + const renderCustomizedLabel = ({ + cx, + cy, + midAngle, + innerRadius, + outerRadius, + percent, + value, + }) => { + if (value === 0) return null; + + const radius = innerRadius + (outerRadius - innerRadius) * 0.5; + const x = cx + radius * Math.cos(-midAngle * RADIAN); + const y = cy + radius * Math.sin(-midAngle * RADIAN); + + return ( + + {percent > 0.05 ? `${parseFloat((percent * 100).toFixed(1))}%` : ''} + + ); + }; + + if (error && !selectedProject) { + return ( +
+ {error} +
+ ); + } + + return ( +
+

Planned Cost Breakdown by Type of Expenditure

+ + {/* Project Filter */} +
+
+ +
+ { + setSearchTerm(e.target.value); + if (!dropdownOpen) setDropdownOpen(true); + }} + onFocus={() => { + setDropdownOpen(true); + setSearchTerm(''); + }} + /> + + ▾ + + {dropdownOpen && ( +
+ {filteredProjects.length > 0 ? ( + filteredProjects.map(project => ( +
{ + setSelectedProject(project._id); + setSelectedProjectName(project.name || project.projectName); + setDropdownOpen(false); + setSearchTerm(''); + }} + onKeyDown={e => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setSelectedProject(project._id); + setSelectedProjectName(project.name || project.projectName); + setDropdownOpen(false); + setSearchTerm(''); + } + }} + > + {project.name || project.projectName} +
+ )) + ) : ( +
No projects found
+ )} +
+ )} +
+
+
+ + {/* Chart Area */} +
+ {loading ? ( +
+ +

Loading planned cost data...

+
+ ) : selectedProject && chartData.length > 0 && totalCost > 0 ? ( + <> + + + + {chartData.map((entry, index) => ( + + ))} + + + } /> + + + + {/* Center display showing total */} +
+
+ Total Planned Cost + ${totalCost.toLocaleString()} +
+
+ + {/* Legend */} +
+
+ {chartData.map((entry, index) => ( +
+
+ {entry.name} + + ${entry.value.toLocaleString()} ( + {((entry.value / totalCost) * 100).toFixed(1)}%) + +
+ ))} +
+
+ + ) : selectedProject ? ( +
+

No planned cost data available for this project.

+
+ ) : ( +
+

Please select a project to view the planned cost breakdown.

+
+ )} +
+ + {error && ( + + {error} + + )} +
+ ); +}; + +export default PlannedCostDonutChart; diff --git a/src/components/PlannedCostDonutChart/PlannedCostDonutChart.module.css b/src/components/PlannedCostDonutChart/PlannedCostDonutChart.module.css new file mode 100644 index 0000000000..e524ad3bf8 --- /dev/null +++ b/src/components/PlannedCostDonutChart/PlannedCostDonutChart.module.css @@ -0,0 +1,492 @@ +.planned-cost-container { + padding: 2rem; + background: #ffffff; + border-radius: 0; + box-shadow: none; + margin: 0; + width: 100%; + transition: all 0.3s ease; + overflow: visible; + min-height: calc(100vh - 80px); + display: flex; + flex-direction: column; +} + +.planned-cost-container.dark-mode { + background: #2d3748; + color: #e2e8f0; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); +} + +.chart-title { + text-align: center; + margin-bottom: 2rem; + color: #2d3748; + font-weight: 600; + font-size: 1.75rem; +} + +.dark-mode .chart-title { + color: #e2e8f0; +} + +.filter-section { + margin-bottom: 2rem; + padding: 1rem; + background: #f7fafc; + border-radius: 8px; + display: flex; + justify-content: center; +} + +.dark-mode .filter-section { + background: #4a5568; +} + +.filter-col { + width: 100%; + max-width: 500px; +} + +.filter-label { + display: block; + font-weight: 600; + margin-bottom: 0.5rem; + color: #4a5568; +} + +.dark-mode .filter-label { + color: #e2e8f0; +} + +/* Searchable dropdown */ +.searchable-dropdown { + position: relative; + width: 100%; +} + +.dropdown-search-input { + width: 100%; + border: 2px solid #e2e8f0; + border-radius: 6px; + padding: 0.75rem; + padding-right: 2.5rem; + font-size: 1rem; + transition: border-color 0.3s ease; + background: #ffffff; + color: #2d3748; +} + +.dropdown-search-input::placeholder { + color: #a0aec0; +} + +.dropdown-search-input:focus { + border-color: #4299e1; + box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.1); + outline: none; +} + +.dark-mode .dropdown-search-input { + background-color: #4a5568 !important; + border-color: #718096; + color: #e2e8f0 !important; + -webkit-text-fill-color: #e2e8f0; +} + +.dark-mode .dropdown-search-input::placeholder { + color: #a0aec0; + -webkit-text-fill-color: #a0aec0; +} + +.dark-mode .dropdown-search-input:focus { + background-color: #4a5568 !important; + border-color: #63b3ed; + color: #e2e8f0 !important; + -webkit-text-fill-color: #e2e8f0; +} + +.dark-mode .dropdown-search-input:-webkit-autofill, +.dark-mode .dropdown-search-input:-webkit-autofill:hover, +.dark-mode .dropdown-search-input:-webkit-autofill:focus { + -webkit-box-shadow: 0 0 0 1000px #4a5568 inset !important; + -webkit-text-fill-color: #e2e8f0 !important; + transition: background-color 5000s ease-in-out 0s; +} + +.dropdown-arrow { + position: absolute; + right: 0.75rem; + top: 50%; + transform: translateY(-50%); + pointer-events: none; + color: #718096; + font-size: 0.8rem; + transition: transform 0.2s ease; +} + +.dropdown-arrow.open { + transform: translateY(-50%) rotate(180deg); +} + +.dark-mode .dropdown-arrow { + color: #a0aec0; +} + +.dropdown-options { + position: absolute; + top: 100%; + left: 0; + right: 0; + max-height: 250px; + overflow-y: auto; + background: #ffffff; + border: 2px solid #e2e8f0; + border-top: none; + border-radius: 0 0 6px 6px; + z-index: 1000; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.dark-mode .dropdown-options { + background: #2d3748; + border-color: #718096; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); +} + +.dropdown-option { + padding: 0.6rem 0.75rem; + cursor: pointer; + font-size: 0.95rem; + color: #2d3748; + transition: background 0.15s ease; +} + +.dropdown-option:hover { + background: #edf2f7; +} + +.dropdown-option.selected { + background: #ebf4ff; + font-weight: 600; + color: #2b6cb0; +} + +.dark-mode .dropdown-option { + color: #e2e8f0; +} + +.dark-mode .dropdown-option:hover { + background: #4a5568; +} + +.dark-mode .dropdown-option.selected { + background: #2a4365; + color: #90cdf4; +} + +.dropdown-no-results { + padding: 0.75rem; + color: #a0aec0; + text-align: center; + font-size: 0.9rem; +} + +.dark-mode .dropdown-no-results { + color: #718096; +} + +.chart-area { + display: flex; + flex-direction: column; + align-items: center; + overflow: visible; + flex: 1; +} + +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 200px; + padding: 2rem; + color: #718096; +} + +.dark-mode .loading-container { + color: #a0aec0; +} + +.loading-container p { + margin-top: 1rem; + font-size: 1.1rem; +} + +.no-data-container, +.select-project-container { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + min-height: 300px; + padding: 2rem; + color: #718096; + font-size: 1.2rem; + text-align: center; +} + +.dark-mode .no-data-container, +.dark-mode .select-project-container { + color: #a0aec0; +} + +/* Chart center info styling */ +.chart-center-info { + text-align: center; + margin: 1rem 0; +} + +.total-cost-display { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; +} + +.total-label { + font-size: 1rem; + color: #718096; + font-weight: 500; +} + +.dark-mode .total-label { + color: #a0aec0; +} + +.total-amount { + font-size: 2rem; + font-weight: 700; + color: #2d3748; +} + +.dark-mode .total-amount { + color: #e2e8f0; +} + +/* Legend styling */ +.legend-container { + margin-top: 2rem; + width: 100%; + max-width: 600px; +} + +.legend-container h4 { + text-align: center; + margin-bottom: 1rem; + color: #2d3748; + font-weight: 600; +} + +.dark-mode .legend-container h4 { + color: #e2e8f0; +} + +.legend-items { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 1rem; +} + +.legend-item { + display: flex; + align-items: center; + padding: 0.75rem; + background: #f7fafc; + border-radius: 6px; + transition: transform 0.2s ease; +} + +.legend-item:hover { + transform: translateY(-2px); +} + +.dark-mode .legend-item { + background: #4a5568; +} + +.legend-color { + width: 20px; + height: 20px; + border-radius: 50%; + margin-right: 0.75rem; + flex-shrink: 0; +} + +.legend-label { + font-weight: 600; + color: #2d3748; + margin-right: auto; + min-width: 80px; +} + +.dark-mode .legend-label { + color: #e2e8f0; +} + +.legend-value { + color: #718096; + font-size: 0.9rem; + text-align: right; +} + +.dark-mode .legend-value { + color: #a0aec0; +} + +/* Tooltip styling */ +.planned-cost-tooltip { + background: rgba(0, 0, 0, 0.9); + color: white; + padding: 0.75rem; + border-radius: 6px; + font-size: 0.9rem; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); +} + +.tooltip-category { + font-weight: 600; + margin: 0 0 0.5rem 0; + color: #ffffff; +} + +.tooltip-cost { + margin: 0 0 0.25rem 0; + color: #e2e8f0; +} + +.tooltip-percentage { + margin: 0; + color: #a0aec0; +} + +/* Dark mode scrollbar for dropdown */ +.dark-mode .dropdown-options::-webkit-scrollbar { + width: 6px; +} + +.dark-mode .dropdown-options::-webkit-scrollbar-track { + background: #2d3748; +} + +.dark-mode .dropdown-options::-webkit-scrollbar-thumb { + background: #718096; + border-radius: 3px; +} + +.dark-mode .dropdown-options::-webkit-scrollbar-thumb:hover { + background: #a0aec0; +} + +/* Dark mode for Alert */ +.dark-mode :global(.alert-danger) { + background: #742a2a; + color: #feb2b2; + border-color: #9b2c2c; +} + +/* Ensure no unnecessary scrollbars */ +html, body { + overflow-x: hidden; +} + +/* Responsive design */ +@media (max-width: 768px) { + .planned-cost-container { + padding: 1rem; + margin: 0.5rem 0; + } + + .chart-title { + font-size: 1.5rem; + margin-bottom: 1.5rem; + } + + .filter-section { + margin-bottom: 1.5rem; + padding: 0.75rem; + } + + .legend-items { + grid-template-columns: 1fr; + gap: 0.75rem; + } + + .legend-item { + padding: 0.5rem; + } + + .legend-label { + min-width: 60px; + font-size: 0.9rem; + } + + .legend-value { + font-size: 0.8rem; + } +} + +@media (max-width: 480px) { + .planned-cost-container { + padding: 0.75rem; + } + + .chart-title { + font-size: 1.25rem; + margin-bottom: 1rem; + } + + .filter-section { + padding: 0.5rem; + } + + .dropdown-search-input { + padding: 0.5rem; + font-size: 0.9rem; + } +} + +/* Animation for chart loading */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.chart-area > * { + animation: fadeIn 0.5s ease-out; +} + +/* Hover effects for interactive elements */ +.dropdown-search-input:hover { + border-color: #cbd5e0; +} + +.dark-mode .dropdown-search-input:hover { + border-color: #a0aec0; +} + +/* Focus states for accessibility */ +.dropdown-search-input:focus-visible { + outline: 2px solid #4299e1; + outline-offset: 2px; +} + +.dark-mode .dropdown-search-input:focus-visible { + outline-color: #63b3ed; +} diff --git a/src/components/PlannedCostDonutChart/index.js b/src/components/PlannedCostDonutChart/index.js new file mode 100644 index 0000000000..4d37658614 --- /dev/null +++ b/src/components/PlannedCostDonutChart/index.js @@ -0,0 +1 @@ +export { default } from './PlannedCostDonutChart'; diff --git a/src/components/PlannedCostDonutChart/plannedCostService.js b/src/components/PlannedCostDonutChart/plannedCostService.js new file mode 100644 index 0000000000..d603026ffc --- /dev/null +++ b/src/components/PlannedCostDonutChart/plannedCostService.js @@ -0,0 +1,138 @@ +import axios from 'axios'; + +const API_BASE_URL = process.env.REACT_APP_APIENDPOINT || 'http://localhost:4500/api'; + +/** + * Fetch all available projects + * @returns {Promise} Array of projects + */ +export const fetchProjects = async () => { + try { + const token = localStorage.getItem('token'); + if (!token) { + throw new Error('No authentication token found'); + } + + const response = await axios.get(`${API_BASE_URL}/projects`, { + headers: { Authorization: token }, + }); + + return response.data || []; + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error fetching projects:', error); + throw new Error('Failed to fetch projects'); + } +}; + +/** + * Fetch planned cost breakdown for a specific project + * @param {string} projectId - The project ID + * @returns {Promise} Planned cost breakdown data + */ +export const fetchPlannedCostBreakdown = async projectId => { + try { + const token = localStorage.getItem('token'); + if (!token) { + throw new Error('No authentication token found'); + } + + const response = await axios.get( + `${API_BASE_URL}/projects/${projectId}/planned-cost-breakdown`, + { + headers: { Authorization: token }, + }, + ); + + return response.data; + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error fetching planned cost breakdown:', error); + throw new Error(error.response?.data?.message || 'Failed to fetch planned cost breakdown'); + } +}; + +/** + * Create or update planned cost for a project category + * @param {string} projectId - The project ID + * @param {string} category - The category (Plumbing, Electrical, Structural, Mechanical) + * @param {number} plannedCost - The planned cost amount + * @returns {Promise} Response data + */ +export const createOrUpdatePlannedCost = async (projectId, category, plannedCost) => { + try { + const token = localStorage.getItem('token'); + if (!token) { + throw new Error('No authentication token found'); + } + + const response = await axios.post( + `${API_BASE_URL}/projects/${projectId}/planned-costs`, + { + category, + plannedCost, + }, + { + headers: { Authorization: token }, + }, + ); + + return response.data; + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error creating/updating planned cost:', error); + throw new Error(error.response?.data?.message || 'Failed to create/update planned cost'); + } +}; + +/** + * Delete planned cost for a project category + * @param {string} projectId - The project ID + * @param {string} category - The category to delete + * @returns {Promise} Response data + */ +export const deletePlannedCost = async (projectId, category) => { + try { + const token = localStorage.getItem('token'); + if (!token) { + throw new Error('No authentication token found'); + } + + const response = await axios.delete( + `${API_BASE_URL}/projects/${projectId}/planned-costs/${category}`, + { + headers: { Authorization: token }, + }, + ); + + return response.data; + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error deleting planned cost:', error); + throw new Error(error.response?.data?.message || 'Failed to delete planned cost'); + } +}; + +/** + * Get all planned costs for a project (detailed view) + * @param {string} projectId - The project ID + * @returns {Promise} Array of planned cost records + */ +export const getAllPlannedCostsForProject = async projectId => { + try { + const token = localStorage.getItem('token'); + if (!token) { + throw new Error('No authentication token found'); + } + + const response = await axios.get(`${API_BASE_URL}/projects/${projectId}/planned-costs`, { + headers: { Authorization: token }, + }); + + return response.data || []; + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error fetching all planned costs:', error); + throw new Error(error.response?.data?.message || 'Failed to fetch planned costs'); + } +}; diff --git a/src/routes.jsx b/src/routes.jsx index 2adfce4c27..3ec84437c6 100644 --- a/src/routes.jsx +++ b/src/routes.jsx @@ -73,7 +73,7 @@ import TSAFormPage5 from './components/TSAForm/pages/TSAFormPage5'; import TSAFormPage6 from './components/TSAForm/pages/TSAFormPage6'; import TSAFormPage7 from './components/TSAForm/pages/TSAFormPage7'; import TSAFormPage8 from './components/TSAForm/pages/TSAFormPage8'; -import DisplayTeamMemberDetails from './components/HGNForm/TopCommunityMembers/TopCommunityMembers'; +import PlannedCostDonutChart from './components/PlannedCostDonutChart'; import HelpPage from './components/LandingPage/HelpPage'; import TeamCard from './components/HGNHelpSkillsDashboard/TeamCard/TeamCard'; import LandingPage from './components/HGNHelpSkillsDashboard/LandingPage'; @@ -893,7 +893,6 @@ export default ( allowedRoles={[UserRole.Administrator, UserRole.CoreTeam, UserRole.Owner]} routePermissions={RoutePermissions.accessHgnSkillsDashboard} /> - @@ -920,6 +919,7 @@ export default ( exact component={PromotionEligibility} /> +