diff --git a/docs/docs/assets/images/build/allocated_stock_table.png b/docs/docs/assets/images/build/allocated_stock_table.png new file mode 100644 index 0000000000..676f4492f9 Binary files /dev/null and b/docs/docs/assets/images/build/allocated_stock_table.png differ diff --git a/docs/docs/build/build.md b/docs/docs/build/build.md index 22f4084761..594b98846a 100644 --- a/docs/docs/build/build.md +++ b/docs/docs/build/build.md @@ -26,14 +26,6 @@ To navigate to the Build Order display, select *Build* from the main navigation {% include "img.html" %} {% endwith %} -#### Tree View - -*Tree View* also provides a tabulated view of Build Orders. Orders are displayed in a hierarchical manner, showing any parent / child relationships between different build orders. - -{% with id="build_tree", url="build/build_tree.png", description="Build Tree" %} -{% include "img.html" %} -{% endwith %} - #### Calendar View *Calendar View* shows a calendar display with upcoming build orders, based on the various dates specified for each build. @@ -121,9 +113,9 @@ The *Build Details* tab provides an overview of the Build Order: {% include "img.html" %} {% endwith %} -### Allocate Stock +### Line Items -The *Allocate Stock* tab provides an interface to allocate required stock (as specified by the BOM) to the build: +The *Line Items* tab provides an interface to allocate required stock (as specified by the BOM) to the build: {% with id="build_allocate", url="build/build_allocate.png", description="Allocation tab" %} {% include "img.html" %} @@ -131,8 +123,13 @@ The *Allocate Stock* tab provides an interface to allocate required stock (as sp The allocation table (as shown above) shows the stock allocation progress for this build. In the example above, there are two BOM lines, which have been partially allocated. -!!! info "Completed Builds" - The *Allocate Stock* tab is not available if the build has been completed! +### Allocated Stock + +The *Allocated Stock* tab displays all stock items which have been *allocated* to this build order. These stock items are reserved for this build, and will be consumed when the build is completed: + +{% with id="allocated_stock_table", url="build/allocated_stock_table.png", description="Allocated Stock Table" %} +{% include "img.html" %} +{% endwith %} ### Consumed Stock diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 19dc675787..bdb4330d90 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,12 +1,16 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 218 +INVENTREE_API_VERSION = 219 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v219 - 2024-07-11 : https://github.com/inventree/InvenTree/pull/7611 + - Adds new fields to the BuildItem API endpoints + - Adds new ordering / filtering options to the BuildItem API endpoints + v218 - 2024-07-11 : https://github.com/inventree/InvenTree/pull/7619 - Adds "can_build" field to the BomItem API diff --git a/src/backend/InvenTree/build/api.py b/src/backend/InvenTree/build/api.py index 8944ecb9a7..e21c60037d 100644 --- a/src/backend/InvenTree/build/api.py +++ b/src/backend/InvenTree/build/api.py @@ -8,12 +8,11 @@ from django.contrib.auth.models import User from rest_framework.exceptions import ValidationError -from django_filters.rest_framework import DjangoFilterBackend from django_filters import rest_framework as rest_filters from importer.mixins import DataExportViewMixin -from InvenTree.api import MetadataView +from InvenTree.api import BulkDeleteMixin, MetadataView from generic.states.api import StatusView from InvenTree.helpers import str2bool, isNull from build.status_codes import BuildStatus, BuildStatusGroups @@ -546,15 +545,17 @@ class BuildItemFilter(rest_filters.FilterSet): return queryset.filter(install_into=None) -class BuildItemList(DataExportViewMixin, ListCreateAPI): +class BuildItemList(DataExportViewMixin, BulkDeleteMixin, ListCreateAPI): """API endpoint for accessing a list of BuildItem objects. - GET: Return list of objects - POST: Create a new BuildItem object """ + queryset = BuildItem.objects.all() serializer_class = build.serializers.BuildItemSerializer filterset_class = BuildItemFilter + filter_backends = SEARCH_ORDER_FILTER_ALIAS def get_serializer(self, *args, **kwargs): """Returns a BuildItemSerializer instance based on the request.""" @@ -571,7 +572,7 @@ class BuildItemList(DataExportViewMixin, ListCreateAPI): def get_queryset(self): """Override the queryset method, to allow filtering by stock_item.part.""" - queryset = BuildItem.objects.all() + queryset = super().get_queryset() queryset = queryset.select_related( 'build_line', @@ -607,8 +608,25 @@ class BuildItemList(DataExportViewMixin, ListCreateAPI): return queryset - filter_backends = [ - DjangoFilterBackend, + ordering_fields = [ + 'part', + 'sku', + 'quantity', + 'location', + 'reference', + ] + + ordering_field_aliases = { + 'part': 'stock_item__part__name', + 'sku': 'stock_item__supplier_part__SKU', + 'location': 'stock_item__location__name', + 'reference': 'build_line__bom_item__reference', + } + + search_fields = [ + 'stock_item__supplier_part__SKU', + 'stock_item__part__name', + 'build_line__bom_item__reference', ] diff --git a/src/backend/InvenTree/build/serializers.py b/src/backend/InvenTree/build/serializers.py index 83b046c0a7..ef84817123 100644 --- a/src/backend/InvenTree/build/serializers.py +++ b/src/backend/InvenTree/build/serializers.py @@ -26,8 +26,9 @@ from stock.serializers import StockItemSerializerBrief, LocationSerializer import common.models from common.serializers import ProjectCodeSerializer from importer.mixins import DataImportExportSerializerMixin +import company.serializers import part.filters -from part.serializers import BomItemSerializer, PartSerializer, PartBriefSerializer +import part.serializers as part_serializers from users.serializers import OwnerSerializer from .models import Build, BuildLine, BuildItem @@ -85,7 +86,7 @@ class BuildSerializer(NotesFieldMixin, DataImportExportSerializerMixin, InvenTre status_text = serializers.CharField(source='get_status_display', read_only=True) - part_detail = PartBriefSerializer(source='part', many=False, read_only=True) + part_detail = part_serializers.PartBriefSerializer(source='part', many=False, read_only=True) part_name = serializers.CharField(source='part.name', read_only=True, label=_('Part Name')) @@ -1062,10 +1063,13 @@ class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali # These fields are only used for data export export_only_fields = [ 'build_reference', - 'bom_reference', 'sku', 'mpn', 'location_name', + 'part_id', + 'part_name', + 'part_ipn', + 'available_quantity', ] class Meta: @@ -1085,6 +1089,7 @@ class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali 'location_detail', 'part_detail', 'stock_item_detail', + 'supplier_part_detail', # The following fields are only used for data export 'bom_reference', @@ -1092,27 +1097,12 @@ class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali 'location_name', 'mpn', 'sku', + 'part_id', + 'part_name', + 'part_ipn', + 'available_quantity', ] - # Export-only fields - sku = serializers.CharField(source='stock_item.supplier_part.SKU', label=_('Supplier Part Number'), read_only=True) - mpn = serializers.CharField(source='stock_item.supplier_part.manufacturer_part.MPN', label=_('Manufacturer Part Number'), read_only=True) - location_name = serializers.CharField(source='stock_item.location.name', label=_('Location Name'), read_only=True) - build_reference = serializers.CharField(source='build.reference', label=_('Build Reference'), read_only=True) - bom_reference = serializers.CharField(source='build_line.bom_item.reference', label=_('BOM Reference'), read_only=True) - - # Annotated fields - build = serializers.PrimaryKeyRelatedField(source='build_line.build', many=False, read_only=True) - - # Extra (optional) detail fields - part_detail = PartBriefSerializer(source='stock_item.part', many=False, read_only=True, pricing=False) - stock_item_detail = StockItemSerializerBrief(source='stock_item', read_only=True) - location = serializers.PrimaryKeyRelatedField(source='stock_item.location', many=False, read_only=True) - location_detail = LocationSerializer(source='stock_item.location', read_only=True) - build_detail = BuildSerializer(source='build_line.build', many=False, read_only=True) - - quantity = InvenTreeDecimalField() - def __init__(self, *args, **kwargs): """Determine which extra details fields should be included""" part_detail = kwargs.pop('part_detail', True) @@ -1134,6 +1124,32 @@ class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali if not build_detail: self.fields.pop('build_detail', None) + # Export-only fields + sku = serializers.CharField(source='stock_item.supplier_part.SKU', label=_('Supplier Part Number'), read_only=True) + mpn = serializers.CharField(source='stock_item.supplier_part.manufacturer_part.MPN', label=_('Manufacturer Part Number'), read_only=True) + location_name = serializers.CharField(source='stock_item.location.name', label=_('Location Name'), read_only=True) + build_reference = serializers.CharField(source='build.reference', label=_('Build Reference'), read_only=True) + bom_reference = serializers.CharField(source='build_line.bom_item.reference', label=_('BOM Reference'), read_only=True) + + # Part detail fields + part_id = serializers.PrimaryKeyRelatedField(source='stock_item.part', label=_('Part ID'), many=False, read_only=True) + part_name = serializers.CharField(source='stock_item.part.name', label=_('Part Name'), read_only=True) + part_ipn = serializers.CharField(source='stock_item.part.IPN', label=_('Part IPN'), read_only=True) + + # Annotated fields + build = serializers.PrimaryKeyRelatedField(source='build_line.build', many=False, read_only=True) + + # Extra (optional) detail fields + part_detail = part_serializers.PartBriefSerializer(source='stock_item.part', many=False, read_only=True, pricing=False) + stock_item_detail = StockItemSerializerBrief(source='stock_item', read_only=True) + location = serializers.PrimaryKeyRelatedField(source='stock_item.location', many=False, read_only=True) + location_detail = LocationSerializer(source='stock_item.location', read_only=True) + build_detail = BuildSerializer(source='build_line.build', many=False, read_only=True) + supplier_part_detail = company.serializers.SupplierPartSerializer(source='stock_item.supplier_part', many=False, read_only=True) + + quantity = InvenTreeDecimalField(label=_('Allocated Quantity')) + available_quantity = InvenTreeDecimalField(source='stock_item.quantity', read_only=True, label=_('Available Quantity')) + class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer): """Serializer for a BuildItem object.""" @@ -1217,8 +1233,8 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali bom_item = serializers.PrimaryKeyRelatedField(label=_('BOM Item'), read_only=True) # Foreign key fields - bom_item_detail = BomItemSerializer(source='bom_item', many=False, read_only=True, pricing=False) - part_detail = PartSerializer(source='bom_item.sub_part', many=False, read_only=True, pricing=False) + bom_item_detail = part_serializers.BomItemSerializer(source='bom_item', many=False, read_only=True, pricing=False) + part_detail = part_serializers.PartSerializer(source='bom_item.sub_part', many=False, read_only=True, pricing=False) allocations = BuildItemSerializer(many=True, read_only=True) # Annotated (calculated) fields diff --git a/src/backend/InvenTree/build/templates/build/detail.html b/src/backend/InvenTree/build/templates/build/detail.html index 7daa1dc218..6cabe1f01c 100644 --- a/src/backend/InvenTree/build/templates/build/detail.html +++ b/src/backend/InvenTree/build/templates/build/detail.html @@ -174,7 +174,7 @@
-

{% trans "Allocate Stock to Build" %}

+

{% trans "Build Order Line Items" %}

{% include "spacer.html" %}
{% if roles.build.add and build.active %} @@ -231,6 +231,18 @@
+
+
+

{% trans "Allocated Stock" %}

+
+
+
+ {% include "filter_list.html" with id='buildorderallocatedstock' %} +
+
+
+
+

@@ -290,6 +302,10 @@ {% block js_ready %} {{ block.super }} +onPanelLoad('allocated', function() { + loadBuildOrderAllocatedStockTable($('#allocated-stock-table'), {{ build.pk }}); +}); + onPanelLoad('consumed', function() { loadStockTable($('#consumed-stock-table'), { filterTarget: '#filter-list-consumed-stock', diff --git a/src/backend/InvenTree/build/templates/build/sidebar.html b/src/backend/InvenTree/build/templates/build/sidebar.html index c038b7782a..1c20429f2f 100644 --- a/src/backend/InvenTree/build/templates/build/sidebar.html +++ b/src/backend/InvenTree/build/templates/build/sidebar.html @@ -5,15 +5,19 @@ {% trans "Build Order Details" as text %} {% include "sidebar_item.html" with label='details' text=text icon="fa-info-circle" %} {% if build.is_active %} -{% trans "Allocate Stock" as text %} -{% include "sidebar_item.html" with label='allocate' text=text icon="fa-tasks" %} +{% trans "Line Items" as text %} +{% include "sidebar_item.html" with label='allocate' text=text icon="fa-list-ol" %} {% trans "Incomplete Outputs" as text %} {% include "sidebar_item.html" with label='outputs' text=text icon="fa-tools" %} {% endif %} {% trans "Completed Outputs" as text %} {% include "sidebar_item.html" with label='completed' text=text icon="fa-boxes" %} +{% if build.is_active %} +{% trans "Allocated Stock" as text %} +{% include "sidebar_item.html" with label='allocated' text=text icon="fa-list" %} +{% endif %} {% trans "Consumed Stock" as text %} -{% include "sidebar_item.html" with label='consumed' text=text icon="fa-list" %} +{% include "sidebar_item.html" with label='consumed' text=text icon="fa-tasks" %} {% trans "Child Build Orders" as text %} {% include "sidebar_item.html" with label='children' text=text icon="fa-sitemap" %} {% trans "Attachments" as text %} diff --git a/src/backend/InvenTree/templates/js/translated/build.js b/src/backend/InvenTree/templates/js/translated/build.js index 5a762d2bd5..d6e5daf95a 100644 --- a/src/backend/InvenTree/templates/js/translated/build.js +++ b/src/backend/InvenTree/templates/js/translated/build.js @@ -58,6 +58,7 @@ duplicateBuildOrder, editBuildOrder, loadBuildLineTable, + loadBuildOrderAllocatedStockTable, loadBuildOrderAllocationTable, loadBuildOutputTable, loadBuildTable, @@ -933,6 +934,180 @@ function deleteBuildOutputs(build_id, outputs, options={}) { } +/** + * Load a table showing all stock allocated to a given Build Order + */ +function loadBuildOrderAllocatedStockTable(table, buildId) { + + let params = { + build: buildId, + part_detail: true, + location_detail: true, + stock_detail: true, + supplier_detail: true, + }; + + let filters = loadTableFilters('buildorderallocatedstock', params); + setupFilterList( + 'buildorderallocatedstock', + $(table), + null, + { + download: true, + custom_actions: [{ + label: 'actions', + actions: [{ + label: 'delete', + title: '{% trans "Delete allocations" %}', + icon: 'fa-trash-alt icon-red', + permission: 'build.delete', + callback: function(data) { + constructForm('{% url "api-build-item-list" %}', { + method: 'DELETE', + multi_delete: true, + title: '{% trans "Delete Stock Allocations" %}', + form_data: { + items: data.map(item => item.pk), + }, + onSuccess: function() { + $(table).bootstrapTable('refresh'); + } + }); + } + }] + }] + } + ); + + $(table).inventreeTable({ + url: '{% url "api-build-item-list" %}', + queryParams: filters, + original: params, + sortable: true, + search: true, + groupBy: false, + sidePagination: 'server', + formatNoMatches: function() { + return '{% trans "No allocated stock" %}'; + }, + columns: [ + { + title: '', + visible: true, + checkbox: true, + switchable: false, + }, + { + field: 'part', + sortable: true, + switchable: false, + title: '{% trans "Part" %}', + formatter: function(value, row) { + return imageHoverIcon(row.part_detail.thumbnail) + renderLink(row.part_detail.full_name, `/part/${row.part_detail.pk}/`); + } + }, + { + field: 'bom_reference', + sortable: true, + switchable: true, + title: '{% trans "Reference" %}', + }, + { + field: 'quantity', + sortable: true, + switchable: false, + title: '{% trans "Allocated Quantity" %}', + formatter: function(value, row) { + let stock_item = row.stock_item_detail; + let text = value; + + if (stock_item.serial && stock_item.quantity == 1) { + text = `# ${stock_item.serial}`; + } + + return renderLink(text, `/stock/item/${stock_item.pk}/`); + } + }, + { + field: 'location', + sortable: true, + title: '{% trans "Location" %}', + formatter: function(value, row) { + if (row.location_detail) { + return locationDetail(row, true); + } + } + }, + { + field: 'install_into', + sortable: true, + title: '{% trans "Build Output" %}', + formatter: function(value, row) { + if (value) { + return renderLink(`{% trans "Stock item" %}: ${value}`, `/stock/item/${value}/`); + } + } + }, + { + field: 'sku', + sortable: true, + title: '{% trans "Supplier Part" %}', + formatter: function(value, row) { + if (row.supplier_part_detail) { + let text = row.supplier_part_detail.SKU; + + return renderLink(text, `/supplier-part/${row.supplier_part_detail.pk}/`); + } + } + }, + { + field: 'pk', + title: '{% trans "Actions" %}', + visible: true, + switchable: false, + sortable: false, + formatter: function(value, row) { + let buttons = ''; + + buttons += makeEditButton('build-item-edit', row.pk, '{% trans "Edit build allocation" %}'); + buttons += makeDeleteButton('build-item-delete', row.pk, '{% trans "Delete build allocation" %}'); + + return wrapButtons(buttons); + } + } + ] + }); + + // Add row callbacks + $(table).on('click', '.build-item-edit', function() { + let pk = $(this).attr('pk'); + + constructForm( + `/api/build/item/${pk}/`, + { + fields: { + quantity: {}, + }, + title: '{% trans "Edit Build Allocation" %}', + refreshTable: table + } + ); + }); + + $(table).on('click', '.build-item-delete', function() { + let pk = $(this).attr('pk'); + + constructForm( + `/api/build/item/${pk}/`, + { + method: 'DELETE', + title: '{% trans "Delete Build Allocation" %}', + refreshTable: table, + } + ); + }); +} + /** * Load a table showing all the BuildOrder allocations for a given part */ diff --git a/src/frontend/src/components/render/Build.tsx b/src/frontend/src/components/render/Build.tsx index 37f8eacde0..90ddbf6cc3 100644 --- a/src/frontend/src/components/render/Build.tsx +++ b/src/frontend/src/components/render/Build.tsx @@ -46,3 +46,9 @@ export function RenderBuildLine({ /> ); } + +export function RenderBuildItem({ + instance +}: Readonly): ReactNode { + return ; +} diff --git a/src/frontend/src/components/render/Instance.tsx b/src/frontend/src/components/render/Instance.tsx index cc179631c2..58a7261834 100644 --- a/src/frontend/src/components/render/Instance.tsx +++ b/src/frontend/src/components/render/Instance.tsx @@ -8,7 +8,7 @@ import { ModelType } from '../../enums/ModelType'; import { navigateToLink } from '../../functions/navigation'; import { apiUrl } from '../../states/ApiState'; import { Thumbnail } from '../images/Thumbnail'; -import { RenderBuildLine, RenderBuildOrder } from './Build'; +import { RenderBuildItem, RenderBuildLine, RenderBuildOrder } from './Build'; import { RenderAddress, RenderCompany, @@ -59,6 +59,7 @@ const RendererLookup: EnumDictionary< [ModelType.address]: RenderAddress, [ModelType.build]: RenderBuildOrder, [ModelType.buildline]: RenderBuildLine, + [ModelType.builditem]: RenderBuildItem, [ModelType.company]: RenderCompany, [ModelType.contact]: RenderContact, [ModelType.manufacturerpart]: RenderManufacturerPart, diff --git a/src/frontend/src/components/render/ModelType.tsx b/src/frontend/src/components/render/ModelType.tsx index d96cc28bb0..e8b12da9ca 100644 --- a/src/frontend/src/components/render/ModelType.tsx +++ b/src/frontend/src/components/render/ModelType.tsx @@ -113,6 +113,11 @@ export const ModelInformationDict: ModelDict = { cui_detail: '/build/line/:pk/', api_endpoint: ApiEndpoints.build_line_list }, + builditem: { + label: t`Build Item`, + label_multiple: t`Build Items`, + api_endpoint: ApiEndpoints.build_item_list + }, company: { label: t`Company`, label_multiple: t`Companies`, diff --git a/src/frontend/src/enums/ApiEndpoints.tsx b/src/frontend/src/enums/ApiEndpoints.tsx index a193216464..5e8aeda199 100644 --- a/src/frontend/src/enums/ApiEndpoints.tsx +++ b/src/frontend/src/enums/ApiEndpoints.tsx @@ -65,6 +65,7 @@ export enum ApiEndpoints { build_output_scrap = 'build/:id/scrap-outputs/', build_output_delete = 'build/:id/delete-outputs/', build_line_list = 'build/line/', + build_item_list = 'build/item/', bom_list = 'bom/', bom_item_validate = 'bom/:id/validate/', diff --git a/src/frontend/src/enums/ModelType.tsx b/src/frontend/src/enums/ModelType.tsx index 90492b98f0..e71944f954 100644 --- a/src/frontend/src/enums/ModelType.tsx +++ b/src/frontend/src/enums/ModelType.tsx @@ -15,6 +15,7 @@ export enum ModelType { stockhistory = 'stockhistory', build = 'build', buildline = 'buildline', + builditem = 'builditem', company = 'company', purchaseorder = 'purchaseorder', purchaseorderline = 'purchaseorderline', diff --git a/src/frontend/src/pages/build/BuildDetail.tsx b/src/frontend/src/pages/build/BuildDetail.tsx index 28e9929b9c..c5635d7278 100644 --- a/src/frontend/src/pages/build/BuildDetail.tsx +++ b/src/frontend/src/pages/build/BuildDetail.tsx @@ -7,6 +7,7 @@ import { IconInfoCircle, IconList, IconListCheck, + IconListNumbers, IconNotes, IconPaperclip, IconQrcode, @@ -45,6 +46,7 @@ import { import { useInstance } from '../../hooks/UseInstance'; import { apiUrl } from '../../states/ApiState'; import { useUserState } from '../../states/UserState'; +import BuildAllocatedStockTable from '../../tables/build/BuildAllocatedStockTable'; import BuildLineTable from '../../tables/build/BuildLineTable'; import { BuildOrderTable } from '../../tables/build/BuildOrderTable'; import BuildOutputTable from '../../tables/build/BuildOutputTable'; @@ -233,9 +235,9 @@ export default function BuildDetail() { content: detailsPanel }, { - name: 'allocate-stock', - label: t`Allocate Stock`, - icon: , + name: 'line-items', + label: t`Line Items`, + icon: , content: build?.pk ? ( ) }, + { + name: 'allocated-stock', + label: t`Allocated Stock`, + icon: , + content: build.pk ? ( + + ) : ( + + ) + }, { name: 'consumed-stock', label: t`Consumed Stock`, - icon: , + icon: , content: ( { + return [ + { + name: 'tracked', + label: t`Allocated to Output`, + description: t`Show items allocated to a build output` + } + ]; + }, []); + + const tableColumns: TableColumn[] = useMemo(() => { + return [ + { + accessor: 'part', + title: t`Part`, + sortable: true, + switchable: false, + render: (record: any) => PartColumn(record.part_detail) + }, + { + accessor: 'bom_reference', + title: t`Reference`, + sortable: true, + switchable: true + }, + { + accessor: 'quantity', + title: t`Allocated Quantity`, + sortable: true, + switchable: false + }, + { + accessor: 'batch', + title: t`Batch Code`, + sortable: false, + switchable: true, + render: (record: any) => record?.stock_item_detail?.batch + }, + { + accessor: 'available', + title: t`Available Quantity`, + render: (record: any) => record?.stock_item_detail?.quantity + }, + LocationColumn({ + accessor: 'location_detail', + switchable: true, + sortable: true + }), + { + accessor: 'install_into', + title: t`Build Output`, + sortable: true + }, + { + accessor: 'sku', + title: t`Supplier Part`, + render: (record: any) => record?.supplier_part_detail?.SKU, + sortable: true + } + ]; + }, []); + + const [selectedItem, setSelectedItem] = useState(0); + + const editItem = useEditApiFormModal({ + pk: selectedItem, + url: ApiEndpoints.build_item_list, + title: t`Edit Build Item`, + fields: { + quantity: {} + }, + table: table + }); + + const deleteItem = useDeleteApiFormModal({ + pk: selectedItem, + url: ApiEndpoints.build_item_list, + title: t`Delete Build Item`, + table: table + }); + + const rowActions = useCallback( + (record: any) => { + return [ + RowEditAction({ + hidden: !user.hasChangeRole(UserRoles.build), + onClick: () => { + setSelectedItem(record.pk); + editItem.open(); + } + }), + RowDeleteAction({ + hidden: !user.hasDeleteRole(UserRoles.build), + onClick: () => { + setSelectedItem(record.pk); + deleteItem.open(); + } + }) + ]; + }, + [user] + ); + + return ( + <> + {editItem.modal} + {deleteItem.modal} + + + ); +} diff --git a/src/frontend/tests/pages/pui_build.spec.ts b/src/frontend/tests/pages/pui_build.spec.ts new file mode 100644 index 0000000000..0703380ee1 --- /dev/null +++ b/src/frontend/tests/pages/pui_build.spec.ts @@ -0,0 +1,34 @@ +import { test } from '../baseFixtures.ts'; +import { baseUrl } from '../defaults.ts'; +import { doQuickLogin } from '../login.ts'; + +test('PUI - Pages - Build Order', async ({ page }) => { + await doQuickLogin(page); + + await page.goto(`${baseUrl}/part/`); + + // Navigate to the correct build order + await page.getByRole('tab', { name: 'Build', exact: true }).click(); + await page.getByRole('cell', { name: 'BO0011' }).click(); + + // Click on some tabs + await page.getByRole('tab', { name: 'Attachments' }).click(); + await page.getByRole('tab', { name: 'Notes' }).click(); + await page.getByRole('tab', { name: 'Incomplete Outputs' }).click(); + await page.getByRole('tab', { name: 'Line Items' }).click(); + await page.getByRole('tab', { name: 'Allocated Stock' }).click(); + + // Check for expected text in the table + await page.getByText('R_10R_0402_1%').click(); + await page + .getByRole('cell', { name: 'R38, R39, R40, R41, R42, R43' }) + .click(); + + // Click through to the "parent" build + await page.getByRole('tab', { name: 'Build Details' }).click(); + await page.getByRole('link', { name: 'BO0010' }).click(); + await page + .getByLabel('Build Details') + .getByText('Making a high level assembly') + .waitFor(); +}); diff --git a/src/frontend/tests/pages/pui_part.spec.ts b/src/frontend/tests/pages/pui_part.spec.ts index 2e9b6938e6..af999de84b 100644 --- a/src/frontend/tests/pages/pui_part.spec.ts +++ b/src/frontend/tests/pages/pui_part.spec.ts @@ -116,8 +116,6 @@ test('PUI - Pages - Part - Pricing (Variant)', async ({ page }) => { // Variant Pricing await page.getByRole('button', { name: 'Variant Pricing' }).click(); - await page.waitForTimeout(500); - await page.getByRole('button', { name: 'Variant Part Not sorted' }).click(); // Variant Pricing - linkjumping let target = page.getByText('Green Chair').first(); diff --git a/src/frontend/tests/pui_stock.spec.ts b/src/frontend/tests/pui_stock.spec.ts index bcd08cdcde..46535302a9 100644 --- a/src/frontend/tests/pui_stock.spec.ts +++ b/src/frontend/tests/pui_stock.spec.ts @@ -32,20 +32,6 @@ test('PUI - Stock', async ({ page }) => { await page.getByRole('tab', { name: 'Installed Items' }).click(); }); -test('PUI - Build', async ({ page }) => { - await doQuickLogin(page); - - await page.getByRole('tab', { name: 'Build' }).click(); - await page.getByText('Widget Assembly Variant').click(); - await page.getByRole('tab', { name: 'Allocate Stock' }).click(); - await page.getByRole('tab', { name: 'Incomplete Outputs' }).click(); - await page.getByRole('tab', { name: 'Completed Outputs' }).click(); - await page.getByRole('tab', { name: 'Consumed Stock' }).click(); - await page.getByRole('tab', { name: 'Child Build Orders' }).click(); - await page.getByRole('tab', { name: 'Attachments' }).click(); - await page.getByRole('tab', { name: 'Notes' }).click(); -}); - test('PUI - Purchasing', async ({ page }) => { await doQuickLogin(page);