Skip to content

Commit 3fa446c

Browse files
committed
#4107 inline editing with error handling + pdm logic update
1 parent 224b45a commit 3fa446c

4 files changed

Lines changed: 139 additions & 12 deletions

File tree

src/backend/src/services/boms.services.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -669,6 +669,12 @@ export default class BillOfMaterialsService {
669669
manufacturer = await BillOfMaterialsService.getSingleManufacturerWithQueryArgs(manufacturerName, organization);
670670
}
671671

672+
// recalculate subtotal on edits
673+
const finalPrice = price !== undefined ? price : (material.price ?? undefined);
674+
const finalQuantity = quantity !== undefined ? quantity : (material.quantity ?? undefined);
675+
const computedSubtotal =
676+
finalPrice !== undefined && finalQuantity !== undefined ? Math.round(finalPrice * Number(finalQuantity)) : undefined;
677+
672678
const updatedMaterial = await prisma.material.update({
673679
where: { materialId },
674680
data: {
@@ -680,12 +686,12 @@ export default class BillOfMaterialsService {
680686
quantity,
681687
unitId: unit ? unit.id : null,
682688
price,
683-
subtotal,
689+
subtotal: computedSubtotal,
684690
linkUrl,
685691
notes,
686692
wbsElementId: project.wbsElementId,
687693
assemblyId,
688-
pdmFileName
694+
pdmFileName: pdmFileName !== undefined ? pdmFileName || null : undefined
689695
},
690696
...getMaterialQueryArgs(organization.organizationId)
691697
});

src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTable.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,21 @@ interface BOMTableProps {
1313
columns: GridColumns<BomRow>;
1414
materials: Material[];
1515
assemblies: Assembly[];
16+
processRowUpdate: (newRow: BomRow, oldRow: BomRow) => Promise<BomRow>;
17+
onProcessRowUpdateError: (error: unknown) => void;
18+
editPerms: boolean;
1619
}
1720

18-
const BOMTable: React.FC<BOMTableProps> = ({ setHideColumn, assignMaterial, columns, materials, assemblies }) => {
21+
const BOMTable: React.FC<BOMTableProps> = ({
22+
setHideColumn,
23+
assignMaterial,
24+
columns,
25+
materials,
26+
assemblies,
27+
processRowUpdate,
28+
onProcessRowUpdateError,
29+
editPerms
30+
}) => {
1931
const [openRows, setOpenRows] = useState<String[]>([]);
2032
const [draggedMaterial, setDraggedMaterial] = useState<Material | null>(null);
2133

@@ -145,6 +157,12 @@ const BOMTable: React.FC<BOMTableProps> = ({ setHideColumn, assignMaterial, colu
145157
sx={bomTableStyles.datagrid}
146158
disableSelectionOnClick
147159
autoHeight={false}
160+
experimentalFeatures={{ newEditingApi: true }}
161+
processRowUpdate={
162+
processRowUpdate as unknown as (newRow: GridValidRowModel, oldRow: GridValidRowModel) => Promise<GridValidRowModel>
163+
}
164+
onProcessRowUpdateError={onProcessRowUpdateError}
165+
isCellEditable={(params) => editPerms && !String(params.row.id).startsWith('assembly')}
148166
onRowClick={openAssembly}
149167
componentsProps={{
150168
row: {

src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/BOM/BOMTableWrapper.tsx

Lines changed: 107 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Box } from '@mui/system';
22
import { GridActionsCellItem, GridColumns, GridRowParams } from '@mui/x-data-grid';
33
import { useEffect, useState } from 'react';
44
import { Assembly, Material, Project, isLeadership } from 'shared';
5+
import Decimal from 'decimal.js';
56
import DeleteIcon from '@mui/icons-material/Delete';
67
import EditIcon from '@mui/icons-material/Edit';
78
import MoveToInboxIcon from '@mui/icons-material/MoveToInbox';
@@ -12,12 +13,15 @@ import {
1213
useAssignMaterialToAssembly,
1314
useDeleteAssembly,
1415
useDeleteMaterial,
15-
useEditMaterialStatus
16+
useEditMaterialStatus,
17+
useGetAllManufacturers,
18+
useGetAllMaterialTypes
1619
} from '../../../../hooks/bom.hooks';
1720
import LoadingIndicator from '../../../../components/LoadingIndicator';
1821
import EditMaterialModal from './MaterialForm/EditMaterialModal';
1922
import { Button, Link, Typography } from '@mui/material';
20-
import { bomBaseColDef } from '../../../../utils/bom.utils';
23+
import { BomRow, bomBaseColDef } from '../../../../utils/bom.utils';
24+
import { centsToDollar } from '../../../../utils/pipes';
2125
import NERModal from '../../../../components/NERModal';
2226
import { StatusDropdownCell } from './BOMTableCustomCells';
2327
import LinkIcon from '@mui/icons-material/Link';
@@ -49,6 +53,8 @@ const BOMTableWrapper: React.FC<BOMTableWrapperProps> = ({
4953
const { mutateAsync: deleteAssemblyMutateAsync, isLoading: deleteAssemblyIsLoading } = useDeleteAssembly(project.wbsNum);
5054
const { mutateAsync: editMaterialStatus } = useEditMaterialStatus(project.wbsNum);
5155
const { mutateAsync: assignMaterialToAssembly } = useAssignMaterialToAssembly();
56+
const { data: materialTypes } = useGetAllMaterialTypes();
57+
const { data: manufacturers } = useGetAllManufacturers();
5258

5359
const [windowWidth, setWindowWidth] = useState(window.innerWidth);
5460

@@ -120,6 +126,85 @@ const BOMTableWrapper: React.FC<BOMTableWrapperProps> = ({
120126
project.teams.some((team) => team.leads.map((lead) => lead.userId).includes(user.userId)) ||
121127
project.teams.some((team) => team.members.map((member) => member.userId).includes(user.userId));
122128

129+
const processRowUpdate = async (newRow: BomRow, oldRow: BomRow): Promise<BomRow> => {
130+
// assemblies are not editable
131+
if (String(newRow.id).startsWith('assembly')) return newRow;
132+
133+
const material = materials.find((m) => m.materialId === newRow.materialId);
134+
if (!material) return newRow;
135+
136+
// MUI writes the edited number directly to the field, so we detect changes via typeof
137+
const newQuantity = typeof newRow.quantity === 'number' ? (newRow.quantity as number) : null;
138+
const newPriceDollars = typeof newRow.price === 'number' ? (newRow.price as number) : null;
139+
140+
if (
141+
newRow.name === oldRow.name &&
142+
newRow.type === oldRow.type &&
143+
newRow.manufacturer === oldRow.manufacturer &&
144+
newRow.manufacturerPN === oldRow.manufacturerPN &&
145+
newRow.pdmFileName === oldRow.pdmFileName &&
146+
newQuantity === null &&
147+
newPriceDollars === null
148+
)
149+
return newRow;
150+
151+
if (newRow.name !== undefined && !newRow.name.trim()) {
152+
toast.error('Name cannot be empty');
153+
return oldRow;
154+
}
155+
if (newQuantity !== null && (isNaN(newQuantity) || newQuantity <= 0)) {
156+
toast.error('Quantity must be a positive number');
157+
return oldRow;
158+
}
159+
if (newPriceDollars !== null && (isNaN(newPriceDollars) || newPriceDollars < 0)) {
160+
toast.error('Price must be a non-negative number');
161+
return oldRow;
162+
}
163+
164+
const changedFields: string[] = [];
165+
if (newRow.name !== oldRow.name) changedFields.push('Name');
166+
if (newRow.type !== oldRow.type) changedFields.push('Type');
167+
if (newRow.manufacturer !== oldRow.manufacturer) changedFields.push('Manufacturer');
168+
if (newRow.manufacturerPN !== oldRow.manufacturerPN) changedFields.push('Manufacturer PN');
169+
if (newRow.pdmFileName !== oldRow.pdmFileName) changedFields.push('PDM File Name');
170+
if (newQuantity !== null) changedFields.push('Quantity');
171+
if (newPriceDollars !== null) changedFields.push('Price');
172+
173+
const priceInCents = newPriceDollars !== null ? Math.round(newPriceDollars * 100) : material.price;
174+
const quantityValue = newQuantity !== null ? newQuantity : Number(material.quantity);
175+
176+
try {
177+
await editMaterialStatus({
178+
materialId: material.materialId,
179+
payload: {
180+
name: newRow.name,
181+
status: material.status,
182+
materialTypeName: newRow.type,
183+
manufacturerName: newRow.manufacturer || undefined,
184+
manufacturerPartNumber: newRow.manufacturerPN || undefined,
185+
pdmFileName: newRow.pdmFileName,
186+
price: priceInCents,
187+
quantity: new Decimal(quantityValue),
188+
unitName: material.unitName,
189+
linkUrl: material.linkUrl,
190+
notes: material.notes,
191+
assemblyId: material.assemblyId
192+
}
193+
});
194+
toast.success(`Material ${changedFields.join(', ')} updated successfully`);
195+
return {
196+
...newRow,
197+
quantity: material.unitName ? `${quantityValue} ${material.unitName}` : `${quantityValue}`,
198+
quantityRaw: quantityValue,
199+
price: priceInCents !== undefined ? `$${centsToDollar(priceInCents)}` : newRow.price,
200+
priceRaw: priceInCents !== undefined ? priceInCents / 100 : newRow.priceRaw
201+
};
202+
} catch (e: unknown) {
203+
if (e instanceof Error) toast.error(e.message, 6000);
204+
return oldRow;
205+
}
206+
};
207+
123208
const selectedMaterial = materials.find((material) => material.materialId === selectedMaterialId);
124209

125210
const getActions = (params: GridRowParams) => {
@@ -327,7 +412,9 @@ const BOMTableWrapper: React.FC<BOMTableWrapperProps> = ({
327412
...bomBaseColDef,
328413
field: 'type',
329414
headerName: 'Type',
330-
type: 'string',
415+
editable: editPerms,
416+
type: 'singleSelect',
417+
valueOptions: materialTypes?.map((mt) => mt.name) ?? [],
331418
sortable: false,
332419
filterable: false,
333420
hide: hideColumn[2]
@@ -337,7 +424,7 @@ const BOMTableWrapper: React.FC<BOMTableWrapperProps> = ({
337424
flex: 1.5,
338425
field: 'name',
339426
headerName: 'Name',
340-
type: 'string',
427+
editable: editPerms,
341428
sortable: false,
342429
filterable: false,
343430
hide: hideColumn[3]
@@ -347,7 +434,9 @@ const BOMTableWrapper: React.FC<BOMTableWrapperProps> = ({
347434
flex: 1.2,
348435
field: 'manufacturer',
349436
headerName: 'Manufacturer',
350-
type: 'string',
437+
editable: editPerms,
438+
type: 'singleSelect',
439+
valueOptions: ['', ...(manufacturers?.map((m) => m.name) ?? [])],
351440
sortable: false,
352441
filterable: false,
353442
hide: hideColumn[4]
@@ -357,7 +446,7 @@ const BOMTableWrapper: React.FC<BOMTableWrapperProps> = ({
357446
flex: 1.5,
358447
field: 'manufacturerPN',
359448
headerName: 'Manufacterer PN',
360-
type: 'string',
449+
editable: editPerms,
361450
sortable: false,
362451
filterable: false,
363452
colSpan: ({ row }) => {
@@ -373,7 +462,7 @@ const BOMTableWrapper: React.FC<BOMTableWrapperProps> = ({
373462
flex: 1.3,
374463
field: 'pdmFileName',
375464
headerName: 'PDM File Name',
376-
type: 'string',
465+
editable: editPerms,
377466
sortable: false,
378467
filterable: false,
379468
hide: hideColumn[6]
@@ -383,6 +472,9 @@ const BOMTableWrapper: React.FC<BOMTableWrapperProps> = ({
383472
field: 'quantity',
384473
headerName: 'Quantity',
385474
type: 'number',
475+
editable: editPerms,
476+
valueGetter: (params) => params.row.quantityRaw,
477+
renderCell: (params) => params.row.quantity,
386478
sortable: false,
387479
filterable: false,
388480
hide: hideColumn[7]
@@ -392,6 +484,9 @@ const BOMTableWrapper: React.FC<BOMTableWrapperProps> = ({
392484
field: 'price',
393485
headerName: 'Price per Unit',
394486
type: 'number',
487+
editable: editPerms,
488+
valueGetter: (params) => params.row.priceRaw,
489+
renderCell: (params) => params.row.price,
395490
sortable: false,
396491
filterable: false,
397492
hide: hideColumn[8]
@@ -400,7 +495,6 @@ const BOMTableWrapper: React.FC<BOMTableWrapperProps> = ({
400495
...bomBaseColDef,
401496
field: 'subtotal',
402497
headerName: 'Subtotal',
403-
type: 'number',
404498
sortable: false,
405499
filterable: false,
406500
hide: hideColumn[9]
@@ -449,6 +543,11 @@ const BOMTableWrapper: React.FC<BOMTableWrapperProps> = ({
449543
columns={columns}
450544
assemblies={assemblies}
451545
materials={materials}
546+
processRowUpdate={processRowUpdate}
547+
onProcessRowUpdateError={(error) => {
548+
if (error instanceof Error) toast.error(error.message, 6000);
549+
}}
550+
editPerms={editPerms}
452551
/>
453552
</Box>
454553
);

src/frontend/src/utils/bom.utils.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ export interface BomRow extends GridValidRowModel {
1515
manufacturerPN: string;
1616
pdmFileName: string;
1717
quantity: string;
18+
quantityRaw?: number;
1819
price: string;
20+
priceRaw?: number;
1921
subtotal: string;
2022
link: string;
2123
notes: string | undefined;
@@ -32,9 +34,11 @@ export const materialToRow = (material: Material, idx: number): BomRow => {
3234
name: material.name,
3335
manufacturer: material.manufacturerName ?? '',
3436
manufacturerPN: material.manufacturerPartNumber ?? '',
35-
pdmFileName: material.pdmFileName ?? 'None',
37+
pdmFileName: material.pdmFileName ?? '',
3638
quantity: material.quantity + (material.unitName ? ' ' + material.unitName : ''),
39+
quantityRaw: material.quantity !== undefined ? Number(material.quantity) : undefined,
3740
price: material.price !== undefined ? `$${centsToDollar(material.price)}` : '',
41+
priceRaw: material.price !== undefined ? material.price / 100 : undefined,
3842
subtotal: material.subtotal !== undefined ? `$${centsToDollar(material.subtotal)}` : '',
3943
link: material.linkUrl,
4044
notes: material.notes,

0 commit comments

Comments
 (0)