Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,20 +24,19 @@ const FinancePieChart: React.FC<FinancePieChartProps> = ({
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 }
]);

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 }
];
Expand Down Expand Up @@ -73,9 +72,7 @@ const FinancePieChart: React.FC<FinancePieChartProps> = ({
}

const sectionColorMap = new Map([
['Pending Leadership', '#562016'],
['Pending Finance', '#8e3c2d'],
['Submitted to SABO', '#dd514c'],
['Pending Reimbursement', '#8e3c2d'],
['Reimbursed', '#797a7a'],
['Available', '#afafaf']
]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -193,7 +194,8 @@ const ProjectViewContainer: React.FC<ProjectViewContainerProps> = ({ 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"
Expand All @@ -216,8 +218,10 @@ const ProjectViewContainer: React.FC<ProjectViewContainerProps> = ({ project, en
<ProjectGantt workPackages={project.workPackages} />
) : tab === 6 ? (
<ChangeRequestTab wbsElement={project} />
) : (
) : tab === 7 ? (
<PartsReviewPage project={project} />
) : (
<ProjectSpendingHistory wbsNum={project.wbsNum} />
Comment on lines +221 to +224
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tab ordering logic is incorrect. Tab index 7 is mapped to PartsReviewPage but should be mapped to ProjectSpendingHistory based on the tab array order. The new Budget tab is at index 7 (8th position starting from 0), but the conditional checks tab === 7 and renders PartsReviewPage instead. The correct logic should check tab === 7 for ProjectSpendingHistory and tab === 6 should remain for PartsReviewPage. Currently, the Budget tab (index 7) will incorrectly show PartsReviewPage.

Copilot uses AI. Check for mistakes.
)}
{deleteModalShow && (
<DeleteProject modalShow={deleteModalShow} handleClose={handleDeleteClose} wbsNum={project.wbsNum} />
Expand Down
235 changes: 235 additions & 0 deletions src/frontend/src/pages/ProjectPage/ProjectSpendingHistory.tsx
Original file line number Diff line number Diff line change
@@ -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<ProjectSpendingHistoryProps> = ({ 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 });
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The WBS number comparison sets workPackageNumber to 0 to match the project level, but this assumes all reimbursement requests should be matched at the project level. If a reimbursement product is linked to a specific work package within this project, it won't be matched because the comparison forces workPackageNumber to 0. This may exclude valid reimbursement requests that are linked to work packages within the project. Consider checking if the project number matches (carNumber and projectNumber) without forcing workPackageNumber to 0.

Suggested change
return equalsWbsNumber((reason as WBSElementData).wbsNum, { ...wbsNum, workPackageNumber: 0 });
const reasonWbs = (reason as WBSElementData).wbsNum;
return (
reasonWbs.carNumber === wbsNum.carNumber &&
reasonWbs.projectNumber === wbsNum.projectNumber
);

Copilot uses AI. Check for mistakes.
}
return false;
});
return hasProjectProduct;
});
}, [allReimbursementRequests, project, wbsNum]);
Comment on lines +17 to +30
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The project dependency in the useMemo is unnecessary since project is only used for a null check and doesn't affect the filtering logic. The filter only depends on allReimbursementRequests and wbsNum. Removing the project dependency would prevent unnecessary recalculation when project data changes but the reimbursement requests haven't changed.

Copilot uses AI. Check for mistakes.

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',
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The status is taken from the first element in the reimbursementStatuses array (index 0), but this may not be the most recent status. The array order should be verified - if statuses are ordered chronologically (oldest first), this would show the oldest status instead of the current status. Consider either sorting by dateCreated DESC or using the last element of the array to get the most recent status.

Copilot uses AI. Check for mistakes.
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);
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The total spent calculation includes all reimbursement requests regardless of their status. This means pending, denied, or cancelled reimbursement requests would also be counted as "spent" budget. The calculation should filter to only include reimbursements with statuses that represent actual spending (e.g., REIMBURSED, or possibly PENDING_FINANCE/SUBMITTED_TO_SABO if those should count as committed funds).

Suggested change
const totalSpent = reimbursementRequests.reduce((sum, rr) => sum + (rr.totalCost || 0), 0);
// Only count reimbursement requests that represent actual spending.
// Currently we treat REIMBURSED as "spent". Other statuses can be added here if needed.
const spendingStatuses = ['REIMBURSED'];
const spentReimbursementRequests = reimbursementRequests.filter(
(rr) =>
rr.reimbursementStatuses?.some((status) => spendingStatuses.includes(status.type)) ?? false
);
const totalSpent = spentReimbursementRequests.reduce((sum, rr) => sum + (rr.totalCost || 0), 0);

Copilot uses AI. Check for mistakes.
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) => (
<Link
href={`/finance/reimbursement-requests/${params.row.reimbursementRequestId}`}
underline="hover"
color="primary"
>
{params.value}
</Link>
)
},
{
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) => (
<Chip
label={params.value.replace(/_/g, ' ')}
color={params.value === 'REIMBURSED' ? 'success' : 'warning'}
size="small"
/>
)
},
{
field: 'totalAmount',
headerName: 'Total Amount',
flex: 0.7,
minWidth: 120,
type: 'number',
valueFormatter: (params) => `$${params.value.toFixed(2)}`
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The valueFormatter assumes params.value is always a number, but if totalAmount is null or undefined, this will cause a runtime error when calling toFixed(2). Add a null check or provide a default value to handle cases where totalAmount might be missing.

Suggested change
valueFormatter: (params) => `$${params.value.toFixed(2)}`
valueFormatter: (params) => {
if (params.value == null) {
return '$0.00';
}
const value =
typeof params.value === 'number' ? params.value : Number(params.value);
if (Number.isNaN(value)) {
return '$0.00';
}
return `$${value.toFixed(2)}`;
}

Copilot uses AI. Check for mistakes.
}
];

const isLoading = rrLoading || projectLoading;

if (isLoading) return <LoadingIndicator />;

if (rrError) {
return <Typography color="error">Failed to load spending history.</Typography>;
}

if (!reimbursementRequests.length) return <Typography>No spending history for this project.</Typography>;

return (
<Box sx={{ mt: 4 }}>
<Typography variant="h5" sx={{ color: 'primary.main', fontWeight: 'bold', mb: 3 }}>
Spending History
</Typography>

{budgetInfo && (
<Card sx={{ mb: 3, backgroundColor: '#2a2a2a', border: '1px solid #444' }}>
<CardContent>
<Grid container spacing={3} alignItems="center">
<Grid item xs={12} md={8}>
<Typography variant="h6" sx={{ mb: 1 }}>
Budget Overview
</Typography>
<Box sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="body2" color="textSecondary">
Spent: ${budgetInfo.totalSpent.toFixed(2)}
</Typography>
<Typography variant="body2" color="textSecondary">
Total Budget: ${budgetInfo.totalBudget.toFixed(2)}
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={budgetInfo.budgetUsedPercentage}
sx={{
height: 10,
borderRadius: 5,
backgroundColor: '#444',
'& .MuiLinearProgress-bar': {
borderRadius: 5,
backgroundColor:
budgetInfo.budgetUsedPercentage > 90
? '#f44336'
: budgetInfo.budgetUsedPercentage > 75
? '#ff9800'
: '#4caf50'
}
}}
/>
</Box>
</Grid>
<Grid item xs={12} md={4}>
<Box sx={{ textAlign: { xs: 'left', md: 'right' } }}>
<Typography variant="body2" color="textSecondary" sx={{ mb: 0.5 }}>
Budget Remaining
</Typography>
<Typography
variant="h5"
sx={{
color: budgetInfo.budgetRemaining >= 0 ? '#4caf50' : '#f44336',
fontWeight: 'bold'
}}
>
${budgetInfo.budgetRemaining.toFixed(2)}
</Typography>
<Typography variant="caption" color="textSecondary">
({budgetInfo.budgetUsedPercentage.toFixed(1)}% used)
</Typography>
</Box>
</Grid>
</Grid>
</CardContent>
</Card>
)}

<Box sx={{ height: 600, width: '100%' }}>
<DataGrid
rows={rows}
columns={columns}
pageSize={25}
rowsPerPageOptions={[10, 25, 50, 100]}
initialState={{
Comment on lines +208 to +210
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pageSize and rowsPerPageOptions props are deprecated in newer versions of MUI DataGrid (v5+). These should be moved into the initialState.pagination object. Use initialState.pagination.pageSize instead of the pageSize prop, and paginationModel.pageSize with onPaginationModelChange for rowsPerPageOptions.

Suggested change
pageSize={25}
rowsPerPageOptions={[10, 25, 50, 100]}
initialState={{
pageSizeOptions={[10, 25, 50, 100]}
initialState={{
pagination: {
paginationModel: { pageSize: 25 }
},

Copilot uses AI. Check for mistakes.
sorting: {
sortModel: [{ field: 'dateSubmitted', sort: 'desc' }]
}
}}
disableSelectionOnClick
sx={{
border: '1px solid #444',
'& .MuiDataGrid-cell': {
borderColor: '#444'
},
'& .MuiDataGrid-columnHeaders': {
backgroundColor: '#2a2a2a',
borderColor: '#444'
},
'& .MuiDataGrid-footerContainer': {
borderColor: '#444'
}
}}
/>
</Box>
</Box>
);
};

export default ProjectSpendingHistory;