Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
4d9e618
3881: added select material to copy modal
Feb 12, 2026
e0e9d8a
#3885: Created basic modal on click; functionality incomplete
nunnyu Feb 27, 2026
8a5383e
#3884 Create Copy BOM Modal with Car/Project Selection
rbessin Mar 8, 2026
7f1849a
#3884 add Accept back to SubmitText
rbessin Mar 8, 2026
7f8f84b
#3885: Refactored and finished UI
nunnyu Mar 10, 2026
0850e4f
#3885: Functionality complete
nunnyu Mar 10, 2026
69ee63d
#3884 merge feature/bom-improvement-pt2
rbessin Mar 11, 2026
78b9811
#3884 addressed PR feedback
rbessin Mar 11, 2026
60224b3
#3884 fix: prettier errors
rbessin Mar 11, 2026
2241c2e
#3884 fix: linting fixes
rbessin Mar 11, 2026
ffd3b28
#3885: Commented out mock use-case of modal for PR
nunnyu Mar 12, 2026
78b6dc6
refactor: #3884 addressed PR feedback on state management and added p…
rbessin Mar 12, 2026
87cebbe
style: fix prettier issues
rbessin Mar 12, 2026
2283283
Merge pull request #3990 from Northeastern-Electric-Racing/#3884-Crea…
chpy04 Mar 13, 2026
967c150
#3916 feat: add isCopied field and modified services, transformers, a…
rbessin Mar 15, 2026
8cb5c14
Merge pull request #4051 from Northeastern-Electric-Racing/#3916-Mark…
wavehassman Mar 16, 2026
62bb609
#3918 feat: add isCopied icon and tooltip
rbessin Mar 17, 2026
a2e1018
Merge pull request #4057 from Northeastern-Electric-Racing/#3918-Mark…
wavehassman Mar 17, 2026
7932c66
#3881: Create Selected Material to Copy Modal
Mar 18, 2026
87c9fac
#3885: Refactored and finished UI
wavehassman Mar 17, 2026
cf6e139
#3885: Commented out mock use-case of modal for PR
nunnyu Mar 10, 2026
497a99d
Merge branch '#3885-BOM-Copy-Confirm' of https://github.com/Northeast…
nunnyu Mar 18, 2026
8e29c07
#3885: Finished linking confirmation modal with the copy BOM modal
nunnyu Mar 18, 2026
de9fffc
search bar + state/query timing issues
Mar 18, 2026
30af8a5
Merge branch 'develop' into feature/bom-improvement-pt2
wavehassman Mar 18, 2026
d864c9a
fixed test
wavehassman Mar 18, 2026
16847a9
branch change
Mar 19, 2026
75400e4
merge conflicts
Mar 19, 2026
4c73b17
linting/prettier checks
Mar 19, 2026
e0b6ba6
lint issues
Mar 19, 2026
7edfe31
merge conflicts resolved
Mar 19, 2026
7f05c72
more lint/prettier
Mar 19, 2026
c73b4cd
#3885: Reverted MaterialFormView from rebase stuff
nunnyu Mar 19, 2026
7dc4bd0
Merge pull request #4034 from Northeastern-Electric-Racing/#3885-BOM-…
wavehassman Mar 19, 2026
650bcd0
material form fixes
Mar 23, 2026
bcdb591
review request changes
Mar 23, 2026
18c9373
linting issues
Mar 23, 2026
49c6eeb
prettier checks
Mar 23, 2026
a26582b
removed unnecessary changes
Mar 25, 2026
11bdb51
final touches
Mar 25, 2026
aa467b1
#3881 requested changes
wavehassman Mar 30, 2026
531bba9
Merge pull request #4059 from Northeastern-Electric-Racing/#3881-crea…
chpy04 Apr 2, 2026
c2edde4
merging in develop
wavehassman Apr 2, 2026
4e9a4a9
#4116 requested changes
wavehassman Apr 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Material" ADD COLUMN "isCopied" BOOLEAN NOT NULL DEFAULT false;
1 change: 1 addition & 0 deletions src/backend/src/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -1020,6 +1020,7 @@ model Material {
linkUrl String
notes String?
reimbursementProducts Reimbursement_Product[]
isCopied Boolean @default(false)

@@index([assemblyId])
@@index([materialTypeId])
Expand Down
3 changes: 2 additions & 1 deletion src/backend/src/services/boms.services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,8 @@ export default class BillOfMaterialsService {
dateCreated: new Date(),
userCreatedId: user.userId,
wbsElementId: destinationProject.wbsElementId,
assemblyId: null
assemblyId: null,
isCopied: true
},
...getMaterialQueryArgs(organization.organizationId)
});
Expand Down
3 changes: 2 additions & 1 deletion src/backend/src/transformers/material.transformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ export const materialTransformer = (material: Prisma.MaterialGetPayload<Material
.filter((p) => p.reimbursementRequest && !p.reimbursementRequest.dateDeleted)
.map((p) => [p.reimbursementRequest!.reimbursementRequestId, p.reimbursementRequest!])
).values()
)
),
isCopied: material.isCopied
};
};

Expand Down
4 changes: 4 additions & 0 deletions src/backend/tests/unmocked/project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ describe('Material Tests', () => {
expect(material.manufacturerName).toEqual('Digikey');
expect(material.manufacturerPartNumber).toEqual('lalsd');
expect(material.quantity?.toString()).toEqual('5');
expect(material.isCopied).toBe(false);
});
});

Expand Down Expand Up @@ -148,6 +149,9 @@ describe('Material Tests', () => {
expect(copiedMat1.notes).toBe('Test notes');

expect(copiedMat2.status).toBe('NOT_READY_TO_ORDER');

expect(copiedMat1.isCopied).toBe(true);
expect(copiedMat2.isCopied).toBe(true);
});

test('Fails when material does not exist', async () => {
Expand Down
32 changes: 31 additions & 1 deletion src/frontend/src/hooks/bom.hooks.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useMutation, useQuery, useQueryClient } from 'react-query';
import { Assembly, Manufacturer, Material, MaterialType, Unit, WbsNumber, wbsPipe } from 'shared';
import { Assembly, Manufacturer, Material, MaterialType, ProjectPreview, Unit, WbsNumber, wbsPipe } from 'shared';
import { useToast } from '../hooks/toasts.hooks';
import {
assignMaterialToAssembly,
Expand Down Expand Up @@ -326,3 +326,33 @@ export const useGetMaterialsForWbsElement = (wbsNum: WbsNumber) => {
return data;
});
};

export const useGetMaterialsForCar = (carNumber: number | null, projects: ProjectPreview[]) => {
const projectsInCar = projects.filter((p) => p.wbsNum.carNumber === carNumber);

return useQuery<Material[], Error>(
['materials', 'car', carNumber ?? 'none'],
async () => {
const results = await Promise.all(
projectsInCar.map(async (p) => {
const { data } = await getMaterialsForWbsElement({
carNumber: p.wbsNum.carNumber,
projectNumber: p.wbsNum.projectNumber,
workPackageNumber: 0
});
return data;
})
);

const flat = results.flat();
const seen = new Set<string>();
return flat.filter((material) => {
const key = `${material.name.toLowerCase()}-${material.assemblyId ?? 'no-assembly'}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
},
{ enabled: carNumber !== null && projectsInCar.length > 0 }
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ const BOMTable: React.FC<BOMTableProps> = ({ setHideColumn, assignMaterial, colu
subtotal: '',
link: '',
notes: '',
assemblyId: assembly.assemblyId
assemblyId: assembly.assemblyId,
isCopied: false
});

assemblyMaterials.forEach((material, indx) => materialsWithAssemblies.push(materialToRow(material, indx)));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import { useToast } from '../../../../hooks/toasts.hooks';
import { useAssignMaterialToAssembly, useDeleteAssembly, useDeleteMaterial } from '../../../../hooks/bom.hooks';
import LoadingIndicator from '../../../../components/LoadingIndicator';
import EditMaterialModal from './MaterialForm/EditMaterialModal';
import { Button, Link, Typography } from '@mui/material';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import { Button, Link, Tooltip, Typography } from '@mui/material';
import { bomBaseColDef } from '../../../../utils/bom.utils';
import NERModal from '../../../../components/NERModal';
import { renderStatusBOM } from './BOMTableCustomCells';
Expand Down Expand Up @@ -300,7 +301,20 @@ const BOMTableWrapper: React.FC<BOMTableWrapperProps> = ({
type: 'string',
sortable: false,
filterable: false,
hide: hideColumn[3]
hide: hideColumn[3],
renderCell: (params) => {
const material = materials.find((m) => m.materialId === params.row.materialId);
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography variant="body2">{params.value}</Typography>
{material?.isCopied && (
<Tooltip title="Copied from another BOM">
<ContentCopyIcon sx={{ fontSize: 14, color: 'warning.main' }} />
</Tooltip>
)}
</Box>
);
}
},
{
...bomBaseColDef,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import NERModal from '../../../../../components/NERModal';
import { useCopyMaterialsToProject } from '../../../../../hooks/bom.hooks';

export interface BOMCopyConfirmModalProps {
open: boolean;
onHide: () => void;
onSuccess: () => void;
materialIds: string[];
sourceProjectName: string;
currentProjectName: string;
destinationWbsNum: string;
}

const BOMCopyConfirmModal = ({
open,
onHide,
onSuccess,
materialIds,
sourceProjectName,
currentProjectName,
destinationWbsNum
}: BOMCopyConfirmModalProps) => {
const { mutateAsync: copyMaterials } = useCopyMaterialsToProject();

const handleConfirm = async () => {
await copyMaterials({ materialIds, destinationWbsNum });
onSuccess();
onHide();
};

const message = `Are you sure you want to copy ${materialIds.length} materials from ${sourceProjectName} to ${currentProjectName}?`;
return (
<NERModal open={open} onHide={onHide} onSubmit={handleConfirm} title="Confirm Copy">
<p>{message}</p>
</NERModal>
);
};

export default BOMCopyConfirmModal;
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { WbsNumber, wbsPipe } from 'shared';
import CopyBOMView from './CopyBOMView';
import { useGetAllCars } from '../../../../../hooks/cars.hooks';
import { useAllProjects } from '../../../../../hooks/projects.hooks';
import React, { useState } from 'react';
import ErrorPage from '../../../../ErrorPage';
import LoadingIndicator from '../../../../../components/LoadingIndicator';
import BOMCopyConfirmModal from './BOMCopyConfirmModal';

export interface CopyBOMModalProps {
open: boolean;
onHide: () => void;
destinationWbsNum: WbsNumber;
currentProjectName: string;
}

const CopyBOMModal: React.FC<CopyBOMModalProps> = ({ open, onHide, destinationWbsNum, currentProjectName }) => {
const { data: cars, isLoading: isLoadingCars, isError: carsIsError, error: carsError } = useGetAllCars();
const { data: projects, isLoading: isLoadingProjects, isError: projectsIsError, error: projectsError } = useAllProjects();
const [confirmOpen, setConfirmOpen] = useState(false);
const [confirmedMaterialIds, setConfirmedMaterialIds] = useState<string[]>([]);
const [confirmedSourceProjectName, setConfirmedSourceProjectName] = useState('');

if (carsIsError) return <ErrorPage message={carsError?.message} />;
if (projectsIsError) return <ErrorPage message={projectsError?.message} />;
if (isLoadingCars || !cars || isLoadingProjects || !projects) return <LoadingIndicator />;

const destinationWbs = wbsPipe(destinationWbsNum);

return (
<>
<CopyBOMView
open={open}
onHide={onHide}
cars={cars}
projects={projects}
onCopy={(materialIds, sourceProjectName) => {
setConfirmedMaterialIds(materialIds);
setConfirmedSourceProjectName(sourceProjectName);
setConfirmOpen(true);
}}
/>
<BOMCopyConfirmModal
open={confirmOpen}
onHide={() => setConfirmOpen(false)}
onSuccess={() => {
onHide();
setConfirmOpen(false);
}}
materialIds={confirmedMaterialIds}
sourceProjectName={confirmedSourceProjectName}
currentProjectName={`${wbsPipe(destinationWbsNum)} - ${currentProjectName}`}
destinationWbsNum={destinationWbs}
/>
</>
);
};

export default CopyBOMModal;
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import React, { useEffect } from 'react';
import { Typography } from '@mui/material';
import { DataGrid, GridColDef } from '@mui/x-data-grid';
import { useState } from 'react';
import { ProjectPreview } from 'shared';
import LoadingIndicator from '../../../../../components/LoadingIndicator';
import { useGetAssembliesForWbsElement, useGetMaterialsForWbsElement } from '../../../../../hooks/bom.hooks';
import ErrorPage from '../../../../ErrorPage';

interface CopyBOMProjectSectionProps {
selectedProject: ProjectPreview;
onSelectionChange: (materialIds: string[]) => void;
}

const columns: GridColDef[] = [
{ field: 'materialName', headerName: 'Material Name', flex: 1 },
{ field: 'manufacturer', headerName: 'Manufacturer', flex: 1 },
{ field: 'materialType', headerName: 'Material Type', flex: 1 },
{ field: 'assembly', headerName: 'Assembly Name', flex: 1 }
];

const CopyBOMProjectSection: React.FC<CopyBOMProjectSectionProps> = ({ selectedProject, onSelectionChange }) => {
const [selectedMaterialIds, setSelectedMaterialIds] = useState<string[]>([]);
const {
data: materials,
isLoading: isLoadingMaterials,
isError: isErrorMaterials,
error: materialsError
} = useGetMaterialsForWbsElement(selectedProject.wbsNum);

const {
data: assemblies,
isLoading: isLoadingAssemblies,
isError: isErrorAssemblies,
error: assembliesError
} = useGetAssembliesForWbsElement(selectedProject.wbsNum);

useEffect(() => {
if (materials) {
const allIds = materials.map((m) => m.materialId);
setSelectedMaterialIds(allIds);
onSelectionChange(allIds);
}
}, [materials, onSelectionChange]);

if (isErrorMaterials) return <ErrorPage message={materialsError?.message} />;
if (isErrorAssemblies) return <ErrorPage message={assembliesError?.message} />;
if (isLoadingMaterials || isLoadingAssemblies || !materials || !assemblies) return <LoadingIndicator />;

const rows = materials.map((m) => ({
id: m.materialId,
materialName: m.name,
manufacturer: m.manufacturer?.name ?? '-',
materialType: m.materialType.name,
assembly: assemblies.find((a) => a.assemblyId === m.assemblyId)?.name ?? '-'
}));

return (
<>
<Typography sx={{ mb: 1 }} variant="body2">
{selectedMaterialIds.length} material{selectedMaterialIds.length !== 1 ? 's' : ''} selected
</Typography>
<DataGrid
rows={rows}
columns={columns}
checkboxSelection
autoHeight
selectionModel={selectedMaterialIds}
onSelectionModelChange={(newModel) => {
const ids = newModel as string[];
setSelectedMaterialIds(ids);
onSelectionChange(ids);
}}
rowsPerPageOptions={[100]}
hideFooterPagination
sx={{
'& .MuiDataGrid-columnHeaders': { backgroundColor: '#ef4345', color: 'white' },
'& .MuiDataGrid-columnHeaders .MuiCheckbox-root': { color: 'white' }
}}
/>
</>
);
};

export default CopyBOMProjectSection;
Loading
Loading