mirror of
https://github.com/inventree/InvenTree.git
synced 2026-04-28 13:54:25 +00:00
[UI] BOM Editing (#10210)
* Support minimum column width * Adjust DescriptionColumn and StatusColumn * Column refactoring * Refactor PartColumn * Refactor LineItemsProgerssColumn * Tweaks * Ensure "can_build" value is not negative * Render row expansion icon * Add subassembly table for BOM * Add controls for BOM editing * Fix row click context * Improve rendering for BOM sub-rows * Hide BOM actions unless editing * Disable row expansion for now * Revert gitleaks changes * Remove gitleaks tags * Remove dead code * Remove commented code * Adjust playwright tests Co-authored-by: Copilot <copilot@github.com> * Update docs Co-authored-by: Copilot <copilot@github.com> * Further playwright fixes --------- Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -92,9 +92,16 @@ Note that inherited BOM Line Items only flow "downwards" in the variant inherita
|
|||||||
!!! info "Editing Inherited Items"
|
!!! info "Editing Inherited Items"
|
||||||
When editing an inherited BOM Line Item for a template part, the changes are automatically reflected in the BOM of any variant parts.
|
When editing an inherited BOM Line Item for a template part, the changes are automatically reflected in the BOM of any variant parts.
|
||||||
|
|
||||||
## BOM Creation
|
## BOM Editing
|
||||||
|
|
||||||
BOMs can be created manually, by adjusting individual line items, or by uploading (importing) an existing BOM file.
|
Bills of Material (BOMs) can be created manually, by adjusting individual line items, or by uploading (importing) an existing BOM file.
|
||||||
|
|
||||||
|
### Editing Mode
|
||||||
|
|
||||||
|
By default, the BOM is displayed in "view" mode. To edit the BOM, click on the {{ icon("edit", color="blue", title="Edit") }} icon at the top of the BOM panel. This will enable editing mode, which allows you to add, adjust or delete BOM line items.
|
||||||
|
|
||||||
|
!!! warning "Permissions"
|
||||||
|
Only users with the appropriate permissions can edit BOMs. If you do not have permission to edit the BOM, the "Edit" icon will not be visible.
|
||||||
|
|
||||||
### Importing a BOM
|
### Importing a BOM
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,19 @@
|
|||||||
|
import { t } from '@lingui/core/macro';
|
||||||
|
import { Alert, Group, Stack, Text } from '@mantine/core';
|
||||||
|
import { showNotification } from '@mantine/notifications';
|
||||||
|
import {
|
||||||
|
IconArrowRight,
|
||||||
|
IconCircleCheck,
|
||||||
|
IconEdit,
|
||||||
|
IconFileUpload,
|
||||||
|
IconLock,
|
||||||
|
IconPlus,
|
||||||
|
IconSwitch3
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { type ReactNode, useCallback, useMemo, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { ActionButton } from '@lib/components/ActionButton';
|
||||||
import {
|
import {
|
||||||
type RowAction,
|
type RowAction,
|
||||||
RowDeleteAction,
|
RowDeleteAction,
|
||||||
@@ -12,20 +28,6 @@ import { navigateToLink } from '@lib/functions/Navigation';
|
|||||||
import useTable from '@lib/hooks/UseTable';
|
import useTable from '@lib/hooks/UseTable';
|
||||||
import type { TableFilter } from '@lib/types/Filters';
|
import type { TableFilter } from '@lib/types/Filters';
|
||||||
import type { TableColumn } from '@lib/types/Tables';
|
import type { TableColumn } from '@lib/types/Tables';
|
||||||
import { t } from '@lingui/core/macro';
|
|
||||||
import { Alert, Group, Stack, Text } from '@mantine/core';
|
|
||||||
import { showNotification } from '@mantine/notifications';
|
|
||||||
import {
|
|
||||||
IconArrowRight,
|
|
||||||
IconCircleCheck,
|
|
||||||
IconFileUpload,
|
|
||||||
IconLock,
|
|
||||||
IconPlus,
|
|
||||||
IconSwitch3
|
|
||||||
} from '@tabler/icons-react';
|
|
||||||
import { type ReactNode, useCallback, useMemo, useState } from 'react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { Thumbnail } from '../../components/images/Thumbnail';
|
|
||||||
import { ActionDropdown } from '../../components/items/ActionDropdown';
|
import { ActionDropdown } from '../../components/items/ActionDropdown';
|
||||||
import { RenderPart } from '../../components/render/Part';
|
import { RenderPart } from '../../components/render/Part';
|
||||||
import { useApi } from '../../contexts/ApiContext';
|
import { useApi } from '../../contexts/ApiContext';
|
||||||
@@ -45,7 +47,8 @@ import {
|
|||||||
DescriptionColumn,
|
DescriptionColumn,
|
||||||
IPNColumn,
|
IPNColumn,
|
||||||
NoteColumn,
|
NoteColumn,
|
||||||
ReferenceColumn
|
ReferenceColumn,
|
||||||
|
RenderPartColumn
|
||||||
} from '../ColumnRenderers';
|
} from '../ColumnRenderers';
|
||||||
import { PartCategoryFilter } from '../Filter';
|
import { PartCategoryFilter } from '../Filter';
|
||||||
import { InvenTreeTable } from '../InvenTreeTable';
|
import { InvenTreeTable } from '../InvenTreeTable';
|
||||||
@@ -80,27 +83,35 @@ export function BomTable({
|
|||||||
const user = useUserState();
|
const user = useUserState();
|
||||||
const table = useTable('bom');
|
const table = useTable('bom');
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const openImporter = useImporterState((state) => state.openImporter);
|
const openImporter = useImporterState((state) => state.openImporter);
|
||||||
|
|
||||||
|
const [isEditing, setIsEditing] = useState<boolean>(false);
|
||||||
|
|
||||||
const tableColumns: TableColumn[] = useMemo(() => {
|
const tableColumns: TableColumn[] = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
accessor: 'sub_part',
|
accessor: 'sub_part',
|
||||||
switchable: false,
|
switchable: false,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
|
minWidth: 250,
|
||||||
render: (record: any) => {
|
render: (record: any) => {
|
||||||
const part = record.sub_part_detail;
|
const part = record.sub_part_detail;
|
||||||
|
|
||||||
const extra = [];
|
const extra = [];
|
||||||
|
|
||||||
if (record.part != partId) {
|
if (partId && record.part != partId) {
|
||||||
extra.push(
|
extra.push(
|
||||||
<Text key='different-parent'>{t`This BOM item is defined for a different parent`}</Text>
|
<Text
|
||||||
|
key='different-parent'
|
||||||
|
size='sm'
|
||||||
|
>{t`This BOM item is defined for a different parent`}</Text>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!record.validated) {
|
if (!record.validated) {
|
||||||
extra.push(
|
extra.push(
|
||||||
<Text key='not-validated' c='red'>
|
<Text key='not-validated' c='red' size='sm'>
|
||||||
{t`This BOM item has not been validated`}
|
{t`This BOM item has not been validated`}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
@@ -109,13 +120,7 @@ export function BomTable({
|
|||||||
return (
|
return (
|
||||||
part && (
|
part && (
|
||||||
<TableHoverCard
|
<TableHoverCard
|
||||||
value={
|
value={<RenderPartColumn part={part} />}
|
||||||
<Thumbnail
|
|
||||||
src={part.thumbnail || part.image}
|
|
||||||
alt={part.description}
|
|
||||||
text={part.full_name}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
iconColor={record.validated ? undefined : 'red'}
|
iconColor={record.validated ? undefined : 'red'}
|
||||||
extra={extra}
|
extra={extra}
|
||||||
title={t`Part Information`}
|
title={t`Part Information`}
|
||||||
@@ -376,7 +381,8 @@ export function BomTable({
|
|||||||
return '-';
|
return '-';
|
||||||
}
|
}
|
||||||
|
|
||||||
const can_build = Math.trunc(record.can_build);
|
const can_build = Math.max(0, Math.trunc(record.can_build));
|
||||||
|
|
||||||
const value = (
|
const value = (
|
||||||
<Text
|
<Text
|
||||||
fs={record.consumable && 'italic'}
|
fs={record.consumable && 'italic'}
|
||||||
@@ -403,7 +409,7 @@ export function BomTable({
|
|||||||
},
|
},
|
||||||
NoteColumn({})
|
NoteColumn({})
|
||||||
];
|
];
|
||||||
}, [partId, params]);
|
}, [isEditing, partId, params]);
|
||||||
|
|
||||||
const tableFilters: TableFilter[] = useMemo(() => {
|
const tableFilters: TableFilter[] = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
@@ -614,7 +620,7 @@ export function BomTable({
|
|||||||
})
|
})
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
[partId, partLocked, user]
|
[isEditing, partId, partLocked, user]
|
||||||
);
|
);
|
||||||
|
|
||||||
const tableActions = useMemo(() => {
|
const tableActions = useMemo(() => {
|
||||||
@@ -624,7 +630,7 @@ export function BomTable({
|
|||||||
tooltip={t`Add BOM Items`}
|
tooltip={t`Add BOM Items`}
|
||||||
position='bottom-start'
|
position='bottom-start'
|
||||||
icon={<IconPlus />}
|
icon={<IconPlus />}
|
||||||
hidden={partLocked || !user.hasAddRole(UserRoles.part)}
|
hidden={!isEditing || partLocked || !user.hasAddRole(UserRoles.part)}
|
||||||
actions={[
|
actions={[
|
||||||
{
|
{
|
||||||
name: t`Add BOM Item`,
|
name: t`Add BOM Item`,
|
||||||
@@ -639,9 +645,29 @@ export function BomTable({
|
|||||||
onClick: () => importBomItem.open()
|
onClick: () => importBomItem.open()
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
|
/>,
|
||||||
|
<ActionButton
|
||||||
|
key='edit-bom'
|
||||||
|
hidden={partLocked || !user.hasChangeRole(UserRoles.part) || isEditing}
|
||||||
|
tooltip={t`Edit BOM`}
|
||||||
|
icon={<IconEdit />}
|
||||||
|
onClick={() => {
|
||||||
|
setIsEditing(true);
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
<ActionButton
|
||||||
|
key='finish-editing'
|
||||||
|
hidden={!isEditing}
|
||||||
|
color='green'
|
||||||
|
tooltip={t`Finish Editing BOM`}
|
||||||
|
icon={<IconCircleCheck />}
|
||||||
|
onClick={() => {
|
||||||
|
setIsEditing(false);
|
||||||
|
table.refreshTable();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
];
|
];
|
||||||
}, [partLocked, user]);
|
}, [isEditing, partLocked, user]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -678,9 +704,11 @@ export function BomTable({
|
|||||||
tableFilters: tableFilters,
|
tableFilters: tableFilters,
|
||||||
modelType: ModelType.part,
|
modelType: ModelType.part,
|
||||||
modelField: 'sub_part',
|
modelField: 'sub_part',
|
||||||
rowActions: rowActions,
|
onCellClick: () => {},
|
||||||
enableSelection: !partLocked,
|
rowActions: isEditing ? rowActions : undefined,
|
||||||
enableBulkDelete: !partLocked && user.hasDeleteRole(UserRoles.part),
|
enableSelection: isEditing && !partLocked,
|
||||||
|
enableBulkDelete:
|
||||||
|
isEditing && !partLocked && user.hasDeleteRole(UserRoles.part),
|
||||||
enableDownload: true
|
enableDownload: true
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -179,10 +179,15 @@ test('Parts - BOM', async ({ browser }) => {
|
|||||||
// Move the mouse away
|
// Move the mouse away
|
||||||
await page.getByRole('link', { name: 'Bill of Materials' }).hover();
|
await page.getByRole('link', { name: 'Bill of Materials' }).hover();
|
||||||
|
|
||||||
const cell = await page.getByRole('cell', {
|
// Enable BOM editing
|
||||||
name: 'Small plastic enclosure, black',
|
await page.getByRole('button', { name: 'action-button-edit-bom' }).click();
|
||||||
exact: true
|
await page
|
||||||
});
|
.getByRole('button', { name: 'action-button-finish-editing-' })
|
||||||
|
.waitFor();
|
||||||
|
|
||||||
|
const cell = await page
|
||||||
|
.getByRole('cell', { name: 'Thumbnail 1551ABK' })
|
||||||
|
.first();
|
||||||
|
|
||||||
await clickOnRowMenu(cell);
|
await clickOnRowMenu(cell);
|
||||||
|
|
||||||
@@ -202,6 +207,12 @@ test('Parts - BOM', async ({ browser }) => {
|
|||||||
|
|
||||||
await page.getByRole('button', { name: 'Add Substitute' }).waitFor();
|
await page.getByRole('button', { name: 'Add Substitute' }).waitFor();
|
||||||
await page.getByRole('button', { name: 'Close' }).click();
|
await page.getByRole('button', { name: 'Close' }).click();
|
||||||
|
|
||||||
|
// Finish editing the BOM
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'action-button-finish-editing-' })
|
||||||
|
.click();
|
||||||
|
await page.getByRole('button', { name: 'action-button-edit-bom' }).waitFor();
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -211,8 +222,15 @@ test('Parts - BOM', async ({ browser }) => {
|
|||||||
test('Parts - BOM Validation', async ({ browser }) => {
|
test('Parts - BOM Validation', async ({ browser }) => {
|
||||||
const page = await doCachedLogin(browser, { url: 'part/107/bom' });
|
const page = await doCachedLogin(browser, { url: 'part/107/bom' });
|
||||||
|
|
||||||
|
// Enable BOM editing
|
||||||
|
await page.getByRole('button', { name: 'action-button-edit-bom' }).click();
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'action-button-finish-editing-' })
|
||||||
|
.waitFor();
|
||||||
|
|
||||||
// Edit line item, to ensure BOM is not valid
|
// Edit line item, to ensure BOM is not valid
|
||||||
const cell = await page.getByRole('cell', { name: 'Red paint Red Paint' });
|
const cell = await page.getByRole('cell', { name: 'Thumbnail Red Paint' });
|
||||||
|
|
||||||
await clickOnRowMenu(cell);
|
await clickOnRowMenu(cell);
|
||||||
await page.getByRole('menuitem', { name: 'Edit', exact: true }).click();
|
await page.getByRole('menuitem', { name: 'Edit', exact: true }).click();
|
||||||
|
|
||||||
@@ -288,6 +306,13 @@ test('Parts - Locking', async ({ browser }) => {
|
|||||||
const page = await doCachedLogin(browser, { url: 'part/104/bom' });
|
const page = await doCachedLogin(browser, { url: 'part/104/bom' });
|
||||||
|
|
||||||
await loadTab(page, 'Bill of Materials');
|
await loadTab(page, 'Bill of Materials');
|
||||||
|
|
||||||
|
// Enable BOM editing
|
||||||
|
await page.getByRole('button', { name: 'action-button-edit-bom' }).click();
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'action-button-finish-editing-' })
|
||||||
|
.waitFor();
|
||||||
|
|
||||||
await page
|
await page
|
||||||
.getByRole('button', { name: 'action-menu-add-bom-items' })
|
.getByRole('button', { name: 'action-menu-add-bom-items' })
|
||||||
.waitFor();
|
.waitFor();
|
||||||
|
|||||||
@@ -76,6 +76,12 @@ test('Importing - BOM', async ({ browser }) => {
|
|||||||
url: 'part/109/bom'
|
url: 'part/109/bom'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Enable BOM editing
|
||||||
|
await page.getByRole('button', { name: 'action-button-edit-bom' }).click();
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'action-button-finish-editing-' })
|
||||||
|
.waitFor();
|
||||||
|
|
||||||
// Open the BOM importer wizard
|
// Open the BOM importer wizard
|
||||||
await page.getByRole('button', { name: 'action-menu-add-bom-items' }).click();
|
await page.getByRole('button', { name: 'action-menu-add-bom-items' }).click();
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from inventree.api import InvenTreeAPI
|
|||||||
server = os.environ.get('INVENTREE_PYTHON_TEST_SERVER', 'http://127.0.0.1:12345')
|
server = os.environ.get('INVENTREE_PYTHON_TEST_SERVER', 'http://127.0.0.1:12345')
|
||||||
user = os.environ.get('INVENTREE_PYTHON_TEST_USERNAME', 'testuser')
|
user = os.environ.get('INVENTREE_PYTHON_TEST_USERNAME', 'testuser')
|
||||||
pwd = os.environ.get('INVENTREE_PYTHON_TEST_PASSWORD', 'testpassword')
|
pwd = os.environ.get('INVENTREE_PYTHON_TEST_PASSWORD', 'testpassword')
|
||||||
|
|
||||||
api_client = InvenTreeAPI(
|
api_client = InvenTreeAPI(
|
||||||
server,
|
server,
|
||||||
username=user,
|
username=user,
|
||||||
|
|||||||
Reference in New Issue
Block a user