Skip to content

Commit 9602027

Browse files
authored
Merge pull request #3736 from Northeastern-Electric-Racing/prospective-sponsors
Company Table Redesigns
2 parents ba2254a + d5dfd95 commit 9602027

33 files changed

Lines changed: 685 additions & 729 deletions

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,25 @@ export default class ReimbursementRequestsController {
497497
}
498498
}
499499

500+
static async setVendorTaxExemptStatus(req: Request, res: Response, next: NextFunction) {
501+
try {
502+
const { vendorId } = req.params;
503+
504+
const { taxExempt } = req.body;
505+
506+
const updatedVendor = await ReimbursementRequestService.setVendorTaxExemptStatus(
507+
vendorId,
508+
taxExempt,
509+
req.currentUser,
510+
req.organization
511+
);
512+
513+
res.status(200).json(updatedVendor);
514+
} catch (error) {
515+
next(error);
516+
}
517+
}
518+
500519
static async deleteVendor(req: Request, res: Response, next: NextFunction) {
501520
try {
502521
const { vendorId } = req.params;

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,11 @@ reimbursementRequestsRouter.post(
101101
ReimbursementRequestController.editVendor
102102
);
103103

104+
reimbursementRequestsRouter.post(
105+
'/vendors/:vendorId/setTaxExemptStatus',
106+
ReimbursementRequestController.setVendorTaxExemptStatus
107+
);
108+
104109
reimbursementRequestsRouter.post('/:vendorId/vendors/delete', ReimbursementRequestController.deleteVendor);
105110

106111
reimbursementRequestsRouter.post(
@@ -167,7 +172,7 @@ reimbursementRequestsRouter.post(
167172
body('taxExempt').optional().isBoolean(),
168173
body('twoFactorContacts').optional().isArray(),
169174
nonEmptyString(body('twoFactorContacts.*')),
170-
nonEmptyString(body('notes')).optional(),
175+
body('notes').optional().isString(),
171176
validateInputs,
172177
ReimbursementRequestController.createVendor
173178
);

src/backend/src/services/finance.services.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1096,15 +1096,20 @@ export default class FinanceServices {
10961096

10971097
if (!tier) throw new NotFoundException('Sponsor Tier', sponsorTierId);
10981098

1099-
const existingSponsor = await prisma.sponsor.findFirst({
1100-
where: {
1101-
name,
1102-
organizationId: organization.organizationId
1103-
}
1104-
});
1099+
if (name !== oldSponsor.name) {
1100+
const existingSponsor = await prisma.sponsor.findFirst({
1101+
where: {
1102+
name: {
1103+
equals: name,
1104+
mode: 'insensitive'
1105+
},
1106+
organizationId: organization.organizationId
1107+
}
1108+
});
11051109

1106-
if (existingSponsor) {
1107-
throw new HttpException(400, `A sponsor with the name "${name}" already exists.`);
1110+
if (existingSponsor) {
1111+
throw new HttpException(400, `A sponsor with the name "${name}" already exists.`);
1112+
}
11081113
}
11091114

11101115
const updatedSponsor = await prisma.sponsor.update({

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1544,6 +1544,42 @@ export default class ReimbursementRequestService {
15441544
return vendorTransformer(vendor);
15451545
}
15461546

1547+
static async setVendorTaxExemptStatus(
1548+
vendorId: string,
1549+
taxExempt: boolean,
1550+
submitter: User,
1551+
organization: Organization
1552+
): Promise<Vendor> {
1553+
const existingVendor = await prisma.vendor.findUnique({
1554+
where: { vendorId, dateDeleted: null },
1555+
include: { twoFactorContacts: { select: { userId: true } } }
1556+
});
1557+
1558+
if (!existingVendor) {
1559+
throw new NotFoundException('Vendor', vendorId);
1560+
}
1561+
1562+
if (existingVendor.organizationId !== organization.organizationId) {
1563+
throw new InvalidOrganizationException('Vendor');
1564+
}
1565+
1566+
const isUserAuthorized =
1567+
existingVendor.addedByUserId === submitter.userId ||
1568+
(await isUserOnFinanceTeam(submitter, organization.organizationId)) ||
1569+
(await userHasPermission(submitter.userId, organization.organizationId, isHead));
1570+
if (!isUserAuthorized) {
1571+
throw new AccessDeniedException(`You are not a member of the finance team!`);
1572+
}
1573+
1574+
const updatedVendor = await prisma.vendor.update({
1575+
where: { vendorId },
1576+
data: { taxExempt },
1577+
...getVendorQueryArgs(organization.organizationId)
1578+
});
1579+
1580+
return vendorTransformer(updatedVendor);
1581+
}
1582+
15471583
/**
15481584
* Deletes the vendor
15491585
*

src/backend/tests/unmocked/reimbursement-requests.test.ts

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { alfred, robinMember, cyborgMember, theVisitorGuest } from '../test-data/users.test-data';
22
import ReimbursementRequestService from '../../src/services/reimbursement-requests.services';
3-
import { AccessDeniedException, DeletedException, HttpException, NotFoundException } from '../../src/utils/errors.utils';
3+
import {
4+
AccessDeniedException,
5+
DeletedException,
6+
HttpException,
7+
InvalidOrganizationException,
8+
NotFoundException
9+
} from '../../src/utils/errors.utils';
410
import { createTestReimbursementRequest, createTestUser, resetUsers } from '../test-utils';
511
import prisma from '../../src/prisma/prisma';
612
import { addDaysToDate, IndexCode, ReimbursementRequest, ReimbursementStatusType, AccountCode } from 'shared';
@@ -1041,4 +1047,82 @@ describe('Reimbursement Requests', () => {
10411047
expect(assignedRRs).toEqual([]);
10421048
});
10431049
});
1050+
1051+
describe('Set vendor tax exempt status', () => {
1052+
test('Finance member can set vendor tax exempt status', async () => {
1053+
const updatedVendor = await ReimbursementRequestService.setVendorTaxExemptStatus(
1054+
createdVendor.vendorId,
1055+
true,
1056+
financeMember,
1057+
org
1058+
);
1059+
1060+
expect(updatedVendor).not.toBeNull();
1061+
expect(updatedVendor.taxExempt).toBe(true);
1062+
});
1063+
1064+
test('Non-finance member cannot set vendor tax exempt status', async () => {
1065+
await expect(
1066+
ReimbursementRequestService.setVendorTaxExemptStatus(createdVendor.vendorId, true, regularMember, org)
1067+
).rejects.toThrow(new AccessDeniedException('You are not a member of the finance team!'));
1068+
});
1069+
1070+
test('Cannot set tax exempt status for non-existent vendor', async () => {
1071+
await expect(
1072+
ReimbursementRequestService.setVendorTaxExemptStatus('non-existent-id', true, financeMember, org)
1073+
).rejects.toThrow(new NotFoundException('Vendor', 'non-existent-id'));
1074+
});
1075+
1076+
test('Cannot set tax exempt status for vendor in different organization', async () => {
1077+
// Create a vendor in a different organization
1078+
const otherOrg = await prisma.organization.create({
1079+
data: {
1080+
name: 'Other Org',
1081+
userCreated: { connect: { userId: financeHead.userId } }
1082+
}
1083+
});
1084+
1085+
const otherMember: User = await prisma.user.create({
1086+
data: {
1087+
firstName: 'Other',
1088+
lastName: 'Member',
1089+
googleAuthId: '99',
1090+
email: 'email@email.other',
1091+
roles: {
1092+
create: {
1093+
roleType: Role_Type.MEMBER,
1094+
organization: {
1095+
connect: { organizationId: otherOrg.organizationId }
1096+
}
1097+
}
1098+
}
1099+
}
1100+
});
1101+
1102+
const otherVendor = await ReimbursementRequestService.createVendor(
1103+
otherMember,
1104+
'Other Org Vendor',
1105+
otherOrg,
1106+
false,
1107+
[],
1108+
'Some notes'
1109+
);
1110+
1111+
await expect(
1112+
ReimbursementRequestService.setVendorTaxExemptStatus(otherVendor.vendorId, true, financeMember, org)
1113+
).rejects.toThrow(new InvalidOrganizationException('Vendor'));
1114+
});
1115+
1116+
test('head can set vendor tax exempt status', async () => {
1117+
const updatedVendor = await ReimbursementRequestService.setVendorTaxExemptStatus(
1118+
createdVendor.vendorId,
1119+
true,
1120+
financeHead,
1121+
org
1122+
);
1123+
1124+
expect(updatedVendor).not.toBeNull();
1125+
expect(updatedVendor.taxExempt).toBe(true);
1126+
});
1127+
});
10441128
});

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,16 @@ export const editVendor = async (id: string, vendorData: EditVendorPayload) => {
441441
return axios.post(apiUrls.financeEditVendor(id), vendorData);
442442
};
443443

444+
/**
445+
* API call to set the tax exempt status of a vendor
446+
* @param vendorId id of vendor to set
447+
* @param taxExempt whether the vendor is tax exempt
448+
* @returns updated vendor
449+
*/
450+
export const setTaxExemptStatus = async (vendorId: string, taxExempt: boolean) => {
451+
return axios.post(apiUrls.financeSetVendorTaxExemptStatus(vendorId), { taxExempt });
452+
};
453+
444454
/**
445455
* API call to delete a given vendor
446456
*
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import React, { useMemo, useState } from 'react';
2+
import { DataGrid, GridColDef, GridRowParams } from '@mui/x-data-grid';
3+
import { Box, Button, Paper, TextField } from '@mui/material';
4+
import type { SxProps } from '@mui/system';
5+
import type { Theme } from '@mui/material/styles';
6+
7+
export type MapRowResult<T> = T & { id: string | number; raw?: T };
8+
9+
interface NERDataGridProps<T> {
10+
items: T[];
11+
// map an item to a row object (must include `id` and may include `raw`)
12+
mapRow: (item: T) => MapRowResult<T>;
13+
columns: GridColDef[];
14+
pageSizeDefault?: number;
15+
rowsPerPageOptions?: number[];
16+
onAdd: () => void;
17+
onRowClick?: (item: T) => void;
18+
// optional simple search fields (keys of mapped row) or a custom filter function
19+
searchFields?: (keyof MapRowResult<T>)[];
20+
searchFilter?: (term: string, row: MapRowResult<T>) => boolean;
21+
// optional sort model to apply initially
22+
initialSortModel?: { field: string; sort: 'asc' | 'desc' }[];
23+
headerHeight?: number;
24+
rowHeight?: number;
25+
paperSx?: SxProps<Theme>;
26+
canEditRow?: (row: MapRowResult<T>) => boolean;
27+
}
28+
29+
function NERDataGrid<T>({
30+
items,
31+
mapRow,
32+
columns,
33+
pageSizeDefault = 10,
34+
rowsPerPageOptions,
35+
onAdd,
36+
onRowClick,
37+
searchFields,
38+
searchFilter,
39+
initialSortModel = [{ field: 'name', sort: 'asc' }],
40+
headerHeight = 56,
41+
rowHeight = 52,
42+
paperSx,
43+
canEditRow
44+
}: NERDataGridProps<T>) {
45+
const [searchTerm, setSearchTerm] = useState('');
46+
const [pageSize, setPageSize] = useState<number>(pageSizeDefault);
47+
48+
// compute standard pagination options if caller didn't pass any
49+
const effectiveRowsPerPageOptions = useMemo(() => {
50+
const base = [5, 10, 25, 50, 100];
51+
const itemsCount = (items ?? []).length;
52+
if (rowsPerPageOptions && rowsPerPageOptions.length > 0) {
53+
const provided = Array.from(new Set(rowsPerPageOptions))
54+
.sort((a, b) => a - b)
55+
.filter((opt) => opt <= itemsCount);
56+
if (provided.length > 0) return provided;
57+
return itemsCount > 0 ? [itemsCount] : base;
58+
}
59+
60+
// default behavior: include standard options up to the total number of items
61+
if (itemsCount === 0) return base;
62+
const opts = base.filter((opt) => opt <= itemsCount);
63+
if (itemsCount < 100 && !opts.includes(itemsCount)) opts.push(itemsCount);
64+
return Array.from(new Set(opts)).sort((a, b) => a - b);
65+
}, [rowsPerPageOptions, items]);
66+
67+
const rows = useMemo(() => items.map(mapRow), [items, mapRow]);
68+
69+
const filteredRows = useMemo(() => {
70+
const term = searchTerm.trim().toLowerCase();
71+
if (!term) return rows;
72+
if (searchFilter) return rows.filter((r) => searchFilter(term, r));
73+
if (searchFields && searchFields.length > 0) {
74+
return rows.filter((r) =>
75+
searchFields.some((f) =>
76+
String(((r as MapRowResult<T>)[f] as unknown) ?? '')
77+
.toLowerCase()
78+
.includes(term)
79+
)
80+
);
81+
}
82+
return rows.filter((r) => JSON.stringify(r).toLowerCase().includes(term));
83+
}, [rows, searchTerm, searchFields, searchFilter]);
84+
85+
return (
86+
<Box>
87+
<Paper
88+
sx={{ borderRadius: '10px 10px 0 0', overflow: 'hidden', display: 'flex', flexDirection: 'column', ...paperSx }}
89+
>
90+
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, px: 2, py: 0.5, height: 48 }}>
91+
<TextField
92+
value={searchTerm}
93+
onChange={(e) => setSearchTerm(e.target.value)}
94+
size="small"
95+
placeholder="Search"
96+
sx={{ flex: 1 }}
97+
/>
98+
<Button variant="contained" size="small" onClick={onAdd} sx={{ ml: 1 }}>
99+
Add
100+
</Button>
101+
</Box>
102+
103+
<Box sx={{ flex: 1, minHeight: 0 }}>
104+
<DataGrid
105+
rows={filteredRows}
106+
columns={columns}
107+
initialState={{ sorting: { sortModel: initialSortModel } }}
108+
pageSize={pageSize}
109+
onPageSizeChange={(newSize) => setPageSize(newSize)}
110+
rowsPerPageOptions={effectiveRowsPerPageOptions}
111+
pagination
112+
disableSelectionOnClick
113+
headerHeight={headerHeight}
114+
rowHeight={rowHeight}
115+
onRowClick={(params: GridRowParams<MapRowResult<T>>) => {
116+
if (!onRowClick) return;
117+
const row = params.row as MapRowResult<T>;
118+
const editable = canEditRow ? canEditRow(row) : true;
119+
if (!editable) return; // do not call onRowClick for non-editable rows
120+
const raw = params.row.raw as T | undefined;
121+
if (raw) onRowClick(raw);
122+
}}
123+
getRowClassName={(params) => {
124+
const row = params.row as MapRowResult<T>;
125+
const editable = canEditRow ? canEditRow(row) : true;
126+
return editable ? 'editable-row' : 'non-editable-row';
127+
}}
128+
sx={{
129+
height: '100%',
130+
'& .MuiDataGrid-columnHeaders': {
131+
backgroundColor: '#ef4345',
132+
color: 'white',
133+
fontWeight: 'bold'
134+
},
135+
'& .MuiDataGrid-columnHeader': {
136+
borderTopLeftRadius: 10,
137+
borderTopRightRadius: 10
138+
},
139+
// per-row hover: mark editable rows with pointer and non-editable rows with default
140+
'& .editable-row:hover': {
141+
cursor: onRowClick ? 'pointer' : 'default'
142+
},
143+
'& .non-editable-row:hover': {
144+
cursor: 'default'
145+
},
146+
'& .non-editable-row': {
147+
opacity: 0.7
148+
},
149+
'& .MuiDataGrid-columnSeparator': {
150+
display: 'none'
151+
}
152+
}}
153+
/>
154+
</Box>
155+
</Paper>
156+
</Box>
157+
);
158+
}
159+
160+
export default NERDataGrid;

0 commit comments

Comments
 (0)