diff --git a/src/backend/src/prisma/migrations/20260402230143_bom_improvements_pt2/migration.sql b/src/backend/src/prisma/migrations/20260402230143_bom_improvements_pt2/migration.sql new file mode 100644 index 0000000000..536b672a3b --- /dev/null +++ b/src/backend/src/prisma/migrations/20260402230143_bom_improvements_pt2/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Material" ADD COLUMN "isCopied" BOOLEAN NOT NULL DEFAULT false; diff --git a/src/backend/src/prisma/schema.prisma b/src/backend/src/prisma/schema.prisma index 47adf8f79e..47acec4093 100644 --- a/src/backend/src/prisma/schema.prisma +++ b/src/backend/src/prisma/schema.prisma @@ -1020,6 +1020,7 @@ model Material { linkUrl String notes String? reimbursementProducts Reimbursement_Product[] + isCopied Boolean @default(false) @@index([assemblyId]) @@index([materialTypeId]) diff --git a/src/backend/src/services/boms.services.ts b/src/backend/src/services/boms.services.ts index a7f434267f..324e9d3cdf 100644 --- a/src/backend/src/services/boms.services.ts +++ b/src/backend/src/services/boms.services.ts @@ -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) }); diff --git a/src/backend/src/transformers/material.transformer.ts b/src/backend/src/transformers/material.transformer.ts index 10e049e15e..58967b542d 100644 --- a/src/backend/src/transformers/material.transformer.ts +++ b/src/backend/src/transformers/material.transformer.ts @@ -48,7 +48,8 @@ export const materialTransformer = (material: Prisma.MaterialGetPayload p.reimbursementRequest && !p.reimbursementRequest.dateDeleted) .map((p) => [p.reimbursementRequest!.reimbursementRequestId, p.reimbursementRequest!]) ).values() - ) + ), + isCopied: material.isCopied }; }; diff --git a/src/backend/tests/unmocked/project.test.ts b/src/backend/tests/unmocked/project.test.ts index 36132df7f4..1135a8246c 100644 --- a/src/backend/tests/unmocked/project.test.ts +++ b/src/backend/tests/unmocked/project.test.ts @@ -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); }); }); @@ -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 () => { diff --git a/src/frontend/src/hooks/bom.hooks.ts b/src/frontend/src/hooks/bom.hooks.ts index 85539f970e..b078517aa3 100644 --- a/src/frontend/src/hooks/bom.hooks.ts +++ b/src/frontend/src/hooks/bom.hooks.ts @@ -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, @@ -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( + ['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(); + 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 } + ); +}; diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTable.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTable.tsx index fe7b8531b1..0becab27bd 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTable.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTable.tsx @@ -63,7 +63,8 @@ const BOMTable: React.FC = ({ setHideColumn, assignMaterial, colu subtotal: '', link: '', notes: '', - assemblyId: assembly.assemblyId + assemblyId: assembly.assemblyId, + isCopied: false }); assemblyMaterials.forEach((material, indx) => materialsWithAssemblies.push(materialToRow(material, indx))); diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTableWrapper.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTableWrapper.tsx index c9afa898dc..4345afcf96 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTableWrapper.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTableWrapper.tsx @@ -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'; @@ -300,7 +301,20 @@ const BOMTableWrapper: React.FC = ({ 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 ( + + {params.value} + {material?.isCopied && ( + + + + )} + + ); + } }, { ...bomBaseColDef, diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/CopyBOM/BOMCopyConfirmModal.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/CopyBOM/BOMCopyConfirmModal.tsx new file mode 100644 index 0000000000..5076bf890f --- /dev/null +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/CopyBOM/BOMCopyConfirmModal.tsx @@ -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 ( + +

{message}

+
+ ); +}; + +export default BOMCopyConfirmModal; diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/CopyBOM/CopyBOMModal.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/CopyBOM/CopyBOMModal.tsx new file mode 100644 index 0000000000..8f77fbcd23 --- /dev/null +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/CopyBOM/CopyBOMModal.tsx @@ -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 = ({ 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([]); + const [confirmedSourceProjectName, setConfirmedSourceProjectName] = useState(''); + + if (carsIsError) return ; + if (projectsIsError) return ; + if (isLoadingCars || !cars || isLoadingProjects || !projects) return ; + + const destinationWbs = wbsPipe(destinationWbsNum); + + return ( + <> + { + setConfirmedMaterialIds(materialIds); + setConfirmedSourceProjectName(sourceProjectName); + setConfirmOpen(true); + }} + /> + setConfirmOpen(false)} + onSuccess={() => { + onHide(); + setConfirmOpen(false); + }} + materialIds={confirmedMaterialIds} + sourceProjectName={confirmedSourceProjectName} + currentProjectName={`${wbsPipe(destinationWbsNum)} - ${currentProjectName}`} + destinationWbsNum={destinationWbs} + /> + + ); +}; + +export default CopyBOMModal; diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/CopyBOM/CopyBOMProjectSection.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/CopyBOM/CopyBOMProjectSection.tsx new file mode 100644 index 0000000000..a53a4a9bf3 --- /dev/null +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/CopyBOM/CopyBOMProjectSection.tsx @@ -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 = ({ selectedProject, onSelectionChange }) => { + const [selectedMaterialIds, setSelectedMaterialIds] = useState([]); + 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 ; + if (isErrorAssemblies) return ; + if (isLoadingMaterials || isLoadingAssemblies || !materials || !assemblies) return ; + + 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 ( + <> + + {selectedMaterialIds.length} material{selectedMaterialIds.length !== 1 ? 's' : ''} selected + + { + 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; diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/CopyBOM/CopyBOMView.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/CopyBOM/CopyBOMView.tsx new file mode 100644 index 0000000000..957500d57b --- /dev/null +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/CopyBOM/CopyBOMView.tsx @@ -0,0 +1,124 @@ +import React, { useRef, useState } from 'react'; +import { Box, Grid } from '@mui/material'; +import { Car, ProjectPreview, wbsPipe } from 'shared'; +import NERModal from '../../../../../components/NERModal'; +import NERAutocomplete from '../../../../../components/NERAutocomplete'; +import CopyBOMProjectSection from './CopyBOMProjectSection'; + +interface CopyBOMViewProps { + open: boolean; + onHide: () => void; + cars: Car[]; + projects: ProjectPreview[]; + onCopy: (materialIds: string[], sourceProjectName: string) => void; +} + +const CopyBOMView: React.FC = ({ open, onHide, cars, projects, onCopy }) => { + const [selectedCar, setSelectedCar] = useState(null); + const [selectedProject, setSelectedProject] = useState(null); + const [hasSelection, setHasSelection] = useState(false); + const selectedMaterialIdsRef = useRef([]); + + const carOptions = cars.map((car) => ({ + label: `${car.wbsNum.carNumber} - ${car.name}`, + id: car.id + })); + + const filteredProjects = selectedCar + ? projects.filter((p) => p.wbsNum.carNumber === selectedCar.wbsNum.carNumber) + : projects; + + const projectOptions = filteredProjects.map((p) => ({ + label: `${wbsPipe(p.wbsNum)} - ${p.name}`, + id: wbsPipe(p.wbsNum) + })); + + const handleSubmit = async () => { + if (!selectedProject) return; + const sourceProjectName = `${wbsPipe(selectedProject.wbsNum)} - ${selectedProject.name}`; + onCopy(selectedMaterialIdsRef.current, sourceProjectName); + }; + + return ( + + + + { + const car = newValue ? (cars.find((c) => c.id === newValue.id) ?? null) : null; + setSelectedCar(car); + setSelectedProject(null); + }} + value={ + selectedCar ? { label: `${selectedCar.wbsNum.carNumber} - ${selectedCar.name}`, id: selectedCar.id } : null + } + placeholder="Select Car" + size="medium" + /> + + + { + const project = newValue ? (filteredProjects.find((p) => wbsPipe(p.wbsNum) === newValue.id) ?? null) : null; + setSelectedProject(project); + }} + value={ + selectedProject + ? { + label: `${wbsPipe(selectedProject.wbsNum)} - ${selectedProject.name}`, + id: wbsPipe(selectedProject.wbsNum) + } + : null + } + placeholder="Select Project" + size="medium" + disabled={!selectedCar} + /> + + + + {selectedProject ? ( + { + selectedMaterialIdsRef.current = ids; + setHasSelection(ids.length > 0); + }} + /> + ) : ( + + Select a project to view its materials + + )} + + + + ); +}; + +export default CopyBOMView; diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/CopyBOM/SelectMaterialToCopyModal.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/CopyBOM/SelectMaterialToCopyModal.tsx new file mode 100644 index 0000000000..94a26981f9 --- /dev/null +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/CopyBOM/SelectMaterialToCopyModal.tsx @@ -0,0 +1,275 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { Autocomplete, InputAdornment, Stack, TextField, Typography } from '@mui/material'; +import SearchIcon from '@mui/icons-material/Search'; +import { useForm } from 'react-hook-form'; +import { Assembly, Car, Material, ProjectPreview, WbsNumber } from 'shared'; + +import NERFormModal from '../../../../../components/NERFormModal'; +import NERAutocomplete from '../../../../../components/NERAutocomplete'; +import LoadingIndicator from '../../../../../components/LoadingIndicator'; +import ErrorPage from '../../../../ErrorPage'; + +import { useGetAllCars } from '../../../../../hooks/cars.hooks'; +import { useAllProjects } from '../../../../../hooks/projects.hooks'; +import { useGetMaterialsForWbsElement, useGetMaterialsForCar } from '../../../../../hooks/bom.hooks'; + +type AutocompleteOption = { label: string; id: string }; + +interface SelectMaterialToCopyModalProps { + open: boolean; + onHide: () => void; + onSelect: (material: Material) => void; + assemblies: Assembly[]; +} + +type FormValues = Record; + +const carToOption = (car: Car): AutocompleteOption => ({ + label: `Car ${car.wbsNum.carNumber} - ${car.name}`, + id: car.wbsElementId +}); + +const projectToOption = (project: ProjectPreview): AutocompleteOption => ({ + label: `${project.wbsNum.carNumber}.${project.wbsNum.projectNumber} - ${project.name}`, + id: project.wbsElementId +}); + +const projectToWbsNumber = (project: ProjectPreview): WbsNumber => ({ + carNumber: project.wbsNum.carNumber, + projectNumber: project.wbsNum.projectNumber, + workPackageNumber: 0 +}); + +const getLatestCar = (cars: Car[]): Car | null => { + if (cars.length === 0) return null; + return [...cars].sort((a, b) => b.wbsNum.carNumber - a.wbsNum.carNumber)[0]; +}; + +const SelectMaterialToCopyModal: React.FC = ({ open, onHide, onSelect, assemblies }) => { + const { reset, handleSubmit } = useForm(); + + const [selectedCar, setSelectedCar] = useState(null); + const [selectedProject, setSelectedProject] = useState(null); + const [selectedMaterial, setSelectedMaterial] = useState(null); + + const { data: cars, isLoading: carsIsLoading, isError: carsIsError, error: carsError } = useGetAllCars(); + + const { data: projects, isLoading: projectsIsLoading, isError: projectsIsError, error: projectsError } = useAllProjects(); + + const allCars = useMemo(() => cars ?? [], [cars]); + const allProjects = useMemo(() => projects ?? [], [projects]); + + const latestCar = useMemo(() => getLatestCar(allCars), [allCars]); + const effectiveCar = selectedCar ?? latestCar; + + const projectsForSelectedCar = useMemo(() => { + if (!effectiveCar) return []; + return allProjects.filter((p) => p.wbsNum.carNumber === effectiveCar.wbsNum.carNumber); + }, [allProjects, effectiveCar]); + + const selectedProjectWbsNum = useMemo( + () => (selectedProject ? projectToWbsNumber(selectedProject) : null), + [selectedProject] + ); + + // Materials for the selected project for autocomplete + const { + data: projectMaterials, + isLoading: projectMaterialsIsLoading, + isError: projectMaterialsIsError, + error: projectMaterialsError + } = useGetMaterialsForWbsElement(selectedProjectWbsNum ?? { carNumber: 0, projectNumber: 0, workPackageNumber: 0 }); + + // All materials across the selected car for search bar + const { + data: carMaterials, + isLoading: carMaterialsIsLoading, + isError: carMaterialsIsError, + error: carMaterialsError + } = useGetMaterialsForCar(effectiveCar?.wbsNum.carNumber ?? null, allProjects); + + const assemblyNameById = useMemo(() => new Map(assemblies.map((a) => [a.assemblyId, a.name])), [assemblies]); + + const materialToOption = useCallback( + (material: Material): AutocompleteOption => ({ + label: [ + material.name, + material.manufacturerName, + material.materialTypeName, + material.assemblyId ? `Assembly: ${assemblyNameById.get(material.assemblyId) ?? material.assemblyId}` : undefined + ] + .filter(Boolean) + .join(' – '), + id: material.materialId + }), + [assemblyNameById] + ); + + const materials = useMemo(() => (selectedProject ? (projectMaterials ?? []) : []), [selectedProject, projectMaterials]); + + const carOptions = useMemo(() => allCars.map(carToOption), [allCars]); + const projectOptions = useMemo(() => projectsForSelectedCar.map(projectToOption), [projectsForSelectedCar]); + const materialOptions = useMemo(() => materials.map(materialToOption), [materials, materialToOption]); + const carMaterialOptions = useMemo(() => (carMaterials ?? []).map(materialToOption), [carMaterials, materialToOption]); + + const selectedCarOption = effectiveCar ? (carOptions.find((o) => o.id === effectiveCar.wbsElementId) ?? null) : null; + const selectedProjectOption = selectedProject ? projectToOption(selectedProject) : null; + const selectedMaterialOption = selectedMaterial ? materialToOption(selectedMaterial) : null; + + // Selecting from the search bar auto-populates the project and material dropdowns + const handleSearchSelect = useCallback( + (_, value: AutocompleteOption | null) => { + if (!value) return; + const material = (carMaterials ?? []).find((m) => m.materialId === value.id) ?? null; + if (!material) return; + const project = allProjects.find((p) => p.wbsElementId === material.wbsElementId) ?? null; + setSelectedProject(project); + setSelectedMaterial(material); + }, + [carMaterials, allProjects] + ); + + const handleCarChange = useCallback( + (_: React.SyntheticEvent, value: AutocompleteOption | null) => { + const next = value ? (allCars.find((c) => c.wbsElementId === value.id) ?? null) : null; + setSelectedCar(next); + setSelectedProject(null); + setSelectedMaterial(null); + }, + [allCars] + ); + + const handleProjectChange = useCallback( + (_: React.SyntheticEvent, value: AutocompleteOption | null) => { + const next = value ? (projectsForSelectedCar.find((p) => p.wbsElementId === value.id) ?? null) : null; + setSelectedProject(next); + setSelectedMaterial(null); + }, + [projectsForSelectedCar] + ); + + const handleMaterialChange = useCallback( + (_: React.SyntheticEvent, value: AutocompleteOption | null) => { + const next = value ? (materials.find((m) => m.materialId === value.id) ?? null) : null; + setSelectedMaterial(next); + }, + [materials] + ); + + const handleCopy = () => { + if (!selectedMaterial) return; + onSelect(selectedMaterial); + onHide(); + reset(); + setSelectedCar(null); + setSelectedProject(null); + setSelectedMaterial(null); + }; + + const handleHide = () => { + onHide(); + reset(); + setSelectedCar(null); + setSelectedProject(null); + setSelectedMaterial(null); + }; + + const modalContent = () => { + if (carsIsError) return ; + if (projectsIsError) return ; + if (projectMaterialsIsError) return ; + if (carMaterialsIsError) return ; + if (carsIsLoading || projectsIsLoading) return ; + + return ( + + option.label} + onChange={handleSearchSelect} + disabled={!effectiveCar || carMaterialsIsLoading} + renderInput={(params) => ( + + + + + {params.InputProps.startAdornment} + + ) + }} + /> + )} + /> + + + + + + + + {!selectedMaterial && ( + + Pick a material to enable "Copy". + + )} + + ); + }; + + return ( + + {modalContent()} + + ); +}; + +export default SelectMaterialToCopyModal; diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/MaterialForm/MaterialFormView.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/MaterialForm/MaterialFormView.tsx index 5520cccd97..5eb4db39b2 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/MaterialForm/MaterialFormView.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/MaterialForm/MaterialFormView.tsx @@ -2,6 +2,7 @@ import { Accordion, AccordionDetails, AccordionSummary, + Button, FormControl, FormHelperText, FormLabel, @@ -14,7 +15,7 @@ import { } from '@mui/material'; import { Box } from '@mui/system'; import { Control, Controller, FieldErrors, UseFormHandleSubmit, UseFormSetValue, UseFormWatch } from 'react-hook-form'; -import { Assembly, Manufacturer, MaterialType, Unit } from 'shared'; +import { Assembly, Manufacturer, Material, MaterialType, Unit } from 'shared'; import ReactHookTextField from '../../../../../components/ReactHookTextField'; import { MaterialFormInput } from './MaterialForm'; import NERFormModal from '../../../../../components/NERFormModal'; @@ -26,6 +27,7 @@ import { displayEnum } from '../../../../../utils/pipes'; import { MaterialStatus } from 'shared'; import React, { useState } from 'react'; import { AddCircle } from '@mui/icons-material'; +import SelectMaterialToCopyModal from '../CopyBOM/SelectMaterialToCopyModal'; export interface MaterialFormViewProps { submitText: 'Add' | 'Edit'; @@ -42,7 +44,6 @@ export interface MaterialFormViewProps { watch: UseFormWatch; createManufacturer: (name: string) => void; setValue: UseFormSetValue; - copyFromExistingBomAction?: React.ReactNode; fromRRForm?: boolean; } @@ -77,6 +78,23 @@ const MaterialFormView: React.FC = ({ const price = watch('price'); const subtotal = quantity && price ? quantity * price : 0; + const [copyModalOpen, setCopyModalOpen] = React.useState(false); + + const handleCopySelect = (m: Material) => { + setValue('name', m.name ?? ''); + setValue('materialTypeName', m.materialTypeName ?? ''); + setValue('manufacturerName', m.manufacturerName ?? ''); + setValue('manufacturerPartNumber', m.manufacturerPartNumber ?? ''); + setValue('pdmFileName', m.pdmFileName ?? ''); + setValue('linkUrl', m.linkUrl ?? ''); + setValue('quantity', m.quantity != null ? Number(m.quantity) : undefined); + setValue('unitName', m.unitName ?? undefined); + setValue('price', m.price != null ? m.price / 100 : undefined); + setValue('notes', m.notes ?? ''); + setValue('assemblyId', undefined); + + setCopyModalOpen(false); + }; const optionalFields = ( @@ -504,7 +522,7 @@ const MaterialFormView: React.FC = ({ )} - {/*submitText === 'Add' && ( + {submitText === 'Add' && ( = ({ - )*/} + )} + setCopyModalOpen(false)} + onSelect={handleCopySelect} + assemblies={assemblies ?? []} + /> ); }; diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOMTab.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOMTab.tsx index e3eba4e447..736e27aa04 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOMTab.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOMTab.tsx @@ -2,11 +2,12 @@ import { Box } from '@mui/system'; import { MaterialPreview, Project, isGuest } from 'shared'; import { NERButton } from '../../../components/NERButton'; import WarningIcon from '@mui/icons-material/Warning'; +import React, { useState } from 'react'; import { Tooltip, useTheme } from '@mui/material'; -import { useState } from 'react'; import BOMTableWrapper from './BOM/BOMTableWrapper'; import CreateMaterialModal from './BOM/MaterialForm/CreateMaterialModal'; import CreateAssemblyModal from './BOM/AssemblyForm/CreateAssemblyModal'; +import CopyBOMModal from './BOM/CopyBOM/CopyBOMModal'; import NERSuccessButton from '../../../components/NERSuccessButton'; import { centsToDollar } from '../../../utils/pipes'; import { useCurrentUser } from '../../../hooks/users.hooks'; @@ -28,9 +29,10 @@ const BOMTab = ({ project }: { project: Project }) => { const [hideColumn, setHideColumn] = useState(initialHideColumn); const [showAddMaterial, setShowAddMaterial] = useState(false); const [showAddAssembly, setShowAddAssembly] = useState(false); + const [showCopyBOM, setShowCopyBOM] = useState(false); const [showImportBOM, setShowImportBOM] = useState(false); - const theme = useTheme(); + const theme = useTheme(); const user = useCurrentUser(); const { @@ -97,6 +99,12 @@ const BOMTab = ({ project }: { project: Project }) => { allUnits={units} assemblies={assemblies} /> + setShowCopyBOM(false)} + destinationWbsNum={project.wbsNum} + currentProjectName={project.name} + /> { > Show All Columns - {/* - {}} disabled={isGuest(user.role)}> + setShowCopyBOM(true)} disabled={isGuest(user.role)}> Copy Existing BOM - */} diff --git a/src/frontend/src/utils/bom.utils.ts b/src/frontend/src/utils/bom.utils.ts index 79aa5950e6..9b78ff43a8 100644 --- a/src/frontend/src/utils/bom.utils.ts +++ b/src/frontend/src/utils/bom.utils.ts @@ -20,6 +20,7 @@ export interface BomRow extends GridValidRowModel { link: string; notes: string | undefined; assemblyId: string | undefined; + isCopied: boolean; } export const materialToRow = (material: Material, idx: number): BomRow => { @@ -38,7 +39,8 @@ export const materialToRow = (material: Material, idx: number): BomRow => { subtotal: material.subtotal !== undefined ? `$${centsToDollar(material.subtotal)}` : '', link: material.linkUrl, notes: material.notes, - assemblyId: material.assemblyId ?? 'assembly-misc' + assemblyId: material.assemblyId ?? 'assembly-misc', + isCopied: material.isCopied }; }; diff --git a/src/frontend/src/utils/teams.utils.ts b/src/frontend/src/utils/teams.utils.ts index ed52063cdb..6487ca9124 100644 --- a/src/frontend/src/utils/teams.utils.ts +++ b/src/frontend/src/utils/teams.utils.ts @@ -46,8 +46,10 @@ export type SubmitText = | 'Create Change Request' | 'Update' | 'Submit Vendor' + | 'Copy' | 'Accept' | 'Send' - | 'Close Attendance'; + | 'Close Attendance' + | 'Copy BOM'; export type CancelText = 'Cancel' | 'Delete' | 'Exit' | 'No'; diff --git a/src/shared/src/types/bom-types.ts b/src/shared/src/types/bom-types.ts index c2d052c67e..4d0ca39ffa 100644 --- a/src/shared/src/types/bom-types.ts +++ b/src/shared/src/types/bom-types.ts @@ -77,6 +77,7 @@ export interface Material { linkUrl: string; notes?: string; reimbursementRequests: MaterialReimbursementRequest[]; + isCopied: boolean; } export type MaterialPreview = Omit<