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/BOMTableWrapper.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTableWrapper.tsx index 381d236a65..4345afcf96 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTableWrapper.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTableWrapper.tsx @@ -298,6 +298,7 @@ const BOMTableWrapper: React.FC = ({ flex: 1.5, field: 'name', headerName: 'Name', + type: 'string', sortable: false, filterable: false, hide: hideColumn[3], 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..fc3b89c531 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 './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/BOM/MaterialForm/SelectMaterialToCopyModal.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/MaterialForm/SelectMaterialToCopyModal.tsx new file mode 100644 index 0000000000..7aece296e1 --- /dev/null +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/MaterialForm/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( + (_: React.SyntheticEvent, 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/utils/teams.utils.ts b/src/frontend/src/utils/teams.utils.ts index c2b52955ba..bae8dad17c 100644 --- a/src/frontend/src/utils/teams.utils.ts +++ b/src/frontend/src/utils/teams.utils.ts @@ -46,6 +46,7 @@ export type SubmitText = | 'Create Change Request' | 'Update' | 'Submit Vendor' + | 'Copy' | 'Accept' | 'Copy BOM';