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"
|
||||
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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
type RowAction,
|
||||
RowDeleteAction,
|
||||
@@ -12,20 +28,6 @@ import { navigateToLink } from '@lib/functions/Navigation';
|
||||
import useTable from '@lib/hooks/UseTable';
|
||||
import type { TableFilter } from '@lib/types/Filters';
|
||||
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 { RenderPart } from '../../components/render/Part';
|
||||
import { useApi } from '../../contexts/ApiContext';
|
||||
@@ -45,7 +47,8 @@ import {
|
||||
DescriptionColumn,
|
||||
IPNColumn,
|
||||
NoteColumn,
|
||||
ReferenceColumn
|
||||
ReferenceColumn,
|
||||
RenderPartColumn
|
||||
} from '../ColumnRenderers';
|
||||
import { PartCategoryFilter } from '../Filter';
|
||||
import { InvenTreeTable } from '../InvenTreeTable';
|
||||
@@ -80,27 +83,35 @@ export function BomTable({
|
||||
const user = useUserState();
|
||||
const table = useTable('bom');
|
||||
const navigate = useNavigate();
|
||||
|
||||
const openImporter = useImporterState((state) => state.openImporter);
|
||||
|
||||
const [isEditing, setIsEditing] = useState<boolean>(false);
|
||||
|
||||
const tableColumns: TableColumn[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
accessor: 'sub_part',
|
||||
switchable: false,
|
||||
sortable: true,
|
||||
minWidth: 250,
|
||||
render: (record: any) => {
|
||||
const part = record.sub_part_detail;
|
||||
|
||||
const extra = [];
|
||||
|
||||
if (record.part != partId) {
|
||||
if (partId && record.part != partId) {
|
||||
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) {
|
||||
extra.push(
|
||||
<Text key='not-validated' c='red'>
|
||||
<Text key='not-validated' c='red' size='sm'>
|
||||
{t`This BOM item has not been validated`}
|
||||
</Text>
|
||||
);
|
||||
@@ -109,13 +120,7 @@ export function BomTable({
|
||||
return (
|
||||
part && (
|
||||
<TableHoverCard
|
||||
value={
|
||||
<Thumbnail
|
||||
src={part.thumbnail || part.image}
|
||||
alt={part.description}
|
||||
text={part.full_name}
|
||||
/>
|
||||
}
|
||||
value={<RenderPartColumn part={part} />}
|
||||
iconColor={record.validated ? undefined : 'red'}
|
||||
extra={extra}
|
||||
title={t`Part Information`}
|
||||
@@ -376,7 +381,8 @@ export function BomTable({
|
||||
return '-';
|
||||
}
|
||||
|
||||
const can_build = Math.trunc(record.can_build);
|
||||
const can_build = Math.max(0, Math.trunc(record.can_build));
|
||||
|
||||
const value = (
|
||||
<Text
|
||||
fs={record.consumable && 'italic'}
|
||||
@@ -403,7 +409,7 @@ export function BomTable({
|
||||
},
|
||||
NoteColumn({})
|
||||
];
|
||||
}, [partId, params]);
|
||||
}, [isEditing, partId, params]);
|
||||
|
||||
const tableFilters: TableFilter[] = useMemo(() => {
|
||||
return [
|
||||
@@ -614,7 +620,7 @@ export function BomTable({
|
||||
})
|
||||
];
|
||||
},
|
||||
[partId, partLocked, user]
|
||||
[isEditing, partId, partLocked, user]
|
||||
);
|
||||
|
||||
const tableActions = useMemo(() => {
|
||||
@@ -624,7 +630,7 @@ export function BomTable({
|
||||
tooltip={t`Add BOM Items`}
|
||||
position='bottom-start'
|
||||
icon={<IconPlus />}
|
||||
hidden={partLocked || !user.hasAddRole(UserRoles.part)}
|
||||
hidden={!isEditing || partLocked || !user.hasAddRole(UserRoles.part)}
|
||||
actions={[
|
||||
{
|
||||
name: t`Add BOM Item`,
|
||||
@@ -639,9 +645,29 @@ export function BomTable({
|
||||
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 (
|
||||
<>
|
||||
@@ -678,9 +704,11 @@ export function BomTable({
|
||||
tableFilters: tableFilters,
|
||||
modelType: ModelType.part,
|
||||
modelField: 'sub_part',
|
||||
rowActions: rowActions,
|
||||
enableSelection: !partLocked,
|
||||
enableBulkDelete: !partLocked && user.hasDeleteRole(UserRoles.part),
|
||||
onCellClick: () => {},
|
||||
rowActions: isEditing ? rowActions : undefined,
|
||||
enableSelection: isEditing && !partLocked,
|
||||
enableBulkDelete:
|
||||
isEditing && !partLocked && user.hasDeleteRole(UserRoles.part),
|
||||
enableDownload: true
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -179,10 +179,15 @@ test('Parts - BOM', async ({ browser }) => {
|
||||
// Move the mouse away
|
||||
await page.getByRole('link', { name: 'Bill of Materials' }).hover();
|
||||
|
||||
const cell = await page.getByRole('cell', {
|
||||
name: 'Small plastic enclosure, black',
|
||||
exact: true
|
||||
});
|
||||
// Enable BOM editing
|
||||
await page.getByRole('button', { name: 'action-button-edit-bom' }).click();
|
||||
await page
|
||||
.getByRole('button', { name: 'action-button-finish-editing-' })
|
||||
.waitFor();
|
||||
|
||||
const cell = await page
|
||||
.getByRole('cell', { name: 'Thumbnail 1551ABK' })
|
||||
.first();
|
||||
|
||||
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: '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 }) => {
|
||||
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
|
||||
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 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' });
|
||||
|
||||
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
|
||||
.getByRole('button', { name: 'action-menu-add-bom-items' })
|
||||
.waitFor();
|
||||
|
||||
@@ -76,6 +76,12 @@ test('Importing - BOM', async ({ browser }) => {
|
||||
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
|
||||
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')
|
||||
user = os.environ.get('INVENTREE_PYTHON_TEST_USERNAME', 'testuser')
|
||||
pwd = os.environ.get('INVENTREE_PYTHON_TEST_PASSWORD', 'testpassword')
|
||||
|
||||
api_client = InvenTreeAPI(
|
||||
server,
|
||||
username=user,
|
||||
|
||||
Reference in New Issue
Block a user