mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-28 03:26:45 +00:00
Edit BOM substitutes (#9521)
* Edit BOM substitutes * Add playwright tests
This commit is contained in:
parent
0707ebf59b
commit
204e2e6d46
@ -102,6 +102,7 @@ export enum ApiEndpoints {
|
|||||||
bom_list = 'bom/',
|
bom_list = 'bom/',
|
||||||
bom_item_validate = 'bom/:id/validate/',
|
bom_item_validate = 'bom/:id/validate/',
|
||||||
bom_validate = 'part/:id/bom-validate/',
|
bom_validate = 'part/:id/bom-validate/',
|
||||||
|
bom_substitute_list = 'bom/substitute/',
|
||||||
|
|
||||||
// Part API endpoints
|
// Part API endpoints
|
||||||
part_list = 'part/',
|
part_list = 'part/',
|
||||||
|
@ -1,4 +1,17 @@
|
|||||||
|
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||||
|
import { apiUrl } from '@lib/functions/Api';
|
||||||
|
import { UserRoles } from '@lib/index';
|
||||||
import type { ApiFormFieldSet } from '@lib/types/Forms';
|
import type { ApiFormFieldSet } from '@lib/types/Forms';
|
||||||
|
import { t } from '@lingui/core/macro';
|
||||||
|
import { Table } from '@mantine/core';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { api } from '../App';
|
||||||
|
import RemoveRowButton from '../components/buttons/RemoveRowButton';
|
||||||
|
import type { TableFieldRowProps } from '../components/forms/fields/TableField';
|
||||||
|
import { RenderPart } from '../components/render/Part';
|
||||||
|
import { showApiErrorMessage } from '../functions/notifications';
|
||||||
|
import { useCreateApiFormModal } from '../hooks/UseForm';
|
||||||
|
import { useUserState } from '../states/UserState';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Field set for BomItem form
|
* Field set for BomItem form
|
||||||
@ -24,3 +37,111 @@ export function bomItemFields(): ApiFormFieldSet {
|
|||||||
optional: {}
|
optional: {}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function BomItemSubstituteRow({
|
||||||
|
props,
|
||||||
|
record
|
||||||
|
}: Readonly<{
|
||||||
|
props: TableFieldRowProps;
|
||||||
|
record: any;
|
||||||
|
}>) {
|
||||||
|
const user = useUserState();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td>
|
||||||
|
{record.part_detail && <RenderPart instance={record.part_detail} />}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{user.hasDeleteRole(UserRoles.part) && (
|
||||||
|
<RemoveRowButton
|
||||||
|
onClick={() => {
|
||||||
|
api
|
||||||
|
.delete(apiUrl(ApiEndpoints.bom_substitute_list, record.pk))
|
||||||
|
.then(() => {
|
||||||
|
props.removeFn(props.idx);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
showApiErrorMessage({
|
||||||
|
error: err,
|
||||||
|
title: t`Error`
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type BomItemSubstituteFormProps = {
|
||||||
|
bomItemId: number;
|
||||||
|
substitutes: any[];
|
||||||
|
onClose?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit substitutes for a BOM item
|
||||||
|
*/
|
||||||
|
export function useEditBomSubstitutesForm(props: BomItemSubstituteFormProps) {
|
||||||
|
const [substitutes, setSubstitutes] = useState<any[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSubstitutes(props.substitutes);
|
||||||
|
}, [props.substitutes]);
|
||||||
|
|
||||||
|
const formFields: ApiFormFieldSet = useMemo(() => {
|
||||||
|
return {
|
||||||
|
substitutes: {
|
||||||
|
field_type: 'table',
|
||||||
|
value: substitutes,
|
||||||
|
ignore: true,
|
||||||
|
modelRenderer: (row: TableFieldRowProps) => {
|
||||||
|
const record = substitutes.find((r) => r.pk == row.item.pk);
|
||||||
|
return record ? (
|
||||||
|
<BomItemSubstituteRow props={row} record={record} />
|
||||||
|
) : null;
|
||||||
|
},
|
||||||
|
headers: [
|
||||||
|
{ title: t`Substitute Part`, style: { width: '100%' } },
|
||||||
|
{ title: '', style: { width: '50px' } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
bom_item: {
|
||||||
|
hidden: true,
|
||||||
|
value: props.bomItemId
|
||||||
|
},
|
||||||
|
part: {
|
||||||
|
filters: {
|
||||||
|
component: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [props, substitutes]);
|
||||||
|
|
||||||
|
return useCreateApiFormModal({
|
||||||
|
title: t`Edit BOM Substitutes`,
|
||||||
|
url: apiUrl(ApiEndpoints.bom_substitute_list),
|
||||||
|
fields: formFields,
|
||||||
|
initialData: {
|
||||||
|
substitutes: substitutes
|
||||||
|
},
|
||||||
|
cancelText: t`Close`,
|
||||||
|
submitText: t`Add Substitute`,
|
||||||
|
successMessage: t`Substitute added`,
|
||||||
|
onClose: () => {
|
||||||
|
props.onClose?.();
|
||||||
|
},
|
||||||
|
checkClose: (response, form) => {
|
||||||
|
// Keep the form open
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
onFormSuccess(data, form) {
|
||||||
|
// Add the new substitute to the list
|
||||||
|
setSubstitutes((old) => [...old, data]);
|
||||||
|
form.setValue('part', null);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
@ -1,24 +1,47 @@
|
|||||||
import type { ApiFormFieldType } from '@lib/types/Forms';
|
import type { ApiFormFieldType } from '@lib/types/Forms';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Table column properties
|
||||||
|
*
|
||||||
|
* @param T - The type of the record
|
||||||
|
* @param accessor - The key in the record to access
|
||||||
|
* @param title - The title of the column - Note: this may be supplied by the API, and is not required, but it can be overridden if required
|
||||||
|
* @param ordering - The key in the record to sort by (defaults to accessor)
|
||||||
|
* @param sortable - Whether the column is sortable
|
||||||
|
* @param switchable - Whether the column is switchable
|
||||||
|
* @param hidden - Whether the column is hidden
|
||||||
|
* @param editable - Whether the value of this column can be edited
|
||||||
|
* @param definition - Optional field definition for the column
|
||||||
|
* @param render - A custom render function
|
||||||
|
* @param filter - A custom filter function
|
||||||
|
* @param filtering - Whether the column is filterable
|
||||||
|
* @param width - The width of the column
|
||||||
|
* @param noWrap - Whether the column should wrap
|
||||||
|
* @param ellipsis - Whether the column should be ellipsized
|
||||||
|
* @param textAlign - The text alignment of the column
|
||||||
|
* @param cellsStyle - The style of the cells in the column
|
||||||
|
* @param extra - Extra data to pass to the render function
|
||||||
|
* @param noContext - Disable context menu for this column
|
||||||
|
*/
|
||||||
export type TableColumnProps<T = any> = {
|
export type TableColumnProps<T = any> = {
|
||||||
accessor?: string; // The key in the record to access
|
accessor?: string;
|
||||||
title?: string; // The title of the column - Note: this may be supplied by the API, and is not required, but it can be overridden if required
|
title?: string;
|
||||||
ordering?: string; // The key in the record to sort by (defaults to accessor)
|
ordering?: string;
|
||||||
sortable?: boolean; // Whether the column is sortable
|
sortable?: boolean;
|
||||||
switchable?: boolean; // Whether the column is switchable
|
switchable?: boolean;
|
||||||
hidden?: boolean; // Whether the column is hidden
|
hidden?: boolean;
|
||||||
editable?: boolean; // Whether the value of this column can be edited
|
editable?: boolean;
|
||||||
definition?: ApiFormFieldType; // Optional field definition for the column
|
definition?: ApiFormFieldType;
|
||||||
render?: (record: T, index?: number) => any; // A custom render function
|
render?: (record: T, index?: number) => any;
|
||||||
filter?: any; // A custom filter function
|
filter?: any;
|
||||||
filtering?: boolean; // Whether the column is filterable
|
filtering?: boolean;
|
||||||
width?: number; // The width of the column
|
width?: number;
|
||||||
noWrap?: boolean; // Whether the column should wrap
|
noWrap?: boolean;
|
||||||
ellipsis?: boolean; // Whether the column should be ellipsized
|
ellipsis?: boolean;
|
||||||
textAlign?: 'left' | 'center' | 'right'; // The text alignment of the column
|
textAlign?: 'left' | 'center' | 'right';
|
||||||
cellsStyle?: any; // The style of the cells in the column
|
cellsStyle?: any;
|
||||||
extra?: any; // Extra data to pass to the render function
|
extra?: any;
|
||||||
noContext?: boolean; // Disable context menu for this column
|
noContext?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -22,11 +22,11 @@ import { AddItemButton } from '../../components/buttons/AddItemButton';
|
|||||||
import { YesNoButton } from '../../components/buttons/YesNoButton';
|
import { YesNoButton } from '../../components/buttons/YesNoButton';
|
||||||
import { Thumbnail } from '../../components/images/Thumbnail';
|
import { Thumbnail } from '../../components/images/Thumbnail';
|
||||||
import ImporterDrawer from '../../components/importer/ImporterDrawer';
|
import ImporterDrawer from '../../components/importer/ImporterDrawer';
|
||||||
|
import { RenderPart } from '../../components/render/Part';
|
||||||
import { useApi } from '../../contexts/ApiContext';
|
import { useApi } from '../../contexts/ApiContext';
|
||||||
import { formatDecimal, formatPriceRange } from '../../defaults/formatters';
|
import { formatDecimal, formatPriceRange } from '../../defaults/formatters';
|
||||||
import { bomItemFields } from '../../forms/BomForms';
|
import { bomItemFields, useEditBomSubstitutesForm } from '../../forms/BomForms';
|
||||||
import { dataImporterSessionFields } from '../../forms/ImporterForms';
|
import { dataImporterSessionFields } from '../../forms/ImporterForms';
|
||||||
import { notYetImplemented } from '../../functions/notifications';
|
|
||||||
import {
|
import {
|
||||||
useApiFormModal,
|
useApiFormModal,
|
||||||
useCreateApiFormModal,
|
useCreateApiFormModal,
|
||||||
@ -145,12 +145,17 @@ export function BomTable({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessor: 'substitutes',
|
accessor: 'substitutes',
|
||||||
// TODO: Show hovercard with list of substitutes
|
|
||||||
render: (row) => {
|
render: (row) => {
|
||||||
const substitutes = row.substitutes ?? [];
|
const substitutes = row.substitutes ?? [];
|
||||||
|
|
||||||
return substitutes.length > 0 ? (
|
return substitutes.length > 0 ? (
|
||||||
row.length
|
<TableHoverCard
|
||||||
|
value={<Text>{substitutes.length}</Text>}
|
||||||
|
title={t`Substitutes`}
|
||||||
|
extra={substitutes.map((sub: any) => (
|
||||||
|
<RenderPart instance={sub.part_detail} />
|
||||||
|
))}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<YesNoButton value={false} />
|
<YesNoButton value={false} />
|
||||||
);
|
);
|
||||||
@ -363,7 +368,7 @@ export function BomTable({
|
|||||||
];
|
];
|
||||||
}, [partId, params]);
|
}, [partId, params]);
|
||||||
|
|
||||||
const [selectedBomItem, setSelectedBomItem] = useState<number>(0);
|
const [selectedBomItem, setSelectedBomItem] = useState<any>({});
|
||||||
|
|
||||||
const importSessionFields = useMemo(() => {
|
const importSessionFields = useMemo(() => {
|
||||||
const fields = dataImporterSessionFields();
|
const fields = dataImporterSessionFields();
|
||||||
@ -401,7 +406,7 @@ export function BomTable({
|
|||||||
|
|
||||||
const editBomItem = useEditApiFormModal({
|
const editBomItem = useEditApiFormModal({
|
||||||
url: ApiEndpoints.bom_list,
|
url: ApiEndpoints.bom_list,
|
||||||
pk: selectedBomItem,
|
pk: selectedBomItem.pk,
|
||||||
title: t`Edit BOM Item`,
|
title: t`Edit BOM Item`,
|
||||||
fields: bomItemFields(),
|
fields: bomItemFields(),
|
||||||
successMessage: t`BOM item updated`,
|
successMessage: t`BOM item updated`,
|
||||||
@ -410,12 +415,20 @@ export function BomTable({
|
|||||||
|
|
||||||
const deleteBomItem = useDeleteApiFormModal({
|
const deleteBomItem = useDeleteApiFormModal({
|
||||||
url: ApiEndpoints.bom_list,
|
url: ApiEndpoints.bom_list,
|
||||||
pk: selectedBomItem,
|
pk: selectedBomItem.pk,
|
||||||
title: t`Delete BOM Item`,
|
title: t`Delete BOM Item`,
|
||||||
successMessage: t`BOM item deleted`,
|
successMessage: t`BOM item deleted`,
|
||||||
table: table
|
table: table
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const editSubstitues = useEditBomSubstitutesForm({
|
||||||
|
bomItemId: selectedBomItem.pk,
|
||||||
|
substitutes: selectedBomItem?.substitutes ?? [],
|
||||||
|
onClose: () => {
|
||||||
|
table.refreshTable();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const validateBom = useApiFormModal({
|
const validateBom = useApiFormModal({
|
||||||
url: ApiEndpoints.bom_validate,
|
url: ApiEndpoints.bom_validate,
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@ -488,21 +501,24 @@ export function BomTable({
|
|||||||
RowEditAction({
|
RowEditAction({
|
||||||
hidden: partLocked || !user.hasChangeRole(UserRoles.part),
|
hidden: partLocked || !user.hasChangeRole(UserRoles.part),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
setSelectedBomItem(record.pk);
|
setSelectedBomItem(record);
|
||||||
editBomItem.open();
|
editBomItem.open();
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
title: t`Edit Substitutes`,
|
title: t`Edit Substitutes`,
|
||||||
color: 'blue',
|
color: 'blue',
|
||||||
hidden: partLocked || !user.hasChangeRole(UserRoles.part),
|
hidden: partLocked || !user.hasAddRole(UserRoles.part),
|
||||||
icon: <IconSwitch3 />,
|
icon: <IconSwitch3 />,
|
||||||
onClick: notYetImplemented
|
onClick: () => {
|
||||||
|
setSelectedBomItem(record);
|
||||||
|
editSubstitues.open();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
RowDeleteAction({
|
RowDeleteAction({
|
||||||
hidden: partLocked || !user.hasDeleteRole(UserRoles.part),
|
hidden: partLocked || !user.hasDeleteRole(UserRoles.part),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
setSelectedBomItem(record.pk);
|
setSelectedBomItem(record);
|
||||||
deleteBomItem.open();
|
deleteBomItem.open();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -543,6 +559,7 @@ export function BomTable({
|
|||||||
{editBomItem.modal}
|
{editBomItem.modal}
|
||||||
{validateBom.modal}
|
{validateBom.modal}
|
||||||
{deleteBomItem.modal}
|
{deleteBomItem.modal}
|
||||||
|
{editSubstitues.modal}
|
||||||
<Stack gap='xs'>
|
<Stack gap='xs'>
|
||||||
{partLocked && (
|
{partLocked && (
|
||||||
<Alert
|
<Alert
|
||||||
|
@ -80,6 +80,39 @@ test('Parts - Supplier Parts', async ({ browser }) => {
|
|||||||
await page.getByText('DIG-84670-SJI - R_550R_0805_1%').waitFor();
|
await page.getByText('DIG-84670-SJI - R_550R_0805_1%').waitFor();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Parts - BOM', async ({ browser }) => {
|
||||||
|
const page = await doCachedLogin(browser, { url: 'part/87/bom' });
|
||||||
|
|
||||||
|
await loadTab(page, 'Bill of Materials');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
const cell = await page.getByRole('cell', {
|
||||||
|
name: 'Small plastic enclosure, black',
|
||||||
|
exact: true
|
||||||
|
});
|
||||||
|
await cell.click({ button: 'right' });
|
||||||
|
|
||||||
|
// Check for expected context menu actions
|
||||||
|
await page.getByRole('button', { name: 'Edit', exact: true }).waitFor();
|
||||||
|
await page.getByRole('button', { name: 'Delete', exact: true }).waitFor();
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'View details', exact: true })
|
||||||
|
.waitFor();
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'Edit Substitutes', exact: true })
|
||||||
|
.click();
|
||||||
|
await page.getByText('Edit BOM Substitutes').waitFor();
|
||||||
|
await page.getByText('1551ACLR').first().waitFor();
|
||||||
|
await page.getByText('1551AGY').first().waitFor();
|
||||||
|
|
||||||
|
await page.getByLabel('related-field-part').fill('enclosure');
|
||||||
|
await page.getByText('1591BTBU').click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Add Substitute' }).waitFor();
|
||||||
|
await page.getByRole('button', { name: 'Close' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
test('Parts - Locking', async ({ browser }) => {
|
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');
|
||||||
|
Loading…
x
Reference in New Issue
Block a user