mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-30 20:55:42 +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:
		| @@ -1,13 +1,17 @@ | ||||
| """InvenTree API version information.""" | ||||
|  | ||||
| # 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.""" | ||||
|  | ||||
|  | ||||
| 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 | ||||
|     - Updates 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') | ||||
|     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') | ||||
|  | ||||
|     def filter_allocated(self, queryset, name, value): | ||||
| @@ -383,12 +400,28 @@ class BuildLineFilter(rest_filters.FilterSet): | ||||
|         return queryset.exclude(flt) | ||||
|  | ||||
|  | ||||
|  | ||||
| class BuildLineEndpoint: | ||||
|     """Mixin class for BuildLine API endpoints.""" | ||||
|  | ||||
|     queryset = BuildLine.objects.all() | ||||
|     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: | ||||
|         """Return the source Build object for the BuildLine queryset. | ||||
|  | ||||
|   | ||||
| @@ -1278,8 +1278,6 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali | ||||
|             'pk', | ||||
|             'build', | ||||
|             'bom_item', | ||||
|             'bom_item_detail', | ||||
|             'part_detail', | ||||
|             'quantity', | ||||
|  | ||||
|             # Build detail fields | ||||
| @@ -1315,6 +1313,11 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali | ||||
|             # Extra fields only for data export | ||||
|             'part_description', | ||||
|             'part_category_name', | ||||
|  | ||||
|             # Extra detail (related field) serializers | ||||
|             'bom_item_detail', | ||||
|             'part_detail', | ||||
|             'build_detail', | ||||
|         ] | ||||
|  | ||||
|         read_only_fields = [ | ||||
| @@ -1323,6 +1326,19 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali | ||||
|             '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_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) | ||||
|     build_detail = BuildSerializer(source='build', part_detail=False, many=False, read_only=True) | ||||
|  | ||||
|     # Annotated (calculated) fields | ||||
|  | ||||
| @@ -1404,9 +1421,13 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali | ||||
|         """ | ||||
|         queryset = queryset.select_related( | ||||
|             'build', | ||||
|             'build__part', | ||||
|             'build__part__pricing_data', | ||||
|             'bom_item', | ||||
|             'bom_item__part', | ||||
|             'bom_item__part__pricing_data', | ||||
|             'bom_item__sub_part', | ||||
|             'bom_item__sub_part__pricing_data' | ||||
|         ) | ||||
|  | ||||
|         # Pre-fetch related fields | ||||
|   | ||||
| @@ -816,6 +816,17 @@ class SalesOrderLineItemFilter(LineItemFilter): | ||||
|  | ||||
|         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: | ||||
|     """Mixin class for SalesOrderLineItem endpoints.""" | ||||
|   | ||||
| @@ -497,9 +497,9 @@ export function useAllocateStockToBuildForm({ | ||||
|   lineItems, | ||||
|   onFormSuccess | ||||
| }: { | ||||
|   buildId: number; | ||||
|   buildId?: number; | ||||
|   outputId?: number | null; | ||||
|   build: any; | ||||
|   build?: any; | ||||
|   lineItems: any[]; | ||||
|   onFormSuccess: (response: any) => void; | ||||
| }) { | ||||
| @@ -533,8 +533,8 @@ export function useAllocateStockToBuildForm({ | ||||
|   }, [lineItems, sourceLocation]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     setSourceLocation(build.take_from); | ||||
|   }, [build.take_from]); | ||||
|     setSourceLocation(build?.take_from); | ||||
|   }, [build?.take_from]); | ||||
|  | ||||
|   const sourceLocationField: ApiFormFieldType = useMemo(() => { | ||||
|     return { | ||||
| @@ -545,7 +545,7 @@ export function useAllocateStockToBuildForm({ | ||||
|       label: t`Source Location`, | ||||
|       description: t`Select the source location for the stock allocation`, | ||||
|       name: 'source_location', | ||||
|       value: build.take_from, | ||||
|       value: build?.take_from, | ||||
|       onValueChange: (value: any) => { | ||||
|         setSourceLocation(value); | ||||
|       } | ||||
|   | ||||
| @@ -2,11 +2,10 @@ import { t } from '@lingui/macro'; | ||||
| import { Accordion } from '@mantine/core'; | ||||
|  | ||||
| import { StylishText } from '../../components/items/StylishText'; | ||||
| import { ModelType } from '../../enums/ModelType'; | ||||
| import { UserRoles } from '../../enums/Roles'; | ||||
| import { useUserState } from '../../states/UserState'; | ||||
| import BuildAllocatedStockTable from '../../tables/build/BuildAllocatedStockTable'; | ||||
| import SalesOrderAllocationTable from '../../tables/sales/SalesOrderAllocationTable'; | ||||
| import PartBuildAllocationsTable from '../../tables/part/PartBuildAllocationsTable'; | ||||
| import PartSalesAllocationsTable from '../../tables/part/PartSalesAllocationsTable'; | ||||
|  | ||||
| export default function PartAllocationPanel({ part }: { part: any }) { | ||||
|   const user = useUserState(); | ||||
| @@ -23,14 +22,7 @@ export default function PartAllocationPanel({ part }: { part: any }) { | ||||
|               <StylishText size="lg">{t`Build Order Allocations`}</StylishText> | ||||
|             </Accordion.Control> | ||||
|             <Accordion.Panel> | ||||
|               <BuildAllocatedStockTable | ||||
|                 partId={part.pk} | ||||
|                 modelField="build" | ||||
|                 modelTarget={ModelType.build} | ||||
|                 showBuildInfo | ||||
|                 showPartInfo | ||||
|                 allowEdit | ||||
|               /> | ||||
|               <PartBuildAllocationsTable partId={part.pk} /> | ||||
|             </Accordion.Panel> | ||||
|           </Accordion.Item> | ||||
|         )} | ||||
| @@ -40,12 +32,7 @@ export default function PartAllocationPanel({ part }: { part: any }) { | ||||
|               <StylishText size="lg">{t`Sales Order Allocations`}</StylishText> | ||||
|             </Accordion.Control> | ||||
|             <Accordion.Panel> | ||||
|               <SalesOrderAllocationTable | ||||
|                 partId={part.pk} | ||||
|                 modelField="order" | ||||
|                 modelTarget={ModelType.salesorder} | ||||
|                 showOrderInfo | ||||
|               /> | ||||
|               <PartSalesAllocationsTable partId={part.pk} /> | ||||
|             </Accordion.Panel> | ||||
|           </Accordion.Item> | ||||
|         )} | ||||
|   | ||||
| @@ -163,10 +163,16 @@ export function LineItemsProgressColumn(): TableColumn { | ||||
| export function ProjectCodeColumn(props: TableColumnProps): TableColumn { | ||||
|   return { | ||||
|     accessor: 'project_code', | ||||
|     ordering: 'project_code', | ||||
|     sortable: true, | ||||
|     render: (record: any) => ( | ||||
|       <ProjectCodeHoverCard projectCode={record.project_code_detail} /> | ||||
|     ), | ||||
|     title: t`Project Code`, | ||||
|     render: (record: any) => { | ||||
|       let project_code = resolveItem( | ||||
|         record, | ||||
|         props.accessor ?? 'project_code_detail' | ||||
|       ); | ||||
|       return <ProjectCodeHoverCard projectCode={project_code} />; | ||||
|     }, | ||||
|     ...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', | ||||
|         switchable: false, | ||||
|         render: (record: any) => { | ||||
|           let quantity = formatDecimal(record.quantity); | ||||
|           let units = record.sub_part_detail?.units; | ||||
|  | ||||
|           return ( | ||||
|             <Group justify="space-between" grow> | ||||
|             <Group justify="space-between" grow wrap="nowrap"> | ||||
|               <Text>{quantity}</Text> | ||||
|               {units && <Text size="xs">{units}</Text>} | ||||
|             </Group> | ||||
|   | ||||
| @@ -1,9 +1,7 @@ | ||||
| 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 { | ||||
|   IconArrowRight, | ||||
|   IconChevronDown, | ||||
|   IconChevronRight, | ||||
|   IconCircleMinus, | ||||
|   IconShoppingCart, | ||||
|   IconTool, | ||||
| @@ -43,6 +41,7 @@ import { | ||||
|   RowEditAction, | ||||
|   RowViewAction | ||||
| } from '../RowActions'; | ||||
| import RowExpansionIcon from '../RowExpansionIcon'; | ||||
| import { TableHoverCard } from '../TableHoverCard'; | ||||
|  | ||||
| /** | ||||
| @@ -53,16 +52,17 @@ import { TableHoverCard } from '../TableHoverCard'; | ||||
|  * | ||||
|  * Note: We expect that the "lineItem" object contains an allocations[] list | ||||
|  */ | ||||
| function BuildLineSubTable({ | ||||
| export function BuildLineSubTable({ | ||||
|   lineItem, | ||||
|   onEditAllocation, | ||||
|   onDeleteAllocation | ||||
| }: { | ||||
|   lineItem: any; | ||||
|   onEditAllocation: (pk: number) => void; | ||||
|   onDeleteAllocation: (pk: number) => void; | ||||
|   onEditAllocation?: (pk: number) => void; | ||||
|   onDeleteAllocation?: (pk: number) => void; | ||||
| }) { | ||||
|   const user = useUserState(); | ||||
|   const navigate = useNavigate(); | ||||
|  | ||||
|   const tableColumns: any[] = useMemo(() => { | ||||
|     return [ | ||||
| @@ -100,16 +100,24 @@ function BuildLineSubTable({ | ||||
|               title={t`Actions`} | ||||
|               index={record.pk} | ||||
|               actions={[ | ||||
|                 RowViewAction({ | ||||
|                   title: t`View Stock Item`, | ||||
|                   modelType: ModelType.stockitem, | ||||
|                   modelId: record.stock_item, | ||||
|                   navigate: navigate | ||||
|                 }), | ||||
|                 RowEditAction({ | ||||
|                   hidden: !user.hasChangeRole(UserRoles.build), | ||||
|                   hidden: | ||||
|                     !onEditAllocation || !user.hasChangeRole(UserRoles.build), | ||||
|                   onClick: () => { | ||||
|                     onEditAllocation(record.pk); | ||||
|                     onEditAllocation?.(record.pk); | ||||
|                   } | ||||
|                 }), | ||||
|                 RowDeleteAction({ | ||||
|                   hidden: !user.hasDeleteRole(UserRoles.build), | ||||
|                   hidden: | ||||
|                     !onDeleteAllocation || !user.hasDeleteRole(UserRoles.build), | ||||
|                   onClick: () => { | ||||
|                     onDeleteAllocation(record.pk); | ||||
|                     onDeleteAllocation?.(record.pk); | ||||
|                   } | ||||
|                 }) | ||||
|               ]} | ||||
| @@ -131,7 +139,7 @@ function BuildLineSubTable({ | ||||
|           pinLastColumn | ||||
|           idAccessor="pk" | ||||
|           columns={tableColumns} | ||||
|           records={lineItem.filteredAllocations} | ||||
|           records={lineItem.filteredAllocations ?? lineItem.allocations} | ||||
|         /> | ||||
|       </Stack> | ||||
|     </Paper> | ||||
| @@ -301,17 +309,10 @@ export default function BuildLineTable({ | ||||
|  | ||||
|           return ( | ||||
|             <Group wrap="nowrap"> | ||||
|               <ActionIcon | ||||
|                 size="sm" | ||||
|                 variant="transparent" | ||||
|                 disabled={!hasAllocatedItems} | ||||
|               > | ||||
|                 {table.isRowExpanded(record.pk) ? ( | ||||
|                   <IconChevronDown /> | ||||
|                 ) : ( | ||||
|                   <IconChevronRight /> | ||||
|                 )} | ||||
|               </ActionIcon> | ||||
|               <RowExpansionIcon | ||||
|                 enabled={hasAllocatedItems} | ||||
|                 expanded={table.isRowExpanded(record.pk)} | ||||
|               /> | ||||
|               <PartColumn part={record.part_detail} /> | ||||
|             </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 { ActionIcon, Group, Text } from '@mantine/core'; | ||||
| import { Group, Text } from '@mantine/core'; | ||||
| import { | ||||
|   IconArrowRight, | ||||
|   IconChevronDown, | ||||
|   IconChevronRight, | ||||
|   IconHash, | ||||
|   IconShoppingCart, | ||||
|   IconSquareArrowRight, | ||||
| @@ -46,6 +44,7 @@ import { | ||||
|   RowEditAction, | ||||
|   RowViewAction | ||||
| } from '../RowActions'; | ||||
| import RowExpansionIcon from '../RowExpansionIcon'; | ||||
| import { TableHoverCard } from '../TableHoverCard'; | ||||
| import SalesOrderAllocationTable from './SalesOrderAllocationTable'; | ||||
|  | ||||
| @@ -73,17 +72,10 @@ export default function SalesOrderLineItemTable({ | ||||
|         render: (record: any) => { | ||||
|           return ( | ||||
|             <Group wrap="nowrap"> | ||||
|               <ActionIcon | ||||
|                 size="sm" | ||||
|                 variant="transparent" | ||||
|                 disabled={!record.allocated} | ||||
|               > | ||||
|                 {table.isRowExpanded(record.pk) ? ( | ||||
|                   <IconChevronDown /> | ||||
|                 ) : ( | ||||
|                   <IconChevronRight /> | ||||
|                 )} | ||||
|               </ActionIcon> | ||||
|               <RowExpansionIcon | ||||
|                 enabled={record.allocated} | ||||
|                 expanded={table.isRowExpanded(record.pk)} | ||||
|               /> | ||||
|               <PartColumn part={record.part_detail} /> | ||||
|             </Group> | ||||
|           ); | ||||
|   | ||||
| @@ -212,7 +212,7 @@ test('Pages - Build Order - Allocation', async ({ page }) => { | ||||
|     { | ||||
|       name: 'Blue Widget', | ||||
|       ipn: 'widget.blue', | ||||
|       available: '45', | ||||
|       available: '39', | ||||
|       required: '5', | ||||
|       allocated: '5' | ||||
|     }, | ||||
|   | ||||
| @@ -100,29 +100,71 @@ test('Parts - Allocations', async ({ page }) => { | ||||
|   await doQuickLogin(page); | ||||
|  | ||||
|   // 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('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(); | ||||
|   await page.getByRole('button', { name: 'Sales Order Allocations' }).waitFor(); | ||||
|  | ||||
|   // Let's look at the allocations for the entire part | ||||
|   await page.getByRole('tab', { name: 'Details' }).click(); | ||||
|   await page.getByRole('link', { name: 'Leg' }).click(); | ||||
|   // Expected order reference values | ||||
|   await page.getByText('BO0001').waitFor(); | ||||
|   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(); | ||||
|   await page.getByText('660 / 760').waitFor(); | ||||
|   // Check "progress" bar of BO0001 | ||||
|   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 | ||||
|   await page.getByText('1 - 4 / 4').waitFor(); | ||||
|   await page.getByRole('cell', { name: 'Making red square tables' }).waitFor(); | ||||
|   // Check row options for BO0001 | ||||
|   await build_order_row.getByLabel(/row-action-menu/).click(); | ||||
|   await page.getByRole('menuitem', { name: 'View Build Order' }).waitFor(); | ||||
|   await page.keyboard.press('Escape'); | ||||
|  | ||||
|   // Navigate through to the build order | ||||
|   await page.getByRole('cell', { name: 'BO0007' }).click(); | ||||
|   await page.getByRole('tab', { name: 'Build Details' }).waitFor(); | ||||
|   // Check "progress" bar of SO0025 | ||||
|   const sales_order_cell = await page.getByRole('cell', { name: 'SO0025' }); | ||||
|   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 }) => { | ||||
|   | ||||
		Reference in New Issue
	
	Block a user