Skip to content
10 changes: 6 additions & 4 deletions src/frontend/src/components/NERDataGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ interface NERDataGridProps<T> {
columns: GridColDef[];
pageSizeDefault?: number;
rowsPerPageOptions?: number[];
onAdd: () => void;
onAdd?: () => void;
addLabel?: string; // optional label for the add/create button (defaults to 'Add')
onRowClick?: (item: T) => void;
// optional simple search fields (keys of mapped row) or a custom filter function
Expand Down Expand Up @@ -97,9 +97,11 @@ function NERDataGrid<T>({
placeholder="Search"
sx={{ flex: 1 }}
/>
<Button variant="contained" size="small" onClick={onAdd} sx={{ ml: 1 }}>
{addLabel}
</Button>
{onAdd && (
<Button variant="contained" size="small" onClick={onAdd} sx={{ ml: 1 }}>
{addLabel}
</Button>
)}
</Box>

<Box sx={{ flex: 1, minHeight: 0 }}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const FinancePieChart: React.FC<FinancePieChartProps> = ({
available
}) => {
const [isLegendOpen, setIsLegendOpen] = useState(true);

const [sectionStates, setSectionStates] = useState([
{ title: 'Pending Approval', color: '#562016', expanded: false },
{ title: 'Approved', color: '#8e3c2d', expanded: false },
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Box } from '@mui/material';
import { useLocation, useHistory } from 'react-router-dom';
import { useState } from 'react';
import { useLocation, useHistory, useParams } from 'react-router-dom';
import { useState, useEffect } from 'react';
import { isGuest, ReimbursementRequest } from 'shared';
import { ReimbursementProduct, ReimbursementStatusType } from 'shared';
import {
Expand Down Expand Up @@ -48,7 +48,12 @@ const ReimbursementRequestInfo = ({
const user = useCurrentUser();
const history = useHistory();
const { pathname } = useLocation();
const [showSidePage, setShowSidePage] = useState(false);
const { id } = useParams<{ id?: string }>();
const [showSidePage, setShowSidePage] = useState(!!id);

useEffect(() => {
if (id) setShowSidePage(true);
}, [id]);

const displayedReimbursementRequests =
canViewAllReimbursementRequests && currentTab === 1 && allReimbursementRequests
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
247 changes: 247 additions & 0 deletions src/frontend/src/pages/ProjectPage/ProjectSpendingHistory.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
import React, { useMemo } from 'react';
import { Box, Typography, Link, LinearProgress } from '@mui/material';
import { GridColDef, GridRenderCellParams } from '@mui/x-data-grid';
import { useAllReimbursementRequests } from '../../hooks/finance.hooks';
import { useSingleProject } from '../../hooks/projects.hooks';
import { WbsNumber, ReimbursementRequest, WBSElementData, equalsWbsNumber, ReimbursementStatusType } from 'shared';
import LoadingIndicator from '../../components/LoadingIndicator';
import { createReimbursementRequestRowData, cleanReimbursementRequestStatus } from '../../utils/reimbursement-request.utils';
import NERDataGrid, { MapRowResult } from '../../components/NERDataGrid';
import { routes } from '../../utils/routes';
import { fullNamePipe, centsToDollar, datePipe } from '../../utils/pipes';

interface ProjectSpendingHistoryProps {
wbsNum: WbsNumber;
}

const getStatusColor = (status: ReimbursementStatusType): string => {
switch (status) {
case 'REIMBURSED':
return '#549d49';
case 'DENIED':
return '#dd514c';
case 'PENDING_FINANCE':
case 'SABO_SUBMITTED':
case 'PENDING_SABO_SUBMISSION':
case 'PENDING_LEADERSHIP_APPROVAL':
case 'LEADERSHIP_APPROVED':
case 'ADVISOR_APPROVED':
return '#997b3e';
default:
return '#797a7a';
}
};

const columns: GridColDef[] = [
{
field: 'identifier',
headerName: 'RR #',
flex: 0.4,
minWidth: 80,
renderCell: (params: GridRenderCellParams) => (
<Link
href={`${routes.REIMBURSEMENT_REQUESTS}/all-requests/${(params.row as any).reimbursementRequestId}`}
underline="hover"
color="primary"
onClick={(e) => e.stopPropagation()}
>
#{params.value}
</Link>
)
},
{
field: 'submitter',
headerName: 'Submitter',
flex: 1,
minWidth: 150,
valueGetter: (params: any) => fullNamePipe(params.row.submitter)
},
{
field: 'products',
headerName: 'Products',
flex: 2,
minWidth: 250,
valueGetter: (params: any) => params.row.reimbursementProducts?.map((p: any) => p.name).join(', ') || 'No products'
},
{
field: 'dateSubmitted',
headerName: 'Date Submitted',
flex: 0.7,
minWidth: 130,
valueGetter: (params: any) => datePipe(params.row.dateSubmitted)
},
{
field: 'status',
headerName: 'Status',
flex: 1,
minWidth: 220,
renderCell: (params: GridRenderCellParams) => (
<Box
sx={{
padding: '3px 8px',
display: 'inline-flex',
borderRadius: '8px',
backgroundColor: getStatusColor(params.value as ReimbursementStatusType),
fontWeight: 700,
whiteSpace: 'nowrap'
}}
>
{cleanReimbursementRequestStatus(params.value as ReimbursementStatusType)}
</Box>
)
},
{
field: 'amount',
headerName: 'Total Amount',
flex: 0.5,
minWidth: 110,
valueGetter: (params: any) => `$${centsToDollar(params.row.amount as number)}`
}
];

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) =>
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;
})
);
}, [allReimbursementRequests, project, wbsNum]);
Comment on lines +106 to +117
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 budgetInfo = useMemo(() => {
if (!project) return null;
const totalBudget = project.budget; // already in dollars
const totalSpent = reimbursementRequests.reduce((sum, rr) => sum + (rr.totalCost || 0), 0) / 100; // cents → dollars
const budgetRemaining = totalBudget - totalSpent;
const budgetUsedPercentage = totalBudget > 0 ? (totalSpent / totalBudget) * 100 : 0;
return {
totalBudget,
totalSpent,
budgetRemaining,
budgetUsedPercentage
};
}, [project, reimbursementRequests]);

const mapRow = (rr: ReimbursementRequest): MapRowResult<ReimbursementRequest> => {
const row = createReimbursementRequestRowData(rr);
return { ...rr, ...row, id: row.id, raw: rr };
};

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 && (
<Box sx={{ mb: 3 }}>
<Typography variant="h6" sx={{ mb: 1 }}>
Budget Overview
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="body2" color="textSecondary">
Spent: ${budgetInfo.totalSpent.toFixed(2)}
</Typography>
<Typography variant="body2" color="textSecondary">
Budget: ${budgetInfo.totalBudget.toFixed(2)}
</Typography>
</Box>
<Box
sx={{
position: 'relative',
border: '1px solid rgba(255,255,255,0.3)',
borderRadius: '6px',
overflow: 'hidden'
}}
>
<LinearProgress
variant="determinate"
value={Math.min(budgetInfo.budgetUsedPercentage, 100)}
sx={{
height: 28,
borderRadius: 0,
backgroundColor: 'rgba(255,255,255,0.1)',
'& .MuiLinearProgress-bar': {
borderRadius: 0,
backgroundColor:
budgetInfo.budgetUsedPercentage > 90
? '#f44336'
: budgetInfo.budgetUsedPercentage > 75
? '#ff9800'
: '#4caf50'
}
}}
/>
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none'
}}
>
<Typography
variant="caption"
sx={{ fontWeight: 'bold', color: 'white', textShadow: '0 1px 3px rgba(0,0,0,0.8)' }}
>
{budgetInfo.budgetUsedPercentage.toFixed(1)}% used
</Typography>
</Box>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 0.5 }}>
<Typography
variant="body2"
sx={{
color: budgetInfo.budgetRemaining >= 0 ? '#4caf50' : '#f44336',
fontWeight: 'bold'
}}
>
${Math.abs(budgetInfo.budgetRemaining).toFixed(2)}{' '}
{budgetInfo.budgetRemaining >= 0 ? 'remaining' : 'over budget'}
</Typography>
</Box>
</Box>
)}

<NERDataGrid
items={reimbursementRequests}
mapRow={mapRow}
columns={columns}
initialSortModel={[{ field: 'identifier', sort: 'desc' }]}
pageSizeDefault={reimbursementRequests.length || 10}
paperSx={{ height: 'calc(100vh - 400px)' }}
searchFilter={(term, row) => {
const q = term.toLowerCase();
const r = row as any;
return (
String(r.identifier).includes(q) ||
fullNamePipe(r.submitter).toLowerCase().includes(q) ||
(r.reimbursementProducts?.map((p: any) => p.name).join(', ') || '').toLowerCase().includes(q) ||
String(r.status).toLowerCase().includes(q)
);
}}
/>
</Box>
);
};

export default ProjectSpendingHistory;