diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6988a362c5..457aa3295b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- [#11861](https://github.com/inventree/InvenTree/pull/11861) adds support for bulk-replacing a component in multiple BOMs simultaneously
- [#11853](https://github.com/inventree/InvenTree/pull/11853) adds BOM comparison functionality, allowing users to compare the BOM of one assembly with another assembly.
- [#11809](https://github.com/inventree/InvenTree/pull/11809) adds multi-level subassembly display mode to the BOM table, allowing users to view multiple levels of subassemblies in a single table view. This is an optional display mode which can be toggled on or off by the user.
- [#11778](https://github.com/inventree/InvenTree/pull/11778) adds inline supplier part creation to po line item addition dialog.
diff --git a/docs/docs/assets/images/build/replace_component.png b/docs/docs/assets/images/build/replace_component.png
new file mode 100644
index 0000000000..bdf0cf6852
Binary files /dev/null and b/docs/docs/assets/images/build/replace_component.png differ
diff --git a/docs/docs/manufacturing/bom.md b/docs/docs/manufacturing/bom.md
index 87093f704e..b21c02b84a 100644
--- a/docs/docs/manufacturing/bom.md
+++ b/docs/docs/manufacturing/bom.md
@@ -192,3 +192,17 @@ When comparing BOMs from two different assemblies, the user can select from the
| *Show common parts* | Display only the BOM line items which are common between the two assemblies. |
In each case, any differences between the BOM line items are highlighted in red.
+
+## Replacing Components
+
+When a component is used in the BOM for multiple assemblies, it can be time consuming to update the BOM for each assembly when a change is required. InvenTree provides a "Replace Component" function which streamlines the process of replacing a component part with another part across multiple BOMs.
+
+To replace a component part within multiple assemblies:
+
+- Navigate to the [Used In](../part/views.md#used-in) tab of the component part detail page
+- Select the assemblies you wish to update by ticking the checkbox next to each assembly
+- Click on the {{ icon("replace", color="blue", title="Replace Component") }} icon to open the "Replace Component" dialog
+
+The following dialog will be displayed, which allows the user to select a new component part to replace the existing component part in the BOM of the selected assemblies:
+
+{{ image("build/replace_component.png", "Replace Component") }}
diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py
index 4c7c25654e..a326effd1e 100644
--- a/src/backend/InvenTree/InvenTree/api_version.py
+++ b/src/backend/InvenTree/InvenTree/api_version.py
@@ -1,11 +1,14 @@
"""InvenTree API version information."""
# InvenTree API version
-INVENTREE_API_VERSION = 482
+INVENTREE_API_VERSION = 483
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
+v483 -> 2026-05-04 : https://github.com/inventree/InvenTree/pull/11861
+ - Enable bulk-update operations on the BomItem API endpoint, allowing multiple BOM items to be updated in a single API call
+
v482 -> 2026-03-15 : https://github.com/inventree/InvenTree/pull/11540
- Add id to the ordering fields of the Parts model
diff --git a/src/backend/InvenTree/part/api.py b/src/backend/InvenTree/part/api.py
index 23e154f751..60daddad09 100644
--- a/src/backend/InvenTree/part/api.py
+++ b/src/backend/InvenTree/part/api.py
@@ -1290,6 +1290,10 @@ class BomFilter(FilterSet):
label=_('Assembly part is testable'), field_name='part__testable'
)
+ part_locked = rest_filters.BooleanFilter(
+ label=_('Assembly part is locked'), field_name='part__locked'
+ )
+
# Filters for linked 'sub_part'
sub_part_active = rest_filters.BooleanFilter(
label=_('Component part is active'), field_name='sub_part__active'
@@ -1406,7 +1410,11 @@ class BomOutputOptions(OutputConfiguration):
class BomList(
- BomMixin, DataExportViewMixin, OutputOptionsMixin, ListCreateDestroyAPIView
+ BomMixin,
+ BulkUpdateMixin,
+ DataExportViewMixin,
+ OutputOptionsMixin,
+ ListCreateDestroyAPIView,
):
"""API endpoint for accessing a list of BomItem objects.
diff --git a/src/frontend/src/tables/bom/UsedInTable.tsx b/src/frontend/src/tables/bom/UsedInTable.tsx
index 059e0dbc9a..034b05c8b8 100644
--- a/src/frontend/src/tables/bom/UsedInTable.tsx
+++ b/src/frontend/src/tables/bom/UsedInTable.tsx
@@ -1,23 +1,28 @@
import { t } from '@lingui/core/macro';
-import { Group, Text } from '@mantine/core';
+import { Alert, Divider, Group, Stack, Text } from '@mantine/core';
import { useCallback, useMemo, useState } from 'react';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { apiUrl } from '@lib/functions/Api';
import useTable from '@lib/hooks/UseTable';
-import { RowEditAction, UserRoles } from '@lib/index';
+import { ActionButton, RowEditAction, UserRoles } from '@lib/index';
import type { TableFilter } from '@lib/types/Filters';
import type { RowAction, TableColumn } from '@lib/types/Tables';
+import { IconReplace } from '@tabler/icons-react';
import { formatDecimal } from '../../defaults/formatters';
import { bomItemFields } from '../../forms/BomForms';
-import { useEditApiFormModal } from '../../hooks/UseForm';
+import {
+ useBulkEditApiFormModal,
+ useEditApiFormModal
+} from '../../hooks/UseForm';
import { useUserState } from '../../states/UserState';
import {
DescriptionColumn,
IPNColumn,
PartColumn,
- ReferenceColumn
+ ReferenceColumn,
+ RenderPartColumn
} from '../ColumnRenderers';
import { InvenTreeTable } from '../InvenTreeTable';
@@ -85,6 +90,11 @@ export function UsedInTable({
label: t`Inherited`,
description: t`Show inherited items`
},
+ {
+ name: 'part_locked',
+ label: t`Locked`,
+ description: t`Show locked assemblies`
+ },
{
name: 'optional',
label: t`Optional`,
@@ -122,7 +132,7 @@ export function UsedInTable({
return [
RowEditAction({
- hidden: locked || !user.hasChangeRole(UserRoles.part),
+ hidden: locked || !user.hasChangeRole(UserRoles.bom),
onClick: () => {
setSelectedBomItem(record);
editBomItem.open();
@@ -133,9 +143,59 @@ export function UsedInTable({
[user]
);
+ const bulkReplaceParts = useMemo(() => {}, [table.selectedRecords]);
+
+ const bulkReplace = useBulkEditApiFormModal({
+ url: ApiEndpoints.bom_list,
+ items: table.selectedIds,
+ title: t`Replace Component`,
+ submitText: t`Replace`,
+ preFormContent: (
+
+ }
+ title={t`Replace Component`}
+ mb='md'
+ >
+ {t`This action cannot be easily undone, so please ensure you have selected the correct assemblies.`}
+
+ {t`The selected assemblies will be updated with the new component.`}
+ {table.selectedRecords.map((record: any) => {
+ return ;
+ })}
+
+
+ ),
+ fields: {
+ sub_part: {
+ filters: {
+ active: true,
+ component: true
+ },
+ default: partId
+ }
+ },
+ onFormSuccess: table.refreshTable
+ });
+
+ const tableActions = useMemo(() => {
+ return [
+ }
+ color='blue'
+ onClick={bulkReplace.open}
+ hidden={!user.hasChangeRole(UserRoles.bom)}
+ disabled={!table.selectedIds.length}
+ />
+ ];
+ }, [user, table.selectedIds]);
+
return (
<>
{editBomItem.modal}
+ {bulkReplace.modal}
>
diff --git a/src/frontend/tests/pages/pui_part.spec.ts b/src/frontend/tests/pages/pui_part.spec.ts
index c7d9adacd6..4fd3df805c 100644
--- a/src/frontend/tests/pages/pui_part.spec.ts
+++ b/src/frontend/tests/pages/pui_part.spec.ts
@@ -305,6 +305,34 @@ test('Parts - BOM Comparison', async ({ browser }) => {
await page.getByText('Blue Paint', { exact: true }).first().waitFor();
});
+test('Parts - Used In', async ({ browser }) => {
+ const page = await doCachedLogin(browser, { url: 'part/4/used_in' });
+
+ // Check for expected elements
+ await page.getByRole('button', { name: 'Assembly Not sorted' }).waitFor();
+ await page.getByText('R33, R34, R35, R36').waitFor();
+
+ // Edit row
+ const cell = await page.getByRole('cell', { name: 'Thumbnail Test Board 1' });
+ await cell.click({ button: 'right' });
+ await page.getByRole('button', { name: 'Edit' }).first().click();
+ await page.getByRole('textbox', { name: 'text-field-reference' }).waitFor();
+ await page.getByRole('button', { name: 'Cancel' }).click();
+
+ // Attempt to replace this part in multiple assemblies
+ await page.getByRole('checkbox', { name: 'Select all records' }).click();
+ await page.getByRole('button', { name: 'action-button-replace-' }).click();
+ await page.getByText('This action cannot be easily undone').waitFor();
+
+ // Submit the form - locked parts should throw an error
+ await page.getByRole('button', { name: 'Replace', exact: true }).click();
+
+ await page.getByText('Form Error').waitFor();
+ await page
+ .getByText('BOM item cannot be modified - assembly is locked')
+ .waitFor();
+});
+
test('Parts - Editing', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: 'part/104/details' });