diff --git a/src/backend/src/controllers/projects.controllers.ts b/src/backend/src/controllers/projects.controllers.ts index 058e6af3c8..a01e868ebf 100644 --- a/src/backend/src/controllers/projects.controllers.ts +++ b/src/backend/src/controllers/projects.controllers.ts @@ -397,7 +397,6 @@ export default class ProjectsController { quantity, unitName, price, - subtotal, linkUrl, notes } = req.body; @@ -413,7 +412,6 @@ export default class ProjectsController { manufacturerPartNumber, quantity, price, - subtotal, notes, unitName, assemblyId, diff --git a/src/backend/src/services/boms.services.ts b/src/backend/src/services/boms.services.ts index 324e9d3cdf..90147d4d78 100644 --- a/src/backend/src/services/boms.services.ts +++ b/src/backend/src/services/boms.services.ts @@ -631,7 +631,6 @@ export default class BillOfMaterialsService { manufacturerPartNumber?: string, quantity?: Decimal, price?: number, - subtotal?: number, notes?: string, unitName?: string, assemblyId?: string, @@ -670,6 +669,12 @@ export default class BillOfMaterialsService { manufacturer = await BillOfMaterialsService.getSingleManufacturerWithQueryArgs(manufacturerName, organization); } + // recalculate subtotal on edits + const finalPrice = price !== undefined ? price : (material.price ?? undefined); + const finalQuantity = quantity !== undefined ? quantity : (material.quantity ?? undefined); + const computedSubtotal = + finalPrice !== undefined && finalQuantity !== undefined ? Math.round(finalPrice * Number(finalQuantity)) : undefined; + const updatedMaterial = await prisma.material.update({ where: { materialId }, data: { @@ -681,12 +686,12 @@ export default class BillOfMaterialsService { quantity, unitId: unit ? unit.id : null, price, - subtotal, + subtotal: computedSubtotal, linkUrl, notes, wbsElementId: project.wbsElementId, assemblyId, - pdmFileName + pdmFileName: pdmFileName !== undefined ? pdmFileName || null : undefined }, ...getMaterialQueryArgs(organization.organizationId) }); diff --git a/src/backend/tests/unmocked/project.test.ts b/src/backend/tests/unmocked/project.test.ts index 1135a8246c..aff632f26e 100644 --- a/src/backend/tests/unmocked/project.test.ts +++ b/src/backend/tests/unmocked/project.test.ts @@ -200,8 +200,7 @@ describe('Material Tests', () => { manufacturer.name, 'lalsd', new Decimal(5), - 10, - 50 + 10 ); expect(newMaterial.name).toEqual('100k Resistor Updated'); diff --git a/src/frontend/src/hooks/bom.hooks.ts b/src/frontend/src/hooks/bom.hooks.ts index b078517aa3..1e716cca82 100644 --- a/src/frontend/src/hooks/bom.hooks.ts +++ b/src/frontend/src/hooks/bom.hooks.ts @@ -313,6 +313,27 @@ export const useCreateMaterialType = () => { ); }; +/** + * Custom React hook to edit a material's status inline. + * @param wbsNum The wbs element the material belongs to + * @returns mutation function to edit a material's status + */ +export const useEditMaterialById = (wbsNum: WbsNumber) => { + const queryClient = useQueryClient(); + return useMutation( + ['materials', 'edit'], + async ({ materialId, payload }) => { + const data = await editMaterial(materialId, payload); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['materials', wbsPipe(wbsNum)]); + } + } + ); +}; + export const useGetAssembliesForWbsElement = (wbsNum: WbsNumber) => { return useQuery(['assemblies', wbsPipe(wbsNum)], async () => { const { data } = await getAssembliesForWbsElement(wbsNum); diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTable.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTable.tsx index 0becab27bd..3886499dbf 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTable.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTable.tsx @@ -13,9 +13,21 @@ interface BOMTableProps { columns: GridColumns; materials: Material[]; assemblies: Assembly[]; + processRowUpdate: (newRow: BomRow, oldRow: BomRow) => Promise; + onProcessRowUpdateError: (error: unknown) => void; + editPerms: boolean; } -const BOMTable: React.FC = ({ setHideColumn, assignMaterial, columns, materials, assemblies }) => { +const BOMTable: React.FC = ({ + setHideColumn, + assignMaterial, + columns, + materials, + assemblies, + processRowUpdate, + onProcessRowUpdateError, + editPerms +}) => { const [openRows, setOpenRows] = useState([]); const [draggedMaterial, setDraggedMaterial] = useState(null); @@ -146,6 +158,12 @@ const BOMTable: React.FC = ({ setHideColumn, assignMaterial, colu sx={bomTableStyles.datagrid} disableSelectionOnClick autoHeight={false} + experimentalFeatures={{ newEditingApi: true }} + processRowUpdate={ + processRowUpdate as unknown as (newRow: GridValidRowModel, oldRow: GridValidRowModel) => Promise + } + onProcessRowUpdateError={onProcessRowUpdateError} + isCellEditable={(params) => editPerms && !String(params.row.id).startsWith('assembly')} onRowClick={openAssembly} componentsProps={{ row: { diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTableCustomCells.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTableCustomCells.tsx index dd92e2b385..7fd3910679 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTableCustomCells.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTableCustomCells.tsx @@ -1,7 +1,8 @@ +import { useState } from 'react'; import { Box } from '@mui/system'; import { GridRenderCellParams } from '@mui/x-data-grid'; import { MaterialStatus } from 'shared'; -import { Typography } from '@mui/material'; +import { Menu, MenuItem, Typography } from '@mui/material'; import { displayEnum } from '../../../../utils/pipes'; const getStatusColor = (status: MaterialStatus) => { @@ -33,6 +34,76 @@ const bomStatusChipStyle = (status: MaterialStatus) => ({ textAlign: 'center' }); +interface StatusDropdownCellProps { + status: MaterialStatus; + disabled?: boolean; + onStatusChange: (newStatus: MaterialStatus) => void; +} + +export const StatusDropdownCell: React.FC = ({ status, disabled, onStatusChange }) => { + const [anchorEl, setAnchorEl] = useState(null); + + const handleClick = (event: React.MouseEvent) => { + event.stopPropagation(); + if (!disabled) setAnchorEl(event.currentTarget); + }; + + const handleClose = () => setAnchorEl(null); + + const handleSelect = (newStatus: MaterialStatus) => { + onStatusChange(newStatus); + handleClose(); + }; + + return ( + <> + + + {displayEnum(status)} + + + + {Object.values(MaterialStatus) + .filter((s) => s !== status) + .map((s) => { + const chipStyle = bomStatusChipStyle(s); + return ( + handleSelect(s)} sx={{ padding: 0 }}> + + + {displayEnum(s)} + + + + ); + })} + + + ); +}; + export const renderStatusBOM = (params: GridRenderCellParams) => { if (!params.value) return; const status = params.value as MaterialStatus; diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTableWrapper.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTableWrapper.tsx index 4345afcf96..5e115af301 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTableWrapper.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTableWrapper.tsx @@ -2,20 +2,29 @@ import { Box } from '@mui/system'; import { GridActionsCellItem, GridColumns, GridRowParams } from '@mui/x-data-grid'; import { useEffect, useState } from 'react'; import { Assembly, Material, Project, isLeadership } from 'shared'; +import Decimal from 'decimal.js'; import DeleteIcon from '@mui/icons-material/Delete'; import EditIcon from '@mui/icons-material/Edit'; import MoveToInboxIcon from '@mui/icons-material/MoveToInbox'; import { useCurrentUser } from '../../../../hooks/users.hooks'; import BOMTable from './BOMTable'; import { useToast } from '../../../../hooks/toasts.hooks'; -import { useAssignMaterialToAssembly, useDeleteAssembly, useDeleteMaterial } from '../../../../hooks/bom.hooks'; +import { + useAssignMaterialToAssembly, + useDeleteAssembly, + useDeleteMaterial, + useEditMaterialById, + useGetAllManufacturers, + useGetAllMaterialTypes +} from '../../../../hooks/bom.hooks'; import LoadingIndicator from '../../../../components/LoadingIndicator'; import EditMaterialModal from './MaterialForm/EditMaterialModal'; +import { BomRow, bomBaseColDef } from '../../../../utils/bom.utils'; +import { centsToDollar } from '../../../../utils/pipes'; 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'; +import { StatusDropdownCell } from './BOMTableCustomCells'; import LinkIcon from '@mui/icons-material/Link'; import NotesIcon from '@mui/icons-material/Notes'; import { routes } from '../../../../utils/routes'; @@ -44,6 +53,9 @@ const BOMTableWrapper: React.FC = ({ const { mutateAsync: deleteMaterialMutateAsync, isLoading: deleteMaterialIsLoading } = useDeleteMaterial(project.wbsNum); const { mutateAsync: deleteAssemblyMutateAsync, isLoading: deleteAssemblyIsLoading } = useDeleteAssembly(project.wbsNum); const { mutateAsync: assignMaterialToAssembly } = useAssignMaterialToAssembly(); + const { mutateAsync: editMaterial } = useEditMaterialById(project.wbsNum); + const { data: materialTypes } = useGetAllMaterialTypes(); + const { data: manufacturers } = useGetAllManufacturers(); const [windowWidth, setWindowWidth] = useState(window.innerWidth); @@ -115,6 +127,84 @@ const BOMTableWrapper: React.FC = ({ project.teams.some((team) => team.leads.map((lead) => lead.userId).includes(user.userId)) || project.teams.some((team) => team.members.map((member) => member.userId).includes(user.userId)); + const processRowUpdate = async (newRow: BomRow, oldRow: BomRow): Promise => { + // assemblies are not editable + if (String(newRow.id).startsWith('assembly')) return newRow; + + const material = materials.find((m) => m.materialId === newRow.materialId); + if (!material) return newRow; + + const newQuantity = typeof newRow.quantity === 'number' ? (newRow.quantity as number) : null; + const newPriceDollars = typeof newRow.price === 'number' ? (newRow.price as number) : null; + + if ( + newRow.name === oldRow.name && + newRow.type === oldRow.type && + newRow.manufacturer === oldRow.manufacturer && + newRow.manufacturerPN === oldRow.manufacturerPN && + newRow.pdmFileName === oldRow.pdmFileName && + newQuantity === null && + newPriceDollars === null + ) + return newRow; + + if (newRow.name !== undefined && !newRow.name.trim()) { + toast.error('Name cannot be empty'); + return oldRow; + } + if (newQuantity !== null && (isNaN(newQuantity) || newQuantity <= 0)) { + toast.error('Quantity must be a positive number'); + return oldRow; + } + if (newPriceDollars !== null && (isNaN(newPriceDollars) || newPriceDollars < 0)) { + toast.error('Price must be a non-negative number'); + return oldRow; + } + + const changedFields: string[] = []; + if (newRow.name !== oldRow.name) changedFields.push('Name'); + if (newRow.type !== oldRow.type) changedFields.push('Type'); + if (newRow.manufacturer !== oldRow.manufacturer) changedFields.push('Manufacturer'); + if (newRow.manufacturerPN !== oldRow.manufacturerPN) changedFields.push('Manufacturer PN'); + if (newRow.pdmFileName !== oldRow.pdmFileName) changedFields.push('PDM File Name'); + if (newQuantity !== null) changedFields.push('Quantity'); + if (newPriceDollars !== null) changedFields.push('Price'); + + const priceInCents = newPriceDollars !== null ? Math.round(newPriceDollars * 100) : material.price; + const quantityValue = newQuantity !== null ? newQuantity : Number(material.quantity); + + try { + await editMaterial({ + materialId: material.materialId, + payload: { + name: newRow.name, + status: material.status, + materialTypeName: newRow.type, + manufacturerName: newRow.manufacturer || undefined, + manufacturerPartNumber: newRow.manufacturerPN || undefined, + pdmFileName: newRow.pdmFileName, + price: priceInCents, + quantity: new Decimal(quantityValue), + unitName: material.unitName, + linkUrl: material.linkUrl, + notes: material.notes, + assemblyId: material.assemblyId + } + }); + toast.success(`Material ${changedFields.join(', ')} updated successfully`); + return { + ...newRow, + quantity: material.unitName ? `${quantityValue} ${material.unitName}` : `${quantityValue}`, + quantityRaw: quantityValue, + price: priceInCents !== undefined ? `$${centsToDollar(priceInCents)}` : newRow.price, + priceRaw: priceInCents !== undefined ? priceInCents / 100 : newRow.priceRaw + }; + } catch (e: unknown) { + if (e instanceof Error) toast.error(e.message, 6000); + return oldRow; + } + }; + const selectedMaterial = materials.find((material) => material.materialId === selectedMaterialId); const getActions = (params: GridRowParams) => { @@ -276,10 +366,45 @@ const BOMTableWrapper: React.FC = ({ }, { ...bomBaseColDef, - flex: 1.2, + flex: 1.4, field: 'status', headerName: 'Status', - renderCell: renderStatusBOM, + renderCell: (params) => { + // assemblies are not editable + if (!params.value || String(params.row.id).startsWith('assembly')) return null; + const material = materials.find((m) => m.materialId === params.row.materialId); + if (!material) return null; + return ( + { + try { + await editMaterial({ + materialId: material.materialId, + payload: { + name: material.name, + status: newStatus, + materialTypeName: material.materialTypeName, + manufacturerName: material.manufacturerName, + manufacturerPartNumber: material.manufacturerPartNumber, + pdmFileName: material.pdmFileName, + price: material.price, + quantity: material.quantity, + unitName: material.unitName, + linkUrl: material.linkUrl, + notes: material.notes, + assemblyId: material.assemblyId + } + }); + toast.success('Status updated successfully'); + } catch (e: unknown) { + if (e instanceof Error) toast.error(e.message, 6000); + } + }} + /> + ); + }, sortable: false, filterable: false, hide: hideColumn[1] @@ -288,7 +413,9 @@ const BOMTableWrapper: React.FC = ({ ...bomBaseColDef, field: 'type', headerName: 'Type', - type: 'string', + editable: editPerms, + type: 'singleSelect', + valueOptions: materialTypes?.map((mt) => mt.name) ?? [], sortable: false, filterable: false, hide: hideColumn[2] @@ -298,7 +425,7 @@ const BOMTableWrapper: React.FC = ({ flex: 1.5, field: 'name', headerName: 'Name', - type: 'string', + editable: editPerms, sortable: false, filterable: false, hide: hideColumn[3], @@ -321,7 +448,9 @@ const BOMTableWrapper: React.FC = ({ flex: 1.2, field: 'manufacturer', headerName: 'Manufacturer', - type: 'string', + editable: editPerms, + type: 'singleSelect', + valueOptions: ['', ...(manufacturers?.map((m) => m.name) ?? [])], sortable: false, filterable: false, hide: hideColumn[4] @@ -331,7 +460,7 @@ const BOMTableWrapper: React.FC = ({ flex: 1.5, field: 'manufacturerPN', headerName: 'Manufacterer PN', - type: 'string', + editable: editPerms, sortable: false, filterable: false, colSpan: ({ row }) => { @@ -347,7 +476,7 @@ const BOMTableWrapper: React.FC = ({ flex: 1.3, field: 'pdmFileName', headerName: 'PDM File Name', - type: 'string', + editable: editPerms, sortable: false, filterable: false, hide: hideColumn[6] @@ -357,6 +486,9 @@ const BOMTableWrapper: React.FC = ({ field: 'quantity', headerName: 'Quantity', type: 'number', + editable: editPerms, + valueGetter: (params) => params.row.quantityRaw, + renderCell: (params) => params.row.quantity, sortable: false, filterable: false, hide: hideColumn[7] @@ -366,6 +498,9 @@ const BOMTableWrapper: React.FC = ({ field: 'price', headerName: 'Price per Unit', type: 'number', + editable: editPerms, + valueGetter: (params) => params.row.priceRaw, + renderCell: (params) => params.row.price, sortable: false, filterable: false, hide: hideColumn[8] @@ -374,7 +509,6 @@ const BOMTableWrapper: React.FC = ({ ...bomBaseColDef, field: 'subtotal', headerName: 'Subtotal', - type: 'number', sortable: false, filterable: false, hide: hideColumn[9] @@ -423,6 +557,11 @@ const BOMTableWrapper: React.FC = ({ columns={columns} assemblies={assemblies} materials={materials} + processRowUpdate={processRowUpdate} + onProcessRowUpdateError={(error) => { + if (error instanceof Error) toast.error(error.message, 6000); + }} + editPerms={editPerms} /> ); diff --git a/src/frontend/src/utils/bom.utils.ts b/src/frontend/src/utils/bom.utils.ts index 9b78ff43a8..122afe2117 100644 --- a/src/frontend/src/utils/bom.utils.ts +++ b/src/frontend/src/utils/bom.utils.ts @@ -15,7 +15,9 @@ export interface BomRow extends GridValidRowModel { manufacturerPN: string; pdmFileName: string; quantity: string; + quantityRaw?: number; price: string; + priceRaw?: number; subtotal: string; link: string; notes: string | undefined; @@ -33,9 +35,11 @@ export const materialToRow = (material: Material, idx: number): BomRow => { name: material.name, manufacturer: material.manufacturerName ?? '', manufacturerPN: material.manufacturerPartNumber ?? '', - pdmFileName: material.pdmFileName ?? 'None', + pdmFileName: material.pdmFileName ?? '', quantity: material.quantity + (material.unitName ? ' ' + material.unitName : ''), + quantityRaw: material.quantity !== undefined ? Number(material.quantity) : undefined, price: material.price !== undefined ? `$${centsToDollar(material.price)}` : '', + priceRaw: material.price !== undefined ? material.price / 100 : undefined, subtotal: material.subtotal !== undefined ? `$${centsToDollar(material.subtotal)}` : '', link: material.linkUrl, notes: material.notes,