diff --git a/src/components/BMDashboard/ExpenditureGraph/ExpenditureLineGraph.css b/src/components/BMDashboard/ExpenditureGraph/ExpenditureLineGraph.css new file mode 100644 index 00000000000..7098f117bd9 --- /dev/null +++ b/src/components/BMDashboard/ExpenditureGraph/ExpenditureLineGraph.css @@ -0,0 +1,119 @@ +:root { + --bg-color: #f9f9f9; + --section-bg: #f5f5f5; + --text-color: #222; + --card-bg: #fff; + --button-bg: #e8a71c; +} + +.dark-mode { + --bg-color: #1b2a41; + --section-bg: #253342; + --text-color: #ffffff; + --card-bg: #16213e; + --button-bg: #e8a71c; +} + +.expenditure-chart-container { + padding: 20px; + background-color: #f9f9f9; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + transition: all 0.3s ease; + min-height: 100vh; + } + +.expenditure-chart-container.dark-mode { + background-color: #1b2a41 !important; + color: #ffffff !important; + box-shadow: none !important; + padding: 0 !important; + min-height: 100vh; + border-radius: 0 !important; +} + +.expenditure-chart-container.dark-mode .chart-inner-container { + background-color: #16213e !important; + border: 1px solid #233554 !important; + box-shadow: none !important; + border-radius: 0 !important; + width: 100%; + height: 100%; + margin: 0; + padding: 20px; +} + +.expenditure-chart-container.dark-mode .filter-controls, +.expenditure-chart-container.dark-mode .project-filter, +.expenditure-chart-container.dark-mode .filter-group { + background-color: #16213e !important; + color: #ffffff !important; + border: none !important; +} + +.expenditure-chart-container.dark-mode .error { + background-color: #2a3a5a !important; + color: #ff6b6b !important; + border: 1px solid #ff6b6b !important; +} + +.expenditure-chart-container.dark-mode .chart-inner-container { + background-color: #16213e !important; + border: 1px solid #233554 !important; + box-shadow: none !important; +} + +.expenditure-chart-container.dark-mode canvas { + background-color: #16213e !important; + border-radius: 8px; +} + + .expenditure-chart-container h1 { + font-size: 1.5rem; + margin-bottom: 20px; + font-weight: 600; + } + + .project-filter { + margin: 20px; + justify-content: flex-end; + display: flex; + align-items: center; + } + + .filter-group { + margin: 20px; + justify-content: flex-end; + display: flex; + align-items: center; + } + #project-select { + margin-left: 15px; + } + +.project-filter { + margin: 20px; + justify-content:flex-end; + display: flex; + align-items: center; +} + +.filter-group { + margin: 20px; + justify-content:flex-end; + display: flex; + align-items: center; +} + +label { + margin: 20px; +} + +#project-select { + margin-left: 15px; +} + +.dark-mode .project-filter, +.dark-mode .filter-group { + background-color: var(--card-bg); +} \ No newline at end of file diff --git a/src/components/BMDashboard/ExpenditureGraph/ExpenditureLineGraph.jsx b/src/components/BMDashboard/ExpenditureGraph/ExpenditureLineGraph.jsx new file mode 100644 index 00000000000..d9bf3fbcfc5 --- /dev/null +++ b/src/components/BMDashboard/ExpenditureGraph/ExpenditureLineGraph.jsx @@ -0,0 +1,608 @@ +import { useRef, useState, useEffect } from 'react'; +import axios from 'axios'; +import Chart from 'chart.js/auto'; +import { ENDPOINTS } from 'utils/URL'; +import { useSelector } from 'react-redux'; + +export default function ExpenditureLineGraph() { + const chartRef = useRef(null); + const [chartInstance, setChartInstance] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [expenditureData, setExpenditureData] = useState([]); + const [projects, setProjects] = useState([]); + const [selectedProject, setSelectedProject] = useState('all'); + const [dateError, setDateError] = useState(null); + const [noDataError, setNoDataError] = useState(null); + + // date filters + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); + const [dateRange, setDateRange] = useState({ start: null, end: null }); + + const darkMode = useSelector(state => state.theme.darkMode); + + // format the date as YYYY-MM-DD + const formatDateForInput = date => { + return date.toISOString().split('T')[0]; + }; + + // use effect fetches data + useEffect(() => { + const fetchExpenditureData = async () => { + try { + setLoading(true); + const response = await axios.get(ENDPOINTS.BM_EXPENDITURE); + if (response?.data?.success) { + const { data } = response.data; + setExpenditureData(data); + // extract unique IDs + const uniqueProjects = [...new Set(data.map(item => item.projectId))]; + setProjects(uniqueProjects); + // default date range + if (data.length > 0) { + const dates = data.map(item => new Date(item.date)); + const minDate = new Date(Math.min(...dates)); + const maxDate = new Date(Math.max(...dates)); + setStartDate(formatDateForInput(minDate)); + setEndDate(formatDateForInput(maxDate)); + // set date range + setDateRange({ + start: minDate, + end: maxDate, + }); + } + } else { + setError('Failed to fetch the data'); + } + } catch (err) { + setError(`Error fetching data: ${err.message}`); + } finally { + setLoading(false); + } + }; + + fetchExpenditureData(); + + // Clean up function + return () => { + if (chartInstance) { + chartInstance.destroy(); + setChartInstance(null); + } + }; + }, []); + + // Destroy and recreate chart when theme changes + useEffect(() => { + if (chartInstance) { + chartInstance.destroy(); + setChartInstance(null); + } + }, [darkMode]); + + const createChart = chartData => { + if (!chartRef.current) return; + + const ctx = chartRef.current.getContext('2d'); + const chartTitle = + selectedProject === 'all' + ? 'Cost Breakdown by Type of Expenditure (all projects)' + : `Cost Breakdown by Type of Expenditure (Project: ${selectedProject})`; + + const gridColor = darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; + const textColor = darkMode ? '#ffffff' : '#666666'; + const chartBackgroundColor = darkMode ? '#1b2a41' : '#ffffff'; + + if (chartInstance) { + chartInstance.destroy(); + } + + const config = { + type: 'line', + data: chartData, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + title: { + display: true, + text: chartTitle, + color: textColor, + font: { + size: 14, + weight: 'bold', + }, + padding: { + top: 10, + bottom: 15, + }, + }, + legend: { + labels: { + color: textColor, + font: { + size: 12, + }, + }, + }, + tooltip: { + backgroundColor: darkMode ? '#3a506b' : 'rgba(0, 0, 0, 0.7)', + titleColor: '#ffffff', + bodyColor: '#ffffff', + borderColor: darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)', + borderWidth: 1, + }, + }, + scales: { + y: { + title: { + display: true, + text: 'Cost($)', + color: textColor, + font: { + size: 12, + weight: 'bold', + }, + }, + ticks: { + color: textColor, + font: { + size: 11, + }, + callback(value) { + if (value >= 1000) { + return `$${value / 1000}k`; + } + return `$${value}`; + }, + }, + grid: { + color: gridColor, + borderColor: gridColor, + }, + }, + x: { + title: { + display: true, + text: 'Month', + color: textColor, + font: { + size: 12, + weight: 'bold', + }, + }, + ticks: { + color: textColor, + font: { + size: 11, + }, + }, + grid: { + color: gridColor, + borderColor: gridColor, + }, + }, + }, + animation: { + onComplete() { + const chart = this; + ctx.save(); + ctx.globalCompositeOperation = 'destination-over'; + ctx.fillStyle = chartBackgroundColor; + ctx.fillRect(0, 0, chart.width, chart.height); + ctx.restore(); + }, + }, + }, + }; + + const newChartInstance = new Chart(ctx, config); + setChartInstance(newChartInstance); + }; + + const processDataForChart = expenditureDataArr => { + // group data by month and cat + const groupedByMonth = {}; + const categories = new Set(); + + // month from date + expenditureDataArr.forEach(item => { + const date = new Date(item.date); + const month = date.toLocaleString('default', { month: 'short' }); + const year = date.getFullYear(); + const monthYear = `${month} ${year}`; + + if (!groupedByMonth[monthYear]) { + groupedByMonth[monthYear] = {}; + } + + if (!groupedByMonth[monthYear][item.category]) { + groupedByMonth[monthYear][item.category] = 0; + } + + groupedByMonth[monthYear][item.category] += item.cost; + categories.add(item.category); + }); + + const labels = Object.keys(groupedByMonth).sort((a, b) => { + const [monthA, yearA] = a.split(' '); + const [monthB, yearB] = b.split(' '); + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + + if (yearA !== yearB) { + return parseInt(yearA, 10) - parseInt(yearB, 10); + } + return months.indexOf(monthA) - months.indexOf(monthB); + }); + + if (labels.length === 1) { + const [month, year] = labels[0].split(' '); + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + const monthIndex = months.indexOf(month); + const nextMonthIndex = (monthIndex + 1) % 12; + const nextYear = nextMonthIndex === 0 ? parseInt(year, 10) + 1 : parseInt(year, 10); + labels.push(`${months[nextMonthIndex]} ${nextYear}`); + } + const datasets = Array.from(categories).map((category, index) => { + const colors = ['#6293CC', '#C55151', '#E8D06B', '#94B66F']; + const data = labels.map(month => groupedByMonth[month]?.[category] || 0); + + return { + label: category, + data, + borderColor: colors[index % colors.length], + backgroundColor: darkMode + ? `${colors[index % colors.length]}33` + : `${colors[index % colors.length]}1A`, + tension: 0.1, + fill: false, + }; + }); + + return { labels, datasets }; + }; + + // handle chart changes when filters change + useEffect(() => { + if (expenditureData.length > 0 && chartRef.current) { + // Reset error states + setDateError(null); + setNoDataError(null); + + // Validate dates + if (dateRange.start && dateRange.end && dateRange.start > dateRange.end) { + setDateError('Start date cannot be greater than end date'); + if (chartInstance) { + chartInstance.destroy(); + setChartInstance(null); + } + return; + } + + let filteredData = expenditureData; + + // if not all data, filter by requested filtered title project + if (selectedProject !== 'all') { + filteredData = expenditureData.filter(item => item.projectId === selectedProject); + } + + // filter dates + if (dateRange.start && dateRange.end) { + filteredData = filteredData.filter(item => { + const itemDate = new Date(item.date); + return itemDate >= dateRange.start && itemDate <= dateRange.end; + }); + } + + // if data after filter? + if (filteredData.length === 0) { + setNoDataError('No data available for the selected date range and project'); + if (chartInstance) { + chartInstance.destroy(); + setChartInstance(null); + } + return; + } + + const processedData = processDataForChart(filteredData); + createChart(processedData); + } + }, [selectedProject, dateRange, expenditureData, darkMode]); + + const handleProjectChange = e => { + setSelectedProject(e.target.value); + }; + + const handleStartDateChange = e => { + const newStartDate = e.target.value; + setStartDate(newStartDate); + if (newStartDate) { + const newStartDateTime = new Date(newStartDate); + setDateRange(prev => ({ + ...prev, + start: newStartDateTime, + })); + } else { + const dates = expenditureData.map(item => new Date(item.date)); + setDateRange(prev => ({ + ...prev, + start: new Date(Math.min(...dates)), + })); + } + }; + + const handleEndDateChange = e => { + const newEndDate = e.target.value; + setEndDate(newEndDate); + if (newEndDate) { + const newEndDateTime = new Date(newEndDate); + setDateRange(prev => ({ + ...prev, + end: newEndDateTime, + })); + } else { + const dates = expenditureData.map(item => new Date(item.date)); + setDateRange(prev => ({ + ...prev, + end: new Date(Math.max(...dates)), + })); + } + }; + + return ( +
+ Loading data... +
+ )} + + {error && ( ++ Error: {error} +
+ )} + {dateError && ( ++ {dateError} +
+ )} + + {noDataError && ( ++ {noDataError} +
+ )} + +📊 Card
; } @@ -244,7 +244,7 @@ export default function WeeklyProjectSummary() { className: 'large', content: ( <> - {Array.from({ length: 4 }).map(() => { + {Array.from({ length: 3 }).map(() => { const uniqueId = uuidv4(); return (