2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-09-13 22:21:37 +00:00
Files
InvenTree/src/frontend/src/tables/purchasing/PurchaseOrderLineItemTable.tsx
Oliver 885ec81a08 Import update (#10188)
* Add field to "update" existing records

* Ensure the ID is first

* Prevent editing of "ID" field

* Extract db instance

* Bump API version

* Prevent edit of "id" field

* Refactoring

* Enhanced playwright tests for data importing

* Update docs

* Update src/backend/InvenTree/importer/models.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/frontend/src/forms/ImporterForms.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Fix silly AI mistake

* Fix for table pagination

- Ensure page does not exceed available records

* Bug fix for playwright test

* Add end-to-end API testing

* Fix unit tests

* Adjust table page logic

* Ensure sensible page size

* Simplify playwright test

* Simplify test again

* Tweak unit test

- Importing has invalidated the BOM?

* Adjust playwright tests

* Further playwright fixes

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-20 15:34:49 +10:00

459 lines
12 KiB
TypeScript

import { t } from '@lingui/core/macro';
import { Text } from '@mantine/core';
import { IconFileArrowLeft, IconSquareArrowRight } from '@tabler/icons-react';
import { useCallback, useMemo, useState } from 'react';
import { ActionButton } from '@lib/components/ActionButton';
import { AddItemButton } from '@lib/components/AddItemButton';
import { ProgressBar } from '@lib/components/ProgressBar';
import {
type RowAction,
RowDeleteAction,
RowDuplicateAction,
RowEditAction,
RowViewAction
} from '@lib/components/RowActions';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import { formatDecimal } from '@lib/functions/Formatting';
import type { TableFilter } from '@lib/types/Filters';
import type { TableColumn } from '@lib/types/Tables';
import { useNavigate } from 'react-router-dom';
import ImporterDrawer from '../../components/importer/ImporterDrawer';
import { RenderInstance } from '../../components/render/Instance';
import { dataImporterSessionFields } from '../../forms/ImporterForms';
import {
usePurchaseOrderLineItemFields,
useReceiveLineItems
} from '../../forms/PurchaseOrderForms';
import {
useCreateApiFormModal,
useDeleteApiFormModal,
useEditApiFormModal
} from '../../hooks/UseForm';
import useStatusCodes from '../../hooks/UseStatusCodes';
import { useTable } from '../../hooks/UseTable';
import { useUserState } from '../../states/UserState';
import {
CurrencyColumn,
DescriptionColumn,
LinkColumn,
LocationColumn,
NoteColumn,
PartColumn,
ReferenceColumn,
TargetDateColumn
} from '../ColumnRenderers';
import { InvenTreeTable } from '../InvenTreeTable';
import { TableHoverCard } from '../TableHoverCard';
/*
* Display a table of purchase order line items, for a specific order
*/
export function PurchaseOrderLineItemTable({
order,
orderDetailRefresh,
orderId,
currency,
supplierId,
params
}: Readonly<{
order: any;
orderDetailRefresh: () => void;
orderId: number;
currency: string;
supplierId?: number;
params?: any;
}>) {
const table = useTable('purchase-order-line-item');
const navigate = useNavigate();
const user = useUserState();
// Data import
const [importOpened, setImportOpened] = useState<boolean>(false);
const [selectedSession, setSelectedSession] = useState<number | undefined>(
undefined
);
const importSessionFields = useMemo(() => {
const fields = dataImporterSessionFields({
modelType: ModelType.purchaseorderlineitem
});
// Specify override values for import
fields.field_overrides.value = {
order: orderId
};
// Specify default values based on the order data
fields.field_defaults.value = {
purchase_price_currency:
order?.order_currency || order?.supplier_detail?.currency || undefined
};
fields.field_filters.value = {
part: {
supplier: supplierId,
active: true
}
};
return fields;
}, [order, orderId, supplierId]);
const importLineItems = useCreateApiFormModal({
url: ApiEndpoints.import_session_list,
title: t`Import Line Items`,
fields: importSessionFields,
onFormSuccess: (response: any) => {
setSelectedSession(response.pk);
setImportOpened(true);
}
});
const [singleRecord, setSingleRecord] = useState(null);
const receiveLineItems = useReceiveLineItems({
items: singleRecord ? [singleRecord] : table.selectedRecords,
orderPk: orderId,
destinationPk: order.destination,
formProps: {
// Timeout is a small hack to prevent function being called before re-render
onClose: () => {
table.clearSelectedRecords();
table.refreshTable();
setTimeout(() => setSingleRecord(null), 500);
}
}
});
const tableColumns: TableColumn[] = useMemo(() => {
return [
{
accessor: 'part',
title: t`Part`,
sortable: true,
ordering: 'part_name',
switchable: false,
render: (record: any) => PartColumn({ part: record.part_detail })
},
{
accessor: 'part_detail.IPN',
sortable: true,
ordering: 'IPN'
},
DescriptionColumn({
accessor: 'part_detail.description'
}),
ReferenceColumn({}),
{
accessor: 'build_order',
title: t`Build Order`,
sortable: true,
defaultVisible: false,
render: (record: any) => {
if (record.build_order_detail) {
return (
<RenderInstance
instance={record.build_order_detail}
model={ModelType.build}
/>
);
} else {
return '-';
}
}
},
{
accessor: 'quantity',
title: t`Quantity`,
sortable: true,
switchable: false,
render: (record: any) => {
const supplier_part = record?.supplier_part_detail ?? {};
const part = record?.part_detail ?? supplier_part?.part_detail ?? {};
const extra = [];
if (
supplier_part?.pack_quantity_native != undefined &&
supplier_part.pack_quantity_native != 1
) {
const total = record.quantity * supplier_part.pack_quantity_native;
extra.push(
<Text key='pack-quantity'>
{t`Pack Quantity`}: {supplier_part.pack_quantity}
</Text>
);
extra.push(
<Text key='total-quantity'>
{t`Total Quantity`}: {total} {part?.units}
</Text>
);
}
return (
<TableHoverCard
value={formatDecimal(record.quantity)}
extra={extra}
title={t`Quantity`}
/>
);
}
},
{
accessor: 'received',
title: t`Received`,
sortable: false,
render: (record: any) => (
<ProgressBar
progressLabel={true}
value={record.received}
maximum={record.quantity}
/>
)
},
{
accessor: 'supplier_part_detail.packaging',
sortable: false,
title: t`Packaging`,
defaultVisible: false
},
{
accessor: 'supplier_part_detail.pack_quantity',
sortable: false,
title: t`Pack Quantity`
},
{
accessor: 'sku',
title: t`Supplier Code`,
switchable: false,
sortable: true,
ordering: 'SKU'
},
LinkColumn({
accessor: 'supplier_part_detail.link',
title: t`Supplier Link`,
sortable: false,
defaultVisible: false
}),
{
accessor: 'mpn',
ordering: 'MPN',
title: t`Manufacturer Code`,
sortable: true,
defaultVisible: false
},
CurrencyColumn({
accessor: 'purchase_price',
title: t`Unit Price`
}),
CurrencyColumn({
accessor: 'total_price',
currency_accessor: 'purchase_price_currency',
title: t`Total Price`
}),
TargetDateColumn({}),
LocationColumn({
accessor: 'destination_detail',
sortable: false,
title: t`Destination`
}),
NoteColumn({}),
LinkColumn({})
];
}, [orderId, user]);
const tableFilters: TableFilter[] = useMemo(() => {
return [
{
name: 'received',
label: t`Received`,
description: t`Show line items which have been received`
}
];
}, []);
const addPurchaseOrderFields = usePurchaseOrderLineItemFields({
create: true,
orderId: orderId,
supplierId: supplierId
});
const [initialData, setInitialData] = useState<any>({});
const newLine = useCreateApiFormModal({
url: ApiEndpoints.purchase_order_line_list,
title: t`Add Line Item`,
fields: addPurchaseOrderFields,
initialData: {
...initialData,
purchase_price_currency: currency
},
onFormSuccess: orderDetailRefresh,
table: table
});
const [selectedLine, setSelectedLine] = useState<number>(0);
const editLineItemFields = usePurchaseOrderLineItemFields({
create: false,
orderId: orderId,
supplierId: supplierId
});
const editLine = useEditApiFormModal({
url: ApiEndpoints.purchase_order_line_list,
pk: selectedLine,
title: t`Edit Line Item`,
fields: editLineItemFields,
onFormSuccess: orderDetailRefresh,
table: table
});
const deleteLine = useDeleteApiFormModal({
url: ApiEndpoints.purchase_order_line_list,
pk: selectedLine,
title: t`Delete Line Item`,
onFormSuccess: orderDetailRefresh,
table: table
});
const poStatus = useStatusCodes({ modelType: ModelType.purchaseorder });
const orderOpen: boolean = useMemo(() => {
return (
order.status == poStatus.PENDING ||
order.status == poStatus.PLACED ||
order.status == poStatus.ON_HOLD
);
}, [order, poStatus]);
const orderPlaced: boolean = useMemo(() => {
return order.status == poStatus.PLACED;
}, [order, poStatus]);
const rowActions = useCallback(
(record: any): RowAction[] => {
const received = (record?.received ?? 0) >= (record?.quantity ?? 0);
return [
{
hidden: received || !orderPlaced,
title: t`Receive line item`,
icon: <IconSquareArrowRight />,
color: 'green',
onClick: () => {
setSingleRecord(record);
receiveLineItems.open();
}
},
RowViewAction({
hidden: !record.build_order,
title: t`View Build Order`,
modelType: ModelType.build,
modelId: record.build_order,
navigate: navigate
}),
RowEditAction({
hidden: !user.hasChangeRole(UserRoles.purchase_order),
onClick: () => {
setSelectedLine(record.pk);
editLine.open();
}
}),
RowDuplicateAction({
hidden: !orderOpen || !user.hasAddRole(UserRoles.purchase_order),
onClick: () => {
setInitialData({ ...record });
newLine.open();
}
}),
RowDeleteAction({
hidden: !user.hasDeleteRole(UserRoles.purchase_order),
onClick: () => {
setSelectedLine(record.pk);
deleteLine.open();
}
})
];
},
[orderId, user, orderOpen, orderPlaced]
);
// Custom table actions
const tableActions = useMemo(() => {
return [
<ActionButton
key='import-line-items'
hidden={!orderOpen || !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`}
onClick={() => {
setInitialData({
order: orderId
});
newLine.open();
}}
hidden={!orderOpen || !user?.hasAddRole(UserRoles.purchase_order)}
/>,
<ActionButton
key='receive-items'
text={t`Receive items`}
icon={<IconSquareArrowRight />}
onClick={() => receiveLineItems.open()}
disabled={table.selectedRecords.length === 0}
hidden={!orderPlaced || !user.hasChangeRole(UserRoles.purchase_order)}
/>
];
}, [orderId, user, table, orderOpen, orderPlaced]);
return (
<>
{importLineItems.modal}
{receiveLineItems.modal}
{newLine.modal}
{editLine.modal}
{deleteLine.modal}
<InvenTreeTable
url={apiUrl(ApiEndpoints.purchase_order_line_list)}
tableState={table}
columns={tableColumns}
props={{
enableSelection: true,
enableDownload: true,
params: {
...params,
order: orderId,
part_detail: true
},
rowActions: rowActions,
tableActions: tableActions,
tableFilters: tableFilters,
modelType: ModelType.supplierpart,
modelField: 'part'
}}
/>
<ImporterDrawer
sessionId={selectedSession ?? -1}
opened={selectedSession != undefined && importOpened}
onClose={() => {
setSelectedSession(undefined);
setImportOpened(false);
table.refreshTable();
}}
/>
</>
);
}