Skip to content

Commit 5387479

Browse files
authored
Merge pull request #3638 from Northeastern-Electric-Racing/3604-maintenence---tracking-spending-on-project-overview
3604 maintenence tracking spending on project overview
2 parents 79105b3 + 6d9e8bc commit 5387479

5 files changed

Lines changed: 268 additions & 9 deletions

File tree

src/frontend/src/components/NERDataGrid.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ interface NERDataGridProps<T> {
1313
columns: GridColDef[];
1414
pageSizeDefault?: number;
1515
rowsPerPageOptions?: number[];
16-
onAdd: () => void;
16+
onAdd?: () => void;
1717
addLabel?: string; // optional label for the add/create button (defaults to 'Add')
1818
onRowClick?: (item: T) => void;
1919
// optional simple search fields (keys of mapped row) or a custom filter function
@@ -97,9 +97,11 @@ function NERDataGrid<T>({
9797
placeholder="Search"
9898
sx={{ flex: 1 }}
9999
/>
100-
<Button variant="contained" size="small" onClick={onAdd} sx={{ ml: 1 }}>
101-
{addLabel}
102-
</Button>
100+
{onAdd && (
101+
<Button variant="contained" size="small" onClick={onAdd} sx={{ ml: 1 }}>
102+
{addLabel}
103+
</Button>
104+
)}
103105
</Box>
104106

105107
<Box sx={{ flex: 1, minHeight: 0 }}>

src/frontend/src/pages/FinancePage/FinanceComponents/PieChart.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const FinancePieChart: React.FC<FinancePieChartProps> = ({
2424
available
2525
}) => {
2626
const [isLegendOpen, setIsLegendOpen] = useState(true);
27+
2728
const [sectionStates, setSectionStates] = useState([
2829
{ title: 'Pending Approval', color: '#562016', expanded: false },
2930
{ title: 'Approved', color: '#8e3c2d', expanded: false },

src/frontend/src/pages/FinancePage/FinanceComponents/ReimbursementRequestInfo.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Box } from '@mui/material';
2-
import { useLocation, useHistory } from 'react-router-dom';
3-
import { useState } from 'react';
2+
import { useLocation, useHistory, useParams } from 'react-router-dom';
3+
import { useState, useEffect } from 'react';
44
import { isGuest, ReimbursementRequest } from 'shared';
55
import { ReimbursementProduct, ReimbursementStatusType } from 'shared';
66
import {
@@ -48,7 +48,12 @@ const ReimbursementRequestInfo = ({
4848
const user = useCurrentUser();
4949
const history = useHistory();
5050
const { pathname } = useLocation();
51-
const [showSidePage, setShowSidePage] = useState(false);
51+
const { id } = useParams<{ id?: string }>();
52+
const [showSidePage, setShowSidePage] = useState(!!id);
53+
54+
useEffect(() => {
55+
if (id) setShowSidePage(true);
56+
}, [id]);
5257

5358
const displayedReimbursementRequests =
5459
canViewAllReimbursementRequests && currentTab === 1 && allReimbursementRequests

src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectViewContainer.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { useGetMaterialsForWbsElement } from '../../../hooks/bom.hooks';
3434
import ChangeRequestTab from '../../../components/ChangeRequestTab';
3535
import PartsReviewPage from './PartReview/PartsReviewPage';
3636
import ActionsMenu from '../../../components/ActionsMenu';
37+
import ProjectSpendingHistory from '../../ProjectPage/ProjectSpendingHistory';
3738
import { useMyTeamAsHead } from '../../../hooks/teams.hooks';
3839

3940
interface ProjectViewContainerProps {
@@ -193,7 +194,8 @@ const ProjectViewContainer: React.FC<ProjectViewContainerProps> = ({ project, en
193194
{ tabUrlValue: 'changes', tabName: 'Changes' },
194195
{ tabUrlValue: 'gantt', tabName: 'Gantt' },
195196
{ tabUrlValue: 'change-requests', tabName: 'Change Requests' },
196-
{ tabUrlValue: 'parts-review', tabName: 'Parts Review' }
197+
{ tabUrlValue: 'parts-review', tabName: 'Parts Review' },
198+
{ tabUrlValue: 'spending', tabName: 'Budget' }
197199
]}
198200
baseUrl={`${routes.PROJECTS}/${wbsNum}`}
199201
defaultTab="overview"
@@ -216,8 +218,10 @@ const ProjectViewContainer: React.FC<ProjectViewContainerProps> = ({ project, en
216218
<ProjectGantt workPackages={project.workPackages} />
217219
) : tab === 6 ? (
218220
<ChangeRequestTab wbsElement={project} />
219-
) : (
221+
) : tab === 7 ? (
220222
<PartsReviewPage project={project} />
223+
) : (
224+
<ProjectSpendingHistory wbsNum={project.wbsNum} />
221225
)}
222226
{deleteModalShow && (
223227
<DeleteProject modalShow={deleteModalShow} handleClose={handleDeleteClose} wbsNum={project.wbsNum} />
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
import React, { useMemo } from 'react';
2+
import { Box, Typography, Link, LinearProgress } from '@mui/material';
3+
import { GridColDef, GridRenderCellParams } from '@mui/x-data-grid';
4+
import { useAllReimbursementRequests } from '../../hooks/finance.hooks';
5+
import { useSingleProject } from '../../hooks/projects.hooks';
6+
import { WbsNumber, ReimbursementRequest, WBSElementData, equalsWbsNumber, ReimbursementStatusType } from 'shared';
7+
import LoadingIndicator from '../../components/LoadingIndicator';
8+
import { createReimbursementRequestRowData, cleanReimbursementRequestStatus } from '../../utils/reimbursement-request.utils';
9+
import NERDataGrid, { MapRowResult } from '../../components/NERDataGrid';
10+
import { routes } from '../../utils/routes';
11+
import { fullNamePipe, centsToDollar, datePipe } from '../../utils/pipes';
12+
13+
interface ProjectSpendingHistoryProps {
14+
wbsNum: WbsNumber;
15+
}
16+
17+
const getStatusColor = (status: ReimbursementStatusType): string => {
18+
switch (status) {
19+
case 'REIMBURSED':
20+
return '#549d49';
21+
case 'DENIED':
22+
return '#dd514c';
23+
case 'PENDING_FINANCE':
24+
case 'SABO_SUBMITTED':
25+
case 'PENDING_SABO_SUBMISSION':
26+
case 'PENDING_LEADERSHIP_APPROVAL':
27+
case 'LEADERSHIP_APPROVED':
28+
case 'ADVISOR_APPROVED':
29+
return '#997b3e';
30+
default:
31+
return '#797a7a';
32+
}
33+
};
34+
35+
const columns: GridColDef[] = [
36+
{
37+
field: 'identifier',
38+
headerName: 'RR #',
39+
flex: 0.4,
40+
minWidth: 80,
41+
renderCell: (params: GridRenderCellParams) => (
42+
<Link
43+
href={`${routes.REIMBURSEMENT_REQUESTS}/all-requests/${(params.row as any).reimbursementRequestId}`}
44+
underline="hover"
45+
color="primary"
46+
onClick={(e) => e.stopPropagation()}
47+
>
48+
#{params.value}
49+
</Link>
50+
)
51+
},
52+
{
53+
field: 'submitter',
54+
headerName: 'Submitter',
55+
flex: 1,
56+
minWidth: 150,
57+
valueGetter: (params: any) => fullNamePipe(params.row.submitter)
58+
},
59+
{
60+
field: 'products',
61+
headerName: 'Products',
62+
flex: 2,
63+
minWidth: 250,
64+
valueGetter: (params: any) => params.row.reimbursementProducts?.map((p: any) => p.name).join(', ') || 'No products'
65+
},
66+
{
67+
field: 'dateSubmitted',
68+
headerName: 'Date Submitted',
69+
flex: 0.7,
70+
minWidth: 130,
71+
valueGetter: (params: any) => datePipe(params.row.dateSubmitted)
72+
},
73+
{
74+
field: 'status',
75+
headerName: 'Status',
76+
flex: 1,
77+
minWidth: 220,
78+
renderCell: (params: GridRenderCellParams) => (
79+
<Box
80+
sx={{
81+
padding: '3px 8px',
82+
display: 'inline-flex',
83+
borderRadius: '8px',
84+
backgroundColor: getStatusColor(params.value as ReimbursementStatusType),
85+
fontWeight: 700,
86+
whiteSpace: 'nowrap'
87+
}}
88+
>
89+
{cleanReimbursementRequestStatus(params.value as ReimbursementStatusType)}
90+
</Box>
91+
)
92+
},
93+
{
94+
field: 'amount',
95+
headerName: 'Total Amount',
96+
flex: 0.5,
97+
minWidth: 110,
98+
valueGetter: (params: any) => `$${centsToDollar(params.row.amount as number)}`
99+
}
100+
];
101+
102+
const ProjectSpendingHistory: React.FC<ProjectSpendingHistoryProps> = ({ wbsNum }) => {
103+
const { data: allReimbursementRequests, isLoading: rrLoading, isError: rrError } = useAllReimbursementRequests();
104+
const { data: project, isLoading: projectLoading } = useSingleProject(wbsNum);
105+
106+
const reimbursementRequests = useMemo(() => {
107+
if (!allReimbursementRequests || !project) return [];
108+
return allReimbursementRequests.filter((rr) =>
109+
rr.reimbursementProducts.some((product) => {
110+
const reason = product.reimbursementProductReason;
111+
if ((reason as WBSElementData).wbsNum) {
112+
return equalsWbsNumber((reason as WBSElementData).wbsNum, { ...wbsNum, workPackageNumber: 0 });
113+
}
114+
return false;
115+
})
116+
);
117+
}, [allReimbursementRequests, project, wbsNum]);
118+
119+
const budgetInfo = useMemo(() => {
120+
if (!project) return null;
121+
const totalBudget = project.budget; // already in dollars
122+
const totalSpent = reimbursementRequests.reduce((sum, rr) => sum + (rr.totalCost || 0), 0) / 100; // cents → dollars
123+
const budgetRemaining = totalBudget - totalSpent;
124+
const budgetUsedPercentage = totalBudget > 0 ? (totalSpent / totalBudget) * 100 : 0;
125+
return {
126+
totalBudget,
127+
totalSpent,
128+
budgetRemaining,
129+
budgetUsedPercentage
130+
};
131+
}, [project, reimbursementRequests]);
132+
133+
const mapRow = (rr: ReimbursementRequest): MapRowResult<ReimbursementRequest> => {
134+
const row = createReimbursementRequestRowData(rr);
135+
return { ...rr, ...row, id: row.id, raw: rr };
136+
};
137+
138+
const isLoading = rrLoading || projectLoading;
139+
140+
if (isLoading) return <LoadingIndicator />;
141+
if (rrError) return <Typography color="error">Failed to load spending history.</Typography>;
142+
if (!reimbursementRequests.length) return <Typography>No spending history for this project.</Typography>;
143+
144+
return (
145+
<Box sx={{ mt: 4 }}>
146+
<Typography variant="h5" sx={{ color: 'primary.main', fontWeight: 'bold', mb: 3 }}>
147+
Spending History
148+
</Typography>
149+
150+
{budgetInfo && (
151+
<Box sx={{ mb: 3 }}>
152+
<Typography variant="h6" sx={{ mb: 1 }}>
153+
Budget Overview
154+
</Typography>
155+
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
156+
<Typography variant="body2" color="textSecondary">
157+
Spent: ${budgetInfo.totalSpent.toFixed(2)}
158+
</Typography>
159+
<Typography variant="body2" color="textSecondary">
160+
Budget: ${budgetInfo.totalBudget.toFixed(2)}
161+
</Typography>
162+
</Box>
163+
<Box
164+
sx={{
165+
position: 'relative',
166+
border: '1px solid rgba(255,255,255,0.3)',
167+
borderRadius: '6px',
168+
overflow: 'hidden'
169+
}}
170+
>
171+
<LinearProgress
172+
variant="determinate"
173+
value={Math.min(budgetInfo.budgetUsedPercentage, 100)}
174+
sx={{
175+
height: 28,
176+
borderRadius: 0,
177+
backgroundColor: 'rgba(255,255,255,0.1)',
178+
'& .MuiLinearProgress-bar': {
179+
borderRadius: 0,
180+
backgroundColor:
181+
budgetInfo.budgetUsedPercentage > 90
182+
? '#f44336'
183+
: budgetInfo.budgetUsedPercentage > 75
184+
? '#ff9800'
185+
: '#4caf50'
186+
}
187+
}}
188+
/>
189+
<Box
190+
sx={{
191+
position: 'absolute',
192+
top: 0,
193+
left: 0,
194+
right: 0,
195+
bottom: 0,
196+
display: 'flex',
197+
alignItems: 'center',
198+
justifyContent: 'center',
199+
pointerEvents: 'none'
200+
}}
201+
>
202+
<Typography
203+
variant="caption"
204+
sx={{ fontWeight: 'bold', color: 'white', textShadow: '0 1px 3px rgba(0,0,0,0.8)' }}
205+
>
206+
{budgetInfo.budgetUsedPercentage.toFixed(1)}% used
207+
</Typography>
208+
</Box>
209+
</Box>
210+
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 0.5 }}>
211+
<Typography
212+
variant="body2"
213+
sx={{
214+
color: budgetInfo.budgetRemaining >= 0 ? '#4caf50' : '#f44336',
215+
fontWeight: 'bold'
216+
}}
217+
>
218+
${Math.abs(budgetInfo.budgetRemaining).toFixed(2)}{' '}
219+
{budgetInfo.budgetRemaining >= 0 ? 'remaining' : 'over budget'}
220+
</Typography>
221+
</Box>
222+
</Box>
223+
)}
224+
225+
<NERDataGrid
226+
items={reimbursementRequests}
227+
mapRow={mapRow}
228+
columns={columns}
229+
initialSortModel={[{ field: 'identifier', sort: 'desc' }]}
230+
pageSizeDefault={reimbursementRequests.length || 10}
231+
paperSx={{ height: 'calc(100vh - 400px)' }}
232+
searchFilter={(term, row) => {
233+
const q = term.toLowerCase();
234+
const r = row as any;
235+
return (
236+
String(r.identifier).includes(q) ||
237+
fullNamePipe(r.submitter).toLowerCase().includes(q) ||
238+
(r.reimbursementProducts?.map((p: any) => p.name).join(', ') || '').toLowerCase().includes(q) ||
239+
String(r.status).toLowerCase().includes(q)
240+
);
241+
}}
242+
/>
243+
</Box>
244+
);
245+
};
246+
247+
export default ProjectSpendingHistory;

0 commit comments

Comments
 (0)