diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 13b9d8abc7..c091b048ba 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -1,13 +1,16 @@ # Runs on releases -name: Publish release notes +name: Publish release on: release: types: [published] +permissions: + contents: read jobs: stable: runs-on: ubuntu-latest + name: Write release to stable branch permissions: contents: write pull-requests: write @@ -28,11 +31,13 @@ jobs: branch: stable force: true - publish-build: + build: runs-on: ubuntu-latest + name: Build and attest frontend permissions: + id-token: write contents: write - pull-requests: write + attestations: write steps: - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # pin@v4.1.7 - name: Environment Setup @@ -43,6 +48,11 @@ jobs: run: cd src/frontend && yarn install - name: Build frontend run: cd src/frontend && npm run compile && npm run build + - name: Create SBOM for frontend + uses: anchore/sbom-action@v0 + with: + artifact-name: frontend-build.spdx + path: src/frontend - name: Write version file - SHA run: cd src/backend/InvenTree/web/static/web/.vite && echo "$GITHUB_SHA" > sha.txt - name: Write version file - TAG @@ -51,10 +61,25 @@ jobs: run: | cd src/backend/InvenTree/web/static/web zip -r ../frontend-build.zip * .vite - - uses: svenstaro/upload-release-action@04733e069f2d7f7f0b4aebc4fbdbce8613b03ccd # pin@2.9.0 + - name: Attest Build Provenance + id: attest + uses: actions/attest-build-provenance@v1 + with: + subject-path: "${{ github.workspace }}/src/backend/InvenTree/web/static/frontend-build.zip" + + - name: Upload frontend + uses: svenstaro/upload-release-action@04733e069f2d7f7f0b4aebc4fbdbce8613b03ccd # pin@2.9.0 with: repo_token: ${{ secrets.GITHUB_TOKEN }} file: src/backend/InvenTree/web/static/frontend-build.zip asset_name: frontend-build.zip tag: ${{ github.ref }} overwrite: true + - name: Upload Attestation + uses: svenstaro/upload-release-action@04733e069f2d7f7f0b4aebc4fbdbce8613b03ccd # pin@2.9.0 + with: + repo_token: ${{ secrets.GITHUB_TOKEN }} + asset_name: frontend-build.intoto.jsonl + file: ${{ steps.attest.outputs.bundle-path}} + tag: ${{ github.ref }} + overwrite: true diff --git a/docs/docs/assets/images/build/build_panel_allocated_stock.png b/docs/docs/assets/images/build/build_panel_allocated_stock.png new file mode 100644 index 0000000000..33ab5bba0b Binary files /dev/null and b/docs/docs/assets/images/build/build_panel_allocated_stock.png differ diff --git a/docs/docs/assets/images/build/build_panel_details.png b/docs/docs/assets/images/build/build_panel_details.png new file mode 100644 index 0000000000..aa37b138d5 Binary files /dev/null and b/docs/docs/assets/images/build/build_panel_details.png differ diff --git a/docs/docs/assets/images/build/build_panel_line_items.png b/docs/docs/assets/images/build/build_panel_line_items.png new file mode 100644 index 0000000000..859e0f34b8 Binary files /dev/null and b/docs/docs/assets/images/build/build_panel_line_items.png differ diff --git a/docs/docs/assets/images/build/build_panel_test_results.png b/docs/docs/assets/images/build/build_panel_test_results.png new file mode 100644 index 0000000000..c6cee64531 Binary files /dev/null and b/docs/docs/assets/images/build/build_panel_test_results.png differ diff --git a/docs/docs/assets/images/build/build_panel_test_statistics.png b/docs/docs/assets/images/build/build_panel_test_statistics.png new file mode 100644 index 0000000000..a71edb7ab5 Binary files /dev/null and b/docs/docs/assets/images/build/build_panel_test_statistics.png differ diff --git a/docs/docs/build/build.md b/docs/docs/build/build.md index 594b98846a..f35fdcaa51 100644 --- a/docs/docs/build/build.md +++ b/docs/docs/build/build.md @@ -103,25 +103,44 @@ For further information, refer to the [stock allocation documentation](./allocat ## Build Order Display -The detail view for a single build order provides multiple display tabs, as follows: +The detail view for a single build order provides multiple display panels, as follows: ### Build Details -The *Build Details* tab provides an overview of the Build Order: +The *Build Details* panel provides an overview of the Build Order: -{% with id="build_details", url="build/build_details.png", description="Details tab" %} +{% with id="build_details", url="build/build_panel_details.png", description="Build details panel" %} {% include "img.html" %} {% endwith %} ### Line Items -The *Line Items* tab provides an interface to allocate required stock (as specified by the BOM) to the build: +The *Line Items* panel displays all the line items (as defined by the [bill of materials](./bom.md)) required to complete the build order. -{% with id="build_allocate", url="build/build_allocate.png", description="Allocation tab" %} +{% with id="build_allocate", url="build/build_panel_line_items.png", description="Build line items panel" %} {% include "img.html" %} {% endwith %} -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. +The allocation table (as shown above) provides an interface to allocate required stock, and also shows the stock allocation progress for each line item in the build. + +### Incomplete Outputs + +The *Incomplete Outputs* panel shows the list of in-progress [build outputs](./output.md) (created stock items) associated with this build. + +{% with id="build_outputs", url="build/build_outputs.png", description="Outputs tab" %} +{% include "img.html" %} +{% endwith %} + +!!! info "Example: Build Outputs" + In the example image above, a single output (serial number 2) has been completed, while serial numbers 1 and 4 are still in progress. + +- Build outputs can be created from this screen, by selecting the *Create New Output* button +- Outputs which are "in progress" can be completed or cancelled +- Completed outputs (which are simply *stock items*) can be viewed in the stock table at the bottom of the screen + +### Completed Outputs + +This panel displays all the completed build outputs (stock items) which have been created by this build order: ### Allocated Stock @@ -138,28 +157,29 @@ The *Consumed Stock* tab displays all stock items which have been *consumed* by - [Tracked stock items](./allocate.md#tracked-stock) are consumed by specific build outputs - [Untracked stock items](./allocate.md#untracked-stock) are consumed by the build order -### Build Outputs - -The *Build Outputs* tab shows the [build outputs](./output.md) (created stock items) associated with this build. - -As shown below, there are separate panels for *incomplete* and *completed* build outputs. - -{% with id="build_outputs", url="build/build_outputs.png", description="Outputs tab" %} -{% include "img.html" %} -{% endwith %} - -!!! info "Example: Build Outputs" - In the example image above, a single output (serial number 2) has been completed, while serial numbers 1 and 4 are still in progress. - -- Build outputs can be created from this screen, by selecting the *Create New Output* button -- Outputs which are "in progress" can be completed or cancelled -- Completed outputs (which are simply *stock items*) can be viewed in the stock table at the bottom of the screen - ### Child Builds If there exist any build orders which are *children* of the selected build order, they are displayed in the *Child Builds* tab: -{% with id="build_childs", url="build/build_childs.png", description="Child builds tab" %} +{% with id="build_childs", url="build/build_childs.png", description="Child builds panel" %} +{% include "img.html" %} +{% endwith %} + +### Test Results + +For *trackable* parts, test results can be recorded against each build output. These results are displayed in the *Test Results* panel: + +{% with id="build_test_results", url="build/build_panel_test_results.png", description="Test Results panel" %} +{% include "img.html" %} +{% endwith %} + +This table provides a summary of the test results for each build output, and allows test results to be quickly added for each build output. + +### Test Statistics + +For *trackable* parts, this panel displays a summary of the test results for all build outputs: + +{% with id="build_test_stats", url="build/build_panel_test_statistics.png", description="Test Statistics panel" %} {% include "img.html" %} {% endwith %} diff --git a/docs/docs/start/config.md b/docs/docs/start/config.md index e038c78753..5653b8a659 100644 --- a/docs/docs/start/config.md +++ b/docs/docs/start/config.md @@ -54,6 +54,8 @@ The following basic options are available: | --- | --- | --- | --- | | INVENTREE_SITE_URL | site_url | Specify a fixed site URL | *Not specified* | | INVENTREE_DEBUG | debug | Enable [debug mode](./intro.md#debug-mode) | True | +| INVENTREE_DEBUG_QUERYCOUNT | debug_querycount | Enable [query count logging](https://github.com/bradmontgomery/django-querycount) in the terminal | False | +| INVENTREE_DEBUG_SHELL | debug_shell | Enable [administrator shell](https://github.com/djk2/django-admin-shell) (only in debug mode) | False | | INVENTREE_LOG_LEVEL | log_level | Set level of logging to terminal | WARNING | | INVENTREE_DB_LOGGING | db_logging | Enable logging of database messages | False | | INVENTREE_TIMEZONE | timezone | Server timezone | UTC | diff --git a/docs/main.py b/docs/main.py index 4dcac9fcf8..11066c4057 100644 --- a/docs/main.py +++ b/docs/main.py @@ -2,6 +2,7 @@ import os import subprocess +import textwrap import requests import yaml diff --git a/pyproject.toml b/pyproject.toml index 421a7fe37f..0946691589 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,4 +87,4 @@ known_django="django" sections=["FUTURE","STDLIB","DJANGO","THIRDPARTY","FIRSTPARTY","LOCALFOLDER"] [tool.codespell] -ignore-words-list = ["assertIn","SME"] +ignore-words-list = ["assertIn","SME","intoto"] diff --git a/src/backend/InvenTree/build/api.py b/src/backend/InvenTree/build/api.py index e21c60037d..5aacea397d 100644 --- a/src/backend/InvenTree/build/api.py +++ b/src/backend/InvenTree/build/api.py @@ -359,6 +359,8 @@ class BuildLineList(BuildLineEndpoint, DataExportViewMixin, ListCreateAPI): 'unit_quantity', 'available_stock', 'trackable', + 'allow_variants', + 'inherited', ] ordering_field_aliases = { @@ -368,6 +370,8 @@ class BuildLineList(BuildLineEndpoint, DataExportViewMixin, ListCreateAPI): 'consumable': 'bom_item__consumable', 'optional': 'bom_item__optional', 'trackable': 'bom_item__sub_part__trackable', + 'allow_variants': 'bom_item__allow_variants', + 'inherited': 'bom_item__inherited', } search_fields = [ diff --git a/src/backend/InvenTree/part/filters.py b/src/backend/InvenTree/part/filters.py index 0b9fafb983..2a35b80714 100644 --- a/src/backend/InvenTree/part/filters.py +++ b/src/backend/InvenTree/part/filters.py @@ -296,15 +296,12 @@ def annotate_default_location(reference=''): rght__gt=OuterRef(f'{reference}rght'), level__lte=OuterRef(f'{reference}level'), parent__isnull=False, - ) + default_location__isnull=False, + ).order_by('-level') return Coalesce( F(f'{reference}default_location'), - Subquery( - subquery.order_by('-level') - .filter(default_location__isnull=False) - .values('default_location') - ), + Subquery(subquery.values('default_location')[:1]), Value(None), output_field=IntegerField(), ) diff --git a/src/backend/InvenTree/part/test_api.py b/src/backend/InvenTree/part/test_api.py index 3d4e4909d9..85af447556 100644 --- a/src/backend/InvenTree/part/test_api.py +++ b/src/backend/InvenTree/part/test_api.py @@ -505,6 +505,82 @@ class PartCategoryAPITest(InvenTreeAPITestCase): self.assertEqual(item['parent'], parent) self.assertEqual(item['subcategories'], subcategories) + def test_part_category_default_location(self): + """Test default location propagation through location trees.""" + """Making a tree structure like this: + main + loc 2 + sub1 + sub2 + loc 3 + sub3 + loc 4 + sub4 + sub5 + Expected behaviour: + main parent loc: Out of test scope. Parent category data not controlled by the test + sub1 parent loc: loc 2 + sub2 parent loc: loc 2 + sub3 parent loc: loc 3 + sub4 parent loc: loc 4 + sub5 parent loc: loc 3 + """ + main = PartCategory.objects.create( + name='main', + parent=PartCategory.objects.first(), + default_location=StockLocation.objects.get(id=2), + ) + sub1 = PartCategory.objects.create(name='sub1', parent=main) + sub2 = PartCategory.objects.create( + name='sub2', parent=sub1, default_location=StockLocation.objects.get(id=3) + ) + sub3 = PartCategory.objects.create( + name='sub3', parent=sub2, default_location=StockLocation.objects.get(id=4) + ) + sub4 = PartCategory.objects.create(name='sub4', parent=sub3) + sub5 = PartCategory.objects.create(name='sub5', parent=sub2) + part = Part.objects.create(name='test', category=sub4) + PartCategory.objects.rebuild() + + # This query will trigger an internal server error if annotation results are not limited to 1 + url = reverse('api-part-list') + response = self.get(url, expected_code=200) + + # sub1, expect main to be propagated + url = reverse('api-part-category-detail', kwargs={'pk': sub1.pk}) + response = self.get(url, expected_code=200) + self.assertEqual( + response.data['parent_default_location'], main.default_location.pk + ) + + # sub2, expect main to be propagated + url = reverse('api-part-category-detail', kwargs={'pk': sub2.pk}) + response = self.get(url, expected_code=200) + self.assertEqual( + response.data['parent_default_location'], main.default_location.pk + ) + + # sub3, expect sub2 to be propagated + url = reverse('api-part-category-detail', kwargs={'pk': sub3.pk}) + response = self.get(url, expected_code=200) + self.assertEqual( + response.data['parent_default_location'], sub2.default_location.pk + ) + + # sub4, expect sub3 to be propagated + url = reverse('api-part-category-detail', kwargs={'pk': sub4.pk}) + response = self.get(url, expected_code=200) + self.assertEqual( + response.data['parent_default_location'], sub3.default_location.pk + ) + + # sub5, expect sub2 to be propagated + url = reverse('api-part-category-detail', kwargs={'pk': sub5.pk}) + response = self.get(url, expected_code=200) + self.assertEqual( + response.data['parent_default_location'], sub2.default_location.pk + ) + class PartOptionsAPITest(InvenTreeAPITestCase): """Tests for the various OPTIONS endpoints in the /part/ API. diff --git a/src/frontend/src/components/details/Details.tsx b/src/frontend/src/components/details/Details.tsx index 2d66ad7069..f58136d628 100644 --- a/src/frontend/src/components/details/Details.tsx +++ b/src/frontend/src/components/details/Details.tsx @@ -122,11 +122,11 @@ function NameBadge({ pk, type }: { pk: string | number; type: BadgeType }) { case 200: return response.data; default: - return undefined; + return {}; } }) .catch(() => { - return undefined; + return {}; }); } }); diff --git a/src/frontend/src/components/forms/ApiForm.tsx b/src/frontend/src/components/forms/ApiForm.tsx index 67159e6398..609b7b2b57 100644 --- a/src/frontend/src/components/forms/ApiForm.tsx +++ b/src/frontend/src/components/forms/ApiForm.tsx @@ -398,7 +398,7 @@ export function ApiForm({ let value: any = data[key]; let field_type = fields[key]?.field_type; - if (field_type == 'file upload') { + if (field_type == 'file upload' && !!value) { hasFiles = true; } @@ -413,7 +413,9 @@ export function ApiForm({ } } - dataForm.append(key, value); + if (value != undefined) { + dataForm.append(key, value); + } }); return api({ diff --git a/src/frontend/src/components/images/Thumbnail.tsx b/src/frontend/src/components/images/Thumbnail.tsx index 2b63b0f16a..63e2414122 100644 --- a/src/frontend/src/components/images/Thumbnail.tsx +++ b/src/frontend/src/components/images/Thumbnail.tsx @@ -50,49 +50,3 @@ export function Thumbnail({ ); } - -export function ThumbnailHoverCard({ - src, - text, - link = '', - alt = t`Thumbnail`, - size = 20 -}: { - src: string; - text: string; - link?: string; - alt?: string; - size?: number; -}) { - const card = useMemo(() => { - return ( - - - {text} - - ); - }, [src, text, alt, size]); - - if (link) { - return ( - - {card} - - ); - } - - return
{card}
; -} - -export function PartHoverCard({ part }: { part: any }) { - return part ? ( - - ) : ( - - ); -} diff --git a/src/frontend/src/forms/SalesOrderForms.tsx b/src/frontend/src/forms/SalesOrderForms.tsx index 9c97f13201..9c11eb5c50 100644 --- a/src/frontend/src/forms/SalesOrderForms.tsx +++ b/src/frontend/src/forms/SalesOrderForms.tsx @@ -47,6 +47,43 @@ export function useSalesOrderFields(): ApiFormFieldSet { }, []); } +export function useSalesOrderLineItemFields({ + customerId, + orderId, + create +}: { + customerId?: number; + orderId?: number; + create?: boolean; +}): ApiFormFieldSet { + const fields = useMemo(() => { + return { + order: { + filters: { + customer_detail: true + }, + disabled: true, + value: create ? orderId : undefined + }, + part: { + filters: { + active: true, + salable: true + } + }, + reference: {}, + quantity: {}, + sale_price: {}, + sale_price_currency: {}, + target_date: {}, + notes: {}, + link: {} + }; + }, []); + + return fields; +} + export function useReturnOrderFields(): ApiFormFieldSet { return useMemo(() => { return { diff --git a/src/frontend/src/forms/StockForms.tsx b/src/frontend/src/forms/StockForms.tsx index fb44f68917..8688680579 100644 --- a/src/frontend/src/forms/StockForms.tsx +++ b/src/frontend/src/forms/StockForms.tsx @@ -28,6 +28,7 @@ import { useSerialNumberGenerator } from '../hooks/UseGenerator'; import { apiUrl } from '../states/ApiState'; +import { useGlobalSettingsState } from '../states/SettingsState'; /** * Construct a set of fields for creating / editing a StockItem instance @@ -932,6 +933,13 @@ export function useTestResultFields({ // Field type for the "value" input const [fieldType, setFieldType] = useState<'string' | 'choice'>('string'); + const settings = useGlobalSettingsState.getState(); + + const includeTestStation = useMemo( + () => settings.isSet('TEST_STATION_DATA'), + [settings] + ); + return useMemo(() => { return { stock_item: { @@ -972,8 +980,15 @@ export function useTestResultFields({ }, attachment: {}, notes: {}, - started_datetime: {}, - finished_datetime: {} + started_datetime: { + hidden: !includeTestStation + }, + finished_datetime: { + hidden: !includeTestStation + }, + test_station: { + hidden: !includeTestStation + } }; - }, [choices, fieldType, partId, itemId]); + }, [choices, fieldType, partId, itemId, includeTestStation]); } diff --git a/src/frontend/src/pages/build/BuildDetail.tsx b/src/frontend/src/pages/build/BuildDetail.tsx index ea10ca0baf..550d795dcb 100644 --- a/src/frontend/src/pages/build/BuildDetail.tsx +++ b/src/frontend/src/pages/build/BuildDetail.tsx @@ -1,6 +1,7 @@ import { t } from '@lingui/macro'; import { Grid, Skeleton, Stack } from '@mantine/core'; import { + IconChecklist, IconClipboardCheck, IconClipboardList, IconDots, @@ -51,6 +52,7 @@ import { useUserState } from '../../states/UserState'; import BuildAllocatedStockTable from '../../tables/build/BuildAllocatedStockTable'; import BuildLineTable from '../../tables/build/BuildLineTable'; import { BuildOrderTable } from '../../tables/build/BuildOrderTable'; +import BuildOrderTestTable from '../../tables/build/BuildOrderTestTable'; import BuildOutputTable from '../../tables/build/BuildOutputTable'; import { AttachmentTable } from '../../tables/general/AttachmentTable'; import { StockItemTable } from '../../tables/stock/StockItemTable'; @@ -307,6 +309,17 @@ export default function BuildDetail() { ) }, + { + name: 'test-results', + label: t`Test Results`, + icon: , + hidden: !build.part_detail?.trackable, + content: build.pk ? ( + + ) : ( + + ) + }, { name: 'test-statistics', label: t`Test Statistics`, diff --git a/src/frontend/src/pages/sales/SalesOrderDetail.tsx b/src/frontend/src/pages/sales/SalesOrderDetail.tsx index 967749a5c4..5fb6c861c6 100644 --- a/src/frontend/src/pages/sales/SalesOrderDetail.tsx +++ b/src/frontend/src/pages/sales/SalesOrderDetail.tsx @@ -47,6 +47,7 @@ import { useInstance } from '../../hooks/UseInstance'; import { useUserState } from '../../states/UserState'; import { BuildOrderTable } from '../../tables/build/BuildOrderTable'; import { AttachmentTable } from '../../tables/general/AttachmentTable'; +import SalesOrderLineItemTable from '../../tables/sales/SalesOrderLineItemTable'; /** * Detail page for a single SalesOrder @@ -249,7 +250,12 @@ export default function SalesOrderDetail() { name: 'line-items', label: t`Line Items`, icon: , - content: + content: ( + + ) }, { name: 'pending-shipments', diff --git a/src/frontend/src/tables/InvenTreeTable.tsx b/src/frontend/src/tables/InvenTreeTable.tsx index a33cb4da01..9b113960c4 100644 --- a/src/frontend/src/tables/InvenTreeTable.tsx +++ b/src/frontend/src/tables/InvenTreeTable.tsx @@ -281,7 +281,7 @@ export function InvenTreeTable({ // If row actions are available, add a column for them if (tableProps.rowActions) { cols.push({ - accessor: 'actions', + accessor: '--actions--', title: ' ', hidden: false, switchable: false, @@ -540,19 +540,29 @@ export function InvenTreeTable({ } }); - // Callback when a row is clicked - const handleRowClick = useCallback( + // Callback when a cell is clicked + const handleCellClick = useCallback( ({ event, record, - index + index, + column, + columnIndex }: { event: React.MouseEvent; record: any; index: number; + column: any; + columnIndex: number; }) => { - if (props.onRowClick) { - // If a custom row click handler is provided, use that + // Ignore any click on the 'actions' column + if (column.accessor == '--actions--') { + return; + } + + if (props.onCellClick) { + props.onCellClick({ event, record, index, column, columnIndex }); + } else if (props.onRowClick) { props.onRowClick(record, index, event); } else if (tableProps.modelType) { const accessor = tableProps.modelField ?? 'pk'; @@ -566,7 +576,7 @@ export function InvenTreeTable({ } } }, - [props.onRowClick] + [props.onRowClick, props.onCellClick] ); return ( @@ -705,8 +715,7 @@ export function InvenTreeTable({ noRecordsText={missingRecordsText} records={tableState.records} columns={dataColumns} - onRowClick={handleRowClick} - onCellClick={tableProps.onCellClick} + onCellClick={handleCellClick} defaultColumnProps={{ noWrap: true, textAlign: 'left', diff --git a/src/frontend/src/tables/bom/UsedInTable.tsx b/src/frontend/src/tables/bom/UsedInTable.tsx index f9227f92e2..bd0e7d8e14 100644 --- a/src/frontend/src/tables/bom/UsedInTable.tsx +++ b/src/frontend/src/tables/bom/UsedInTable.tsx @@ -2,14 +2,13 @@ import { t } from '@lingui/macro'; import { Group, Text } from '@mantine/core'; import { useMemo } from 'react'; -import { PartHoverCard } from '../../components/images/Thumbnail'; import { formatDecimal } from '../../defaults/formatters'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ModelType } from '../../enums/ModelType'; import { useTable } from '../../hooks/UseTable'; import { apiUrl } from '../../states/ApiState'; import { TableColumn } from '../Column'; -import { ReferenceColumn } from '../ColumnRenderers'; +import { PartColumn, ReferenceColumn } from '../ColumnRenderers'; import { TableFilter } from '../Filter'; import { InvenTreeTable } from '../InvenTreeTable'; @@ -31,12 +30,14 @@ export function UsedInTable({ accessor: 'part', switchable: false, sortable: true, - render: (record: any) => + title: t`Assembly`, + render: (record: any) => PartColumn(record.part_detail) }, { accessor: 'sub_part', sortable: true, - render: (record: any) => + title: t`Component`, + render: (record: any) => PartColumn(record.sub_part_detail) }, { accessor: 'quantity', diff --git a/src/frontend/src/tables/build/BuildLineTable.tsx b/src/frontend/src/tables/build/BuildLineTable.tsx index 90ee524b51..5d0b6c90ac 100644 --- a/src/frontend/src/tables/build/BuildLineTable.tsx +++ b/src/frontend/src/tables/build/BuildLineTable.tsx @@ -7,7 +7,6 @@ import { } from '@tabler/icons-react'; import { useCallback, useMemo } from 'react'; -import { PartHoverCard } from '../../components/images/Thumbnail'; import { ProgressBar } from '../../components/items/ProgressBar'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ModelType } from '../../enums/ModelType'; @@ -15,7 +14,7 @@ import { useTable } from '../../hooks/UseTable'; import { apiUrl } from '../../states/ApiState'; import { useUserState } from '../../states/UserState'; import { TableColumn } from '../Column'; -import { BooleanColumn } from '../ColumnRenderers'; +import { BooleanColumn, PartColumn } from '../ColumnRenderers'; import { TableFilter } from '../Filter'; import { InvenTreeTable } from '../InvenTreeTable'; import { TableHoverCard } from '../TableHoverCard'; @@ -131,7 +130,7 @@ export default function BuildLineTable({ params = {} }: { params?: any }) { ordering: 'part', sortable: true, switchable: false, - render: (record: any) => + render: (record: any) => PartColumn(record.part_detail) }, { accessor: 'bom_item_detail.reference', diff --git a/src/frontend/src/tables/build/BuildOrderTable.tsx b/src/frontend/src/tables/build/BuildOrderTable.tsx index 868840f551..18ff6f4f05 100644 --- a/src/frontend/src/tables/build/BuildOrderTable.tsx +++ b/src/frontend/src/tables/build/BuildOrderTable.tsx @@ -2,7 +2,6 @@ import { t } from '@lingui/macro'; import { useMemo } from 'react'; import { AddItemButton } from '../../components/buttons/AddItemButton'; -import { PartHoverCard } from '../../components/images/Thumbnail'; import { ProgressBar } from '../../components/items/ProgressBar'; import { RenderUser } from '../../components/render/User'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; @@ -22,6 +21,7 @@ import { TableColumn } from '../Column'; import { CreationDateColumn, DateColumn, + PartColumn, ProjectCodeColumn, ReferenceColumn, ResponsibleColumn, @@ -41,7 +41,7 @@ function buildOrderTableColumns(): TableColumn[] { accessor: 'part', sortable: true, switchable: false, - render: (record: any) => + render: (record: any) => PartColumn(record.part_detail) }, { accessor: 'title', diff --git a/src/frontend/src/tables/build/BuildOrderTestTable.tsx b/src/frontend/src/tables/build/BuildOrderTestTable.tsx new file mode 100644 index 0000000000..371e7530a6 --- /dev/null +++ b/src/frontend/src/tables/build/BuildOrderTestTable.tsx @@ -0,0 +1,256 @@ +import { t } from '@lingui/macro'; +import { ActionIcon, Badge, Group, Text, Tooltip } from '@mantine/core'; +import { IconCirclePlus } from '@tabler/icons-react'; +import { useQuery } from '@tanstack/react-query'; +import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; + +import { api } from '../../App'; +import { PassFailButton } from '../../components/buttons/YesNoButton'; +import { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField'; +import { RenderUser } from '../../components/render/User'; +import { formatDate } from '../../defaults/formatters'; +import { ApiEndpoints } from '../../enums/ApiEndpoints'; +import { useTestResultFields } from '../../forms/StockForms'; +import { useCreateApiFormModal } from '../../hooks/UseForm'; +import { useTable } from '../../hooks/UseTable'; +import { apiUrl } from '../../states/ApiState'; +import { useUserState } from '../../states/UserState'; +import { TableColumn } from '../Column'; +import { LocationColumn } from '../ColumnRenderers'; +import { TableFilter } from '../Filter'; +import { InvenTreeTable } from '../InvenTreeTable'; +import { TableHoverCard } from '../TableHoverCard'; + +/** + * A table which displays all "test results" for the outputs generated by a build order. + */ +export default function BuildOrderTestTable({ + buildId, + partId +}: { + buildId: number; + partId: number; +}) { + const table = useTable('build-tests'); + const user = useUserState(); + + // Fetch the test templates required for this build order + const { data: testTemplates } = useQuery({ + queryKey: ['build-test-templates', partId, buildId], + queryFn: async () => { + if (!partId) { + return []; + } + + return api + .get(apiUrl(ApiEndpoints.part_test_template_list), { + params: { + part: partId, + include_inherited: true, + enabled: true, + required: true + } + }) + .then((res) => res.data) + .catch((err) => []); + } + }); + + // Reload the table data whenever the set of templates changes + useEffect(() => { + table.refreshTable(); + }, [testTemplates]); + + const [selectedOutput, setSelectedOutput] = useState(0); + const [selectedTemplate, setSelectedTemplate] = useState(0); + + const testResultFields: ApiFormFieldSet = useTestResultFields({ + partId: partId, + itemId: selectedOutput + }); + + const createTestResult = useCreateApiFormModal({ + url: apiUrl(ApiEndpoints.stock_test_result_list), + title: t`Add Test Result`, + fields: testResultFields, + initialData: { + template: selectedTemplate, + result: true + }, + onFormSuccess: () => table.refreshTable(), + successMessage: t`Test result added` + }); + + // Generate a table column for each test template + const testColumns: TableColumn[] = useMemo(() => { + if (!testTemplates || testTemplates.length == 0) { + return []; + } + + return testTemplates.map((template: any) => { + return { + accessor: `test_${template.pk}`, + title: template.test_name, + sortable: false, + switchable: true, + render: (record: any) => { + let tests = record.tests || []; + + // Find the most recent test result (highest primary key) + let test = tests + .filter((test: any) => test.template == template.pk) + .sort((a: any, b: any) => b.pk - a.pk) + .shift(); + + // No test result recorded + if (!test || test.result === undefined) { + return ( + + {t`No Result`} + + { + setSelectedOutput(record.pk); + setSelectedTemplate(template.pk); + createTestResult.open(); + }} + > + + + + + ); + } + + let extra: ReactNode[] = []; + + if (test.value) { + extra.push( + + {t`Value`}: {test.value} + + ); + } + + if (test.notes) { + extra.push( + + {t`Notes`}: {test.notes} + + ); + } + + if (test.date) { + extra.push( + + {t`Date`}: {formatDate(test.date)} + + ); + } + + if (test.user_detail) { + extra.push(); + } + + return ( + } + title={template.test_name} + extra={extra} + /> + ); + } + }; + }); + }, [testTemplates]); + + const tableColumns: TableColumn[] = useMemo(() => { + // Fixed columns + let columns: TableColumn[] = [ + { + accessor: 'stock', + title: t`Build Output`, + sortable: true, + switchable: false, + render: (record: any) => { + if (record.serial) { + return `# ${record.serial}`; + } else { + let extra: ReactNode[] = []; + + if (record.batch) { + extra.push( + + {t`Batch Code`}: {record.batch} + + ); + } + + return ( + + {t`Quantity`}: {record.quantity} + + } + title={t`Build Output`} + extra={extra} + /> + ); + } + } + }, + LocationColumn({ + accessor: 'location_detail' + }) + ]; + + return [...columns, ...testColumns]; + }, [testColumns]); + + const tableFilters: TableFilter[] = useMemo(() => { + return [ + { + name: 'is_building', + label: t`In Production`, + description: t`Show build outputs currently in production` + } + ]; + }, []); + + const tableActions = useMemo(() => { + return []; + }, []); + + const rowActions = useCallback( + (record: any) => { + return []; + }, + [user] + ); + + return ( + <> + {createTestResult.modal} + + + ); +} diff --git a/src/frontend/src/tables/build/BuildOutputTable.tsx b/src/frontend/src/tables/build/BuildOutputTable.tsx index b98d7a4dec..b1c65ddb75 100644 --- a/src/frontend/src/tables/build/BuildOutputTable.tsx +++ b/src/frontend/src/tables/build/BuildOutputTable.tsx @@ -47,9 +47,9 @@ export default function BuildOutputTable({ build }: { build: any }) { // Fetch the test templates associated with the partId const { data: testTemplates } = useQuery({ - queryKey: ['buildoutputtests', build.part], + queryKey: ['buildoutputtests', partId], queryFn: async () => { - if (!partId) { + if (!partId || partId < 0) { return []; } @@ -322,7 +322,7 @@ export default function BuildOutputTable({ build }: { build: any }) { } } ]; - }, [buildId, partId]); + }, [buildId, partId, testTemplates]); return ( <> diff --git a/src/frontend/src/tables/purchasing/PurchaseOrderLineItemTable.tsx b/src/frontend/src/tables/purchasing/PurchaseOrderLineItemTable.tsx index 5166469637..5d10c1484c 100644 --- a/src/frontend/src/tables/purchasing/PurchaseOrderLineItemTable.tsx +++ b/src/frontend/src/tables/purchasing/PurchaseOrderLineItemTable.tsx @@ -5,7 +5,6 @@ import { useCallback, useMemo, useState } from 'react'; import { ActionButton } from '../../components/buttons/ActionButton'; import { AddItemButton } from '../../components/buttons/AddItemButton'; -import { Thumbnail } from '../../components/images/Thumbnail'; import ImporterDrawer from '../../components/importer/ImporterDrawer'; import { ProgressBar } from '../../components/items/ProgressBar'; import { RenderStockLocation } from '../../components/render/Stock'; @@ -30,6 +29,7 @@ import { CurrencyColumn, LinkColumn, NoteColumn, + PartColumn, ReferenceColumn, TargetDateColumn, TotalPriceColumn @@ -124,14 +124,7 @@ export function PurchaseOrderLineItemTable({ title: t`Internal Part`, sortable: true, switchable: false, - render: (record: any) => { - return ( - - ); - } + render: (record: any) => PartColumn(record.part_detail) }, { accessor: 'description', diff --git a/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx b/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx new file mode 100644 index 0000000000..957a32cdb2 --- /dev/null +++ b/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx @@ -0,0 +1,272 @@ +import { t } from '@lingui/macro'; +import { Text } from '@mantine/core'; +import { IconSquareArrowRight } from '@tabler/icons-react'; +import { ReactNode, useCallback, useMemo, useState } from 'react'; + +import { AddItemButton } from '../../components/buttons/AddItemButton'; +import { ProgressBar } from '../../components/items/ProgressBar'; +import { formatCurrency } from '../../defaults/formatters'; +import { ApiEndpoints } from '../../enums/ApiEndpoints'; +import { ModelType } from '../../enums/ModelType'; +import { UserRoles } from '../../enums/Roles'; +import { useSalesOrderLineItemFields } from '../../forms/SalesOrderForms'; +import { + useCreateApiFormModal, + useDeleteApiFormModal, + useEditApiFormModal +} from '../../hooks/UseForm'; +import { useTable } from '../../hooks/UseTable'; +import { apiUrl } from '../../states/ApiState'; +import { useUserState } from '../../states/UserState'; +import { TableColumn } from '../Column'; +import { DateColumn, LinkColumn, PartColumn } from '../ColumnRenderers'; +import { InvenTreeTable } from '../InvenTreeTable'; +import { + RowDeleteAction, + RowDuplicateAction, + RowEditAction +} from '../RowActions'; +import { TableHoverCard } from '../TableHoverCard'; + +export default function SalesOrderLineItemTable({ + orderId, + customerId +}: { + orderId: number; + customerId: number; +}) { + const user = useUserState(); + const table = useTable('sales-order-line-item'); + + const tableColumns: TableColumn[] = useMemo(() => { + return [ + { + accessor: 'part', + sortable: true, + switchable: false, + render: (record: any) => PartColumn(record?.part_detail) + }, + { + accessor: 'part_detail.IPN', + title: t`IPN`, + switchable: true + }, + { + accessor: 'part_detail.description', + title: t`Description`, + sortable: false, + switchable: true + }, + { + accessor: 'reference', + sortable: false, + switchable: true + }, + { + accessor: 'quantity', + sortable: true + }, + { + accessor: 'sale_price', + render: (record: any) => + formatCurrency(record.sale_price, { + currency: record.sale_price_currency + }) + }, + { + accessor: 'total_price', + title: t`Total Price`, + render: (record: any) => + formatCurrency(record.sale_price, { + currency: record.sale_price_currency, + multiplier: record.quantity + }) + }, + DateColumn({ + accessor: 'target_date', + sortable: true, + title: t`Target Date` + }), + { + accessor: 'stock', + title: t`Available Stock`, + render: (record: any) => { + let part_stock = record?.available_stock ?? 0; + let variant_stock = record?.available_variant_stock ?? 0; + let available = part_stock + variant_stock; + + let required = Math.max( + record.quantity - record.allocated - record.shipped, + 0 + ); + + let color: string | undefined = undefined; + let text: string = `${available}`; + + let extra: ReactNode[] = []; + + if (available <= 0) { + color = 'red'; + text = t`No stock available`; + } else if (available < required) { + color = 'orange'; + } + + if (variant_stock > 0) { + extra.push({t`Includes variant stock`}); + } + + return ( + {text}} + extra={extra} + title={t`Stock Information`} + /> + ); + } + }, + { + accessor: 'allocated', + render: (record: any) => ( + + ) + }, + { + accessor: 'shipped', + render: (record: any) => ( + + ) + }, + { + accessor: 'notes' + }, + LinkColumn({ + accessor: 'link' + }) + ]; + }, []); + + const [selectedLine, setSelectedLine] = useState(0); + + const [initialData, setInitialData] = useState({}); + + const createLineFields = useSalesOrderLineItemFields({ + orderId: orderId, + customerId: customerId, + create: true + }); + + const newLine = useCreateApiFormModal({ + url: ApiEndpoints.sales_order_line_list, + title: t`Add Line Item`, + fields: createLineFields, + initialData: initialData, + table: table + }); + + const editLineFields = useSalesOrderLineItemFields({ + orderId: orderId, + customerId: customerId, + create: false + }); + + const editLine = useEditApiFormModal({ + url: ApiEndpoints.sales_order_line_list, + pk: selectedLine, + title: t`Edit Line Item`, + fields: editLineFields, + table: table + }); + + const deleteLine = useDeleteApiFormModal({ + url: ApiEndpoints.sales_order_line_list, + pk: selectedLine, + title: t`Delete Line Item`, + table: table + }); + + const tableActions = useMemo(() => { + return [ + { + setInitialData({ + order: orderId + }); + newLine.open(); + }} + hidden={!user.hasAddRole(UserRoles.sales_order)} + /> + ]; + }, [user]); + + const rowActions = useCallback( + (record: any) => { + const allocated = (record?.allocated ?? 0) > (record?.quantity ?? 0); + + return [ + { + hidden: allocated || !user.hasChangeRole(UserRoles.sales_order), + title: t`Allocate stock`, + icon: , + color: 'green' + }, + RowEditAction({ + hidden: !user.hasChangeRole(UserRoles.sales_order), + onClick: () => { + setSelectedLine(record.pk); + editLine.open(); + } + }), + RowDuplicateAction({ + hidden: !user.hasAddRole(UserRoles.sales_order), + onClick: () => { + setInitialData(record); + newLine.open(); + } + }), + RowDeleteAction({ + hidden: !user.hasDeleteRole(UserRoles.sales_order), + onClick: () => { + setSelectedLine(record.pk); + deleteLine.open(); + } + }) + ]; + }, + [user] + ); + + return ( + <> + {editLine.modal} + {deleteLine.modal} + {newLine.modal} + + + ); +} diff --git a/src/frontend/src/tables/settings/TemplateTable.tsx b/src/frontend/src/tables/settings/TemplateTable.tsx index d0d0e36592..37e25ad526 100644 --- a/src/frontend/src/tables/settings/TemplateTable.tsx +++ b/src/frontend/src/tables/settings/TemplateTable.tsx @@ -11,6 +11,7 @@ import { TemplateEditor } from '../../components/editors/TemplateEditor'; import { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField'; +import { AttachmentLink } from '../../components/items/AttachmentLink'; import { DetailDrawer } from '../../components/nav/DetailDrawer'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ModelType } from '../../enums/ModelType'; @@ -134,7 +135,11 @@ export function TemplateTable({ sortable: false, switchable: true, render: (record: any) => { - return record.template?.split('/')?.pop() ?? '-'; + if (!record.template) { + return '-'; + } + + return ; } }, { diff --git a/src/frontend/tests/pages/pui_build.spec.ts b/src/frontend/tests/pages/pui_build.spec.ts index 93df2ca397..e7c7a3275b 100644 --- a/src/frontend/tests/pages/pui_build.spec.ts +++ b/src/frontend/tests/pages/pui_build.spec.ts @@ -24,6 +24,16 @@ test('PUI - Pages - Build Order', async ({ page }) => { .getByRole('cell', { name: 'R38, R39, R40, R41, R42, R43' }) .waitFor(); + // Check "test results" + await page.getByRole('tab', { name: 'Test Results' }).click(); + await page.getByText('Quantity: 25').waitFor(); + await page.getByText('Continuity Checks').waitFor(); + await page + .getByRole('row', { name: 'Quantity: 16 No location set' }) + .getByRole('button') + .hover(); + await page.getByText('Add Test Result').waitFor(); + // Click through to the "parent" build await page.getByRole('tab', { name: 'Build Details' }).click(); await page.getByRole('link', { name: 'BO0010' }).click();