From 224b45a930d0d5713f8107f50a007295cdd6debc Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Wed, 8 Apr 2026 20:32:34 -0400 Subject: [PATCH 01/11] #4107 inline status dropdown --- src/frontend/src/hooks/bom.hooks.ts | 21 +++++ .../BOM/BOMTableCustomCells.tsx | 80 ++++++++++++++++++- .../BOM/BOMTableWrapper.tsx | 48 ++++++++++- 3 files changed, 142 insertions(+), 7 deletions(-) diff --git a/src/frontend/src/hooks/bom.hooks.ts b/src/frontend/src/hooks/bom.hooks.ts index 85539f970e..1681e74b8b 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 useEditMaterialStatus = (wbsNum: WbsNumber) => { + const queryClient = useQueryClient(); + return useMutation( + ['materials', 'edit', 'status'], + 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/BOMTableCustomCells.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTableCustomCells.tsx index dd92e2b385..bdf85e9329 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTableCustomCells.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTableCustomCells.tsx @@ -1,7 +1,13 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +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) => { @@ -16,8 +22,6 @@ const getStatusColor = (status: MaterialStatus) => { return '#1b537a'; case MaterialStatus.ReadyToOrder: return '#D34B27'; - default: - return 'grey'; } }; @@ -33,6 +37,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 c9afa898dc..8982dc1026 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTableWrapper.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTableWrapper.tsx @@ -8,13 +8,18 @@ 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, + useEditMaterialStatus +} from '../../../../hooks/bom.hooks'; import LoadingIndicator from '../../../../components/LoadingIndicator'; import EditMaterialModal from './MaterialForm/EditMaterialModal'; import { Button, Link, 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'; @@ -42,6 +47,7 @@ const BOMTableWrapper: React.FC = ({ const [modalShow, setModalShow] = useState(false); const { mutateAsync: deleteMaterialMutateAsync, isLoading: deleteMaterialIsLoading } = useDeleteMaterial(project.wbsNum); const { mutateAsync: deleteAssemblyMutateAsync, isLoading: deleteAssemblyIsLoading } = useDeleteAssembly(project.wbsNum); + const { mutateAsync: editMaterialStatus } = useEditMaterialStatus(project.wbsNum); const { mutateAsync: assignMaterialToAssembly } = useAssignMaterialToAssembly(); const [windowWidth, setWindowWidth] = useState(window.innerWidth); @@ -275,10 +281,44 @@ const BOMTableWrapper: React.FC = ({ }, { ...bomBaseColDef, - flex: 1.2, + flex: 1.4, field: 'status', headerName: 'Status', - renderCell: renderStatusBOM, + renderCell: (params) => { + 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 editMaterialStatus({ + 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] From 3fa446c95138a922029a0625319fee1c6fcfe97b Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Sat, 11 Apr 2026 09:50:28 -0400 Subject: [PATCH 02/11] #4107 inline editing with error handling + pdm logic update --- src/backend/src/services/boms.services.ts | 10 +- .../ProjectViewContainer/BOM/BOMTable.tsx | 20 ++- .../BOM/BOMTableWrapper.tsx | 115 ++++++++++++++++-- src/frontend/src/utils/bom.utils.ts | 6 +- 4 files changed, 139 insertions(+), 12 deletions(-) diff --git a/src/backend/src/services/boms.services.ts b/src/backend/src/services/boms.services.ts index a7f434267f..3fd8645de5 100644 --- a/src/backend/src/services/boms.services.ts +++ b/src/backend/src/services/boms.services.ts @@ -669,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: { @@ -680,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/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTable.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTable.tsx index fe7b8531b1..e016b4e917 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); @@ -145,6 +157,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/BOMTableWrapper.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTableWrapper.tsx index 8982dc1026..967d256078 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTableWrapper.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTableWrapper.tsx @@ -2,6 +2,7 @@ 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'; @@ -12,12 +13,15 @@ import { useAssignMaterialToAssembly, useDeleteAssembly, useDeleteMaterial, - useEditMaterialStatus + useEditMaterialStatus, + useGetAllManufacturers, + useGetAllMaterialTypes } from '../../../../hooks/bom.hooks'; import LoadingIndicator from '../../../../components/LoadingIndicator'; import EditMaterialModal from './MaterialForm/EditMaterialModal'; import { Button, Link, Typography } from '@mui/material'; -import { bomBaseColDef } from '../../../../utils/bom.utils'; +import { BomRow, bomBaseColDef } from '../../../../utils/bom.utils'; +import { centsToDollar } from '../../../../utils/pipes'; import NERModal from '../../../../components/NERModal'; import { StatusDropdownCell } from './BOMTableCustomCells'; import LinkIcon from '@mui/icons-material/Link'; @@ -49,6 +53,8 @@ const BOMTableWrapper: React.FC = ({ const { mutateAsync: deleteAssemblyMutateAsync, isLoading: deleteAssemblyIsLoading } = useDeleteAssembly(project.wbsNum); const { mutateAsync: editMaterialStatus } = useEditMaterialStatus(project.wbsNum); const { mutateAsync: assignMaterialToAssembly } = useAssignMaterialToAssembly(); + const { data: materialTypes } = useGetAllMaterialTypes(); + const { data: manufacturers } = useGetAllManufacturers(); const [windowWidth, setWindowWidth] = useState(window.innerWidth); @@ -120,6 +126,85 @@ 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; + + // MUI writes the edited number directly to the field, so we detect changes via typeof + 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 editMaterialStatus({ + 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) => { @@ -327,7 +412,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] @@ -337,7 +424,7 @@ const BOMTableWrapper: React.FC = ({ flex: 1.5, field: 'name', headerName: 'Name', - type: 'string', + editable: editPerms, sortable: false, filterable: false, hide: hideColumn[3] @@ -347,7 +434,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] @@ -357,7 +446,7 @@ const BOMTableWrapper: React.FC = ({ flex: 1.5, field: 'manufacturerPN', headerName: 'Manufacterer PN', - type: 'string', + editable: editPerms, sortable: false, filterable: false, colSpan: ({ row }) => { @@ -373,7 +462,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] @@ -383,6 +472,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] @@ -392,6 +484,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] @@ -400,7 +495,6 @@ const BOMTableWrapper: React.FC = ({ ...bomBaseColDef, field: 'subtotal', headerName: 'Subtotal', - type: 'number', sortable: false, filterable: false, hide: hideColumn[9] @@ -449,6 +543,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 79aa5950e6..1e136b6ca8 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; @@ -32,9 +34,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, From 6b3726bec25b5dbfc211d74eb7d01f971488dcfc Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Sat, 11 Apr 2026 10:12:28 -0400 Subject: [PATCH 03/11] #4107 fixed naming --- src/frontend/src/hooks/bom.hooks.ts | 4 ++-- .../ProjectViewContainer/BOM/BOMTableCustomCells.tsx | 7 ++----- .../ProjectViewContainer/BOM/BOMTableWrapper.tsx | 10 +++++----- 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/frontend/src/hooks/bom.hooks.ts b/src/frontend/src/hooks/bom.hooks.ts index 1681e74b8b..c4f99c9aea 100644 --- a/src/frontend/src/hooks/bom.hooks.ts +++ b/src/frontend/src/hooks/bom.hooks.ts @@ -318,10 +318,10 @@ export const useCreateMaterialType = () => { * @param wbsNum The wbs element the material belongs to * @returns mutation function to edit a material's status */ -export const useEditMaterialStatus = (wbsNum: WbsNumber) => { +export const useEditMaterialById = (wbsNum: WbsNumber) => { const queryClient = useQueryClient(); return useMutation( - ['materials', 'edit', 'status'], + ['materials', 'edit'], async ({ materialId, payload }) => { const data = await editMaterial(materialId, payload); return data; diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTableCustomCells.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTableCustomCells.tsx index bdf85e9329..7fd3910679 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTableCustomCells.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTableCustomCells.tsx @@ -1,8 +1,3 @@ -/* - * This file is part of NER's FinishLine and licensed under GNU AGPLv3. - * See the LICENSE file in the repository root folder for details. - */ - import { useState } from 'react'; import { Box } from '@mui/system'; import { GridRenderCellParams } from '@mui/x-data-grid'; @@ -22,6 +17,8 @@ const getStatusColor = (status: MaterialStatus) => { return '#1b537a'; case MaterialStatus.ReadyToOrder: return '#D34B27'; + default: + return 'grey'; } }; diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTableWrapper.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTableWrapper.tsx index 967d256078..bcdf02aaf8 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTableWrapper.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTableWrapper.tsx @@ -13,7 +13,7 @@ import { useAssignMaterialToAssembly, useDeleteAssembly, useDeleteMaterial, - useEditMaterialStatus, + useEditMaterialById, useGetAllManufacturers, useGetAllMaterialTypes } from '../../../../hooks/bom.hooks'; @@ -51,8 +51,8 @@ const BOMTableWrapper: React.FC = ({ const [modalShow, setModalShow] = useState(false); const { mutateAsync: deleteMaterialMutateAsync, isLoading: deleteMaterialIsLoading } = useDeleteMaterial(project.wbsNum); const { mutateAsync: deleteAssemblyMutateAsync, isLoading: deleteAssemblyIsLoading } = useDeleteAssembly(project.wbsNum); - const { mutateAsync: editMaterialStatus } = useEditMaterialStatus(project.wbsNum); const { mutateAsync: assignMaterialToAssembly } = useAssignMaterialToAssembly(); + const { mutateAsync: editMaterial } = useEditMaterialById(project.wbsNum); const { data: materialTypes } = useGetAllMaterialTypes(); const { data: manufacturers } = useGetAllManufacturers(); @@ -133,7 +133,6 @@ const BOMTableWrapper: React.FC = ({ const material = materials.find((m) => m.materialId === newRow.materialId); if (!material) return newRow; - // MUI writes the edited number directly to the field, so we detect changes via typeof const newQuantity = typeof newRow.quantity === 'number' ? (newRow.quantity as number) : null; const newPriceDollars = typeof newRow.price === 'number' ? (newRow.price as number) : null; @@ -174,7 +173,7 @@ const BOMTableWrapper: React.FC = ({ const quantityValue = newQuantity !== null ? newQuantity : Number(material.quantity); try { - await editMaterialStatus({ + await editMaterial({ materialId: material.materialId, payload: { name: newRow.name, @@ -370,6 +369,7 @@ const BOMTableWrapper: React.FC = ({ field: 'status', headerName: 'Status', 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; @@ -379,7 +379,7 @@ const BOMTableWrapper: React.FC = ({ disabled={!editPerms} onStatusChange={async (newStatus) => { try { - await editMaterialStatus({ + await editMaterial({ materialId: material.materialId, payload: { name: material.name, From 5152789a93fbe624cd8ecdb0fa1a912a3cab9bf7 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Sat, 11 Apr 2026 10:23:23 -0400 Subject: [PATCH 04/11] #4107 typescript fix --- src/backend/src/services/boms.services.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/backend/src/services/boms.services.ts b/src/backend/src/services/boms.services.ts index 3a6964ac94..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, From 8ee4a71986e935bcba1507fbcb4d4318bba47f87 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Sat, 11 Apr 2026 19:05:49 -0400 Subject: [PATCH 05/11] #4107 test fix --- src/backend/src/controllers/projects.controllers.ts | 2 -- src/backend/tests/unmocked/project.test.ts | 3 +-- 2 files changed, 1 insertion(+), 4 deletions(-) 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/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'); From 1592ff5d191315d1e7c4ecd7320c3688050c622c Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Thu, 16 Apr 2026 13:55:17 -0400 Subject: [PATCH 06/11] #4107 removed subtotal from backend --- .../src/controllers/projects.controllers.ts | 2 - src/backend/src/prisma/seed.ts | 3 -- src/backend/src/services/boms.services.ts | 8 ++-- src/backend/src/utils/validation.utils.ts | 1 - .../BOM/BOMTableWrapper.tsx | 38 +++++++++---------- .../BOM/ImportBOMModal.tsx | 1 - .../BOM/MaterialForm/MaterialForm.tsx | 4 +- 7 files changed, 23 insertions(+), 34 deletions(-) diff --git a/src/backend/src/controllers/projects.controllers.ts b/src/backend/src/controllers/projects.controllers.ts index a01e868ebf..ccf3b8bc4c 100644 --- a/src/backend/src/controllers/projects.controllers.ts +++ b/src/backend/src/controllers/projects.controllers.ts @@ -220,7 +220,6 @@ export default class ProjectsController { quantity, unitName, price, - subtotal, linkUrl, notes } = req.body; @@ -237,7 +236,6 @@ export default class ProjectsController { manufacturerPartNumber, quantity, price, - subtotal, notes, assemblyId, pdmFileName, diff --git a/src/backend/src/prisma/seed.ts b/src/backend/src/prisma/seed.ts index 6734f879af..478a9da357 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -3529,7 +3529,6 @@ const performSeed: () => Promise = async () => { 'abcdef', new Decimal(20), 30, - 600, 'Here are some notes', assembly1.assemblyId, undefined, @@ -3552,7 +3551,6 @@ const performSeed: () => Promise = async () => { 'bacfed', new Decimal(10), 7, - 70, 'Here are some more notes', undefined, undefined, @@ -3575,7 +3573,6 @@ const performSeed: () => Promise = async () => { 'lalsd', new Decimal(5), 10, - 50, undefined, undefined, undefined diff --git a/src/backend/src/services/boms.services.ts b/src/backend/src/services/boms.services.ts index 90147d4d78..7f26044edc 100644 --- a/src/backend/src/services/boms.services.ts +++ b/src/backend/src/services/boms.services.ts @@ -56,7 +56,6 @@ export default class BillOfMaterialsService { * @param manufacturerPartNumber the manufacturer part number for the material (optional) * @param quantity the quantity of material as a number (optional) * @param price the price of the material in whole cents (optional) - * @param subtotal the subtotal of the price for the material in whole cents (optional) * @param notes any notes about the material as a string (optional) * @param assemblyId the id of the Assembly for the material (optional) * @param pdmFileName the name of the pdm file for the material (optional) @@ -75,7 +74,6 @@ export default class BillOfMaterialsService { manufacturerPartNumber?: string, quantity?: Decimal, price?: number, - subtotal?: number, notes?: string, assemblyId?: string, pdmFileName?: string, @@ -119,6 +117,9 @@ export default class BillOfMaterialsService { if (!perms) throw new AccessDeniedException('create materials'); + const computedSubtotal = + price !== undefined && quantity !== undefined ? Math.round(price * Number(quantity)) : undefined; + const createdMaterial = await prisma.material.create({ data: { userCreatedId: creator.userId, @@ -132,7 +133,7 @@ export default class BillOfMaterialsService { quantity, unitId: unit ? unit.id : null, price, - subtotal, + subtotal: computedSubtotal, linkUrl, notes, dateCreated: new Date(), @@ -611,7 +612,6 @@ export default class BillOfMaterialsService { * @param manufacturerPartNumber the manufacturerPartNumber of the edited material (optional) * @param quantity the quantity of the edited material (optional) * @param price the price of the edited material (optional) - * @param subtotal the subtotal of the edited material (optional) * @param notes the notes of the edited material (optional) * @param unitName the unit name of the edited material (optional) * @param assemblyId the assembly id of the edited material (optional) diff --git a/src/backend/src/utils/validation.utils.ts b/src/backend/src/utils/validation.utils.ts index 5e5a8758d5..883f1865c4 100644 --- a/src/backend/src/utils/validation.utils.ts +++ b/src/backend/src/utils/validation.utils.ts @@ -284,7 +284,6 @@ export const materialValidators = [ decimalMinZero(body('quantity')).optional(), nonEmptyString(body('unitName')).optional(), intMinZero(body('price')).optional(), // in cents - intMinZero(body('subtotal')).optional(), // in cents body('linkUrl').optional().isString(), body('notes').isString().optional() ]; diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTableWrapper.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTableWrapper.tsx index 5e115af301..5d4cc35fb3 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTableWrapper.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTableWrapper.tsx @@ -129,13 +129,13 @@ const BOMTableWrapper: React.FC = ({ const processRowUpdate = async (newRow: BomRow, oldRow: BomRow): Promise => { // assemblies are not editable - if (String(newRow.id).startsWith('assembly')) return newRow; + if (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; + const quantityChanged = newRow.quantityRaw !== oldRow.quantityRaw; + const priceChanged = newRow.priceRaw !== oldRow.priceRaw; if ( newRow.name === oldRow.name && @@ -143,8 +143,8 @@ const BOMTableWrapper: React.FC = ({ newRow.manufacturer === oldRow.manufacturer && newRow.manufacturerPN === oldRow.manufacturerPN && newRow.pdmFileName === oldRow.pdmFileName && - newQuantity === null && - newPriceDollars === null + !quantityChanged && + !priceChanged ) return newRow; @@ -152,11 +152,11 @@ const BOMTableWrapper: React.FC = ({ toast.error('Name cannot be empty'); return oldRow; } - if (newQuantity !== null && (isNaN(newQuantity) || newQuantity <= 0)) { + if (quantityChanged && newRow.quantityRaw !== undefined && (isNaN(newRow.quantityRaw) || newRow.quantityRaw <= 0)) { toast.error('Quantity must be a positive number'); return oldRow; } - if (newPriceDollars !== null && (isNaN(newPriceDollars) || newPriceDollars < 0)) { + if (priceChanged && newRow.priceRaw !== undefined && (isNaN(newRow.priceRaw) || newRow.priceRaw < 0)) { toast.error('Price must be a non-negative number'); return oldRow; } @@ -167,11 +167,12 @@ const BOMTableWrapper: React.FC = ({ 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'); + if (quantityChanged) changedFields.push('Quantity'); + if (priceChanged) changedFields.push('Price'); - const priceInCents = newPriceDollars !== null ? Math.round(newPriceDollars * 100) : material.price; - const quantityValue = newQuantity !== null ? newQuantity : Number(material.quantity); + const priceInCents = priceChanged && newRow.priceRaw !== undefined ? Math.round(newRow.priceRaw * 100) : material.price; + const quantityValue = + quantityChanged && newRow.quantityRaw != null ? new Decimal(newRow.quantityRaw) : material.quantity; try { await editMaterial({ @@ -183,8 +184,8 @@ const BOMTableWrapper: React.FC = ({ manufacturerName: newRow.manufacturer || undefined, manufacturerPartNumber: newRow.manufacturerPN || undefined, pdmFileName: newRow.pdmFileName, - price: priceInCents, - quantity: new Decimal(quantityValue), + price: priceInCents, + quantity: quantityValue, unitName: material.unitName, linkUrl: material.linkUrl, notes: material.notes, @@ -195,7 +196,6 @@ const BOMTableWrapper: React.FC = ({ 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 }; @@ -209,7 +209,7 @@ const BOMTableWrapper: React.FC = ({ const getActions = (params: GridRowParams) => { const actions: JSX.Element[] = []; - const rowId = String(params.row.id); + const rowId = params.row.id; const material = materials.find((mat) => mat.materialId === params.row.materialId); const shouldShowInMenu = windowWidth < 1000; @@ -371,7 +371,7 @@ const BOMTableWrapper: React.FC = ({ headerName: 'Status', renderCell: (params) => { // assemblies are not editable - if (!params.value || String(params.row.id).startsWith('assembly')) return null; + if (!params.value || params.row.id.startsWith('assembly')) return null; const material = materials.find((m) => m.materialId === params.row.materialId); if (!material) return null; return ( @@ -483,11 +483,10 @@ const BOMTableWrapper: React.FC = ({ }, { ...bomBaseColDef, - field: 'quantity', + field: 'quantityRaw', headerName: 'Quantity', type: 'number', editable: editPerms, - valueGetter: (params) => params.row.quantityRaw, renderCell: (params) => params.row.quantity, sortable: false, filterable: false, @@ -495,11 +494,10 @@ const BOMTableWrapper: React.FC = ({ }, { ...bomBaseColDef, - field: 'price', + field: 'priceRaw', headerName: 'Price per Unit', type: 'number', editable: editPerms, - valueGetter: (params) => params.row.priceRaw, renderCell: (params) => params.row.price, sortable: false, filterable: false, diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/ImportBOMModal.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/ImportBOMModal.tsx index 6f04769f19..b95de53459 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/ImportBOMModal.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/ImportBOMModal.tsx @@ -350,7 +350,6 @@ const ImportBOMModal: React.FC = ({ open, onHide, wbsNum, a manufacturerPartNumber: material.manufacturerPartNumber || undefined, quantity: new Decimal(material.quantity), price: material.unitPrice, - subtotal: material.subtotal, unitName: material.unit || undefined, assemblyId: material.assemblyId || undefined, linkUrl: '', diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/MaterialForm/MaterialForm.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/MaterialForm/MaterialForm.tsx index a815c2983c..23b48aa6c3 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/MaterialForm/MaterialForm.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/MaterialForm/MaterialForm.tsx @@ -60,7 +60,6 @@ export interface MaterialDataSubmission { linkUrl?: string; notes?: string; assemblyId?: string; - subtotal?: number; } export interface MaterialFormProps { @@ -124,8 +123,7 @@ const MaterialForm: React.FC = ({ const onSubmitWrapper = (data: MaterialFormInput): void => { const price = data.price != null ? Math.round(data.price * 100) : undefined; - const subtotal = price != null && data.quantity != null ? parseFloat((data.quantity * price).toFixed(2)) : undefined; - onSubmit({ ...data, subtotal, price, quantity: data.quantity != null ? new Decimal(data.quantity) : undefined }); + onSubmit({ ...data, price, quantity: data.quantity != null ? new Decimal(data.quantity) : undefined }); }; const createManufacturerWrapper = async (manufacturerName: string): Promise => { From c9b66a03f09950adcb472a28fec6cdc9cc885f39 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Thu, 16 Apr 2026 15:34:21 -0400 Subject: [PATCH 07/11] #4107 test and prettier fix --- src/backend/tests/unmocked/project.test.ts | 10 +++------- .../ProjectViewContainer/BOM/BOMTableWrapper.tsx | 2 +- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/backend/tests/unmocked/project.test.ts b/src/backend/tests/unmocked/project.test.ts index aff632f26e..3e197a130e 100644 --- a/src/backend/tests/unmocked/project.test.ts +++ b/src/backend/tests/unmocked/project.test.ts @@ -40,8 +40,7 @@ describe('Material Tests', () => { manufacturer.name, 'lalsd', new Decimal(5), - 10, - 50 + 10 ); expect(material.name).toEqual('100k Resistor'); @@ -99,7 +98,6 @@ describe('Material Tests', () => { 'CAP-100UF', new Decimal(10), 50, - 500, 'Test notes' ); @@ -114,8 +112,7 @@ describe('Material Tests', () => { manufacturer.name, 'CAP-220UF', new Decimal(5), - 75, - 375 + 75 ); const newMaterialIds = await BillOfMaterials.copyMaterialsToProject( @@ -185,8 +182,7 @@ describe('Material Tests', () => { manufacturer.name, 'lalsd', new Decimal(5), - 10, - 50 + 10 ); const newMaterial = await BillOfMaterials.editMaterial( diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTableWrapper.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTableWrapper.tsx index 5d4cc35fb3..6b2b4e9f4f 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTableWrapper.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTableWrapper.tsx @@ -184,7 +184,7 @@ const BOMTableWrapper: React.FC = ({ manufacturerName: newRow.manufacturer || undefined, manufacturerPartNumber: newRow.manufacturerPN || undefined, pdmFileName: newRow.pdmFileName, - price: priceInCents, + price: priceInCents, quantity: quantityValue, unitName: material.unitName, linkUrl: material.linkUrl, From 02c269c0161795f55a08c8057ae01aa28d500fd8 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Thu, 16 Apr 2026 15:46:45 -0400 Subject: [PATCH 08/11] #4107 update test --- src/backend/tests/unmocked/reimbursement-requests.test.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/backend/tests/unmocked/reimbursement-requests.test.ts b/src/backend/tests/unmocked/reimbursement-requests.test.ts index 5d8541bb20..3464631899 100644 --- a/src/backend/tests/unmocked/reimbursement-requests.test.ts +++ b/src/backend/tests/unmocked/reimbursement-requests.test.ts @@ -1170,8 +1170,7 @@ describe('Reimbursement Requests', () => { manufacturerId: manufacturer.id, linkUrl: 'https://example.com', quantity: 1, - price: 100, - subtotal: 100 + price: 100 } }); }); @@ -1276,8 +1275,7 @@ describe('Reimbursement Requests', () => { manufacturerId: material.manufacturerId, linkUrl: 'https://example.com', quantity: 2, - price: 200, - subtotal: 400 + price: 200 } }); From 59b8f79a4c8633fe04cefbe90b6d3e5b7faf5eab Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Sat, 18 Apr 2026 11:20:25 -0400 Subject: [PATCH 09/11] #4107 simplified price/quanity + proper loading --- .../ProjectViewContainer/BOM/BOMTable.tsx | 5 +- .../BOM/BOMTableWrapper.tsx | 48 ++++++++++++------- src/frontend/src/utils/bom.utils.ts | 14 +++--- 3 files changed, 40 insertions(+), 27 deletions(-) diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTable.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTable.tsx index 3886499dbf..8d7af1f412 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTable.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTable.tsx @@ -70,8 +70,9 @@ const BOMTable: React.FC = ({ assembly.materials.reduce(addMaterialCosts, 0) )} ${arrowSymbol(assembly.assemblyId)}`, pdmFileName: '', - quantity: '', - price: '', + quantity: undefined, + price: undefined, + unitName: undefined, subtotal: '', link: '', notes: '', diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTableWrapper.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTableWrapper.tsx index 6b2b4e9f4f..b15d6d434e 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTableWrapper.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTableWrapper.tsx @@ -17,6 +17,7 @@ import { useGetAllManufacturers, useGetAllMaterialTypes } from '../../../../hooks/bom.hooks'; +import ErrorPage from '../../../ErrorPage'; import LoadingIndicator from '../../../../components/LoadingIndicator'; import EditMaterialModal from './MaterialForm/EditMaterialModal'; import { BomRow, bomBaseColDef } from '../../../../utils/bom.utils'; @@ -54,8 +55,18 @@ const BOMTableWrapper: React.FC = ({ 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 { + data: materialTypes, + isLoading: materialTypesIsLoading, + isError: materialTypesIsError, + error: materialTypesError + } = useGetAllMaterialTypes(); + const { + data: manufacturers, + isLoading: manufacturersIsLoading, + isError: manufacturersIsError, + error: manufacturersError + } = useGetAllManufacturers(); const [windowWidth, setWindowWidth] = useState(window.innerWidth); @@ -85,7 +96,10 @@ const BOMTableWrapper: React.FC = ({ } }, [setHideColumn]); - if (deleteMaterialIsLoading || deleteAssemblyIsLoading) return ; + if (deleteMaterialIsLoading || deleteAssemblyIsLoading || manufacturersIsLoading || materialTypesIsLoading) + return ; + if (manufacturersIsError) return ; + if (materialTypesIsError) return ; const assignMaterial = (materialId: string, assemblyId?: string) => async () => { try { @@ -134,8 +148,8 @@ const BOMTableWrapper: React.FC = ({ const material = materials.find((m) => m.materialId === newRow.materialId); if (!material) return newRow; - const quantityChanged = newRow.quantityRaw !== oldRow.quantityRaw; - const priceChanged = newRow.priceRaw !== oldRow.priceRaw; + const quantityChanged = newRow.quantity !== oldRow.quantity; + const priceChanged = newRow.price !== oldRow.price; if ( newRow.name === oldRow.name && @@ -152,11 +166,11 @@ const BOMTableWrapper: React.FC = ({ toast.error('Name cannot be empty'); return oldRow; } - if (quantityChanged && newRow.quantityRaw !== undefined && (isNaN(newRow.quantityRaw) || newRow.quantityRaw <= 0)) { + if (quantityChanged && newRow.quantity !== undefined && newRow.quantity <= 0) { toast.error('Quantity must be a positive number'); return oldRow; } - if (priceChanged && newRow.priceRaw !== undefined && (isNaN(newRow.priceRaw) || newRow.priceRaw < 0)) { + if (priceChanged && newRow.price !== undefined && newRow.price < 0) { toast.error('Price must be a non-negative number'); return oldRow; } @@ -170,9 +184,8 @@ const BOMTableWrapper: React.FC = ({ if (quantityChanged) changedFields.push('Quantity'); if (priceChanged) changedFields.push('Price'); - const priceInCents = priceChanged && newRow.priceRaw !== undefined ? Math.round(newRow.priceRaw * 100) : material.price; - const quantityValue = - quantityChanged && newRow.quantityRaw != null ? new Decimal(newRow.quantityRaw) : material.quantity; + const priceInCents = priceChanged && newRow.price !== undefined ? Math.round(newRow.price * 100) : material.price; + const quantityValue = quantityChanged && newRow.quantity != null ? new Decimal(newRow.quantity) : material.quantity; try { await editMaterial({ @@ -195,9 +208,8 @@ const BOMTableWrapper: React.FC = ({ toast.success(`Material ${changedFields.join(', ')} updated successfully`); return { ...newRow, - quantity: material.unitName ? `${quantityValue} ${material.unitName}` : `${quantityValue}`, - price: priceInCents !== undefined ? `$${centsToDollar(priceInCents)}` : newRow.price, - priceRaw: priceInCents !== undefined ? priceInCents / 100 : newRow.priceRaw + quantity: Number(quantityValue), + price: priceInCents !== undefined ? priceInCents / 100 : newRow.price }; } catch (e: unknown) { if (e instanceof Error) toast.error(e.message, 6000); @@ -483,22 +495,24 @@ const BOMTableWrapper: React.FC = ({ }, { ...bomBaseColDef, - field: 'quantityRaw', + field: 'quantity', headerName: 'Quantity', type: 'number', editable: editPerms, - renderCell: (params) => params.row.quantity, + renderCell: ({ value, row }) => (value != null ? (row.unitName ? `${value} ${row.unitName}` : `${value}`) : ''), // show unit (e.g. 5 kg) if available + valueParser: (value) => (value == null || value === '' ? undefined : Number(value)), // convert back to number for editing sortable: false, filterable: false, hide: hideColumn[7] }, { ...bomBaseColDef, - field: 'priceRaw', + field: 'price', headerName: 'Price per Unit', type: 'number', editable: editPerms, - renderCell: (params) => params.row.price, + renderCell: ({ value }) => (value != null ? `$${centsToDollar(Math.round(value * 100))}` : ''), // $ formatting with cents to dollar conversion + valueParser: (value) => (value == null || value === '' ? undefined : Number(value)), // convert back to number for editing sortable: false, filterable: false, hide: hideColumn[8] diff --git a/src/frontend/src/utils/bom.utils.ts b/src/frontend/src/utils/bom.utils.ts index 122afe2117..0d283b9dec 100644 --- a/src/frontend/src/utils/bom.utils.ts +++ b/src/frontend/src/utils/bom.utils.ts @@ -14,10 +14,9 @@ export interface BomRow extends GridValidRowModel { manufacturer: string; manufacturerPN: string; pdmFileName: string; - quantity: string; - quantityRaw?: number; - price: string; - priceRaw?: number; + quantity: number | undefined; + unitName: string | undefined; + price: number | undefined; subtotal: string; link: string; notes: string | undefined; @@ -36,10 +35,9 @@ export const materialToRow = (material: Material, idx: number): BomRow => { manufacturer: material.manufacturerName ?? '', manufacturerPN: material.manufacturerPartNumber ?? '', 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, + quantity: material.quantity !== undefined ? Number(material.quantity) : undefined, + unitName: material.unitName, + price: material.price !== undefined ? material.price / 100 : undefined, subtotal: material.subtotal !== undefined ? `$${centsToDollar(material.subtotal)}` : '', link: material.linkUrl, notes: material.notes, From c3949befc65bdf03c9c074fdfeb545992cd738dd Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Sat, 18 Apr 2026 11:31:54 -0400 Subject: [PATCH 10/11] #4107 simplify subtotal comp --- src/backend/src/services/boms.services.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/backend/src/services/boms.services.ts b/src/backend/src/services/boms.services.ts index 7f26044edc..9b74044036 100644 --- a/src/backend/src/services/boms.services.ts +++ b/src/backend/src/services/boms.services.ts @@ -670,8 +670,8 @@ export default class BillOfMaterialsService { } // recalculate subtotal on edits - const finalPrice = price !== undefined ? price : (material.price ?? undefined); - const finalQuantity = quantity !== undefined ? quantity : (material.quantity ?? undefined); + const finalPrice = price ?? material.price ?? undefined; + const finalQuantity = quantity ?? material.quantity ?? undefined; const computedSubtotal = finalPrice !== undefined && finalQuantity !== undefined ? Math.round(finalPrice * Number(finalQuantity)) : undefined; From 626f17b0cc7160f7dcf7cf8b11411d1d7db0f572 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Sat, 18 Apr 2026 15:43:54 -0400 Subject: [PATCH 11/11] #4107 remove casting and fix errors --- .../ProjectDetailPage/ProjectViewContainer/BOM/BOMTable.tsx | 4 ++-- .../ProjectViewContainer/BOM/BOMTableWrapper.tsx | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTable.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTable.tsx index 8d7af1f412..b2e5f176f1 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTable.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTable.tsx @@ -152,7 +152,7 @@ const BOMTable: React.FC = ({ rows={rows.concat(materialsWithAssemblies.filter(isAssemblyOpen))} getRowClassName={(params) => { const stripe = params.indexRelativeToCurrentPage % 2 === 0 ? 'even' : 'odd'; - const isAssemblyRow = String(params.row.id).startsWith('assembly-'); + const isAssemblyRow = params.row.id.startsWith('assembly-'); return `super-app-theme--${stripe}${isAssemblyRow ? ' super-app-theme--assembly' : ''}`; }} rowsPerPageOptions={[100]} @@ -164,7 +164,7 @@ const BOMTable: React.FC = ({ processRowUpdate as unknown as (newRow: GridValidRowModel, oldRow: GridValidRowModel) => Promise } onProcessRowUpdateError={onProcessRowUpdateError} - isCellEditable={(params) => editPerms && !String(params.row.id).startsWith('assembly')} + isCellEditable={(params) => editPerms && params.row.id.startsWith('assembly')} onRowClick={openAssembly} componentsProps={{ row: { diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTableWrapper.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTableWrapper.tsx index b15d6d434e..f5378e4481 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTableWrapper.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTableWrapper.tsx @@ -96,11 +96,12 @@ const BOMTableWrapper: React.FC = ({ } }, [setHideColumn]); - if (deleteMaterialIsLoading || deleteAssemblyIsLoading || manufacturersIsLoading || materialTypesIsLoading) - return ; if (manufacturersIsError) return ; if (materialTypesIsError) return ; + if (deleteMaterialIsLoading || deleteAssemblyIsLoading || manufacturersIsLoading || materialTypesIsLoading) + return ; + const assignMaterial = (materialId: string, assemblyId?: string) => async () => { try { await assignMaterialToAssembly({ materialId, assemblyId }).finally(() =>