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();