2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-10-21 08:27:38 +00:00

[UI] Part import (#10609)

* Allow import of parts from file

* Extend default values for part import

* Small visual tweaks

* Update documentation

* Update playwright test
This commit is contained in:
Oliver
2025-10-18 18:12:05 +11:00
committed by GitHub
parent 72d127219f
commit 803d22155b
11 changed files with 162 additions and 71 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View File

@@ -2,17 +2,20 @@
title: Creating a Part title: Creating a Part
--- ---
## Part Creation Form ## Part Creation
New parts can be created from the *Part Category* view, by pressing the *New Part* button: New parts can be created manually via the web interface, or imported from an external source.
To create or import a part, navigate to the *Parts* view in the user interface, and select the *Add Parts* dropdown menu above the parts table:
{{ image("part/new_parts_dropdown.png", "Add parts dropdown") }}
!!! info "Permissions" !!! info "Permissions"
If the user does not have "create" permission for the *Part* permission group, the *New Part* button will not be available. If the user does not have "create" permission for the *Part* permission group, the *Add Parts* menu will not be available.
{{ image("part/new_part.png", "New part") }} ## Create Part Form
New parts can be created manually by selecting the *Create Part* option from the menu. A part creation form is opened as shown below:
A part creation form is opened as shown below:
{{ image("part/part_create_form.png", "New part form") }} {{ image("part/part_create_form.png", "New part form") }}
@@ -31,7 +34,6 @@ If this setting is enabled, the following elements are available in the form:
Checking the *Create Initial Stock* form input then allows the creation of an initial quantity of stock for the new part. Checking the *Create Initial Stock* form input then allows the creation of an initial quantity of stock for the new part.
### Supplier Options ### Supplier Options
If the part is marked as *Purchaseable*, the form provides some extra options to initialize the new part with manufacturer and / or supplier information: If the part is marked as *Purchaseable*, the form provides some extra options to initialize the new part with manufacturer and / or supplier information:
@@ -42,9 +44,44 @@ If the *Add Supplier Data* option is checked, then supplier part and manufacture
{{ image("part/part_new_suppliers.png", "Part supplier information") }} {{ image("part/part_new_suppliers.png", "Part supplier information") }}
## Import from File
Parts can be imported from an external file, by selecting the *Import from File* option.
This action opens the [data import wizard](../settings/import.md), which steps the user through the process of importing parts from the selected file.
## Import from Supplier
InvenTree can integrate with external suppliers and import data from them, which helps to setup your system. Currently parts, supplier parts and manufacturer parts can be created automatically.
!!! info "Plugin Required"
To import parts from a supplier, you must install a plugin which supports that supplier.
### Requirements
1. Install a supplier mixin plugin for you supplier
2. Goto "Admin Center > Plugins > [The supplier plugin]" and set the supplier company setting. Some plugins may require additional settings like API tokens.
### Import a part
New parts can be imported from the _Part Category_ view, by pressing the _Import Part_ button:
{{ image("part/import_part.png", "Import part") }}
Then just follow the wizard to confirm the category, select the parameters and create initial stock.
{{ image("part/import_part_wizard.png", "Import part wizard") }}
### Import a supplier part
If you already have the part created, you can also just import the supplier part with it's corresponding manufacturer part. Open the supplier panel for the part and use the "Import supplier part" button:
{{ image("part/import_supplier_part.png", "Import supplier part") }}
## Other Part Creation Methods ## Other Part Creation Methods
The following alternative methods for creating parts are supported: In addition to the primary methods for creating or importing part data, the following methods are supported:
- [Via the REST API](../api/index.md) - [Via the REST API](../api/index.md)
- [Using the Python library](../api/python/index.md) - [Using the Python library](../api/python/index.md)

View File

@@ -1,28 +0,0 @@
---
title: Importing Data from suppliers
---
## Import data from suppliers
InvenTree can integrate with external suppliers and import data from them, which helps to setup your system. Currently parts, supplier parts and manufacturer parts can be created automatically.
### Requirements
1. Install a supplier mixin plugin for you supplier
2. Goto "Admin Center > Plugins > [The supplier plugin]" and set the supplier company setting. Some plugins may require additional settings like API tokens.
### Import a part
New parts can be imported from the _Part Category_ view, by pressing the _Import Part_ button:
{{ image("part/import_part.png", "Import part") }}
Then just follow the wizard to confirm the category, select the parameters and create initial stock.
{{ image("part/import_part_wizard.png", "Import part wizard") }}
### Import a supplier part
If you already have the part created, you can also just import the supplier part with it's corresponding manufacturer part. Open the supplier panel for the part and use the "Import supplier part" button:
{{ image("part/import_supplier_part.png", "Import supplier part") }}

View File

@@ -146,7 +146,6 @@ nav:
- Parts: - Parts:
- Parts: part/index.md - Parts: part/index.md
- Creating Parts: part/create.md - Creating Parts: part/create.md
- Importing Parts: part/import.md
- Virtual Parts: part/virtual.md - Virtual Parts: part/virtual.md
- Part Views: part/views.md - Part Views: part/views.md
- Tracking: part/trackable.md - Tracking: part/trackable.md

View File

@@ -188,6 +188,7 @@ export function PrintingActions({
<ActionDropdown <ActionDropdown
tooltip={t`Printing Actions`} tooltip={t`Printing Actions`}
icon={<IconPrinter />} icon={<IconPrinter />}
position='bottom-start'
disabled={!enabled} disabled={!enabled}
actions={[ actions={[
{ {

View File

@@ -48,7 +48,8 @@ export function ActionDropdown({
actions, actions,
disabled = false, disabled = false,
hidden = false, hidden = false,
noindicator = false noindicator = false,
position
}: { }: {
icon: ReactNode; icon: ReactNode;
tooltip: string; tooltip: string;
@@ -57,6 +58,7 @@ export function ActionDropdown({
disabled?: boolean; disabled?: boolean;
hidden?: boolean; hidden?: boolean;
noindicator?: boolean; noindicator?: boolean;
position?: FloatingPosition;
}): ReactNode { }): ReactNode {
const hasActions = useMemo(() => { const hasActions = useMemo(() => {
return actions.some((action) => !action.hidden); return actions.some((action) => !action.hidden);
@@ -71,7 +73,7 @@ export function ActionDropdown({
}, [tooltip]); }, [tooltip]);
return !hidden && hasActions ? ( return !hidden && hasActions ? (
<Menu position='bottom-end' key={menuName}> <Menu position={position ?? 'bottom-end'} key={menuName}>
<Indicator disabled={!indicatorProps} {...indicatorProps?.indicator}> <Indicator disabled={!indicatorProps} {...indicatorProps?.indicator}>
<Menu.Target> <Menu.Target>
<Tooltip <Tooltip

View File

@@ -21,7 +21,7 @@ import {
Tooltip Tooltip
} from '@mantine/core'; } from '@mantine/core';
import { showNotification } from '@mantine/notifications'; import { showNotification } from '@mantine/notifications';
import { IconArrowDown, IconPlus } from '@tabler/icons-react'; import { IconArrowDown, IconPlus, IconSearch } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { import {
type FormEventHandler, type FormEventHandler,
@@ -197,7 +197,7 @@ const SearchStep = ({
aria-label='textbox-search-for-part' aria-label='textbox-search-for-part'
flex={1} flex={1}
placeholder='Search for a part' placeholder='Search for a part'
label={t`Search...`} label={t`Search`}
value={searchValue} value={searchValue}
onChange={(event) => setSearchValue(event.currentTarget.value)} onChange={(event) => setSearchValue(event.currentTarget.value)}
/> />
@@ -224,7 +224,12 @@ const SearchStep = ({
: t`Select supplier` : t`Select supplier`
} }
/> />
<Button disabled={!searchValue || !supplier} type='submit'> <Button
color='blue'
disabled={!searchValue || !supplier}
type='submit'
leftSection={<IconSearch />}
>
<Trans>Search</Trans> <Trans>Search</Trans>
</Button> </Button>
</Group> </Group>
@@ -794,7 +799,7 @@ export default function ImportPartWizard({
// Create the wizard manager // Create the wizard manager
const wizard = useWizard({ const wizard = useWizard({
title: t`Import Part`, title: t`Import Supplier Part`,
steps: [ steps: [
t`Search Supplier Part`, t`Search Supplier Part`,
// if partId is provided, a inventree part already exists, just import the mp/sp // if partId is provided, a inventree part already exists, just import the mp/sp

View File

@@ -1,9 +1,3 @@
import { t } from '@lingui/core/macro';
import { Group, Text } from '@mantine/core';
import { type ReactNode, useCallback, useMemo, useState } from 'react';
import { ActionButton } from '@lib/components/ActionButton';
import { AddItemButton } from '@lib/components/AddItemButton';
import { import {
type RowAction, type RowAction,
RowDuplicateAction, RowDuplicateAction,
@@ -17,11 +11,21 @@ import type { TableFilter } from '@lib/types/Filters';
import type { ApiFormFieldSet } from '@lib/types/Forms'; import type { ApiFormFieldSet } from '@lib/types/Forms';
import type { TableColumn } from '@lib/types/Tables'; import type { TableColumn } from '@lib/types/Tables';
import type { InvenTreeTableProps } from '@lib/types/Tables'; import type { InvenTreeTableProps } from '@lib/types/Tables';
import { IconPackageImport, IconShoppingCart } from '@tabler/icons-react'; import { t } from '@lingui/core/macro';
import { Group, Text } from '@mantine/core';
import {
IconFileUpload,
IconPackageImport,
IconPlus,
IconShoppingCart
} from '@tabler/icons-react';
import { type ReactNode, useCallback, useMemo, useState } from 'react';
import ImporterDrawer from '../../components/importer/ImporterDrawer';
import { ActionDropdown } from '../../components/items/ActionDropdown'; import { ActionDropdown } from '../../components/items/ActionDropdown';
import ImportPartWizard from '../../components/wizards/ImportPartWizard'; import ImportPartWizard from '../../components/wizards/ImportPartWizard';
import OrderPartsWizard from '../../components/wizards/OrderPartsWizard'; import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
import { formatDecimal, formatPriceRange } from '../../defaults/formatters'; import { formatDecimal, formatPriceRange } from '../../defaults/formatters';
import { dataImporterSessionFields } from '../../forms/ImporterForms';
import { usePartFields } from '../../forms/PartForms'; import { usePartFields } from '../../forms/PartForms';
import { InvenTreeIcon } from '../../functions/icons'; import { InvenTreeIcon } from '../../functions/icons';
import { import {
@@ -331,10 +335,12 @@ function partTableFilters(): TableFilter[] {
* @returns * @returns
*/ */
export function PartListTable({ export function PartListTable({
enableImport = true,
props, props,
defaultPartData defaultPartData
}: Readonly<{ }: Readonly<{
props: InvenTreeTableProps; enableImport?: boolean;
props?: InvenTreeTableProps;
defaultPartData?: any; defaultPartData?: any;
}>) { }>) {
const tableColumns = useMemo(() => partTableColumns(), []); const tableColumns = useMemo(() => partTableColumns(), []);
@@ -344,9 +350,39 @@ export function PartListTable({
const user = useUserState(); const user = useUserState();
const globalSettings = useGlobalSettingsState(); const globalSettings = useGlobalSettingsState();
const [importOpened, setImportOpened] = useState<boolean>(false);
const [selectedSession, setSelectedSession] = useState<number | undefined>(
undefined
);
const importSessionFields = useMemo(() => {
const fields = dataImporterSessionFields({
modelType: ModelType.part
});
// Override default field values with provided fields
fields.field_defaults.value = {
...props?.params,
...defaultPartData
};
return fields;
}, [defaultPartData, props?.params]);
const importParts = useCreateApiFormModal({
url: ApiEndpoints.import_session_list,
title: t`Import Parts`,
fields: importSessionFields,
onFormSuccess: (response: any) => {
setSelectedSession(response.pk);
setImportOpened(true);
}
});
const initialPartData = useMemo(() => { const initialPartData = useMemo(() => {
return defaultPartData ?? props.params ?? {}; return defaultPartData ?? props?.params ?? {};
}, [defaultPartData, props.params]); }, [defaultPartData, props?.params]);
const newPart = useCreateApiFormModal({ const newPart = useCreateApiFormModal({
url: ApiEndpoints.part_list, url: ApiEndpoints.part_list,
@@ -463,6 +499,7 @@ export function PartListTable({
tooltip={t`Part Actions`} tooltip={t`Part Actions`}
icon={<InvenTreeIcon icon='part' />} icon={<InvenTreeIcon icon='part' />}
disabled={!table.hasSelectedRecords} disabled={!table.hasSelectedRecords}
position='bottom-start'
actions={[ actions={[
{ {
name: t`Set Category`, name: t`Set Category`,
@@ -485,24 +522,37 @@ export function PartListTable({
} }
]} ]}
/>, />,
<AddItemButton <ActionDropdown
key='add-part' key='add-parts-actions'
tooltip={t`Add Parts`}
position='bottom-start'
icon={<IconPlus />}
hidden={!user.hasAddRole(UserRoles.part)} hidden={!user.hasAddRole(UserRoles.part)}
tooltip={t`Add Part`} actions={[
onClick={() => newPart.open()} {
/>, name: t`Create Part`,
<ActionButton icon: <IconPlus />,
key='import-part' tooltip: t`Create a new part`,
hidden={ onClick: () => newPart.open()
supplierPlugins.length === 0 || !user.hasAddRole(UserRoles.part) },
} {
tooltip={t`Import Part`} name: t`Import from File`,
color='green' icon: <IconFileUpload />,
icon={<IconPackageImport />} tooltip: t`Import parts from a file`,
onClick={() => importPartWizard.openWizard()} onClick: () => importParts.open(),
hidden: !enableImport
},
{
name: t`Import from Supplier`,
icon: <IconPackageImport />,
tooltip: t`Import parts from a supplier plugin`,
hidden: !enableImport || supplierPlugins.length === 0,
onClick: () => importPartWizard.openWizard()
}
]}
/> />
]; ];
}, [user, table.hasSelectedRecords, supplierPlugins]); }, [user, enableImport, table.hasSelectedRecords, supplierPlugins]);
return ( return (
<> <>
@@ -510,6 +560,7 @@ export function PartListTable({
{duplicatePart.modal} {duplicatePart.modal}
{editPart.modal} {editPart.modal}
{setCategory.modal} {setCategory.modal}
{importParts.modal}
{orderPartsWizard.wizard} {orderPartsWizard.wizard}
{importPartWizard.wizard} {importPartWizard.wizard}
<InvenTreeTable <InvenTreeTable
@@ -527,12 +578,21 @@ export function PartListTable({
enableReports: true, enableReports: true,
enableLabels: true, enableLabels: true,
params: { params: {
...props.params, ...props?.params,
category_detail: true, category_detail: true,
location_detail: true location_detail: true
} }
}} }}
/> />
<ImporterDrawer
sessionId={selectedSession ?? -1}
opened={selectedSession != undefined && importOpened}
onClose={() => {
setSelectedSession(undefined);
setImportOpened(false);
table.refreshTable();
}}
/>
</> </>
); );
} }

View File

@@ -35,6 +35,7 @@ export function PartVariantTable({ part }: Readonly<{ part: any }>) {
return ( return (
<PartListTable <PartListTable
enableImport={false}
props={{ props={{
enableDownload: false, enableDownload: false,
tableFilters: tableFilters, tableFilters: tableFilters,

View File

@@ -653,6 +653,7 @@ test('Parts - Duplicate', async ({ browser }) => {
// Open "duplicate part" dialog // Open "duplicate part" dialog
await page.getByLabel('action-menu-part-actions').click(); await page.getByLabel('action-menu-part-actions').click();
await page.getByLabel('action-menu-part-actions-duplicate').click(); await page.getByLabel('action-menu-part-actions-duplicate').click();
// Check for expected fields // Check for expected fields
@@ -686,9 +687,22 @@ test('Parts - Import supplier part', async ({ browser }) => {
await page.reload(); await page.reload();
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
await page.getByRole('button', { name: 'action-button-import-part' }).click(); // Open "Add parts" menu
await page.getByRole('button', { name: 'action-menu-add-parts' }).click();
await page
.getByRole('menuitem', { name: 'action-menu-add-parts-create-part' })
.waitFor();
await page
.getByRole('menuitem', { name: 'action-menu-add-parts-import-from-file' })
.waitFor();
await page
.getByRole('menuitem', {
name: 'action-menu-add-parts-import-from-supplier'
})
.click();
await page await page
.getByRole('textbox', { name: 'textbox-search-for-part' }) .getByRole('textbox', { name: 'textbox-search-for-part' })
.fill('M5'); .fill('M5');