Skip to content

Commit ed869ad

Browse files
authored
Merge pull request #3922 from Northeastern-Electric-Racing/#3886-Implement-Bulk-Copy-Materials
#3886 implemented bulk copy button functionality
2 parents 981e56a + 2354b31 commit ed869ad

7 files changed

Lines changed: 254 additions & 0 deletions

File tree

src/backend/src/controllers/projects.controllers.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,22 @@ export default class ProjectsController {
236236
}
237237
}
238238

239+
static async copyMaterialsToProject(req: Request, res: Response, next: NextFunction) {
240+
try {
241+
const { materialIds, destinationWbsNum } = req.body;
242+
243+
const newMaterialIds = await BillOfMaterialsService.copyMaterialsToProject(
244+
req.currentUser,
245+
materialIds,
246+
destinationWbsNum,
247+
req.organization
248+
);
249+
res.status(200).json(newMaterialIds);
250+
} catch (error: unknown) {
251+
next(error);
252+
}
253+
}
254+
239255
static async createManufacturer(req: Request, res: Response, next: NextFunction) {
240256
try {
241257
const { name } = req.body;

src/backend/src/routes/projects.routes.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
validateInputs,
88
materialValidators
99
} from '../utils/validation.utils.js';
10+
import { validateWBS } from 'shared';
1011
import ProjectsController from '../controllers/projects.controllers.js';
1112

1213
const projectRouter = express.Router();
@@ -93,6 +94,16 @@ projectRouter.post(
9394
);
9495
projectRouter.post('/bom/material/:wbsNum/create', ...materialValidators, validateInputs, ProjectsController.createMaterial);
9596
projectRouter.post('/bom/material/:materialId/edit', ...materialValidators, validateInputs, ProjectsController.editMaterial);
97+
projectRouter.post(
98+
'/bom/material/copy',
99+
body('materialIds').isArray({ min: 1 }),
100+
nonEmptyString(body('materialIds.*')),
101+
body('destinationWbsNum').customSanitizer((value) => {
102+
return validateWBS(value);
103+
}),
104+
validateInputs,
105+
ProjectsController.copyMaterialsToProject
106+
);
96107

97108
projectRouter.post(
98109
'/bom/assembly/:assemblyId/edit',

src/backend/src/services/boms.services.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,78 @@ export default class BillOfMaterialsService {
157157
return materialTransformer(createdMaterial);
158158
}
159159

160+
/**
161+
* Copy materials to project
162+
* @param user The user making the copy
163+
* @param materialIds The ids of the materials to be copied
164+
* @param destinationProjectId The id of the project to copy the materials to
165+
* @param organization The organization the user is currently in
166+
* @returns an array of the newly created material ids
167+
* @throws errors that will be added here later
168+
*/
169+
static async copyMaterialsToProject(
170+
user: User,
171+
materialIds: string[],
172+
destinationProjectId: WbsNumber,
173+
organization: Organization
174+
): Promise<string[]> {
175+
// Fetch materials to be copied
176+
const materials = await prisma.material.findMany({
177+
where: {
178+
materialId: { in: materialIds },
179+
dateDeleted: null
180+
},
181+
...getMaterialQueryArgs(organization.organizationId)
182+
});
183+
184+
if (materials.length !== materialIds.length) throw new NotFoundException('Material', 'Not all materials found');
185+
186+
const invalidMaterials = materials.filter(
187+
(material) => material.materialType.organizationId !== organization.organizationId
188+
);
189+
if (invalidMaterials.length > 0) throw new HttpException(400, 'All materials must be from the current organization');
190+
191+
const destinationProject = await ProjectsService.getSingleProjectWithQueryArgs(destinationProjectId, organization);
192+
193+
const perms =
194+
(await userHasPermission(user.userId, organization.organizationId, isLeadership)) ||
195+
isUserPartOfTeams(destinationProject.teams, user);
196+
197+
if (!perms) throw new AccessDeniedException('Permission to copy materials denied');
198+
199+
return await prisma.$transaction(async (tx) => {
200+
const newMaterialIds: string[] = [];
201+
202+
for (const material of materials) {
203+
const newMaterial = await tx.material.create({
204+
data: {
205+
name: material.name,
206+
status: Material_Status.NOT_READY_TO_ORDER,
207+
materialTypeId: material.materialTypeId,
208+
manufacturerId: material.manufacturerId,
209+
manufacturerPartNumber: material.manufacturerPartNumber,
210+
pdmFileName: material.pdmFileName,
211+
quantity: material.quantity,
212+
unitId: material.unitId,
213+
price: material.price,
214+
subtotal: material.subtotal,
215+
linkUrl: material.linkUrl,
216+
notes: material.notes,
217+
dateCreated: new Date(),
218+
userCreatedId: user.userId,
219+
wbsElementId: destinationProject.wbsElementId,
220+
assemblyId: null
221+
},
222+
...getMaterialQueryArgs(organization.organizationId)
223+
});
224+
225+
newMaterialIds.push(newMaterial.materialId);
226+
}
227+
228+
return newMaterialIds;
229+
});
230+
}
231+
160232
/**
161233
* Create an assembly
162234
* @param name The name of the assembly to be created

src/backend/tests/unmocked/project.test.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import prisma from '../../src/prisma/prisma.js';
12
import { createTestReimbursementRequest, resetUsers } from '../test-utils.js';
23
import { Organization, User } from '@prisma/client';
34
import BillOfMaterials from '../../src/services/boms.services.js';
@@ -93,6 +94,119 @@ describe('Material Tests', () => {
9394
});
9495
});
9596

97+
describe('Copy materials to project', () => {
98+
test('Successfully copies materials and resets key fields', async () => {
99+
const materialType = await BillOfMaterials.createMaterialType('Capacitor', createdUser, org);
100+
const manufacturer = await BillOfMaterials.createManufacturer(createdUser, 'Mouser', org);
101+
102+
const car = await prisma.car.findFirst({
103+
where: {
104+
wbsElement: {
105+
organizationId: org.organizationId
106+
}
107+
}
108+
});
109+
110+
await prisma.project.create({
111+
data: {
112+
summary: 'Test destination project',
113+
car: {
114+
connect: { carId: car!.carId }
115+
},
116+
wbsElement: {
117+
create: {
118+
carNumber: 0,
119+
projectNumber: 2,
120+
workPackageNumber: 0,
121+
name: 'Destination Project',
122+
organizationId: org.organizationId
123+
}
124+
}
125+
}
126+
});
127+
128+
const material1 = await BillOfMaterials.createMaterial(
129+
createdUser,
130+
'100uF Capacitor',
131+
MaterialStatus.Ordered,
132+
materialType.name,
133+
'https://example.com/mat1',
134+
{ carNumber: 0, projectNumber: 1, workPackageNumber: 0 },
135+
org,
136+
manufacturer.name,
137+
'CAP-100UF',
138+
new Decimal(10),
139+
50,
140+
500,
141+
'Test notes',
142+
undefined,
143+
undefined,
144+
undefined,
145+
reimbursementRequest.reimbursementRequestId
146+
);
147+
148+
const material2 = await BillOfMaterials.createMaterial(
149+
createdUser,
150+
'220uF Capacitor',
151+
MaterialStatus.ReadyToOrder,
152+
materialType.name,
153+
'https://example.com/mat2',
154+
{ carNumber: 0, projectNumber: 1, workPackageNumber: 0 },
155+
org,
156+
manufacturer.name,
157+
'CAP-220UF',
158+
new Decimal(5),
159+
75,
160+
375
161+
);
162+
163+
const newMaterialIds = await BillOfMaterials.copyMaterialsToProject(
164+
createdUser,
165+
[material1.materialId, material2.materialId],
166+
{ carNumber: 0, projectNumber: 2, workPackageNumber: 0 },
167+
org
168+
);
169+
170+
expect(newMaterialIds.length).toBe(2);
171+
172+
const copiedMaterials = await prisma.material.findMany({
173+
where: { materialId: { in: newMaterialIds } },
174+
include: { wbsElement: true }
175+
});
176+
177+
const copiedMat1 = copiedMaterials.find((m) => m.name === '100uF Capacitor')!;
178+
const copiedMat2 = copiedMaterials.find((m) => m.name === '220uF Capacitor')!;
179+
180+
expect(copiedMat1.wbsElement.projectNumber).toBe(2);
181+
expect(copiedMat2.wbsElement.projectNumber).toBe(2);
182+
183+
expect(copiedMat1.status).toBe('NOT_READY_TO_ORDER');
184+
expect(copiedMat1.userCreatedId).toBe(createdUser.userId);
185+
expect(copiedMat1.reimbursementRequestId).toBeNull();
186+
expect(copiedMat1.assemblyId).toBeNull();
187+
188+
expect(copiedMat1.name).toBe('100uF Capacitor');
189+
expect(copiedMat1.price).toBe(50);
190+
expect(copiedMat1.quantity?.toString()).toBe('10');
191+
expect(copiedMat1.manufacturerPartNumber).toBe('CAP-100UF');
192+
expect(copiedMat1.notes).toBe('Test notes');
193+
194+
expect(copiedMat2.status).toBe('NOT_READY_TO_ORDER');
195+
expect(copiedMat2.reimbursementRequestId).toBeNull();
196+
});
197+
198+
test('Fails when material does not exist', async () => {
199+
await expect(
200+
BillOfMaterials.copyMaterialsToProject(
201+
createdUser,
202+
['non-existent-id'],
203+
{ carNumber: 0, projectNumber: 1, workPackageNumber: 0 },
204+
org
205+
)
206+
).rejects.toThrow(NotFoundException);
207+
});
208+
});
209+
96210
describe('Edit a material', () => {
97211
test('Updates the reimbursement request when originally undefined', async () => {
98212
const materialType = await BillOfMaterials.createMaterialType('Resistor', createdUser, org);

src/frontend/src/apis/bom.api.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,17 @@ export const editMaterial = async (materialId: string, material: MaterialDataSub
105105
return data;
106106
};
107107

108+
/**
109+
* Requests to copy materials to a project.
110+
* @param materialIds The IDs of materials to copy
111+
* @param destinationWbsNum The destination project WBS number
112+
* @returns Array of newly created material IDs
113+
*/
114+
export const copyMaterialsToProject = async (materialIds: string[], destinationWbsNum: string) => {
115+
const { data } = await axios.post(apiUrls.bomCopyMaterials(), { materialIds, destinationWbsNum });
116+
return data;
117+
};
118+
108119
/**
109120
* Soft deletes a material.
110121
* @param materialId

src/frontend/src/hooks/bom.hooks.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { useMutation, useQuery, useQueryClient } from 'react-query';
22
import { Assembly, Manufacturer, Material, MaterialType, Unit, WbsNumber, wbsPipe } from 'shared';
3+
import { useToast } from '../hooks/toasts.hooks';
34
import {
45
assignMaterialToAssembly,
56
createAssembly,
67
createManufacturer,
78
deleteManufacturer,
89
createMaterial,
10+
copyMaterialsToProject,
911
createMaterialType,
1012
createUnit,
1113
deleteSingleAssembly,
@@ -139,6 +141,32 @@ export const useDeleteMaterial = (wbsNum: WbsNumber) => {
139141
);
140142
};
141143

144+
/**
145+
* Custom React hook to copy materials to a project.
146+
* @returns the mutation function to copy materials
147+
*/
148+
export const useCopyMaterialsToProject = () => {
149+
const queryClient = useQueryClient();
150+
const toast = useToast();
151+
152+
return useMutation<string[], Error, { materialIds: string[]; destinationWbsNum: string }>(
153+
['materials', 'copy'],
154+
async ({ materialIds, destinationWbsNum }) => {
155+
const data = await copyMaterialsToProject(materialIds, destinationWbsNum);
156+
return data;
157+
},
158+
{
159+
onSuccess: (newMaterialIds, variables) => {
160+
queryClient.invalidateQueries(['materials', variables.destinationWbsNum]);
161+
toast.success(`Successfully copied ${newMaterialIds.length} material${newMaterialIds.length !== 1 ? 's' : ''}!`);
162+
},
163+
onError: () => {
164+
toast.error('Failed to copy materials');
165+
}
166+
}
167+
);
168+
};
169+
142170
/**
143171
* Custom React hook to delete a assembly.
144172
* @param wbsNum The wbs element you are deleting the assembly from

src/frontend/src/utils/urls.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,7 @@ const bomGetAssembliesByWbsNum = (wbsNum: WbsNumber) => `${bomEndpoints()}/${wbs
328328
const bomCreateMaterial = (wbsNum: WbsNumber) => `${materialEndpoints()}/${wbsPipe(wbsNum)}/create`;
329329
const bomEditMaterial = (materialId: string) => `${materialEndpoints()}/${materialId}/edit`;
330330
const bomDeleteMaterial = (materialId: string) => `${materialEndpoints()}/${materialId}/delete`;
331+
const bomCopyMaterials = () => `${materialEndpoints()}/copy`;
331332
const bomCreateAssembly = (wbsNum: WbsNumber) => `${assemblyEndpoints()}/${wbsPipe(wbsNum)}/create`;
332333
const bomDeleteAssembly = (assemblyId: string) => `${assemblyEndpoints()}/${assemblyId}/delete`;
333334
const bomAssignAssembly = (materialId: string) => `${materialEndpoints()}/${materialId}/assign-assembly`;
@@ -687,6 +688,7 @@ export const apiUrls = {
687688
bomCreateMaterial,
688689
bomEditMaterial,
689690
bomDeleteMaterial,
691+
bomCopyMaterials,
690692
bomCreateAssembly,
691693
bomDeleteAssembly,
692694
bomAssignAssembly,

0 commit comments

Comments
 (0)