diff --git a/src/frontend/src/pages/FinancePage/FinanceComponents/PieChart.tsx b/src/frontend/src/pages/FinancePage/FinanceComponents/PieChart.tsx index f80e336eaa..5376dacc44 100644 --- a/src/frontend/src/pages/FinancePage/FinanceComponents/PieChart.tsx +++ b/src/frontend/src/pages/FinancePage/FinanceComponents/PieChart.tsx @@ -24,10 +24,11 @@ const FinancePieChart: React.FC = ({ available }) => { const [isLegendOpen, setIsLegendOpen] = useState(true); + + const pendingReimbursement = pendingLeadership + pendingFinance + submittedToSABO; + const [sectionStates, setSectionStates] = useState([ - { title: 'Pending Leadership', color: '#562016', expanded: false }, - { title: 'Pending Finance', color: '#8e3c2d', expanded: false }, - { title: 'Submitted to SABO', color: '#dd514c', expanded: false }, + { title: 'Pending Reimbursement', color: '#8e3c2d', expanded: false }, { title: 'Reimbursed', color: '#797a7a', expanded: false }, { title: 'Available', color: '#afafaf', expanded: false } ]); @@ -35,9 +36,7 @@ const FinancePieChart: React.FC = ({ const MIN_PERCENTAGE = 0.05; const data = [ - { name: 'Pending Leadership', value: pendingLeadership }, - { name: 'Pending Finance', value: pendingFinance }, - { name: 'Submitted to SABO', value: submittedToSABO }, + { name: 'Pending Reimbursement', value: pendingReimbursement }, { name: 'Reimbursed', value: reimbursed }, { name: 'Available', value: available } ]; @@ -73,9 +72,7 @@ const FinancePieChart: React.FC = ({ } const sectionColorMap = new Map([ - ['Pending Leadership', '#562016'], - ['Pending Finance', '#8e3c2d'], - ['Submitted to SABO', '#dd514c'], + ['Pending Reimbursement', '#8e3c2d'], ['Reimbursed', '#797a7a'], ['Available', '#afafaf'] ]); diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectViewContainer.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectViewContainer.tsx index 82ca5e59d7..1a13260a18 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectViewContainer.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectViewContainer.tsx @@ -34,6 +34,7 @@ import { useGetMaterialsForWbsElement } from '../../../hooks/bom.hooks'; import ChangeRequestTab from '../../../components/ChangeRequestTab'; import PartsReviewPage from './PartReview/PartsReviewPage'; import ActionsMenu from '../../../components/ActionsMenu'; +import ProjectSpendingHistory from '../../ProjectPage/ProjectSpendingHistory'; import { useMyTeamAsHead } from '../../../hooks/teams.hooks'; interface ProjectViewContainerProps { @@ -193,7 +194,8 @@ const ProjectViewContainer: React.FC = ({ project, en { tabUrlValue: 'changes', tabName: 'Changes' }, { tabUrlValue: 'gantt', tabName: 'Gantt' }, { tabUrlValue: 'change-requests', tabName: 'Change Requests' }, - { tabUrlValue: 'parts-review', tabName: 'Parts Review' } + { tabUrlValue: 'parts-review', tabName: 'Parts Review' }, + { tabUrlValue: 'spending', tabName: 'Budget' } ]} baseUrl={`${routes.PROJECTS}/${wbsNum}`} defaultTab="overview" @@ -216,8 +218,10 @@ const ProjectViewContainer: React.FC = ({ project, en ) : tab === 6 ? ( - ) : ( + ) : tab === 7 ? ( + ) : ( + )} {deleteModalShow && ( diff --git a/src/frontend/src/pages/ProjectPage/ProjectSpendingHistory.tsx b/src/frontend/src/pages/ProjectPage/ProjectSpendingHistory.tsx new file mode 100644 index 0000000000..641253f9f2 --- /dev/null +++ b/src/frontend/src/pages/ProjectPage/ProjectSpendingHistory.tsx @@ -0,0 +1,235 @@ +import React, { useMemo } from 'react'; +import { Box, Typography, Link, LinearProgress, Card, CardContent, Grid, Chip } from '@mui/material'; +import { DataGrid, GridColDef, GridRenderCellParams } from '@mui/x-data-grid'; +import { useAllReimbursementRequests } from '../../hooks/finance.hooks'; +import { useSingleProject } from '../../hooks/projects.hooks'; +import { WbsNumber, ReimbursementRequest, WBSElementData, equalsWbsNumber } from 'shared'; +import LoadingIndicator from '../../components/LoadingIndicator'; + +interface ProjectSpendingHistoryProps { + wbsNum: WbsNumber; +} + +const ProjectSpendingHistory: React.FC = ({ wbsNum }) => { + const { data: allReimbursementRequests, isLoading: rrLoading, isError: rrError } = useAllReimbursementRequests(); + const { data: project, isLoading: projectLoading } = useSingleProject(wbsNum); + + const reimbursementRequests = useMemo(() => { + if (!allReimbursementRequests || !project) return []; + + return allReimbursementRequests.filter((rr) => { + const hasProjectProduct = rr.reimbursementProducts.some((product) => { + const reason = product.reimbursementProductReason; + if ((reason as WBSElementData).wbsNum) { + return equalsWbsNumber((reason as WBSElementData).wbsNum, { ...wbsNum, workPackageNumber: 0 }); + } + return false; + }); + return hasProjectProduct; + }); + }, [allReimbursementRequests, project, wbsNum]); + + const getSubmittedDate = (rr: ReimbursementRequest): Date | null => { + const pendingFinanceStatus = rr.reimbursementStatuses?.find((status) => status.type === 'PENDING_FINANCE'); + if (pendingFinanceStatus) { + return new Date(pendingFinanceStatus.dateCreated); + } + return null; + }; + + const rows = useMemo(() => { + return reimbursementRequests.map((rr) => ({ + id: rr.reimbursementRequestId, + submitter: `${rr.recipient?.firstName} ${rr.recipient?.lastName}` || rr.recipient?.email || 'N/A', + description: + rr.reimbursementProducts?.map((p) => p.name).join(', ') || + rr.accountCode?.name || + rr.vendor?.name || + 'No description', + dateSubmitted: getSubmittedDate(rr), + status: rr.reimbursementStatuses?.[0]?.type || 'UNKNOWN', + totalAmount: (rr.totalCost || 0) / 100, + reimbursementRequestId: rr.reimbursementRequestId + })); + }, [reimbursementRequests]); + + const budgetInfo = useMemo(() => { + if (!project) return null; + + const totalBudget = project.budget; + const totalSpent = reimbursementRequests.reduce((sum, rr) => sum + (rr.totalCost || 0), 0); + const budgetRemaining = totalBudget - totalSpent; + const budgetUsedPercentage = totalBudget > 0 ? (totalSpent / totalBudget) * 100 : 0; + + return { + totalBudget: totalBudget / 100, + totalSpent: totalSpent / 100, + budgetRemaining: budgetRemaining / 100, + budgetUsedPercentage: Math.min(budgetUsedPercentage, 100) + }; + }, [project, reimbursementRequests]); + + const columns: GridColDef[] = [ + { + field: 'submitter', + headerName: 'Submitter', + flex: 1, + minWidth: 150, + renderCell: (params: GridRenderCellParams) => ( + + {params.value} + + ) + }, + { + field: 'description', + headerName: 'Description', + flex: 2, + minWidth: 250 + }, + { + field: 'dateSubmitted', + headerName: 'Date Submitted', + flex: 1, + minWidth: 130, + type: 'date', + valueFormatter: (params) => { + return params.value ? new Date(params.value).toLocaleDateString() : '-'; + } + }, + { + field: 'status', + headerName: 'Status', + flex: 1, + minWidth: 150, + renderCell: (params: GridRenderCellParams) => ( + + ) + }, + { + field: 'totalAmount', + headerName: 'Total Amount', + flex: 0.7, + minWidth: 120, + type: 'number', + valueFormatter: (params) => `$${params.value.toFixed(2)}` + } + ]; + + const isLoading = rrLoading || projectLoading; + + if (isLoading) return ; + + if (rrError) { + return Failed to load spending history.; + } + + if (!reimbursementRequests.length) return No spending history for this project.; + + return ( + + + Spending History + + + {budgetInfo && ( + + + + + + Budget Overview + + + + + Spent: ${budgetInfo.totalSpent.toFixed(2)} + + + Total Budget: ${budgetInfo.totalBudget.toFixed(2)} + + + 90 + ? '#f44336' + : budgetInfo.budgetUsedPercentage > 75 + ? '#ff9800' + : '#4caf50' + } + }} + /> + + + + + + Budget Remaining + + = 0 ? '#4caf50' : '#f44336', + fontWeight: 'bold' + }} + > + ${budgetInfo.budgetRemaining.toFixed(2)} + + + ({budgetInfo.budgetUsedPercentage.toFixed(1)}% used) + + + + + + + )} + + + + + + ); +}; + +export default ProjectSpendingHistory;