Skip to content

Commit da0f15a

Browse files
authored
Merge pull request #2687 from Northeastern-Electric-Racing/Leadership-Approval-Reimbursement-Status
Add Leadership Approval Mechanism
2 parents 5731362 + 52cb530 commit da0f15a

11 files changed

Lines changed: 174 additions & 9 deletions

File tree

src/backend/src/controllers/reimbursement-requests.controllers.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,23 @@ export default class ReimbursementRequestsController {
295295
}
296296
}
297297

298+
static async leadershipApproveReimbursementRequest(req: Request, res: Response, next: NextFunction) {
299+
try {
300+
const { requestId } = req.params;
301+
const user = await getCurrentUser(res);
302+
const organizationId = getOrganizationId(req.headers);
303+
304+
const reimbursementStatus = await ReimbursementRequestService.leadershipApproveReimbursementRequest(
305+
requestId,
306+
user,
307+
organizationId
308+
);
309+
res.status(200).json(reimbursementStatus);
310+
} catch (error: unknown) {
311+
next(error);
312+
}
313+
}
314+
298315
static async approveReimbursementRequest(req: Request, res: Response, next: NextFunction) {
299316
try {
300317
const { requestId } = req.params;
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterEnum
2+
ALTER TYPE "Reimbursement_Status_Type" ADD VALUE 'PENDING_LEADERSHIP_APPROVAL';

src/backend/src/prisma/schema.prisma

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ enum Club_Accounts {
7777
}
7878

7979
enum Reimbursement_Status_Type {
80+
PENDING_LEADERSHIP_APPROVAL
8081
PENDING_FINANCE
8182
SABO_SUBMITTED
8283
ADVISOR_APPROVED
@@ -424,12 +425,12 @@ model Link {
424425
linkType Link_Type @relation(name: "linkTypes", fields: [linkTypeId], references: [id])
425426
426427
// Either its on a wbsElement, a wbsProposedChanges, or an organization
427-
wbsElementId String?
428-
wbsElment WBS_Element? @relation(name: "links", fields: [wbsElementId], references: [wbsElementId])
429-
wbsProposedChangesId String?
430-
wbsProposedChanges Wbs_Proposed_Changes? @relation(name: "proposedChangeLinks", fields: [wbsProposedChangesId], references: [wbsProposedChangesId])
431-
organization Organization? @relation(fields: [organizationId], references: [organizationId])
432-
organizationId String?
428+
wbsElementId String?
429+
wbsElment WBS_Element? @relation(name: "links", fields: [wbsElementId], references: [wbsElementId])
430+
wbsProposedChangesId String?
431+
wbsProposedChanges Wbs_Proposed_Changes? @relation(name: "proposedChangeLinks", fields: [wbsProposedChangesId], references: [wbsProposedChangesId])
432+
organization Organization? @relation(fields: [organizationId], references: [organizationId])
433+
organizationId String?
433434
}
434435

435436
model Description_Bullet_Type {

src/backend/src/routes/reimbursement-requests.routes.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,10 @@ reimbursementRequestsRouter.post(
137137
);
138138

139139
reimbursementRequestsRouter.post('/:requestId/approve', ReimbursementRequestController.approveReimbursementRequest);
140+
reimbursementRequestsRouter.post(
141+
'/:requestId/leadership-approve',
142+
ReimbursementRequestController.leadershipApproveReimbursementRequest
143+
);
140144
reimbursementRequestsRouter.delete('/:requestId/delete', ReimbursementRequestController.deleteReimbursementRequest);
141145
reimbursementRequestsRouter.post('/:requestId/deny', ReimbursementRequestController.denyReimbursementRequest);
142146

src/backend/src/services/reimbursement-requests.services.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ export default class ReimbursementRequestService {
171171
totalCost,
172172
reimbursementStatuses: {
173173
create: {
174-
type: ReimbursementStatusType.PENDING_FINANCE,
174+
type: ReimbursementStatusType.PENDING_LEADERSHIP_APPROVAL,
175175
userId: recipient.userId
176176
}
177177
},
@@ -847,6 +847,53 @@ export default class ReimbursementRequestService {
847847
return reimbursementRequestTransformer(reimbursementRequest);
848848
}
849849

850+
/**
851+
* Adds a reimbursement status with type pending finance to the given reimbursement request
852+
*
853+
* @param reimbursementRequestId The id of the reimbursement request to approve
854+
* @param submitter The person approving the reimbursement request
855+
* @param organizationId The organization the user is currently in
856+
* @returns The Pending Finance reimbursement status
857+
*/
858+
static async leadershipApproveReimbursementRequest(
859+
reimbursementRequestId: string,
860+
submitter: User,
861+
organizationId: string
862+
) {
863+
if (!(await userHasPermission(submitter.userId, organizationId, isHead)))
864+
throw new AccessDeniedException('Only a head or admin can approve reimbursement requests');
865+
866+
const reimbursementRequest = await prisma.reimbursement_Request.findUnique({
867+
where: { reimbursementRequestId },
868+
include: {
869+
reimbursementStatuses: true
870+
}
871+
});
872+
873+
if (!reimbursementRequest) throw new NotFoundException('Reimbursement Request', reimbursementRequestId);
874+
if (reimbursementRequest.dateDeleted) throw new DeletedException('Reimbursement Request', reimbursementRequestId);
875+
if (reimbursementRequest.organizationId !== organizationId)
876+
throw new InvalidOrganizationException('Reimbursement Request');
877+
878+
if (
879+
reimbursementRequest.reimbursementStatuses.some(
880+
(reimbursementStatus) => reimbursementStatus.type === Reimbursement_Status_Type.PENDING_FINANCE
881+
)
882+
)
883+
throw new HttpException(400, 'This reimbursement request has already been approved by leadership');
884+
885+
const reimbursementStatus = await prisma.reimbursement_Status.create({
886+
data: {
887+
type: ReimbursementStatusType.PENDING_FINANCE,
888+
userId: submitter.userId,
889+
reimbursementRequestId: reimbursementRequest.reimbursementRequestId
890+
},
891+
...getReimbursementStatusQueryArgs(organizationId)
892+
});
893+
894+
return reimbursementStatusTransformer(reimbursementStatus);
895+
}
896+
850897
/**
851898
* Adds a reimbursement status with type sabo submitted to the given reimbursement request
852899
*
@@ -872,6 +919,11 @@ export default class ReimbursementRequestService {
872919
if (reimbursementRequest.organizationId !== organizationId)
873920
throw new InvalidOrganizationException('Reimbursement Request');
874921

922+
if (
923+
!reimbursementRequest.reimbursementStatuses.some((status) => status.type === ReimbursementStatusType.PENDING_FINANCE)
924+
) {
925+
throw new HttpException(400, 'This reimbursement request has not been approved by leadership');
926+
}
875927
if (
876928
reimbursementRequest.reimbursementStatuses.some((status) => status.type === ReimbursementStatusType.SABO_SUBMITTED)
877929
) {

src/frontend/src/apis/finance.api.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,16 @@ export const approveReimbursementRequest = (id: string) => {
169169
return axios.post(apiUrls.financeApproveReimbursementRequest(id));
170170
};
171171

172+
/**
173+
* Leadership approve Reimbursement Request (set it to Pending Finance)
174+
*
175+
* @param id of the reimbursement request being approved by finance
176+
* @returns the pending finance reimbursement status
177+
*/
178+
export const leadershipApproveReimbursementRequest = (id: string) => {
179+
return axios.post(apiUrls.financeLeadershipApprove(id));
180+
};
181+
172182
/**
173183
* Deny Reimbursement Request
174184
*

src/frontend/src/hooks/finance.hooks.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ import {
2828
createAccountCode,
2929
createVendor,
3030
editVendor,
31-
getAllAccountCodes
31+
getAllAccountCodes,
32+
leadershipApproveReimbursementRequest
3233
} from '../apis/finance.api';
3334
import {
3435
ClubAccount,
@@ -315,6 +316,28 @@ export const useApproveReimbursementRequest = (id: string) => {
315316
);
316317
};
317318

319+
/**
320+
* Custom react hook to approve a reimbursement request for the leadership team
321+
*
322+
* @param id id of the reimbursement request to approve
323+
* @returns the created pending finance reimbursement status
324+
*/
325+
export const useLeadershipApproveReimbursementRequest = (id: string) => {
326+
const queryClient = useQueryClient();
327+
return useMutation<ReimbursementStatus, Error>(
328+
['reimbursement-requests', 'edit'],
329+
async () => {
330+
const { data } = await leadershipApproveReimbursementRequest(id);
331+
return data;
332+
},
333+
{
334+
onSuccess: () => {
335+
queryClient.invalidateQueries(['reimbursement-requests', id]);
336+
}
337+
}
338+
);
339+
};
340+
318341
/**
319342
* Custom react hook to deny a reimbursement request for the finance team
320343
*

src/frontend/src/pages/FinancePage/ReimbursementRequestDetailPage/ReimbursementRequestDetailsView.tsx

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,15 @@ import { Grid, Typography, useTheme, Link, IconButton } from '@mui/material';
1515
import { Box } from '@mui/system';
1616
import { useState } from 'react';
1717
import { useHistory } from 'react-router-dom';
18-
import { ReimbursementRequest } from 'shared';
18+
import { ReimbursementRequest, isHead } from 'shared';
1919
import ActionsMenu, { ButtonInfo } from '../../../components/ActionsMenu';
2020
import NERModal from '../../../components/NERModal';
2121
import PageLayout from '../../../components/PageLayout';
2222
import VerticalDetailDisplay from '../../../components/VerticalDetailDisplay';
2323
import {
2424
useDeleteReimbursementRequest,
2525
useDenyReimbursementRequest,
26+
useLeadershipApproveReimbursementRequest,
2627
useMarkReimbursementRequestAsDelivered,
2728
useMarkReimbursementRequestAsReimbursed
2829
} from '../../../hooks/finance.hooks';
@@ -64,6 +65,7 @@ const ReimbursementRequestDetailsView: React.FC<ReimbursementRequestDetailsViewP
6465
const toast = useToast();
6566
const [showDeleteModal, setShowDeleteModal] = useState(false);
6667
const [showDenyModal, setShowDenyModal] = useState(false);
68+
const [showLeadershipApproveModal, setShowLeadershipApproveModal] = useState(false);
6769
const [showMarkDelivered, setShowMarkDelivered] = useState(false);
6870
const [showMarkReimbursed, setShowMarkReimbursed] = useState(false);
6971
const [showSubmitToSaboModal, setShowSubmitToSaboModal] = useState(false);
@@ -75,7 +77,12 @@ const ReimbursementRequestDetailsView: React.FC<ReimbursementRequestDetailsViewP
7577
const { mutateAsync: markReimbursed } = useMarkReimbursementRequestAsReimbursed(
7678
reimbursementRequest.reimbursementRequestId
7779
);
80+
const { mutateAsync: leadershipApproveReimbursementRequest } = useLeadershipApproveReimbursementRequest(
81+
reimbursementRequest.reimbursementRequestId
82+
);
83+
7884
const isSaboSubmitted = isReimbursementRequestSaboSubmitted(reimbursementRequest);
85+
const isLeadershipApproved = isReimbursementRequestAdvisorApproved(reimbursementRequest);
7986

8087
const handleDelete = async () => {
8188
try {
@@ -121,6 +128,17 @@ const ReimbursementRequestDetailsView: React.FC<ReimbursementRequestDetailsViewP
121128
}
122129
};
123130

131+
const handleLeadershipApprove = async () => {
132+
try {
133+
await leadershipApproveReimbursementRequest();
134+
setShowLeadershipApproveModal(false);
135+
} catch (e: unknown) {
136+
if (e instanceof Error) {
137+
toast.error(e.message, 3000);
138+
}
139+
}
140+
};
141+
124142
const DeleteModal = () => {
125143
return (
126144
<NERModal
@@ -151,6 +169,21 @@ const ReimbursementRequestDetailsView: React.FC<ReimbursementRequestDetailsViewP
151169
);
152170
};
153171

172+
const LeadershipApproveModal = () => {
173+
return (
174+
<NERModal
175+
open={showLeadershipApproveModal}
176+
onHide={() => setShowLeadershipApproveModal(false)}
177+
title="Warning!"
178+
cancelText="No"
179+
submitText="Yes"
180+
onSubmit={handleLeadershipApprove}
181+
>
182+
<Typography>Are you sure you want to approve this reimbursement request?</Typography>
183+
</NERModal>
184+
);
185+
};
186+
154187
const MarkDeliveredModal = () => (
155188
<NERModal
156189
open={showMarkDelivered}
@@ -303,6 +336,16 @@ const ReimbursementRequestDetailsView: React.FC<ReimbursementRequestDetailsViewP
303336
icon: isSaboSubmitted ? <Assignment /> : <CheckIcon />,
304337
disabled: !user.isFinance
305338
},
339+
{
340+
title: 'Leadership Approve',
341+
onClick: () => setShowLeadershipApproveModal(true),
342+
icon: <CheckIcon />,
343+
disabled:
344+
!isHead(user.role) ||
345+
isReimbursementRequestDenied(reimbursementRequest) ||
346+
isReimbursementRequestReimbursed(reimbursementRequest) ||
347+
isLeadershipApproved
348+
},
306349
{
307350
title: 'Deny',
308351
onClick: () => setShowDenyModal(true),
@@ -337,6 +380,7 @@ const ReimbursementRequestDetailsView: React.FC<ReimbursementRequestDetailsViewP
337380
<DenyModal />
338381
<MarkDeliveredModal />
339382
<MarkReimbursedModal />
383+
<LeadershipApproveModal />
340384
<SubmitToSaboModal
341385
open={showSubmitToSaboModal}
342386
setOpen={setShowSubmitToSaboModal}

src/frontend/src/utils/reimbursement-request.utils.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export const descendingComparator = <T>(a: T, b: T, orderBy: keyof T) => {
4545

4646
export const statusDescendingComparator = (a: ReimbursementStatusType, b: ReimbursementStatusType) => {
4747
const statusOrder = new Map<ReimbursementStatusType, number>([
48+
[ReimbursementStatusType.PENDING_LEADERSHIP_APPROVAL, 0],
4849
[ReimbursementStatusType.PENDING_FINANCE, 1],
4950
[ReimbursementStatusType.SABO_SUBMITTED, 2],
5051
[ReimbursementStatusType.ADVISOR_APPROVED, 3],
@@ -123,6 +124,8 @@ export const cleanReimbursementRequestStatus = (status: ReimbursementStatusType)
123124
case ReimbursementStatusType.DENIED: {
124125
return 'Denied';
125126
}
127+
default:
128+
return 'Pending Leadership Approval';
126129
}
127130
};
128131

@@ -142,6 +145,12 @@ export const isReimbursementRequestSaboSubmitted = (reimbursementRequest: Reimbu
142145
.includes(ReimbursementStatusType.SABO_SUBMITTED);
143146
};
144147

148+
export const isReimbursementRequestLeadershipApproved = (reimbursementRequest: ReimbursementRequest) => {
149+
return !reimbursementRequest.reimbursementStatuses
150+
.map((status) => status.type)
151+
.includes(ReimbursementStatusType.PENDING_FINANCE);
152+
};
153+
145154
export const isReimbursementRequestDenied = (reimbursementRequest: ReimbursementRequest) => {
146155
return reimbursementRequest.reimbursementStatuses.map((status) => status.type).includes(ReimbursementStatusType.DENIED);
147156
};

src/frontend/src/utils/urls.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ const financeEditAccountCode = (accountCodeId: string) => `${getAllAccountCodes(
122122
const financeCreateAccountCode = () => `${getAllAccountCodes()}/create`;
123123
const financeCreateVendor = () => `${financeEndpoints()}/vendors/create`;
124124
const financeEditVendor = (vendorId: string) => `${financeEndpoints()}/${vendorId}/vendors/edit`;
125+
const financeLeadershipApprove = (id: string) => `${financeEndpoints()}/${id}/leadership-approve`;
125126

126127
/**************** Bill of Material Endpoints **************************/
127128
const bomEndpoints = () => `${API_URL}/projects/bom`;
@@ -270,6 +271,7 @@ export const apiUrls = {
270271
financeCreateAccountCode,
271272
financeCreateVendor,
272273
financeEditVendor,
274+
financeLeadershipApprove,
273275

274276
bomEndpoints,
275277
bomGetMaterialsByWbsNum,

0 commit comments

Comments
 (0)