[UI] Data import for order lines (#12692)

* [UI] Data import for order lines

* Adjust playwright tests
This commit is contained in:
Oliver
2026-08-24 22:20:18 +10:00
committed by GitHub
parent 528bb085d7
commit e12bdcfb69
11 changed files with 202 additions and 34 deletions
@@ -0,0 +1,48 @@
import { IconFileUpload, IconPlus } from '@tabler/icons-react';
import { ActionDropdown } from './ActionDropdown';
/**
* A split "add" button which allows either direct creation of a single line item,
* or bulk creation via a data import session.
*/
export function LineItemCreationMenu({
tooltip,
addLabel,
importLabel,
hidden = false,
enableImport = true,
onAdd,
onImport
}: Readonly<{
tooltip: string;
addLabel: string;
importLabel: string;
hidden?: boolean;
enableImport?: boolean;
onAdd: () => void;
onImport: () => void;
}>) {
return (
<ActionDropdown
key='add-line-item-actions'
tooltip={tooltip}
position='bottom-start'
icon={<IconPlus />}
hidden={hidden}
actions={[
{
name: addLabel,
icon: <IconPlus />,
onClick: onAdd
},
{
name: importLabel,
icon: <IconFileUpload />,
hidden: !enableImport,
onClick: onImport
}
]}
/>
);
}
@@ -171,6 +171,7 @@ export default function PurchaseOrderDetail() {
<Accordion.Panel> <Accordion.Panel>
<ExtraLineItemTable <ExtraLineItemTable
endpoint={ApiEndpoints.purchase_order_extra_line_list} endpoint={ApiEndpoints.purchase_order_extra_line_list}
importModelType='purchaseorderextraline'
orderId={order.pk} orderId={order.pk}
orderDetailRefresh={refreshInstance} orderDetailRefresh={refreshInstance}
currency={orderCurrency} currency={orderCurrency}
@@ -136,6 +136,7 @@ export default function ReturnOrderDetail() {
<Accordion.Panel> <Accordion.Panel>
<ExtraLineItemTable <ExtraLineItemTable
endpoint={ApiEndpoints.return_order_extra_line_list} endpoint={ApiEndpoints.return_order_extra_line_list}
importModelType='returnorderextraline'
orderId={order.pk} orderId={order.pk}
orderDetailRefresh={refreshInstance} orderDetailRefresh={refreshInstance}
currency={orderCurrency} currency={orderCurrency}
@@ -173,6 +173,7 @@ export default function SalesOrderDetail() {
<Accordion.Panel> <Accordion.Panel>
<ExtraLineItemTable <ExtraLineItemTable
endpoint={ApiEndpoints.sales_order_extra_line_list} endpoint={ApiEndpoints.sales_order_extra_line_list}
importModelType='salesorderextraline'
orderId={order.pk} orderId={order.pk}
editable={lineItemsEditable} editable={lineItemsEditable}
orderDetailRefresh={refreshInstance} orderDetailRefresh={refreshInstance}
@@ -1,18 +1,19 @@
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { AddItemButton } from '@lib/components/AddItemButton';
import { import {
type RowAction, type RowAction,
RowDeleteAction, RowDeleteAction,
RowDuplicateAction, RowDuplicateAction,
RowEditAction RowEditAction
} from '@lib/components/RowActions'; } from '@lib/components/RowActions';
import type { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import type { ModelType } from '@lib/enums/ModelType';
import type { UserRoles } from '@lib/enums/Roles'; import type { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api'; import { apiUrl } from '@lib/functions/Api';
import useTable from '@lib/hooks/UseTable'; import useTable from '@lib/hooks/UseTable';
import type { TableColumn } from '@lib/types/Tables'; import type { TableColumn } from '@lib/types/Tables';
import { LineItemCreationMenu } from '../../components/items/LineItemCreationMenu';
import { import {
DecimalColumn, DecimalColumn,
DescriptionColumn, DescriptionColumn,
@@ -25,15 +26,18 @@ import {
import { InvenTreeTable } from '../../components/tables/InvenTreeTable'; import { InvenTreeTable } from '../../components/tables/InvenTreeTable';
import { formatCurrency } from '../../defaults/formatters'; import { formatCurrency } from '../../defaults/formatters';
import { extraLineItemFields } from '../../forms/CommonForms'; import { extraLineItemFields } from '../../forms/CommonForms';
import { dataImporterSessionFields } from '../../forms/ImporterForms';
import { import {
useCreateApiFormModal, useCreateApiFormModal,
useDeleteApiFormModal, useDeleteApiFormModal,
useEditApiFormModal useEditApiFormModal
} from '../../hooks/UseForm'; } from '../../hooks/UseForm';
import { useImporterState } from '../../states/ImporterState';
import { useUserState } from '../../states/UserState'; import { useUserState } from '../../states/UserState';
export default function ExtraLineItemTable({ export default function ExtraLineItemTable({
endpoint, endpoint,
importModelType,
orderId, orderId,
orderDetailRefresh, orderDetailRefresh,
currency, currency,
@@ -41,6 +45,7 @@ export default function ExtraLineItemTable({
role role
}: Readonly<{ }: Readonly<{
endpoint: ApiEndpoints; endpoint: ApiEndpoints;
importModelType: ModelType | string;
orderId: number; orderId: number;
editable: boolean; editable: boolean;
orderDetailRefresh: () => void; orderDetailRefresh: () => void;
@@ -49,6 +54,7 @@ export default function ExtraLineItemTable({
}>) { }>) {
const table = useTable('extra-line-item'); const table = useTable('extra-line-item');
const user = useUserState(); const user = useUserState();
const openImporter = useImporterState((state) => state.openImporter);
const tableColumns: TableColumn[] = useMemo(() => { const tableColumns: TableColumn[] = useMemo(() => {
return [ return [
@@ -126,6 +132,31 @@ export default function ExtraLineItemTable({
table: table table: table
}); });
const importSessionFields = useMemo(() => {
const fields = dataImporterSessionFields({ modelType: importModelType });
fields.field_overrides.value = {
order: orderId
};
fields.field_defaults.value = {
price_currency: currency
};
return fields;
}, [orderId, currency, importModelType]);
const importLineItems = useCreateApiFormModal({
url: ApiEndpoints.import_session_list,
title: t`Import Line Items`,
fields: importSessionFields,
onFormSuccess: (response: any) => {
openImporter(response.pk, {
onClose: table.refreshTable
});
}
});
const rowActions = useCallback( const rowActions = useCallback(
(record: any): RowAction[] => { (record: any): RowAction[] => {
return [ return [
@@ -157,25 +188,29 @@ export default function ExtraLineItemTable({
const tableActions = useMemo(() => { const tableActions = useMemo(() => {
return [ return [
<AddItemButton <LineItemCreationMenu
key='add-line-item' key='add-line-item-actions'
tooltip={t`Add Extra Line Item`} tooltip={t`Add Extra Line Item`}
addLabel={t`Add Extra Line Item`}
importLabel={t`Import Line Items`}
hidden={!editable || !user.hasAddRole(role)} hidden={!editable || !user.hasAddRole(role)}
onClick={() => { onAdd={() => {
setInitialData({ setInitialData({
order: orderId order: orderId
}); });
newLineItem.open(); newLineItem.open();
}} }}
onImport={() => importLineItems.open()}
/> />
]; ];
}, [editable, user, role]); }, [editable, user, role, orderId, importLineItems]);
return ( return (
<> <>
{newLineItem.modal} {newLineItem.modal}
{editLineItem.modal} {editLineItem.modal}
{deleteLineItem.modal} {deleteLineItem.modal}
{importLineItems.modal}
<InvenTreeTable <InvenTreeTable
tableState={table} tableState={table}
url={apiUrl(endpoint)} url={apiUrl(endpoint)}
@@ -1,10 +1,9 @@
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { Text } from '@mantine/core'; import { Text } from '@mantine/core';
import { IconFileArrowLeft, IconSquareArrowRight } from '@tabler/icons-react'; import { IconSquareArrowRight } from '@tabler/icons-react';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { ActionButton } from '@lib/components/ActionButton'; import { ActionButton } from '@lib/components/ActionButton';
import { AddItemButton } from '@lib/components/AddItemButton';
import { ProgressBar } from '@lib/components/ProgressBar'; import { ProgressBar } from '@lib/components/ProgressBar';
import { import {
type RowAction, type RowAction,
@@ -37,6 +36,7 @@ import {
} from '../../components/tables/ColumnRenderers'; } from '../../components/tables/ColumnRenderers';
import { InvenTreeTable } from '../../components/tables/InvenTreeTable'; import { InvenTreeTable } from '../../components/tables/InvenTreeTable';
import { LineItemCreationMenu } from '../../components/items/LineItemCreationMenu';
import { AppRowViewAction } from '../../components/tables/AppRowActions'; import { AppRowViewAction } from '../../components/tables/AppRowActions';
import { TableHoverCard } from '../../components/tables/TableHoverCard'; import { TableHoverCard } from '../../components/tables/TableHoverCard';
import { formatCurrency } from '../../defaults/formatters'; import { formatCurrency } from '../../defaults/formatters';
@@ -397,23 +397,19 @@ export function PurchaseOrderLineItemTable({
// Custom table actions // Custom table actions
const tableActions = useMemo(() => { const tableActions = useMemo(() => {
return [ return [
<ActionButton <LineItemCreationMenu
key='import-line-items' key='add-line-item-actions'
hidden={!editable || !user.hasAddRole(UserRoles.purchase_order)}
tooltip={t`Import Line Items`}
icon={<IconFileArrowLeft />}
onClick={() => importLineItems.open()}
/>,
<AddItemButton
key='add-line-item'
tooltip={t`Add Line Item`} tooltip={t`Add Line Item`}
onClick={() => { addLabel={t`Add Line Item`}
importLabel={t`Import Line Items`}
hidden={!editable || !user.hasAddRole(UserRoles.purchase_order)}
onAdd={() => {
setInitialData({ setInitialData({
order: orderId order: orderId
}); });
newLine.open(); newLine.open();
}} }}
hidden={!editable || !user?.hasAddRole(UserRoles.purchase_order)} onImport={() => importLineItems.open()}
/>, />,
<ActionButton <ActionButton
key='receive-items' key='receive-items'
@@ -3,7 +3,6 @@ import { IconFlag, IconSquareArrowRight } from '@tabler/icons-react';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { ActionButton } from '@lib/components/ActionButton'; import { ActionButton } from '@lib/components/ActionButton';
import { AddItemButton } from '@lib/components/AddItemButton';
import { import {
type RowAction, type RowAction,
RowDeleteAction, RowDeleteAction,
@@ -17,6 +16,7 @@ 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 { Alert } from '@mantine/core'; import { Alert } from '@mantine/core';
import { LineItemCreationMenu } from '../../components/items/LineItemCreationMenu';
import { import {
DateColumn, DateColumn,
DescriptionColumn, DescriptionColumn,
@@ -33,6 +33,7 @@ import {
import { StatusFilterOptions } from '../../components/tables/Filter'; import { StatusFilterOptions } from '../../components/tables/Filter';
import { InvenTreeTable } from '../../components/tables/InvenTreeTable'; import { InvenTreeTable } from '../../components/tables/InvenTreeTable';
import { formatCurrency } from '../../defaults/formatters'; import { formatCurrency } from '../../defaults/formatters';
import { dataImporterSessionFields } from '../../forms/ImporterForms';
import { import {
useReceiveReturnOrderLineItems, useReceiveReturnOrderLineItems,
useReturnOrderLineItemFields useReturnOrderLineItemFields
@@ -44,6 +45,7 @@ import {
useEditApiFormModal useEditApiFormModal
} from '../../hooks/UseForm'; } from '../../hooks/UseForm';
import useStatusCodes from '../../hooks/UseStatusCodes'; import useStatusCodes from '../../hooks/UseStatusCodes';
import { useImporterState } from '../../states/ImporterState';
import { useUserState } from '../../states/UserState'; import { useUserState } from '../../states/UserState';
export default function ReturnOrderLineItemTable({ export default function ReturnOrderLineItemTable({
@@ -63,6 +65,7 @@ export default function ReturnOrderLineItemTable({
}>) { }>) {
const table = useTable('return-order-line-item'); const table = useTable('return-order-line-item');
const user = useUserState(); const user = useUserState();
const openImporter = useImporterState((state) => state.openImporter);
const roStatus = useStatusCodes({ modelType: ModelType.returnorder }); const roStatus = useStatusCodes({ modelType: ModelType.returnorder });
@@ -112,6 +115,33 @@ export default function ReturnOrderLineItemTable({
table: table table: table
}); });
const importSessionFields = useMemo(() => {
const fields = dataImporterSessionFields({
modelType: ModelType.returnorderlineitem
});
fields.field_overrides.value = {
order: orderId
};
fields.field_defaults.value = {
price_currency: currency
};
return fields;
}, [orderId, currency]);
const importLineItems = useCreateApiFormModal({
url: ApiEndpoints.import_session_list,
title: t`Import Line Items`,
fields: importSessionFields,
onFormSuccess: (response: any) => {
openImporter(response.pk, {
onClose: table.refreshTable
});
}
});
const setOutcome = useBulkEditApiFormModal({ const setOutcome = useBulkEditApiFormModal({
url: ApiEndpoints.return_order_line_list, url: ApiEndpoints.return_order_line_list,
items: table.selectedIds, items: table.selectedIds,
@@ -204,13 +234,16 @@ export default function ReturnOrderLineItemTable({
const tableActions = useMemo(() => { const tableActions = useMemo(() => {
return [ return [
<AddItemButton <LineItemCreationMenu
key='add-line-item' key='add-line-item-actions'
tooltip={t`Add Line Item`} tooltip={t`Add Line Item`}
addLabel={t`Add Line Item`}
importLabel={t`Import Line Items`}
hidden={!editable || !user.hasAddRole(UserRoles.return_order)} hidden={!editable || !user.hasAddRole(UserRoles.return_order)}
onClick={() => { onAdd={() => {
newLine.open(); newLine.open();
}} }}
onImport={() => importLineItems.open()}
/>, />,
<ActionButton <ActionButton
key='receive-items' key='receive-items'
@@ -240,7 +273,14 @@ export default function ReturnOrderLineItemTable({
disabled={table.selectedRecords.length == 0} disabled={table.selectedRecords.length == 0}
/> />
]; ];
}, [user, editable, inProgress, orderId, table.selectedRecords]); }, [
user,
editable,
inProgress,
orderId,
table.selectedRecords,
importLineItems
]);
const [selectedItems, setSelectedItems] = useState<any[]>([]); const [selectedItems, setSelectedItems] = useState<any[]>([]);
@@ -294,6 +334,7 @@ export default function ReturnOrderLineItemTable({
{deleteLine.modal} {deleteLine.modal}
{receiveLineItems.modal} {receiveLineItems.modal}
{setOutcome.modal} {setOutcome.modal}
{importLineItems.modal}
<InvenTreeTable <InvenTreeTable
url={apiUrl(ApiEndpoints.return_order_line_list)} url={apiUrl(ApiEndpoints.return_order_line_list)}
tableState={table} tableState={table}
@@ -13,7 +13,6 @@ import { type ReactNode, useCallback, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { ActionButton } from '@lib/components/ActionButton'; import { ActionButton } from '@lib/components/ActionButton';
import { AddItemButton } from '@lib/components/AddItemButton';
import { ProgressBar } from '@lib/components/ProgressBar'; import { ProgressBar } from '@lib/components/ProgressBar';
import { import {
type RowAction, type RowAction,
@@ -44,12 +43,14 @@ import {
} from '../../components/tables/ColumnRenderers'; } from '../../components/tables/ColumnRenderers';
import { InvenTreeTable } from '../../components/tables/InvenTreeTable'; import { InvenTreeTable } from '../../components/tables/InvenTreeTable';
import { LineItemCreationMenu } from '../../components/items/LineItemCreationMenu';
import { AppRowViewAction } from '../../components/tables/AppRowActions'; import { AppRowViewAction } from '../../components/tables/AppRowActions';
import RowExpansionIcon from '../../components/tables/RowExpansionIcon'; import RowExpansionIcon from '../../components/tables/RowExpansionIcon';
import { TableHoverCard } from '../../components/tables/TableHoverCard'; import { TableHoverCard } from '../../components/tables/TableHoverCard';
import OrderPartsWizard from '../../components/wizards/OrderPartsWizard'; import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
import { formatCurrency, formatDecimal } from '../../defaults/formatters'; import { formatCurrency, formatDecimal } from '../../defaults/formatters';
import { useBuildOrderFields } from '../../forms/BuildForms'; import { useBuildOrderFields } from '../../forms/BuildForms';
import { dataImporterSessionFields } from '../../forms/ImporterForms';
import { import {
useAllocateToSalesOrderForm, useAllocateToSalesOrderForm,
useSalesOrderAllocateSerialsFields, useSalesOrderAllocateSerialsFields,
@@ -62,6 +63,7 @@ import {
useDeleteApiFormModal, useDeleteApiFormModal,
useEditApiFormModal useEditApiFormModal
} from '../../hooks/UseForm'; } from '../../hooks/UseForm';
import { useImporterState } from '../../states/ImporterState';
import { useUserState } from '../../states/UserState'; import { useUserState } from '../../states/UserState';
import SalesOrderAllocationTable from './SalesOrderAllocationTable'; import SalesOrderAllocationTable from './SalesOrderAllocationTable';
@@ -81,6 +83,34 @@ export default function SalesOrderLineItemTable({
const navigate = useNavigate(); const navigate = useNavigate();
const user = useUserState(); const user = useUserState();
const table = useTable('sales-order-line-item'); const table = useTable('sales-order-line-item');
const openImporter = useImporterState((state) => state.openImporter);
const importSessionFields = useMemo(() => {
const fields = dataImporterSessionFields({
modelType: 'salesorderlineitem'
});
fields.field_overrides.value = {
order: orderId
};
fields.field_defaults.value = {
sale_price_currency: currency
};
return fields;
}, [orderId, currency]);
const importLineItems = useCreateApiFormModal({
url: ApiEndpoints.import_session_list,
title: t`Import Line Items`,
fields: importSessionFields,
onFormSuccess: (response: any) => {
openImporter(response.pk, {
onClose: table.refreshTable
});
}
});
const tableColumns: TableColumn[] = useMemo(() => { const tableColumns: TableColumn[] = useMemo(() => {
return [ return [
@@ -408,16 +438,19 @@ export default function SalesOrderLineItemTable({
const tableActions = useMemo(() => { const tableActions = useMemo(() => {
return [ return [
<AddItemButton <LineItemCreationMenu
key='add-line-item' key='add-line-item-actions'
tooltip={t`Add Line Item`} tooltip={t`Add Line Item`}
onClick={() => { addLabel={t`Add Line Item`}
importLabel={t`Import Line Items`}
hidden={!editable || !user.hasAddRole(UserRoles.sales_order)}
onAdd={() => {
setInitialData({ setInitialData({
order: orderId order: orderId
}); });
newLine.open(); newLine.open();
}} }}
hidden={!editable || !user.hasAddRole(UserRoles.sales_order)} onImport={() => importLineItems.open()}
/>, />,
<ActionButton <ActionButton
key='order-parts' key='order-parts'
@@ -463,7 +496,8 @@ export default function SalesOrderLineItemTable({
user, user,
orderId, orderId,
table.hasSelectedRecords, table.hasSelectedRecords,
table.selectedRecords table.selectedRecords,
importLineItems
]); ]);
const rowActions = useCallback( const rowActions = useCallback(
@@ -603,6 +637,7 @@ export default function SalesOrderLineItemTable({
{editLine.modal} {editLine.modal}
{deleteLine.modal} {deleteLine.modal}
{newLine.modal} {newLine.modal}
{importLineItems.modal}
{newBuildOrder.modal} {newBuildOrder.modal}
{allocateBySerials.modal} {allocateBySerials.modal}
{allocateStock.modal} {allocateStock.modal}
@@ -379,9 +379,11 @@ test('Purchase Orders - Price Breaks', async ({ browser }) => {
url: 'purchasing/purchase-order/14/line-items' url: 'purchasing/purchase-order/14/line-items'
}); });
await page.getByRole('button', { name: 'action-menu-add-line-item' }).click();
await page await page
.getByRole('button', { name: 'action-button-add-line-item' }) .getByRole('menuitem', { name: 'action-menu-add-line-item-add' })
.click(); .click();
await page.getByLabel('related-field-part').fill('002.01'); await page.getByLabel('related-field-part').fill('002.01');
await page.getByRole('option', { name: 'PCBWOY PCB-002.01' }).click(); await page.getByRole('option', { name: 'PCBWOY PCB-002.01' }).click();
+3 -1
View File
@@ -435,9 +435,11 @@ test('Sales Orders - Price Breaks', async ({ browser }) => {
url: 'sales/sales-order/14/line-items' url: 'sales/sales-order/14/line-items'
}); });
await page.getByRole('button', { name: 'action-menu-add-line-item' }).click();
await page await page
.getByRole('button', { name: 'action-button-add-line-item' }) .getByRole('menuitem', { name: 'action-menu-add-line-item-add' })
.click(); .click();
await page.getByLabel('related-field-part').fill('software'); await page.getByLabel('related-field-part').fill('software');
await page.getByRole('option', { name: 'Software License' }).click(); await page.getByRole('option', { name: 'Software License' }).click();
+8 -2
View File
@@ -162,8 +162,11 @@ test('Importing - Purchase Order', async ({ browser }) => {
url: 'purchasing/purchase-order/15/line-items' url: 'purchasing/purchase-order/15/line-items'
}); });
await page.getByRole('button', { name: 'action-menu-add-line-item' }).click();
await page await page
.getByRole('button', { name: 'action-button-import-line-' }) .getByRole('menuitem', {
name: 'action-menu-add-line-item-import-line-items'
})
.click(); .click();
const fileInput = await page.locator('input[type="file"]'); const fileInput = await page.locator('input[type="file"]');
@@ -181,8 +184,11 @@ test('Importing - Natural Keys', async ({ browser }) => {
}); });
// Import line item data, but use natural keys as the import fields // Import line item data, but use natural keys as the import fields
await page.getByRole('button', { name: 'action-menu-add-line-item' }).click();
await page await page
.getByRole('button', { name: 'action-button-import-line-' }) .getByRole('menuitem', {
name: 'action-menu-add-line-item-import-line-items'
})
.click(); .click();
const fileInput = await page.locator('input[type="file"]'); const fileInput = await page.locator('input[type="file"]');