mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-28 11:36:44 +00:00
[PUI] Part allocations (#8458)
* Add new backend filters for BuildLine API * PUI: Better display of part allocations against build orders * Add 'order_outstanding' filter to SalesOrderLineItem API * Add new table showing outstanding SalesOrder allocations against a part * Update playwright test * Cleanup * Bump API version * Add more table columns * Tweak UsedInTable * Another table tweak * Tweak playwright tests
This commit is contained in:
parent
ad39d3fd95
commit
255a5d083e
@ -1,13 +1,17 @@
|
|||||||
"""InvenTree API version information."""
|
"""InvenTree API version information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# InvenTree API version
|
||||||
INVENTREE_API_VERSION = 278
|
INVENTREE_API_VERSION = 279
|
||||||
|
|
||||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||||
|
|
||||||
|
|
||||||
INVENTREE_API_TEXT = """
|
INVENTREE_API_TEXT = """
|
||||||
|
|
||||||
|
v279 - 2024-11-09 : https://github.com/inventree/InvenTree/pull/8458
|
||||||
|
- Adds "order_outstanding" and "part" filters to the BuildLine API endpoint
|
||||||
|
- Adds "order_outstanding" filter to the SalesOrderLineItem API endpoint
|
||||||
|
|
||||||
v278 - 2024-11-07 : https://github.com/inventree/InvenTree/pull/8445
|
v278 - 2024-11-07 : https://github.com/inventree/InvenTree/pull/8445
|
||||||
- Updates to the SalesOrder API endpoints
|
- Updates to the SalesOrder API endpoints
|
||||||
- Add "shipment count" information to the SalesOrder API endpoints
|
- Add "shipment count" information to the SalesOrder API endpoints
|
||||||
|
@ -357,6 +357,23 @@ class BuildLineFilter(rest_filters.FilterSet):
|
|||||||
tracked = rest_filters.BooleanFilter(label=_('Tracked'), field_name='bom_item__sub_part__trackable')
|
tracked = rest_filters.BooleanFilter(label=_('Tracked'), field_name='bom_item__sub_part__trackable')
|
||||||
testable = rest_filters.BooleanFilter(label=_('Testable'), field_name='bom_item__sub_part__testable')
|
testable = rest_filters.BooleanFilter(label=_('Testable'), field_name='bom_item__sub_part__testable')
|
||||||
|
|
||||||
|
part = rest_filters.ModelChoiceFilter(
|
||||||
|
queryset=part.models.Part.objects.all(),
|
||||||
|
label=_('Part'),
|
||||||
|
field_name='bom_item__sub_part',
|
||||||
|
)
|
||||||
|
|
||||||
|
order_outstanding = rest_filters.BooleanFilter(
|
||||||
|
label=_('Order Outstanding'),
|
||||||
|
method='filter_order_outstanding'
|
||||||
|
)
|
||||||
|
|
||||||
|
def filter_order_outstanding(self, queryset, name, value):
|
||||||
|
"""Filter by whether the associated BuildOrder is 'outstanding'."""
|
||||||
|
if str2bool(value):
|
||||||
|
return queryset.filter(build__status__in=BuildStatusGroups.ACTIVE_CODES)
|
||||||
|
return queryset.exclude(build__status__in=BuildStatusGroups.ACTIVE_CODES)
|
||||||
|
|
||||||
allocated = rest_filters.BooleanFilter(label=_('Allocated'), method='filter_allocated')
|
allocated = rest_filters.BooleanFilter(label=_('Allocated'), method='filter_allocated')
|
||||||
|
|
||||||
def filter_allocated(self, queryset, name, value):
|
def filter_allocated(self, queryset, name, value):
|
||||||
@ -383,12 +400,28 @@ class BuildLineFilter(rest_filters.FilterSet):
|
|||||||
return queryset.exclude(flt)
|
return queryset.exclude(flt)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class BuildLineEndpoint:
|
class BuildLineEndpoint:
|
||||||
"""Mixin class for BuildLine API endpoints."""
|
"""Mixin class for BuildLine API endpoints."""
|
||||||
|
|
||||||
queryset = BuildLine.objects.all()
|
queryset = BuildLine.objects.all()
|
||||||
serializer_class = build.serializers.BuildLineSerializer
|
serializer_class = build.serializers.BuildLineSerializer
|
||||||
|
|
||||||
|
def get_serializer(self, *args, **kwargs):
|
||||||
|
"""Return the serializer instance for this endpoint."""
|
||||||
|
|
||||||
|
kwargs['context'] = self.get_serializer_context()
|
||||||
|
|
||||||
|
try:
|
||||||
|
params = self.request.query_params
|
||||||
|
|
||||||
|
kwargs['part_detail'] = str2bool(params.get('part_detail', True))
|
||||||
|
kwargs['build_detail'] = str2bool(params.get('build_detail', False))
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return self.serializer_class(*args, **kwargs)
|
||||||
|
|
||||||
def get_source_build(self) -> Build:
|
def get_source_build(self) -> Build:
|
||||||
"""Return the source Build object for the BuildLine queryset.
|
"""Return the source Build object for the BuildLine queryset.
|
||||||
|
|
||||||
|
@ -1278,8 +1278,6 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
|
|||||||
'pk',
|
'pk',
|
||||||
'build',
|
'build',
|
||||||
'bom_item',
|
'bom_item',
|
||||||
'bom_item_detail',
|
|
||||||
'part_detail',
|
|
||||||
'quantity',
|
'quantity',
|
||||||
|
|
||||||
# Build detail fields
|
# Build detail fields
|
||||||
@ -1315,6 +1313,11 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
|
|||||||
# Extra fields only for data export
|
# Extra fields only for data export
|
||||||
'part_description',
|
'part_description',
|
||||||
'part_category_name',
|
'part_category_name',
|
||||||
|
|
||||||
|
# Extra detail (related field) serializers
|
||||||
|
'bom_item_detail',
|
||||||
|
'part_detail',
|
||||||
|
'build_detail',
|
||||||
]
|
]
|
||||||
|
|
||||||
read_only_fields = [
|
read_only_fields = [
|
||||||
@ -1323,6 +1326,19 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
|
|||||||
'allocations',
|
'allocations',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
"""Determine which extra details fields should be included"""
|
||||||
|
part_detail = kwargs.pop('part_detail', True)
|
||||||
|
build_detail = kwargs.pop('build_detail', False)
|
||||||
|
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
if not part_detail:
|
||||||
|
self.fields.pop('part_detail', None)
|
||||||
|
|
||||||
|
if not build_detail:
|
||||||
|
self.fields.pop('build_detail', None)
|
||||||
|
|
||||||
# Build info fields
|
# Build info fields
|
||||||
build_reference = serializers.CharField(source='build.reference', label=_('Build Reference'), read_only=True)
|
build_reference = serializers.CharField(source='build.reference', label=_('Build Reference'), read_only=True)
|
||||||
|
|
||||||
@ -1362,6 +1378,7 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
|
|||||||
)
|
)
|
||||||
|
|
||||||
part_detail = part_serializers.PartBriefSerializer(source='bom_item.sub_part', many=False, read_only=True, pricing=False)
|
part_detail = part_serializers.PartBriefSerializer(source='bom_item.sub_part', many=False, read_only=True, pricing=False)
|
||||||
|
build_detail = BuildSerializer(source='build', part_detail=False, many=False, read_only=True)
|
||||||
|
|
||||||
# Annotated (calculated) fields
|
# Annotated (calculated) fields
|
||||||
|
|
||||||
@ -1404,9 +1421,13 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
|
|||||||
"""
|
"""
|
||||||
queryset = queryset.select_related(
|
queryset = queryset.select_related(
|
||||||
'build',
|
'build',
|
||||||
|
'build__part',
|
||||||
|
'build__part__pricing_data',
|
||||||
'bom_item',
|
'bom_item',
|
||||||
'bom_item__part',
|
'bom_item__part',
|
||||||
|
'bom_item__part__pricing_data',
|
||||||
'bom_item__sub_part',
|
'bom_item__sub_part',
|
||||||
|
'bom_item__sub_part__pricing_data'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Pre-fetch related fields
|
# Pre-fetch related fields
|
||||||
|
@ -816,6 +816,17 @@ class SalesOrderLineItemFilter(LineItemFilter):
|
|||||||
|
|
||||||
return queryset.exclude(order__status__in=SalesOrderStatusGroups.COMPLETE)
|
return queryset.exclude(order__status__in=SalesOrderStatusGroups.COMPLETE)
|
||||||
|
|
||||||
|
order_outstanding = rest_filters.BooleanFilter(
|
||||||
|
label=_('Order Outstanding'), method='filter_order_outstanding'
|
||||||
|
)
|
||||||
|
|
||||||
|
def filter_order_outstanding(self, queryset, name, value):
|
||||||
|
"""Filter by whether the order is 'outstanding' or not."""
|
||||||
|
if str2bool(value):
|
||||||
|
return queryset.filter(order__status__in=SalesOrderStatusGroups.OPEN)
|
||||||
|
|
||||||
|
return queryset.exclude(order__status__in=SalesOrderStatusGroups.OPEN)
|
||||||
|
|
||||||
|
|
||||||
class SalesOrderLineItemMixin:
|
class SalesOrderLineItemMixin:
|
||||||
"""Mixin class for SalesOrderLineItem endpoints."""
|
"""Mixin class for SalesOrderLineItem endpoints."""
|
||||||
|
@ -497,9 +497,9 @@ export function useAllocateStockToBuildForm({
|
|||||||
lineItems,
|
lineItems,
|
||||||
onFormSuccess
|
onFormSuccess
|
||||||
}: {
|
}: {
|
||||||
buildId: number;
|
buildId?: number;
|
||||||
outputId?: number | null;
|
outputId?: number | null;
|
||||||
build: any;
|
build?: any;
|
||||||
lineItems: any[];
|
lineItems: any[];
|
||||||
onFormSuccess: (response: any) => void;
|
onFormSuccess: (response: any) => void;
|
||||||
}) {
|
}) {
|
||||||
@ -533,8 +533,8 @@ export function useAllocateStockToBuildForm({
|
|||||||
}, [lineItems, sourceLocation]);
|
}, [lineItems, sourceLocation]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSourceLocation(build.take_from);
|
setSourceLocation(build?.take_from);
|
||||||
}, [build.take_from]);
|
}, [build?.take_from]);
|
||||||
|
|
||||||
const sourceLocationField: ApiFormFieldType = useMemo(() => {
|
const sourceLocationField: ApiFormFieldType = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
@ -545,7 +545,7 @@ export function useAllocateStockToBuildForm({
|
|||||||
label: t`Source Location`,
|
label: t`Source Location`,
|
||||||
description: t`Select the source location for the stock allocation`,
|
description: t`Select the source location for the stock allocation`,
|
||||||
name: 'source_location',
|
name: 'source_location',
|
||||||
value: build.take_from,
|
value: build?.take_from,
|
||||||
onValueChange: (value: any) => {
|
onValueChange: (value: any) => {
|
||||||
setSourceLocation(value);
|
setSourceLocation(value);
|
||||||
}
|
}
|
||||||
|
@ -2,11 +2,10 @@ import { t } from '@lingui/macro';
|
|||||||
import { Accordion } from '@mantine/core';
|
import { Accordion } from '@mantine/core';
|
||||||
|
|
||||||
import { StylishText } from '../../components/items/StylishText';
|
import { StylishText } from '../../components/items/StylishText';
|
||||||
import { ModelType } from '../../enums/ModelType';
|
|
||||||
import { UserRoles } from '../../enums/Roles';
|
import { UserRoles } from '../../enums/Roles';
|
||||||
import { useUserState } from '../../states/UserState';
|
import { useUserState } from '../../states/UserState';
|
||||||
import BuildAllocatedStockTable from '../../tables/build/BuildAllocatedStockTable';
|
import PartBuildAllocationsTable from '../../tables/part/PartBuildAllocationsTable';
|
||||||
import SalesOrderAllocationTable from '../../tables/sales/SalesOrderAllocationTable';
|
import PartSalesAllocationsTable from '../../tables/part/PartSalesAllocationsTable';
|
||||||
|
|
||||||
export default function PartAllocationPanel({ part }: { part: any }) {
|
export default function PartAllocationPanel({ part }: { part: any }) {
|
||||||
const user = useUserState();
|
const user = useUserState();
|
||||||
@ -23,14 +22,7 @@ export default function PartAllocationPanel({ part }: { part: any }) {
|
|||||||
<StylishText size="lg">{t`Build Order Allocations`}</StylishText>
|
<StylishText size="lg">{t`Build Order Allocations`}</StylishText>
|
||||||
</Accordion.Control>
|
</Accordion.Control>
|
||||||
<Accordion.Panel>
|
<Accordion.Panel>
|
||||||
<BuildAllocatedStockTable
|
<PartBuildAllocationsTable partId={part.pk} />
|
||||||
partId={part.pk}
|
|
||||||
modelField="build"
|
|
||||||
modelTarget={ModelType.build}
|
|
||||||
showBuildInfo
|
|
||||||
showPartInfo
|
|
||||||
allowEdit
|
|
||||||
/>
|
|
||||||
</Accordion.Panel>
|
</Accordion.Panel>
|
||||||
</Accordion.Item>
|
</Accordion.Item>
|
||||||
)}
|
)}
|
||||||
@ -40,12 +32,7 @@ export default function PartAllocationPanel({ part }: { part: any }) {
|
|||||||
<StylishText size="lg">{t`Sales Order Allocations`}</StylishText>
|
<StylishText size="lg">{t`Sales Order Allocations`}</StylishText>
|
||||||
</Accordion.Control>
|
</Accordion.Control>
|
||||||
<Accordion.Panel>
|
<Accordion.Panel>
|
||||||
<SalesOrderAllocationTable
|
<PartSalesAllocationsTable partId={part.pk} />
|
||||||
partId={part.pk}
|
|
||||||
modelField="order"
|
|
||||||
modelTarget={ModelType.salesorder}
|
|
||||||
showOrderInfo
|
|
||||||
/>
|
|
||||||
</Accordion.Panel>
|
</Accordion.Panel>
|
||||||
</Accordion.Item>
|
</Accordion.Item>
|
||||||
)}
|
)}
|
||||||
|
@ -163,10 +163,16 @@ export function LineItemsProgressColumn(): TableColumn {
|
|||||||
export function ProjectCodeColumn(props: TableColumnProps): TableColumn {
|
export function ProjectCodeColumn(props: TableColumnProps): TableColumn {
|
||||||
return {
|
return {
|
||||||
accessor: 'project_code',
|
accessor: 'project_code',
|
||||||
|
ordering: 'project_code',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
render: (record: any) => (
|
title: t`Project Code`,
|
||||||
<ProjectCodeHoverCard projectCode={record.project_code_detail} />
|
render: (record: any) => {
|
||||||
),
|
let project_code = resolveItem(
|
||||||
|
record,
|
||||||
|
props.accessor ?? 'project_code_detail'
|
||||||
|
);
|
||||||
|
return <ProjectCodeHoverCard projectCode={project_code} />;
|
||||||
|
},
|
||||||
...props
|
...props
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
16
src/frontend/src/tables/RowExpansionIcon.tsx
Normal file
16
src/frontend/src/tables/RowExpansionIcon.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { ActionIcon } from '@mantine/core';
|
||||||
|
import { IconChevronDown, IconChevronRight } from '@tabler/icons-react';
|
||||||
|
|
||||||
|
export default function RowExpansionIcon({
|
||||||
|
enabled,
|
||||||
|
expanded
|
||||||
|
}: {
|
||||||
|
enabled: boolean;
|
||||||
|
expanded: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ActionIcon size="sm" variant="transparent" disabled={!enabled}>
|
||||||
|
{expanded ? <IconChevronDown /> : <IconChevronRight />}
|
||||||
|
</ActionIcon>
|
||||||
|
);
|
||||||
|
}
|
@ -51,12 +51,13 @@ export function UsedInTable({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessor: 'quantity',
|
accessor: 'quantity',
|
||||||
|
switchable: false,
|
||||||
render: (record: any) => {
|
render: (record: any) => {
|
||||||
let quantity = formatDecimal(record.quantity);
|
let quantity = formatDecimal(record.quantity);
|
||||||
let units = record.sub_part_detail?.units;
|
let units = record.sub_part_detail?.units;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group justify="space-between" grow>
|
<Group justify="space-between" grow wrap="nowrap">
|
||||||
<Text>{quantity}</Text>
|
<Text>{quantity}</Text>
|
||||||
{units && <Text size="xs">{units}</Text>}
|
{units && <Text size="xs">{units}</Text>}
|
||||||
</Group>
|
</Group>
|
||||||
|
@ -1,9 +1,7 @@
|
|||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { ActionIcon, Alert, Group, Paper, Stack, Text } from '@mantine/core';
|
import { Alert, Group, Paper, Stack, Text } from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
IconArrowRight,
|
IconArrowRight,
|
||||||
IconChevronDown,
|
|
||||||
IconChevronRight,
|
|
||||||
IconCircleMinus,
|
IconCircleMinus,
|
||||||
IconShoppingCart,
|
IconShoppingCart,
|
||||||
IconTool,
|
IconTool,
|
||||||
@ -43,6 +41,7 @@ import {
|
|||||||
RowEditAction,
|
RowEditAction,
|
||||||
RowViewAction
|
RowViewAction
|
||||||
} from '../RowActions';
|
} from '../RowActions';
|
||||||
|
import RowExpansionIcon from '../RowExpansionIcon';
|
||||||
import { TableHoverCard } from '../TableHoverCard';
|
import { TableHoverCard } from '../TableHoverCard';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -53,16 +52,17 @@ import { TableHoverCard } from '../TableHoverCard';
|
|||||||
*
|
*
|
||||||
* Note: We expect that the "lineItem" object contains an allocations[] list
|
* Note: We expect that the "lineItem" object contains an allocations[] list
|
||||||
*/
|
*/
|
||||||
function BuildLineSubTable({
|
export function BuildLineSubTable({
|
||||||
lineItem,
|
lineItem,
|
||||||
onEditAllocation,
|
onEditAllocation,
|
||||||
onDeleteAllocation
|
onDeleteAllocation
|
||||||
}: {
|
}: {
|
||||||
lineItem: any;
|
lineItem: any;
|
||||||
onEditAllocation: (pk: number) => void;
|
onEditAllocation?: (pk: number) => void;
|
||||||
onDeleteAllocation: (pk: number) => void;
|
onDeleteAllocation?: (pk: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const user = useUserState();
|
const user = useUserState();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const tableColumns: any[] = useMemo(() => {
|
const tableColumns: any[] = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
@ -100,16 +100,24 @@ function BuildLineSubTable({
|
|||||||
title={t`Actions`}
|
title={t`Actions`}
|
||||||
index={record.pk}
|
index={record.pk}
|
||||||
actions={[
|
actions={[
|
||||||
|
RowViewAction({
|
||||||
|
title: t`View Stock Item`,
|
||||||
|
modelType: ModelType.stockitem,
|
||||||
|
modelId: record.stock_item,
|
||||||
|
navigate: navigate
|
||||||
|
}),
|
||||||
RowEditAction({
|
RowEditAction({
|
||||||
hidden: !user.hasChangeRole(UserRoles.build),
|
hidden:
|
||||||
|
!onEditAllocation || !user.hasChangeRole(UserRoles.build),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
onEditAllocation(record.pk);
|
onEditAllocation?.(record.pk);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
RowDeleteAction({
|
RowDeleteAction({
|
||||||
hidden: !user.hasDeleteRole(UserRoles.build),
|
hidden:
|
||||||
|
!onDeleteAllocation || !user.hasDeleteRole(UserRoles.build),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
onDeleteAllocation(record.pk);
|
onDeleteAllocation?.(record.pk);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
]}
|
]}
|
||||||
@ -131,7 +139,7 @@ function BuildLineSubTable({
|
|||||||
pinLastColumn
|
pinLastColumn
|
||||||
idAccessor="pk"
|
idAccessor="pk"
|
||||||
columns={tableColumns}
|
columns={tableColumns}
|
||||||
records={lineItem.filteredAllocations}
|
records={lineItem.filteredAllocations ?? lineItem.allocations}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
@ -301,17 +309,10 @@ export default function BuildLineTable({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Group wrap="nowrap">
|
<Group wrap="nowrap">
|
||||||
<ActionIcon
|
<RowExpansionIcon
|
||||||
size="sm"
|
enabled={hasAllocatedItems}
|
||||||
variant="transparent"
|
expanded={table.isRowExpanded(record.pk)}
|
||||||
disabled={!hasAllocatedItems}
|
/>
|
||||||
>
|
|
||||||
{table.isRowExpanded(record.pk) ? (
|
|
||||||
<IconChevronDown />
|
|
||||||
) : (
|
|
||||||
<IconChevronRight />
|
|
||||||
)}
|
|
||||||
</ActionIcon>
|
|
||||||
<PartColumn part={record.part_detail} />
|
<PartColumn part={record.part_detail} />
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
|
130
src/frontend/src/tables/part/PartBuildAllocationsTable.tsx
Normal file
130
src/frontend/src/tables/part/PartBuildAllocationsTable.tsx
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import { Group, Text } from '@mantine/core';
|
||||||
|
import { DataTableRowExpansionProps } from 'mantine-datatable';
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { ProgressBar } from '../../components/items/ProgressBar';
|
||||||
|
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||||
|
import { ModelType } from '../../enums/ModelType';
|
||||||
|
import { UserRoles } from '../../enums/Roles';
|
||||||
|
import { useTable } from '../../hooks/UseTable';
|
||||||
|
import { apiUrl } from '../../states/ApiState';
|
||||||
|
import { useUserState } from '../../states/UserState';
|
||||||
|
import { TableColumn } from '../Column';
|
||||||
|
import {
|
||||||
|
DescriptionColumn,
|
||||||
|
ProjectCodeColumn,
|
||||||
|
StatusColumn
|
||||||
|
} from '../ColumnRenderers';
|
||||||
|
import { InvenTreeTable } from '../InvenTreeTable';
|
||||||
|
import { RowViewAction } from '../RowActions';
|
||||||
|
import RowExpansionIcon from '../RowExpansionIcon';
|
||||||
|
import { BuildLineSubTable } from '../build/BuildLineTable';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A "simplified" BuildOrderLineItem table showing all outstanding build order allocations for a given part.
|
||||||
|
*/
|
||||||
|
export default function PartBuildAllocationsTable({
|
||||||
|
partId
|
||||||
|
}: {
|
||||||
|
partId: number;
|
||||||
|
}) {
|
||||||
|
const user = useUserState();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const table = useTable('part-build-allocations');
|
||||||
|
|
||||||
|
const tableColumns: TableColumn[] = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
accessor: 'build',
|
||||||
|
title: t`Build Order`,
|
||||||
|
sortable: true,
|
||||||
|
render: (record: any) => (
|
||||||
|
<Group wrap="nowrap" gap="xs">
|
||||||
|
<RowExpansionIcon
|
||||||
|
enabled={record.allocated > 0}
|
||||||
|
expanded={table.isRowExpanded(record.pk)}
|
||||||
|
/>
|
||||||
|
<Text>{record.build_detail?.reference}</Text>
|
||||||
|
</Group>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
DescriptionColumn({
|
||||||
|
accessor: 'build_detail.title'
|
||||||
|
}),
|
||||||
|
ProjectCodeColumn({
|
||||||
|
accessor: 'build_detail.project_code_detail'
|
||||||
|
}),
|
||||||
|
StatusColumn({
|
||||||
|
accessor: 'build_detail.status',
|
||||||
|
model: ModelType.build,
|
||||||
|
title: t`Order Status`
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
accessor: 'allocated',
|
||||||
|
sortable: true,
|
||||||
|
title: t`Required Stock`,
|
||||||
|
render: (record: any) => (
|
||||||
|
<ProgressBar
|
||||||
|
progressLabel
|
||||||
|
value={record.allocated}
|
||||||
|
maximum={record.quantity}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}, [table.isRowExpanded]);
|
||||||
|
|
||||||
|
const rowActions = useCallback(
|
||||||
|
(record: any) => {
|
||||||
|
return [
|
||||||
|
RowViewAction({
|
||||||
|
title: t`View Build Order`,
|
||||||
|
modelType: ModelType.build,
|
||||||
|
modelId: record.build,
|
||||||
|
hidden: !user.hasViewRole(UserRoles.build),
|
||||||
|
navigate: navigate
|
||||||
|
})
|
||||||
|
];
|
||||||
|
},
|
||||||
|
[user]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Control row expansion
|
||||||
|
const rowExpansion: DataTableRowExpansionProps<any> = useMemo(() => {
|
||||||
|
return {
|
||||||
|
allowMultiple: true,
|
||||||
|
expandable: ({ record }: { record: any }) => {
|
||||||
|
// Only items with allocated stock can be expanded
|
||||||
|
return table.isRowExpanded(record.pk) || record.allocated > 0;
|
||||||
|
},
|
||||||
|
content: ({ record }: { record: any }) => {
|
||||||
|
return <BuildLineSubTable lineItem={record} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [table.isRowExpanded]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<InvenTreeTable
|
||||||
|
url={apiUrl(ApiEndpoints.build_line_list)}
|
||||||
|
tableState={table}
|
||||||
|
columns={tableColumns}
|
||||||
|
props={{
|
||||||
|
minHeight: 200,
|
||||||
|
params: {
|
||||||
|
part: partId,
|
||||||
|
consumable: false,
|
||||||
|
build_detail: true,
|
||||||
|
order_outstanding: true
|
||||||
|
},
|
||||||
|
enableColumnSwitching: false,
|
||||||
|
enableSearch: false,
|
||||||
|
rowActions: rowActions,
|
||||||
|
rowExpansion: rowExpansion
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
132
src/frontend/src/tables/part/PartSalesAllocationsTable.tsx
Normal file
132
src/frontend/src/tables/part/PartSalesAllocationsTable.tsx
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import { Group, Text } from '@mantine/core';
|
||||||
|
import { DataTableRowExpansionProps } from 'mantine-datatable';
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { ProgressBar } from '../../components/items/ProgressBar';
|
||||||
|
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||||
|
import { ModelType } from '../../enums/ModelType';
|
||||||
|
import { UserRoles } from '../../enums/Roles';
|
||||||
|
import { useTable } from '../../hooks/UseTable';
|
||||||
|
import { apiUrl } from '../../states/ApiState';
|
||||||
|
import { useUserState } from '../../states/UserState';
|
||||||
|
import { TableColumn } from '../Column';
|
||||||
|
import {
|
||||||
|
DescriptionColumn,
|
||||||
|
ProjectCodeColumn,
|
||||||
|
StatusColumn
|
||||||
|
} from '../ColumnRenderers';
|
||||||
|
import { InvenTreeTable } from '../InvenTreeTable';
|
||||||
|
import { RowViewAction } from '../RowActions';
|
||||||
|
import RowExpansionIcon from '../RowExpansionIcon';
|
||||||
|
import SalesOrderAllocationTable from '../sales/SalesOrderAllocationTable';
|
||||||
|
|
||||||
|
export default function PartSalesAllocationsTable({
|
||||||
|
partId
|
||||||
|
}: {
|
||||||
|
partId: number;
|
||||||
|
}) {
|
||||||
|
const user = useUserState();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const table = useTable('part-sales-allocations');
|
||||||
|
|
||||||
|
const tableColumns: TableColumn[] = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
accessor: 'order',
|
||||||
|
title: t`Sales Order`,
|
||||||
|
render: (record: any) => (
|
||||||
|
<Group wrap="nowrap" gap="xs">
|
||||||
|
<RowExpansionIcon
|
||||||
|
enabled={record.allocated > 0}
|
||||||
|
expanded={table.isRowExpanded(record.pk)}
|
||||||
|
/>
|
||||||
|
<Text>{record.order_detail?.reference}</Text>
|
||||||
|
</Group>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
DescriptionColumn({
|
||||||
|
accessor: 'order_detail.description'
|
||||||
|
}),
|
||||||
|
ProjectCodeColumn({
|
||||||
|
accessor: 'order_detail.project_code_detail'
|
||||||
|
}),
|
||||||
|
StatusColumn({
|
||||||
|
accessor: 'order_detail.status',
|
||||||
|
model: ModelType.salesorder,
|
||||||
|
title: t`Order Status`
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
accessor: 'allocated',
|
||||||
|
title: t`Required Stock`,
|
||||||
|
render: (record: any) => (
|
||||||
|
<ProgressBar
|
||||||
|
progressLabel
|
||||||
|
value={record.allocated}
|
||||||
|
maximum={record.quantity}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}, [table.isRowExpanded]);
|
||||||
|
|
||||||
|
const rowActions = useCallback(
|
||||||
|
(record: any) => {
|
||||||
|
return [
|
||||||
|
RowViewAction({
|
||||||
|
title: t`View Sales Order`,
|
||||||
|
modelType: ModelType.salesorder,
|
||||||
|
modelId: record.order,
|
||||||
|
hidden: !user.hasViewRole(UserRoles.sales_order),
|
||||||
|
navigate: navigate
|
||||||
|
})
|
||||||
|
];
|
||||||
|
},
|
||||||
|
[user]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Control row expansion
|
||||||
|
const rowExpansion: DataTableRowExpansionProps<any> = useMemo(() => {
|
||||||
|
return {
|
||||||
|
allowMultiple: true,
|
||||||
|
expandable: ({ record }: { record: any }) => {
|
||||||
|
return table.isRowExpanded(record.pk) || record.allocated > 0;
|
||||||
|
},
|
||||||
|
content: ({ record }: { record: any }) => {
|
||||||
|
return (
|
||||||
|
<SalesOrderAllocationTable
|
||||||
|
showOrderInfo={false}
|
||||||
|
showPartInfo={false}
|
||||||
|
lineItemId={record.pk}
|
||||||
|
partId={record.part}
|
||||||
|
allowEdit
|
||||||
|
isSubTable
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [table.isRowExpanded]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<InvenTreeTable
|
||||||
|
url={apiUrl(ApiEndpoints.sales_order_line_list)}
|
||||||
|
tableState={table}
|
||||||
|
columns={tableColumns}
|
||||||
|
props={{
|
||||||
|
minHeight: 200,
|
||||||
|
params: {
|
||||||
|
part: partId,
|
||||||
|
order_detail: true,
|
||||||
|
order_outstanding: true
|
||||||
|
},
|
||||||
|
enableSearch: false,
|
||||||
|
enableColumnSwitching: false,
|
||||||
|
rowExpansion: rowExpansion,
|
||||||
|
rowActions: rowActions
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -1,9 +1,7 @@
|
|||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { ActionIcon, Group, Text } from '@mantine/core';
|
import { Group, Text } from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
IconArrowRight,
|
IconArrowRight,
|
||||||
IconChevronDown,
|
|
||||||
IconChevronRight,
|
|
||||||
IconHash,
|
IconHash,
|
||||||
IconShoppingCart,
|
IconShoppingCart,
|
||||||
IconSquareArrowRight,
|
IconSquareArrowRight,
|
||||||
@ -46,6 +44,7 @@ import {
|
|||||||
RowEditAction,
|
RowEditAction,
|
||||||
RowViewAction
|
RowViewAction
|
||||||
} from '../RowActions';
|
} from '../RowActions';
|
||||||
|
import RowExpansionIcon from '../RowExpansionIcon';
|
||||||
import { TableHoverCard } from '../TableHoverCard';
|
import { TableHoverCard } from '../TableHoverCard';
|
||||||
import SalesOrderAllocationTable from './SalesOrderAllocationTable';
|
import SalesOrderAllocationTable from './SalesOrderAllocationTable';
|
||||||
|
|
||||||
@ -73,17 +72,10 @@ export default function SalesOrderLineItemTable({
|
|||||||
render: (record: any) => {
|
render: (record: any) => {
|
||||||
return (
|
return (
|
||||||
<Group wrap="nowrap">
|
<Group wrap="nowrap">
|
||||||
<ActionIcon
|
<RowExpansionIcon
|
||||||
size="sm"
|
enabled={record.allocated}
|
||||||
variant="transparent"
|
expanded={table.isRowExpanded(record.pk)}
|
||||||
disabled={!record.allocated}
|
/>
|
||||||
>
|
|
||||||
{table.isRowExpanded(record.pk) ? (
|
|
||||||
<IconChevronDown />
|
|
||||||
) : (
|
|
||||||
<IconChevronRight />
|
|
||||||
)}
|
|
||||||
</ActionIcon>
|
|
||||||
<PartColumn part={record.part_detail} />
|
<PartColumn part={record.part_detail} />
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
|
@ -212,7 +212,7 @@ test('Pages - Build Order - Allocation', async ({ page }) => {
|
|||||||
{
|
{
|
||||||
name: 'Blue Widget',
|
name: 'Blue Widget',
|
||||||
ipn: 'widget.blue',
|
ipn: 'widget.blue',
|
||||||
available: '45',
|
available: '39',
|
||||||
required: '5',
|
required: '5',
|
||||||
allocated: '5'
|
allocated: '5'
|
||||||
},
|
},
|
||||||
|
@ -100,29 +100,71 @@ test('Parts - Allocations', async ({ page }) => {
|
|||||||
await doQuickLogin(page);
|
await doQuickLogin(page);
|
||||||
|
|
||||||
// Let's look at the allocations for a single stock item
|
// Let's look at the allocations for a single stock item
|
||||||
await page.goto(`${baseUrl}/stock/item/324/`);
|
|
||||||
|
// TODO: Un-comment these lines!
|
||||||
|
// await page.goto(`${baseUrl}/stock/item/324/`);
|
||||||
|
// await page.getByRole('tab', { name: 'Allocations' }).click();
|
||||||
|
|
||||||
|
// await page.getByRole('button', { name: 'Build Order Allocations' }).waitFor();
|
||||||
|
// await page.getByRole('cell', { name: 'Making some blue chairs' }).waitFor();
|
||||||
|
// await page.getByRole('cell', { name: 'Making tables for SO 0003' }).waitFor();
|
||||||
|
|
||||||
|
// Let's look at the allocations for an entire part
|
||||||
|
await page.goto(`${baseUrl}/part/74/details`);
|
||||||
|
|
||||||
|
// Check that the overall allocations are displayed correctly
|
||||||
|
await page.getByText('11 / 825').waitFor();
|
||||||
|
await page.getByText('6 / 110').waitFor();
|
||||||
|
|
||||||
|
// Navigate to the "Allocations" tab
|
||||||
await page.getByRole('tab', { name: 'Allocations' }).click();
|
await page.getByRole('tab', { name: 'Allocations' }).click();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Build Order Allocations' }).waitFor();
|
await page.getByRole('button', { name: 'Build Order Allocations' }).waitFor();
|
||||||
await page.getByRole('cell', { name: 'Making some blue chairs' }).waitFor();
|
await page.getByRole('button', { name: 'Sales Order Allocations' }).waitFor();
|
||||||
await page.getByRole('cell', { name: 'Making tables for SO 0003' }).waitFor();
|
|
||||||
|
|
||||||
// Let's look at the allocations for the entire part
|
// Expected order reference values
|
||||||
await page.getByRole('tab', { name: 'Details' }).click();
|
await page.getByText('BO0001').waitFor();
|
||||||
await page.getByRole('link', { name: 'Leg' }).click();
|
await page.getByText('BO0016').waitFor();
|
||||||
|
await page.getByText('BO0019').waitFor();
|
||||||
|
await page.getByText('SO0008').waitFor();
|
||||||
|
await page.getByText('SO0025').waitFor();
|
||||||
|
|
||||||
await page.getByRole('tab', { name: 'Part Details' }).click();
|
// Check "progress" bar of BO0001
|
||||||
await page.getByText('660 / 760').waitFor();
|
const build_order_cell = await page.getByRole('cell', { name: 'BO0001' });
|
||||||
|
const build_order_row = await build_order_cell
|
||||||
|
.locator('xpath=ancestor::tr')
|
||||||
|
.first();
|
||||||
|
await build_order_row.getByText('11 / 75').waitFor();
|
||||||
|
|
||||||
await page.getByRole('tab', { name: 'Allocations' }).click();
|
// Expand allocations against BO0001
|
||||||
|
await build_order_cell.click();
|
||||||
|
await page.getByRole('cell', { name: '# 3', exact: true }).waitFor();
|
||||||
|
await page.getByRole('cell', { name: 'Room 101', exact: true }).waitFor();
|
||||||
|
await build_order_cell.click();
|
||||||
|
|
||||||
// Number of table records
|
// Check row options for BO0001
|
||||||
await page.getByText('1 - 4 / 4').waitFor();
|
await build_order_row.getByLabel(/row-action-menu/).click();
|
||||||
await page.getByRole('cell', { name: 'Making red square tables' }).waitFor();
|
await page.getByRole('menuitem', { name: 'View Build Order' }).waitFor();
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
// Navigate through to the build order
|
// Check "progress" bar of SO0025
|
||||||
await page.getByRole('cell', { name: 'BO0007' }).click();
|
const sales_order_cell = await page.getByRole('cell', { name: 'SO0025' });
|
||||||
await page.getByRole('tab', { name: 'Build Details' }).waitFor();
|
const sales_order_row = await sales_order_cell
|
||||||
|
.locator('xpath=ancestor::tr')
|
||||||
|
.first();
|
||||||
|
await sales_order_row.getByText('3 / 10').waitFor();
|
||||||
|
|
||||||
|
// Expand allocations against SO0025
|
||||||
|
await sales_order_cell.click();
|
||||||
|
await page.getByRole('cell', { name: '161', exact: true });
|
||||||
|
await page.getByRole('cell', { name: '169', exact: true });
|
||||||
|
await page.getByRole('cell', { name: '170', exact: true });
|
||||||
|
await sales_order_cell.click();
|
||||||
|
|
||||||
|
// Check row options for SO0025
|
||||||
|
await sales_order_row.getByLabel(/row-action-menu/).click();
|
||||||
|
await page.getByRole('menuitem', { name: 'View Sales Order' }).waitFor();
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Parts - Pricing (Nothing, BOM)', async ({ page }) => {
|
test('Parts - Pricing (Nothing, BOM)', async ({ page }) => {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user