[PUI] Sales order tables (#7793)

* Add placeholder for more sales order actions

* Add <SalesOrderShipmentTable />

* Allow filtering by date fields

* Add <ReturnOrderLineItemTable />

- Add label rendering for ReturnOrderLineItem

* Add placeholder actions

* Edit / delete / add line items for return order

* Fix for duplicate action

* Cleanup unused code

* Bump API version

* Update playwright tests
This commit is contained in:
Oliver 2024-08-03 18:49:09 +10:00 committed by GitHub
parent dee519e848
commit abe9b19ead
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 517 additions and 27 deletions

View File

@ -1,12 +1,16 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 231
INVENTREE_API_VERSION = 232
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v232 - 2024-08-03 : https://github.com/inventree/InvenTree/pull/7793
- Allow ordering of SalesOrderShipment API by 'shipment_date' and 'delivery_date'
v231 - 2024-08-03 : https://github.com/inventree/InvenTree/pull/7794
- Optimize BuildItem and BuildLine serializers to improve API efficiency

View File

@ -1042,7 +1042,9 @@ class SalesOrderShipmentList(ListCreateAPI):
serializer_class = serializers.SalesOrderShipmentSerializer
filterset_class = SalesOrderShipmentFilter
filter_backends = [rest_filters.DjangoFilterBackend]
filter_backends = SEARCH_ORDER_FILTER_ALIAS
ordering_fields = ['delivery_date', 'shipment_date']
class SalesOrderShipmentDetail(RetrieveUpdateDestroyAPI):

View File

@ -21,6 +21,7 @@ import { ModelInformationDict } from './ModelType';
import {
RenderPurchaseOrder,
RenderReturnOrder,
RenderReturnOrderLineItem,
RenderSalesOrder,
RenderSalesOrderShipment
} from './Order';
@ -73,6 +74,7 @@ const RendererLookup: EnumDictionary<
[ModelType.purchaseorder]: RenderPurchaseOrder,
[ModelType.purchaseorderlineitem]: RenderPurchaseOrder,
[ModelType.returnorder]: RenderReturnOrder,
[ModelType.returnorderlineitem]: RenderReturnOrderLineItem,
[ModelType.salesorder]: RenderSalesOrder,
[ModelType.salesordershipment]: RenderSalesOrderShipment,
[ModelType.stocklocation]: RenderStockLocation,

View File

@ -173,6 +173,11 @@ export const ModelInformationDict: ModelDict = {
api_endpoint: ApiEndpoints.return_order_list,
admin_url: '/order/returnorder/'
},
returnorderlineitem: {
label: t`Return Order Line Item`,
label_multiple: t`Return Order Line Items`,
api_endpoint: ApiEndpoints.return_order_line_list
},
address: {
label: t`Address`,
label_multiple: t`Addresses`,

View File

@ -62,6 +62,23 @@ export function RenderReturnOrder(
);
}
export function RenderReturnOrderLineItem(
props: Readonly<InstanceRenderInterface>
): ReactNode {
const { instance } = props;
return (
<RenderInlineModel
{...props}
primary={instance.reference}
suffix={StatusRenderer({
status: instance.outcome,
type: ModelType.returnorderlineitem
})}
/>
);
}
/**
* Inline rendering of a single SalesOrder instance
*/

View File

@ -2,6 +2,7 @@ import { Badge, Center, MantineSize } from '@mantine/core';
import { colorMap } from '../../defaults/backendMappings';
import { ModelType } from '../../enums/ModelType';
import { resolveItem } from '../../functions/conversion';
import { useGlobalStatusState } from '../../states/StatusState';
interface StatusCodeInterface {
@ -132,10 +133,16 @@ export const StatusRenderer = ({
* Render the status badge in a table
*/
export function TableStatusRenderer(
type: ModelType
type: ModelType,
accessor?: string
): ((record: any) => any) | undefined {
return (record: any) =>
record.status && (
<Center>{StatusRenderer({ status: record.status, type: type })}</Center>
return (record: any) => {
const status = resolveItem(record, accessor ?? 'status');
return (
status && (
<Center>{StatusRenderer({ status: status, type: type })}</Center>
)
);
};
}

View File

@ -9,8 +9,8 @@ import { ModelType } from '../enums/ModelType';
export const statusCodeList: Record<string, ModelType> = {
BuildStatus: ModelType.build,
PurchaseOrderStatus: ModelType.purchaseorder,
ReturnOrderLineStatus: ModelType.purchaseorderlineitem,
ReturnOrderStatus: ModelType.returnorder,
ReturnOrderLineStatus: ModelType.returnorderlineitem,
SalesOrderStatus: ModelType.salesorder,
StockHistoryCode: ModelType.stockhistory,
StockStatus: ModelType.stockitem,

View File

@ -132,6 +132,7 @@ export enum ApiEndpoints {
sales_order_shipment_list = 'order/so/shipment/',
return_order_list = 'order/ro/',
return_order_line_list = 'order/ro-line/',
// Template API endpoints
label_list = 'label/template/',

View File

@ -22,6 +22,7 @@ export enum ModelType {
salesorder = 'salesorder',
salesordershipment = 'salesordershipment',
returnorder = 'returnorder',
returnorderlineitem = 'returnorderlineitem',
importsession = 'importsession',
address = 'address',
contact = 'contact',

View File

@ -0,0 +1,38 @@
import { useMemo } from 'react';
export function useReturnOrderLineItemFields({
orderId,
customerId,
create
}: {
orderId: number;
customerId: number;
create?: boolean;
}) {
return useMemo(() => {
return {
order: {
disabled: true,
filters: {
customer_detail: true
}
},
item: {
filters: {
customer: customerId,
part_detail: true,
serialized: true
}
},
reference: {},
outcome: {
hidden: create == true
},
price: {},
price_currency: {},
target_date: {},
notes: {},
link: {}
};
}, [create, orderId, customerId]);
}

View File

@ -84,6 +84,23 @@ export function useSalesOrderLineItemFields({
return fields;
}
export function useSalesOrderShipmentFields(): ApiFormFieldSet {
return useMemo(() => {
return {
order: {
disabled: true
},
reference: {},
shipment_date: {},
delivery_date: {},
tracking_number: {},
invoice_number: {},
link: {},
notes: {}
};
}, []);
}
export function useReturnOrderFields(): ApiFormFieldSet {
return useMemo(() => {
return {

View File

@ -26,7 +26,6 @@ import {
UnlinkBarcodeAction,
ViewBarcodeAction
} from '../../components/items/ActionDropdown';
import { PlaceholderPanel } from '../../components/items/Placeholder';
import InstanceDetail from '../../components/nav/InstanceDetail';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
@ -43,6 +42,7 @@ import {
import { useInstance } from '../../hooks/UseInstance';
import { useUserState } from '../../states/UserState';
import { AttachmentTable } from '../../tables/general/AttachmentTable';
import ReturnOrderLineItemTable from '../../tables/sales/ReturnOrderLineItemTable';
/**
* Detail page for a single ReturnOrder
@ -227,7 +227,12 @@ export default function ReturnOrderDetail() {
name: 'line-items',
label: t`Line Items`,
icon: <IconList />,
content: <PlaceholderPanel />
content: (
<ReturnOrderLineItemTable
orderId={order.pk}
customerId={order.customer}
/>
)
},
{
name: 'attachments',

View File

@ -7,8 +7,7 @@ import {
IconNotes,
IconPaperclip,
IconTools,
IconTruckDelivery,
IconTruckLoading
IconTruckDelivery
} from '@tabler/icons-react';
import { ReactNode, useMemo } from 'react';
import { useParams } from 'react-router-dom';
@ -29,7 +28,6 @@ import {
UnlinkBarcodeAction,
ViewBarcodeAction
} from '../../components/items/ActionDropdown';
import { PlaceholderPanel } from '../../components/items/Placeholder';
import InstanceDetail from '../../components/nav/InstanceDetail';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
@ -48,6 +46,7 @@ import { useUserState } from '../../states/UserState';
import { BuildOrderTable } from '../../tables/build/BuildOrderTable';
import { AttachmentTable } from '../../tables/general/AttachmentTable';
import SalesOrderLineItemTable from '../../tables/sales/SalesOrderLineItemTable';
import SalesOrderShipmentTable from '../../tables/sales/SalesOrderShipmentTable';
/**
* Detail page for a single SalesOrder
@ -258,16 +257,10 @@ export default function SalesOrderDetail() {
)
},
{
name: 'pending-shipments',
label: t`Pending Shipments`,
icon: <IconTruckLoading />,
content: <PlaceholderPanel />
},
{
name: 'completed-shipments',
label: t`Completed Shipments`,
name: 'shipments',
label: t`Shipments`,
icon: <IconTruckDelivery />,
content: <PlaceholderPanel />
content: <SalesOrderShipmentTable orderId={order.pk} />
},
{
name: 'build-orders',

View File

@ -163,16 +163,19 @@ export function ProjectCodeColumn(props: TableColumnProps): TableColumn {
export function StatusColumn({
model,
sortable,
accessor
accessor,
title
}: {
model: ModelType;
sortable?: boolean;
accessor?: string;
title?: string;
}) {
return {
accessor: accessor ?? 'status',
sortable: sortable ?? true,
render: TableStatusRenderer(model)
title: title,
render: TableStatusRenderer(model, accessor ?? 'status')
};
}

View File

@ -0,0 +1,199 @@
import { t } from '@lingui/macro';
import { IconSquareArrowRight } from '@tabler/icons-react';
import { useCallback, useMemo, useState } from 'react';
import { AddItemButton } from '../../components/buttons/AddItemButton';
import { formatCurrency } from '../../defaults/formatters';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import { useReturnOrderLineItemFields } from '../../forms/ReturnOrderForms';
import {
useCreateApiFormModal,
useDeleteApiFormModal,
useEditApiFormModal
} from '../../hooks/UseForm';
import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
import { TableColumn } from '../Column';
import {
DateColumn,
LinkColumn,
NoteColumn,
PartColumn,
ReferenceColumn,
StatusColumn
} from '../ColumnRenderers';
import { StatusFilterOptions, TableFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
import { RowDeleteAction, RowEditAction } from '../RowActions';
export default function ReturnOrderLineItemTable({
orderId,
customerId
}: {
orderId: number;
customerId: number;
}) {
const table = useTable('return-order-line-item');
const user = useUserState();
const [selectedLine, setSelectedLine] = useState<number>(0);
const newLineFields = useReturnOrderLineItemFields({
orderId: orderId,
customerId: customerId,
create: true
});
const editLineFields = useReturnOrderLineItemFields({
orderId: orderId,
customerId: customerId
});
const newLine = useCreateApiFormModal({
url: ApiEndpoints.return_order_line_list,
title: t`Add Line Item`,
fields: newLineFields,
initialData: {
order: orderId
},
table: table
});
const editLine = useEditApiFormModal({
url: ApiEndpoints.return_order_line_list,
pk: selectedLine,
title: t`Edit Line Item`,
fields: editLineFields,
table: table
});
const deleteLine = useDeleteApiFormModal({
url: ApiEndpoints.return_order_line_list,
pk: selectedLine,
title: t`Delete Line Item`,
table: table
});
const tableColumns: TableColumn[] = useMemo(() => {
return [
{
accessor: 'part',
title: t`Part`,
switchable: false,
render: (record: any) => PartColumn(record?.part_detail)
},
{
accessor: 'item',
title: t`Stock Item`,
switchable: false
},
ReferenceColumn({}),
StatusColumn({
model: ModelType.returnorderlineitem,
sortable: true,
accessor: 'outcome'
}),
{
accessor: 'price',
render: (record: any) =>
formatCurrency(record.price, { currency: record.price_currency })
},
DateColumn({
accessor: 'target_date',
title: t`Target Date`
}),
DateColumn({
accessor: 'received_date',
title: t`Received Date`
}),
NoteColumn({
accessor: 'notes'
}),
LinkColumn({})
];
}, []);
const tableFilters: TableFilter[] = useMemo(() => {
return [
{
name: 'received',
label: t`Received`,
description: t`Show items which have been received`
},
{
name: 'status',
label: t`Status`,
description: t`Filter by line item status`,
choiceFunction: StatusFilterOptions(ModelType.returnorderlineitem)
}
];
}, []);
const tableActions = useMemo(() => {
return [
<AddItemButton
tooltip={t`Add line item`}
hidden={!user.hasAddRole(UserRoles.return_order)}
onClick={() => {
newLine.open();
}}
/>
];
}, [user, orderId]);
const rowActions = useCallback(
(record: any) => {
const received: boolean = !!record?.received_date;
return [
{
hidden: received || !user.hasChangeRole(UserRoles.return_order),
title: t`Receive Item`,
icon: <IconSquareArrowRight />
},
RowEditAction({
hidden: !user.hasChangeRole(UserRoles.return_order),
onClick: () => {
setSelectedLine(record.pk);
editLine.open();
}
}),
RowDeleteAction({
hidden: !user.hasDeleteRole(UserRoles.return_order),
onClick: () => {
setSelectedLine(record.pk);
deleteLine.open();
}
})
];
},
[user]
);
return (
<>
{newLine.modal}
{editLine.modal}
{deleteLine.modal}
<InvenTreeTable
url={apiUrl(ApiEndpoints.return_order_line_list)}
tableState={table}
columns={tableColumns}
props={{
params: {
order: orderId,
part_detail: true,
item_detail: true,
order_detail: true
},
tableActions: tableActions,
tableFilters: tableFilters,
rowActions: rowActions
}}
/>
</>
);
}

View File

@ -1,6 +1,10 @@
import { t } from '@lingui/macro';
import { Text } from '@mantine/core';
import { IconSquareArrowRight } from '@tabler/icons-react';
import {
IconShoppingCart,
IconSquareArrowRight,
IconTools
} from '@tabler/icons-react';
import { ReactNode, useCallback, useMemo, useState } from 'react';
import { AddItemButton } from '../../components/buttons/AddItemButton';
@ -206,7 +210,7 @@ export default function SalesOrderLineItemTable({
hidden={!user.hasAddRole(UserRoles.sales_order)}
/>
];
}, [user]);
}, [user, orderId]);
const rowActions = useCallback(
(record: any) => {
@ -219,6 +223,24 @@ export default function SalesOrderLineItemTable({
icon: <IconSquareArrowRight />,
color: 'green'
},
{
hidden:
allocated ||
!user.hasAddRole(UserRoles.build) ||
!record?.part_detail?.assembly,
title: t`Build stock`,
icon: <IconTools />,
color: 'blue'
},
{
hidden:
allocated ||
!user.hasAddRole(UserRoles.purchase_order) ||
!record?.part_detail?.purchaseable,
title: t`Order stock`,
icon: <IconShoppingCart />,
color: 'blue'
},
RowEditAction({
hidden: !user.hasChangeRole(UserRoles.sales_order),
onClick: () => {

View File

@ -0,0 +1,175 @@
import { t } from '@lingui/macro';
import { IconTruckDelivery } from '@tabler/icons-react';
import { useCallback, useMemo, useState } from 'react';
import { AddItemButton } from '../../components/buttons/AddItemButton';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { UserRoles } from '../../enums/Roles';
import { useSalesOrderShipmentFields } from '../../forms/SalesOrderForms';
import {
useCreateApiFormModal,
useDeleteApiFormModal,
useEditApiFormModal
} from '../../hooks/UseForm';
import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
import { TableColumn } from '../Column';
import { DateColumn, LinkColumn, NoteColumn } from '../ColumnRenderers';
import { TableFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
import { RowDeleteAction, RowEditAction } from '../RowActions';
export default function SalesOrderShipmentTable({
orderId
}: {
orderId: number;
}) {
const user = useUserState();
const table = useTable('sales-order-shipment');
const [selectedShipment, setSelectedShipment] = useState<number>(0);
const newShipmentFields = useSalesOrderShipmentFields();
const editShipmentFields = useSalesOrderShipmentFields();
const newShipment = useCreateApiFormModal({
url: ApiEndpoints.sales_order_shipment_list,
fields: newShipmentFields,
title: t`Create Shipment`,
table: table,
initialData: {
order: orderId
}
});
const deleteShipment = useDeleteApiFormModal({
url: ApiEndpoints.sales_order_shipment_list,
pk: selectedShipment,
title: t`Delete Shipment`,
table: table
});
const editShipment = useEditApiFormModal({
url: ApiEndpoints.sales_order_shipment_list,
pk: selectedShipment,
fields: editShipmentFields,
title: t`Edit Shipment`,
table: table
});
const tableColumns: TableColumn[] = useMemo(() => {
return [
{
accessor: 'reference',
title: t`Shipment Reference`,
switchable: false
},
{
accessor: 'allocations',
title: t`Items`,
render: (record: any) => {
let allocations = record?.allocations ?? [];
return allocations.length;
}
},
DateColumn({
accessor: 'shipment_date',
title: t`Shipment Date`
}),
DateColumn({
accessor: 'delivery_date',
title: t`Delivery Date`
}),
{
accessor: 'tracking_number'
},
{
accessor: 'invoice_number'
},
LinkColumn({
accessor: 'link'
}),
NoteColumn({
accessor: 'notes'
})
];
}, []);
const rowActions = useCallback(
(record: any) => {
const shipped: boolean = !!record.shipment_date;
return [
{
hidden: shipped || !user.hasChangeRole(UserRoles.sales_order),
title: t`Complete Shipment`,
icon: <IconTruckDelivery />
},
RowEditAction({
hidden: !user.hasChangeRole(UserRoles.sales_order),
onClick: () => {
setSelectedShipment(record.pk);
editShipment.open();
}
}),
RowDeleteAction({
hidden: !user.hasDeleteRole(UserRoles.sales_order),
onClick: () => {
setSelectedShipment(record.pk);
deleteShipment.open();
}
})
];
},
[user]
);
const tableActions = useMemo(() => {
return [
<AddItemButton
tooltip={t`Add shipment`}
hidden={!user.hasAddRole(UserRoles.sales_order)}
onClick={() => {
newShipment.open();
}}
/>
];
}, [user]);
const tableFilters: TableFilter[] = useMemo(() => {
return [
{
name: 'shipped',
label: t`Shipped`,
description: t`Show shipments which have been shipped`
},
{
name: 'delivered',
label: t`Delivered`,
description: t`Show shipments which have been delivered`
}
];
}, []);
return (
<>
{newShipment.modal}
{editShipment.modal}
{deleteShipment.modal}
<InvenTreeTable
url={apiUrl(ApiEndpoints.sales_order_shipment_list)}
tableState={table}
columns={tableColumns}
props={{
tableActions: tableActions,
tableFilters: tableFilters,
rowActions: rowActions,
params: {
order: orderId
}
}}
/>
</>
);
}

View File

@ -101,8 +101,7 @@ test('PUI - Sales', async ({ page }) => {
.getByText('Selling some stuff')
.waitFor();
await page.getByRole('tab', { name: 'Line Items' }).click();
await page.getByRole('tab', { name: 'Pending Shipments' }).click();
await page.getByRole('tab', { name: 'Completed Shipments' }).click();
await page.getByRole('tab', { name: 'Shipments' }).click();
await page.getByRole('tab', { name: 'Build Orders' }).click();
await page.getByText('No records found').first().waitFor();
await page.getByRole('tab', { name: 'Attachments' }).click();