From 0470dc34b70317dce00bd3c9f9aa3fe6c988fe48 Mon Sep 17 00:00:00 2001 From: suzunn Date: Mon, 29 Jun 2026 17:24:00 +0300 Subject: [PATCH 1/8] fix: preserve supplier import stock workflow (#12215) Co-authored-by: Oliver --- src/frontend/src/components/forms/ApiForm.tsx | 10 ++++++++-- src/frontend/src/tables/stock/StockItemTable.tsx | 10 +--------- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/frontend/src/components/forms/ApiForm.tsx b/src/frontend/src/components/forms/ApiForm.tsx index df6c56e5ad..86c5144ad7 100644 --- a/src/frontend/src/components/forms/ApiForm.tsx +++ b/src/frontend/src/components/forms/ApiForm.tsx @@ -455,6 +455,12 @@ export function ApiForm({ } }) .then((response) => { + const followPk = Array.isArray(response.data) + ? response.data.length === 1 + ? response.data[0]?.pk + : undefined + : response.data?.pk; + switch (response.status) { case 200: case 201: @@ -469,12 +475,12 @@ export function ApiForm({ if ( props.follow && props.modelType && - response.data?.pk && + followPk && !keepOpenRef.current ) { // If we want to automatically follow the returned data if (!!navigate && !keepOpenRef.current) { - navigate(getDetailUrl(props.modelType, response.data?.pk)); + navigate(getDetailUrl(props.modelType, followPk)); } } else if (props.table) { // If we want to automatically update or reload a linked table diff --git a/src/frontend/src/tables/stock/StockItemTable.tsx b/src/frontend/src/tables/stock/StockItemTable.tsx index 3191ce97ed..45b3d1f639 100644 --- a/src/frontend/src/tables/stock/StockItemTable.tsx +++ b/src/frontend/src/tables/stock/StockItemTable.tsx @@ -1,6 +1,5 @@ import { t } from '@lingui/core/macro'; import { useMemo, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; import { ActionButton } from '@lib/components/ActionButton'; import { AddItemButton } from '@lib/components/AddItemButton'; @@ -8,7 +7,6 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ModelType } from '@lib/enums/ModelType'; import { UserRoles } from '@lib/enums/Roles'; import { apiUrl } from '@lib/functions/Api'; -import { getDetailUrl } from '@lib/functions/Navigation'; import useTable from '@lib/hooks/UseTable'; import type { TableFilter } from '@lib/types/Filters'; import type { StockOperationProps } from '@lib/types/Forms'; @@ -381,8 +379,6 @@ export function StockItemTable({ [settings] ); - const navigate = useNavigate(); - const tableColumns = useMemo( () => stockItemTableColumns({ @@ -433,11 +429,7 @@ export function StockItemTable({ }, follow: params.openNewStockItem ?? true, table: table, - onFormSuccess: (response: any) => { - // Returns a list that may contain multiple serialized stock items - // Navigate to the first result - navigate(getDetailUrl(ModelType.stockitem, response[0].pk)); - }, + modelType: ModelType.stockitem, successMessage: t`Stock item created`, keepOpenOption: true }); From f7e267cca81260ce83f3115555e97d99a82e52b9 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 30 Jun 2026 00:44:25 +1000 Subject: [PATCH 2/8] Fix dashboard issue (#12275) --- src/frontend/vite.config.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/frontend/vite.config.ts b/src/frontend/vite.config.ts index 2aa813204e..19175a62ba 100644 --- a/src/frontend/vite.config.ts +++ b/src/frontend/vite.config.ts @@ -90,7 +90,8 @@ export default defineConfig(({ command, mode }) => { } }, define: { - ...__INVENTREE_VERSION_INFO__ + ...__INVENTREE_VERSION_INFO__, + 'process.env.NODE_ENV': JSON.stringify(mode) } }; }); From d00681e20c17c8e23d2795e19b652a7dd44af207 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 08:26:27 +1000 Subject: [PATCH 3/8] chore(deps): bump the dependencies group with 5 updates (#12278) * chore(deps): bump the dependencies group with 5 updates Bumps the dependencies group with 5 updates: | Package | From | To | | --- | --- | --- | | [actions/checkout](https://github.com/actions/checkout) | `6.0.3` | `7.0.0` | | [oasdiff/oasdiff-action/diff](https://github.com/oasdiff/oasdiff-action) | `0.1.1` | `0.1.3` | | [CodSpeedHQ/action](https://github.com/codspeedhq/action) | `4.17.5` | `4.17.6` | | [zizmorcore/zizmor-action](https://github.com/zizmorcore/zizmor-action) | `0.5.6` | `0.5.7` | | [pkgr/action/publish](https://github.com/pkgr/action) | `3bce081ae512c5020856e237d37b3f5479d4aa71` | `c5666febcd31750da6428042193fc5b2fb765435` | Updates `actions/checkout` from 6.0.3 to 7.0.0 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/df4cb1c069e1874edd31b4311f1884172cec0e10...9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0) Updates `oasdiff/oasdiff-action/diff` from 0.1.1 to 0.1.3 - [Release notes](https://github.com/oasdiff/oasdiff-action/releases) - [Commits](https://github.com/oasdiff/oasdiff-action/compare/5fbe96ede8d0c53aeadef122d7a0abb79152d493...ccc2442df0d99f8c419ed73e3de88641c91b3bc6) Updates `CodSpeedHQ/action` from 4.17.5 to 4.17.6 - [Release notes](https://github.com/codspeedhq/action/releases) - [Changelog](https://github.com/CodSpeedHQ/action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codspeedhq/action/compare/c145068895e045cc725ee76fcd2307624b65c3af...63f3e98b61959fe67f146a3ff022e4136fe9bb9c) Updates `zizmorcore/zizmor-action` from 0.5.6 to 0.5.7 - [Release notes](https://github.com/zizmorcore/zizmor-action/releases) - [Commits](https://github.com/zizmorcore/zizmor-action/compare/5f14fd08f7cf1cb1609c1e344975f152c7ee938d...192e21d79ab29983730a13d1382995c2307fbcaa) Updates `pkgr/action/publish` from 3bce081ae512c5020856e237d37b3f5479d4aa71 to c5666febcd31750da6428042193fc5b2fb765435 - [Release notes](https://github.com/pkgr/action/releases) - [Commits](https://github.com/pkgr/action/compare/3bce081ae512c5020856e237d37b3f5479d4aa71...c5666febcd31750da6428042193fc5b2fb765435) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: dependencies - dependency-name: oasdiff/oasdiff-action/diff dependency-version: 0.1.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: dependencies - dependency-name: CodSpeedHQ/action dependency-version: 4.17.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: dependencies - dependency-name: zizmorcore/zizmor-action dependency-version: 0.5.7 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: dependencies - dependency-name: pkgr/action/publish dependency-version: c5666febcd31750da6428042193fc5b2fb765435 dependency-type: direct:production dependency-group: dependencies ... Signed-off-by: dependabot[bot] * Update checkout action version comment in workflow * fix pins * change pin * fix pin --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Matthias Mair --- .github/workflows/check_translations.yaml | 2 +- .github/workflows/docker.yaml | 8 ++--- .github/workflows/frontend.yaml | 10 +++---- .github/workflows/import_export.yaml | 4 +-- .github/workflows/qc_checks.yaml | 36 +++++++++++------------ .github/workflows/release.yaml | 16 +++++----- .github/workflows/scorecard.yaml | 2 +- .github/workflows/translations.yaml | 2 +- 8 files changed, 40 insertions(+), 40 deletions(-) diff --git a/.github/workflows/check_translations.yaml b/.github/workflows/check_translations.yaml index 37a31849bd..2ce87a8c1d 100644 --- a/.github/workflows/check_translations.yaml +++ b/.github/workflows/check_translations.yaml @@ -31,7 +31,7 @@ jobs: steps: - name: Checkout Code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index 42a292d3d5..561e861d57 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -39,7 +39,7 @@ jobs: docker: ${{ steps.filter.outputs.docker }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # pin@v4.0.1 @@ -67,7 +67,7 @@ jobs: steps: - name: Check out repo - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Test Docker Image @@ -139,7 +139,7 @@ jobs: steps: - name: Check out repo - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Run Migration Tests @@ -163,7 +163,7 @@ jobs: steps: - name: Check out repo - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Set Up Python ${{ env.python_version }} diff --git a/.github/workflows/frontend.yaml b/.github/workflows/frontend.yaml index 7065f6f054..51276ddf7f 100644 --- a/.github/workflows/frontend.yaml +++ b/.github/workflows/frontend.yaml @@ -46,7 +46,7 @@ jobs: force: ${{ steps.force.outputs.force }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # pin@v4.0.1 @@ -69,7 +69,7 @@ jobs: timeout-minutes: 60 steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Environment Setup @@ -123,7 +123,7 @@ jobs: VITE_COVERAGE: false steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Environment Setup @@ -205,7 +205,7 @@ jobs: VITE_COVERAGE: true steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Environment Setup @@ -273,7 +273,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false diff --git a/.github/workflows/import_export.yaml b/.github/workflows/import_export.yaml index 5d692a5059..e9a500b4aa 100644 --- a/.github/workflows/import_export.yaml +++ b/.github/workflows/import_export.yaml @@ -48,7 +48,7 @@ jobs: outputs: server: ${{ steps.filter.outputs.server }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # pin@v4.0.1 @@ -76,7 +76,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 persist-credentials: false diff --git a/.github/workflows/qc_checks.yaml b/.github/workflows/qc_checks.yaml index d616acde94..13ce079806 100644 --- a/.github/workflows/qc_checks.yaml +++ b/.github/workflows/qc_checks.yaml @@ -44,7 +44,7 @@ jobs: submit-performance: ${{ steps.runner-perf.outputs.submit-performance }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # pin@v4.0.1 @@ -108,7 +108,7 @@ jobs: if: needs.paths-filter.outputs.cicd == 'true' || needs.paths-filter.outputs.server == 'true' || needs.paths-filter.outputs.frontend == 'true' || needs.paths-filter.outputs.requirements == 'true' || needs.paths-filter.outputs.force == 'true' steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Set up Python ${{ env.python_version }} @@ -130,7 +130,7 @@ jobs: if: needs.paths-filter.outputs.server == 'true' || needs.paths-filter.outputs.requirements == 'true' || needs.paths-filter.outputs.force == 'true' steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Environment Setup @@ -152,7 +152,7 @@ jobs: steps: - name: Checkout Code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Set up Python ${{ env.python_version }} @@ -190,7 +190,7 @@ jobs: version: ${{ steps.version.outputs.version }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Environment Setup @@ -222,7 +222,7 @@ jobs: echo "Downloaded api.yaml" - name: Running OpenAPI Spec diff action id: breaking_changes - uses: oasdiff/oasdiff-action/diff@5fbe96ede8d0c53aeadef122d7a0abb79152d493 # pin@main + uses: oasdiff/oasdiff-action/diff@3e9d440d37f468355457604348009f50e0cddbf3 # v0.1.4 with: base: "api.yaml" revision: "src/backend/InvenTree/schema.yml" @@ -275,7 +275,7 @@ jobs: version: ${{ needs.schema.outputs.version }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 name: Checkout Code with: repository: inventree/schema @@ -332,7 +332,7 @@ jobs: node_version: '>=24' steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Environment Setup @@ -363,7 +363,7 @@ jobs: pip install . if: needs.paths-filter.outputs.submit-performance == 'true' - name: Performance Reporting - uses: CodSpeedHQ/action@c145068895e045cc725ee76fcd2307624b65c3af # pin@v4.17.5 + uses: CodSpeedHQ/action@63f3e98b61959fe67f146a3ff022e4136fe9bb9c # v4.17.6 # check if we are in inventree/inventree - reporting only works in that OIDC context if: github.repository == 'inventree/InvenTree' && needs.paths-filter.outputs.submit-performance == 'true' with: @@ -390,7 +390,7 @@ jobs: python_version: ${{ matrix.python_version }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Environment Setup @@ -441,7 +441,7 @@ jobs: INVENTREE_AUTO_UPDATE: true steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Environment Setup @@ -454,7 +454,7 @@ jobs: env: node_version: '>=24' - name: Performance Reporting - uses: CodSpeedHQ/action@c145068895e045cc725ee76fcd2307624b65c3af # pin@v4.17.5 + uses: CodSpeedHQ/action@63f3e98b61959fe67f146a3ff022e4136fe9bb9c # v4.17.6 with: mode: walltime run: inv dev.test --pytest @@ -492,7 +492,7 @@ jobs: - 6379:6379 steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Environment Setup @@ -541,7 +541,7 @@ jobs: - 3306:3306 steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Environment Setup @@ -584,7 +584,7 @@ jobs: - 5432:5432 steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Environment Setup @@ -618,7 +618,7 @@ jobs: INVENTREE_PLUGINS_ENABLED: false steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false name: Checkout Code @@ -674,8 +674,8 @@ jobs: security-events: write steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Run zizmor 🌈 - uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6 + uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 314e2891a8..7a8f69897a 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -20,7 +20,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - name: Checkout Code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Version Check @@ -45,7 +45,7 @@ jobs: artifact-metadata: write steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Environment Setup @@ -115,7 +115,7 @@ jobs: INVENTREE_DEBUG: true steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Environment Setup @@ -156,7 +156,7 @@ jobs: - debian:13 steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 persist-credentials: false @@ -216,7 +216,7 @@ jobs: pip install --require-hashes -r contrib/dev_reqs/requirements.txt python3 .github/scripts/version_check.py - name: Package - current release channel - uses: pkgr/action/package@c5666febcd31750da6428042193fc5b2fb765435 # pin@main + uses: pkgr/action/package@c5666febcd31750da6428042193fc5b2fb765435 # main id: package with: target: ${{ matrix.target }} @@ -234,7 +234,7 @@ jobs: INVENTREE_CONFIG_FILE=/opt/inventree/config.yaml APP_REPO=inventree/InvenTree - name: Publish to go.packager.io - current release channel - uses: pkgr/action/publish@3bce081ae512c5020856e237d37b3f5479d4aa71 # pin@main + uses: pkgr/action/publish@c5666febcd31750da6428042193fc5b2fb765435 # main with: target: ${{ matrix.target }} token: ${{ secrets.PACKAGER_RELEASE_TOKEN }} @@ -249,7 +249,7 @@ jobs: PACKAGE_NAME: ${{ matrix.target }}-${{ steps.setup.outputs.version }}.tar.gz GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Package - stable release channel - uses: pkgr/action/package@c5666febcd31750da6428042193fc5b2fb765435 # pin@main + uses: pkgr/action/package@c5666febcd31750da6428042193fc5b2fb765435 # main id: package-stable with: target: ${{ matrix.target }} @@ -267,7 +267,7 @@ jobs: INVENTREE_CONFIG_FILE=/opt/inventree/config.yaml APP_REPO=inventree/InvenTree - name: Publish to go.packager.io - stable release channel - uses: pkgr/action/publish@3bce081ae512c5020856e237d37b3f5479d4aa71 # pin@main + uses: pkgr/action/publish@c5666febcd31750da6428042193fc5b2fb765435 # main with: target: ${{ matrix.target }} token: ${{ secrets.PACKAGER_RELEASE_TOKEN }} diff --git a/.github/workflows/scorecard.yaml b/.github/workflows/scorecard.yaml index 5e74fd9529..3b28b761d0 100644 --- a/.github/workflows/scorecard.yaml +++ b/.github/workflows/scorecard.yaml @@ -32,7 +32,7 @@ jobs: steps: - name: "Checkout code" - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false diff --git a/.github/workflows/translations.yaml b/.github/workflows/translations.yaml index e1da913823..baf461cba7 100644 --- a/.github/workflows/translations.yaml +++ b/.github/workflows/translations.yaml @@ -32,7 +32,7 @@ jobs: steps: - name: Checkout Code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Environment Setup From 635ea8179eac8adb058cfea824876d6824ecb70a Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 30 Jun 2026 09:53:40 +1000 Subject: [PATCH 4/8] [UI] Fix 'available' display (#12279) - Hide entirely for items not "in_stock" --- src/frontend/src/pages/stock/StockDetail.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/frontend/src/pages/stock/StockDetail.tsx b/src/frontend/src/pages/stock/StockDetail.tsx index fa4bb80fa9..c2e700a3b8 100644 --- a/src/frontend/src/pages/stock/StockDetail.tsx +++ b/src/frontend/src/pages/stock/StockDetail.tsx @@ -275,7 +275,8 @@ export default function StockDetail() { name: 'available_stock', label: t`Available`, unit: part?.units, - icon: 'stock' + icon: 'stock', + hidden: stockitem.in_stock == false }, { type: 'number', From 414aac0224d75936d2c56f6d4e3958df75ab4979 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 30 Jun 2026 15:39:05 +1000 Subject: [PATCH 5/8] Fix stocktake bug for counting serialized items (#12280) * Fix stocktake bug for counting serialized items * Add unit test --- src/backend/InvenTree/stock/models.py | 16 +++++++-------- src/backend/InvenTree/stock/test_api.py | 26 +++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/backend/InvenTree/stock/models.py b/src/backend/InvenTree/stock/models.py index 290377ff0b..39a476d9c8 100644 --- a/src/backend/InvenTree/stock/models.py +++ b/src/backend/InvenTree/stock/models.py @@ -2706,8 +2706,8 @@ class StockItem( ) tracking_info['old_status_logical'] = old_status_logical - if self.updateQuantity(count): - tracking_info['quantity'] = float(count) + if self.serialized or self.updateQuantity(count): + tracking_info['quantity'] = 1 if self.serialized else float(count) self.stocktake_date = InvenTree.helpers.current_date() self.stocktake_user = user @@ -2727,12 +2727,12 @@ class StockItem( deltas=tracking_info, ) - trigger_event( - StockEvents.ITEM_COUNTED, - 'stockitem.counted', - id=self.id, - quantity=float(self.quantity), - ) + trigger_event( + StockEvents.ITEM_COUNTED, + 'stockitem.counted', + id=self.id, + quantity=1 if self.serialized else float(self.quantity), + ) return True diff --git a/src/backend/InvenTree/stock/test_api.py b/src/backend/InvenTree/stock/test_api.py index fdc0ebce54..9a42e4beda 100644 --- a/src/backend/InvenTree/stock/test_api.py +++ b/src/backend/InvenTree/stock/test_api.py @@ -2289,6 +2289,32 @@ class StocktakeTest(StockAPITestCase): self.assertEqual(response.data['items'][0]['pk'], 1234) self.assertEqual(response.data['items'][0]['quantity'], target[endpoint]) + def test_count_serialized(self): + """Test that counting a serialized stock item correctly updates stocktake_date.""" + import datetime + + # Fixture item pk=501 is a serialized item (serial=1) + item = StockItem.objects.get(pk=501) + self.assertTrue(item.serialized) + self.assertEqual(item.quantity, 1) + + # Clear any existing stocktake date so we can verify it gets set + item.stocktake_date = None + item.save() + + url = reverse('api-stock-count') + + # Count the serialized item — quantity must be 1 + data = {'items': [{'pk': item.pk, 'quantity': 1}]} + response = self.post(url, data, expected_code=201) + + self.assertEqual(response.data['items'][0]['pk'], item.pk) + self.assertEqual(response.data['items'][0]['quantity'], '1.00000') + + # stocktake_date must have been set to today + item.refresh_from_db() + self.assertEqual(item.stocktake_date, datetime.date.today()) + def test_transfer(self): """Test stock transfers.""" stock_item = StockItem.objects.get(pk=1234) From 6111aace1f7fda656016b06f85e43a5e4c22d097 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 30 Jun 2026 18:10:40 +1000 Subject: [PATCH 6/8] [UI] Table field refactor (#12274) * Use callback funcs * Don't use idx to identify rows * Add debug function for finding why a component re-rendered * Do not pass 'control' through to each row * Prevent unnecessary re-rendering of table rows * Adjust order of operations for hooks * Keep props hidden * Use lightweight NumberInput * Use NumberInput elsewhere * Add comment * use rowId instead of idx * Generic row memos * Compare errors too * Fix for BomItemSubstituteRow * Adjust more forms * memoize quantity * Memoize build lines * Fix re-rendering issues for build allocation * Fix for useConsumeBuildLinesForm * Fix for transfer order table * Fix useReceiveLineItems * Remove memoized pattern * Fix row keys * Cleanup * Create useStockItems hook for memoizing items * Refactoring * More refactoring * Remove obj reference - preventing shallow comparison from working * Add error message to useWhyDidYouUpdate * Cleanup * Cleanup dead code * Adjust modal width * Change attr name * Remove autoFillFilters prop * Adjustments for serialized stock * Fix typing * Bump frontend version * Adjustments for playwright testing * Fix ref issue * Remove debug entry * Update CHANGELOG.md * Reintroduce index to table header * Refactor common component --- CHANGELOG.md | 2 + src/frontend/lib/types/Forms.tsx | 2 - src/frontend/package.json | 2 +- .../components/forms/fields/ApiFormField.tsx | 4 +- .../forms/fields/RelatedModelField.tsx | 1 + .../components/forms/fields/TableField.tsx | 312 +++++++++++--- .../importer/ImportDataSelector.tsx | 9 - src/frontend/src/forms/BomForms.tsx | 4 +- src/frontend/src/forms/BuildForms.tsx | 96 +++-- src/frontend/src/forms/PurchaseOrderForms.tsx | 98 +++-- src/frontend/src/forms/ReturnOrderForms.tsx | 7 +- src/frontend/src/forms/SalesOrderForms.tsx | 50 +-- src/frontend/src/forms/StockForms.tsx | 398 +++++++++++------- src/frontend/src/forms/TransferOrderForms.tsx | 66 +-- src/frontend/src/functions/debug.tsx | 36 ++ src/frontend/src/pages/part/PartDetail.tsx | 3 +- .../src/pages/stock/LocationDetail.tsx | 3 +- src/frontend/src/pages/stock/StockDetail.tsx | 3 +- src/frontend/src/states/LocalState.tsx | 2 +- .../tables/build/BuildAllocatedStockTable.tsx | 1 - .../src/tables/build/BuildOutputTable.tsx | 6 +- .../sales/SalesOrderAllocationTable.tsx | 1 - .../tables/settings/SelectionListDrawer.tsx | 2 - .../src/tables/stock/StockItemTable.tsx | 1 - .../stock/TransferOrderAllocationTable.tsx | 1 - .../tests/pages/pui_dashboard.spec.ts | 2 - 26 files changed, 724 insertions(+), 388 deletions(-) create mode 100644 src/frontend/src/functions/debug.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bea76d17c..5bb687ad6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- [#12274](https://github.com/inventree/InvenTree/pull/12274) fixes a bug in rendering the "table field" component in frontend forms. Any plugins which make use of the "table field" component in their forms may be affected by this change, and will need to update their form definitions accordingly. + ### Removed ## 1.4.0 - 2026-06-24 diff --git a/src/frontend/lib/types/Forms.tsx b/src/frontend/lib/types/Forms.tsx index 03acfb0ad9..48323dd6a0 100644 --- a/src/frontend/lib/types/Forms.tsx +++ b/src/frontend/lib/types/Forms.tsx @@ -221,8 +221,6 @@ export interface BulkEditApiFormModalProps extends ApiFormModalProps { export type StockOperationProps = { items?: any[]; - pk?: number; filters?: any; - model: ModelType.stockitem | 'location' | ModelType.part; refresh: () => void; }; diff --git a/src/frontend/package.json b/src/frontend/package.json index 8c1bf2e93e..4ceefd58c5 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -1,7 +1,7 @@ { "name": "@inventreedb/ui", "description": "UI components for the InvenTree project", - "version": "1.4.6", + "version": "1.5.0", "private": false, "type": "module", "license": "MIT", diff --git a/src/frontend/src/components/forms/fields/ApiFormField.tsx b/src/frontend/src/components/forms/fields/ApiFormField.tsx index 85e128a1fd..d9adc749f3 100644 --- a/src/frontend/src/components/forms/fields/ApiFormField.tsx +++ b/src/frontend/src/components/forms/fields/ApiFormField.tsx @@ -247,7 +247,9 @@ export function ApiFormField({ ); case 'tags': diff --git a/src/frontend/src/components/forms/fields/RelatedModelField.tsx b/src/frontend/src/components/forms/fields/RelatedModelField.tsx index bb88471c0c..a744f3f4ac 100644 --- a/src/frontend/src/components/forms/fields/RelatedModelField.tsx +++ b/src/frontend/src/components/forms/fields/RelatedModelField.tsx @@ -419,6 +419,7 @@ export function RelatedModelField({ ...definition, addCreateFields: undefined, autoFill: undefined, + autoFillFilters: undefined, modelRenderer: undefined, onValueChange: undefined, adjustFilters: undefined, diff --git a/src/frontend/src/components/forms/fields/TableField.tsx b/src/frontend/src/components/forms/fields/TableField.tsx index 5d209b1fee..d0792a2172 100644 --- a/src/frontend/src/components/forms/fields/TableField.tsx +++ b/src/frontend/src/components/forms/fields/TableField.tsx @@ -1,9 +1,23 @@ import { t } from '@lingui/core/macro'; import { Trans } from '@lingui/react/macro'; -import { Alert, Container, Group, Stack, Table, Text } from '@mantine/core'; +import { + Alert, + Container, + Group, + NumberInput, + Stack, + Table, + Text +} from '@mantine/core'; import { IconExclamationCircle } from '@tabler/icons-react'; -import { type ReactNode, useCallback, useEffect, useMemo } from 'react'; -import type { FieldValues, UseControllerReturn } from 'react-hook-form'; +import { + type ReactNode, + memo, + useCallback, + useEffect, + useMemo, + useRef +} from 'react'; import { AddItemButton } from '@lib/components/AddItemButton'; import { identifierString } from '@lib/functions/Conversion'; @@ -13,35 +27,34 @@ import { StandaloneField } from '../StandaloneField'; export interface TableFieldRowProps { item: any; - idx: number; + rowId: string | number; rowErrors: any; - control: UseControllerReturn; - changeFn: (idx: number, key: string, value: any) => void; - removeFn: (idx: number) => void; + changeFn: (rowId: number | string, key: string, value: any) => void; + removeFn: (rowId: number | string) => void; } function TableFieldRow({ item, - idx, - errors, - definition, - control, + rowId, + rowErrors, + modelRenderer, + columnCount, changeFn, removeFn }: Readonly<{ item: any; - idx: number; - errors: any; - definition: ApiFormFieldType; - control: UseControllerReturn; - changeFn: (idx: number, key: string, value: any) => void; - removeFn: (idx: number) => void; + rowId: string | number; + rowErrors: any; + modelRenderer?: ApiFormFieldType['modelRenderer']; + columnCount?: number; + changeFn: (rowId: number | string, key: string, value: any) => void; + removeFn: (rowId: number | string) => void; }>) { // Table fields require render function - if (!definition.modelRenderer) { + if (!modelRenderer) { return ( - + }> {t`modelRenderer entry required for tables`} @@ -50,16 +63,60 @@ function TableFieldRow({ ); } - return definition.modelRenderer({ + return modelRenderer({ item: item, - idx: idx, - rowErrors: errors, - control: control, + rowId: rowId, + rowErrors: rowErrors, changeFn: changeFn, removeFn: removeFn }); } +// Memoize each table field row, so that we don't re-render the entire table when a single row is updated +function areShallowEqual(previousValue: any, nextValue: any): boolean { + if (previousValue === nextValue) { + return true; + } + + if (!previousValue || !nextValue) { + return false; + } + + if (typeof previousValue !== 'object' || typeof nextValue !== 'object') { + return previousValue === nextValue; + } + + const previousKeys = Object.keys(previousValue); + const nextKeys = Object.keys(nextValue); + + if (previousKeys.length !== nextKeys.length) { + return false; + } + + for (const key of previousKeys) { + if (previousValue[key] !== nextValue[key]) { + return false; + } + } + + return true; +} + +const MemoizedTableFieldRow = memo( + TableFieldRow, + (previousProps, nextProps) => { + return ( + previousProps.rowId === nextProps.rowId && + areShallowEqual(previousProps.item, nextProps.item) && + areShallowEqual(previousProps.rowErrors, nextProps.rowErrors) && + previousProps.modelRenderer === nextProps.modelRenderer && + previousProps.changeFn === nextProps.changeFn && + previousProps.removeFn === nextProps.removeFn && + previousProps.columnCount === nextProps.columnCount + ); + } +); + export function TableFieldErrorWrapper({ props, errorKey, @@ -83,33 +140,131 @@ export function TableFieldErrorWrapper({ ); } -export function TableField({ +function TableFieldComponent({ definition, fieldName, - control + value, + onChange, + error }: Readonly<{ definition: ApiFormFieldType; fieldName: string; - control: UseControllerReturn; + value: any; + onChange: (value: any) => void; + error?: any; }>) { - const { - field, - fieldState: { error } - } = control; - const { value } = field; + const valueRef = useRef(value); + const onChangeRef = useRef(onChange); + const rowIndexByIdRef = useRef(new Map()); + const generatedRowIdsRef = useRef(new WeakMap()); + const generatedRowIdCounterRef = useRef(0); - const onRowFieldChange = (idx: number, key: string, value: any) => { - const val = field.value; - val[idx][key] = value; + const getRowIdentifier = useCallback( + (item: any, idx: number): string | number => { + if (item && typeof item === 'object') { + const intrinsicId = item.pk ?? item.item ?? item.id ?? item.uuid; - field.onChange(val); - }; + if (intrinsicId !== undefined && intrinsicId !== null) { + return intrinsicId; + } - const removeRow = (idx: number) => { - const val = field.value; - val.splice(idx, 1); - field.onChange(val); - }; + const existingGeneratedId = generatedRowIdsRef.current.get(item); + + if (existingGeneratedId) { + return existingGeneratedId; + } + + generatedRowIdCounterRef.current += 1; + const generatedId = `table-row-generated-${generatedRowIdCounterRef.current}`; + generatedRowIdsRef.current.set(item, generatedId); + + return generatedId; + } + + return item ?? idx; + }, + [] + ); + + // Keep refs in sync with latest values without introducing them as deps + valueRef.current = value; + onChangeRef.current = onChange; + + useEffect(() => { + const nextRowIndexById = new Map(); + + value?.forEach((item: any, idx: number) => { + nextRowIndexById.set(getRowIdentifier(item, idx), idx); + }); + + rowIndexByIdRef.current = nextRowIndexById; + }, [value, getRowIdentifier]); + + const resolveRowIndex = useCallback((identifier: number | string) => { + const mappedIndex = rowIndexByIdRef.current.get(identifier); + + if (mappedIndex !== undefined) { + return mappedIndex; + } + + if (typeof identifier === 'number' && identifier >= 0) { + return identifier; + } + + return undefined; + }, []); + + const onRowFieldChange = useCallback( + (identifier: number | string, key: string, rowValue: any) => { + const idx = resolveRowIndex(identifier); + + if (idx === undefined) { + return; + } + + const currentValue = valueRef.current; + + if (!Array.isArray(currentValue) || currentValue[idx] === undefined) { + return; + } + + const nextValue = [...currentValue]; + const currentRow = nextValue[idx]; + + if (currentRow && typeof currentRow === 'object') { + nextValue[idx] = { + ...currentRow, + [key]: rowValue + }; + } + + valueRef.current = nextValue; + onChangeRef.current(nextValue); + }, + [resolveRowIndex] + ); + + const removeRow = useCallback( + (identifier: number | string) => { + const idx = resolveRowIndex(identifier); + + if (idx === undefined) { + return; + } + + const currentValue = valueRef.current; + + if (!Array.isArray(currentValue)) { + return; + } + + const nextValue = [...currentValue]; + nextValue.splice(idx, 1); + + onChangeRef.current(nextValue); + }, + [resolveRowIndex] + ); // Extract errors associated with the current row const rowErrors: any = useCallback( @@ -125,7 +280,7 @@ export function TableField({ @@ -146,14 +301,16 @@ export function TableField({ {(value?.length ?? 0) > 0 ? ( value.map((item: any, idx: number) => { + const rowId = getRowIdentifier(item, idx); + return ( - @@ -189,9 +346,7 @@ export function TableField({ if (definition.addRow === undefined) return; const ret = definition.addRow(); if (ret) { - const val = field.value; - val.push(ret); - field.onChange(val); + onChange([...(value ?? []), ret]); } }} /> @@ -203,6 +358,19 @@ export function TableField({ ); } +export const TableField = memo( + TableFieldComponent, + (previousProps, nextProps) => { + return ( + previousProps.definition === nextProps.definition && + previousProps.fieldName === nextProps.fieldName && + areShallowEqual(previousProps.value, nextProps.value) && + areShallowEqual(previousProps.error, nextProps.error) && + previousProps.onChange === nextProps.onChange + ); + } +); + /* * Display an "extra" row below the main table row, for additional information. * - Each "row" can display an extra row of information below the main row @@ -224,10 +392,16 @@ export function TableFieldExtraRow({ emptyValue?: any; onValueChange: (value: any) => void; }) { + const hasMounted = useRef(false); + // Callback whenever the visibility of the sub-field changes + // Skip the initial mount — the value was never set, nothing to reset useEffect(() => { + if (!hasMounted.current) { + hasMounted.current = true; + return; + } if (!visible) { - // If the sub-field is hidden, reset the value to the "empty" value onValueChange(emptyValue); } }, [visible]); @@ -262,3 +436,37 @@ export function TableFieldExtraRow({ ) ); } + +export function TableFieldQuantityInput({ + value, + onChange, + error, + min +}: { + value: number | ''; + onChange: (value: number | '') => void; + error?: string; + min?: number; +}) { + return ( + { + if (typeof v === 'number') { + onChange(Number.isFinite(v) ? v : ''); + } else if (v.trim() !== '') { + const parsed = Number.parseFloat(v); + onChange(Number.isFinite(parsed) ? parsed : ''); + } else { + onChange(''); + } + }} + error={error} + /> + ); +} diff --git a/src/frontend/src/components/importer/ImportDataSelector.tsx b/src/frontend/src/components/importer/ImportDataSelector.tsx index 82aadf2d5c..cf426f5cf2 100644 --- a/src/frontend/src/components/importer/ImportDataSelector.tsx +++ b/src/frontend/src/components/importer/ImportDataSelector.tsx @@ -172,15 +172,6 @@ export default function ImporterDataSelector({ description: fieldDef.help_text, filters: filters }; - - console.log('Defined Field:', field); - console.log({ - ...fieldDef, - ...customField, - field_type: fieldDef.type, - description: fieldDef.help_text, - filters: filters - }); } } diff --git a/src/frontend/src/forms/BomForms.tsx b/src/frontend/src/forms/BomForms.tsx index dc92103edd..3f93a830d7 100644 --- a/src/frontend/src/forms/BomForms.tsx +++ b/src/frontend/src/forms/BomForms.tsx @@ -75,7 +75,7 @@ function BomItemSubstituteRow({ api .delete(apiUrl(ApiEndpoints.bom_substitute_list, record.pk)) .then(() => { - props.removeFn(props.idx); + props.removeFn(props.rowId); }) .catch((err) => { showApiErrorMessage({ @@ -116,7 +116,7 @@ export function useEditBomSubstitutesForm(props: BomItemSubstituteFormProps) { modelRenderer: (row: TableFieldRowProps) => { const record = substitutes.find((r) => r.pk == row.item.pk); return record ? ( - + ) : null; }, headers: [ diff --git a/src/frontend/src/forms/BuildForms.tsx b/src/frontend/src/forms/BuildForms.tsx index 2ff3c0bd53..2f694c841c 100644 --- a/src/frontend/src/forms/BuildForms.tsx +++ b/src/frontend/src/forms/BuildForms.tsx @@ -21,6 +21,7 @@ import { apiUrl } from '@lib/functions/Api'; import type { ApiFormFieldSet, ApiFormFieldType } from '@lib/types/Forms'; import { TableFieldErrorWrapper, + TableFieldQuantityInput, type TableFieldRowProps } from '../components/forms/fields/TableField'; import { StatusRenderer } from '../components/render/StatusRenderer'; @@ -272,15 +273,10 @@ function BuildOutputFormRow({ // Non-serialized output - quantity can be changed return ( - { - props.changeFn(props.idx, 'quantity', value); - } + { + props.changeFn(props.rowId, 'quantity', value); }} error={props.rowErrors?.quantity?.message} /> @@ -310,7 +306,7 @@ function BuildOutputFormRow({ />{' '} - props.removeFn(props.idx)} /> + props.removeFn(props.rowId)} /> @@ -344,6 +340,7 @@ export function useCompleteBuildOutputsForm({ const buildOutputs = useMemo(() => { return outputs.map((output: any) => { return { + id: output.pk, output: output.pk, quantity: output.quantity }; @@ -358,7 +355,7 @@ export function useCompleteBuildOutputsForm({ modelRenderer: (row: TableFieldRowProps) => { const record = outputs.find((output) => output.pk == row.item.output); return ( - + ); }, headers: [ @@ -426,6 +423,7 @@ export function useScrapBuildOutputsForm({ const buildOutputs = useMemo(() => { return outputs.map((output: any) => { return { + id: output.pk, output: output.pk, quantity: output.quantity }; @@ -440,7 +438,7 @@ export function useScrapBuildOutputsForm({ modelRenderer: (row: TableFieldRowProps) => { const record = outputs.find((output) => output.pk == row.item.output); return ( - + ); }, headers: [ @@ -497,6 +495,7 @@ export function useCancelBuildOutputsForm({ const buildOutputs = useMemo(() => { return outputs.map((output: any) => { return { + id: output.pk, output: output.pk }; }); @@ -562,6 +561,8 @@ function BuildAllocateLineRow({ record: any; sourceLocation: number | undefined; }>) { + const [quantity, setQuantity] = useState(props.item?.quantity ?? 0); + const stockField: ApiFormFieldType = useMemo(() => { return { field_type: 'related field', @@ -582,7 +583,7 @@ function BuildAllocateLineRow({ value: props.item.stock_item, name: 'stock_item', onValueChange: (value: any, instance: any) => { - props.changeFn(props.idx, 'stock_item', value); + props.changeFn(props.rowId, 'stock_item', value); // Update the allocated quantity based on the selected stock item if (instance) { @@ -590,7 +591,7 @@ function BuildAllocateLineRow({ if (available < props.item.quantity) { props.changeFn( - props.idx, + props.rowId, 'quantity', Math.min(props.item.quantity, available) ); @@ -600,18 +601,6 @@ function BuildAllocateLineRow({ }; }, [record, props]); - const quantityField: ApiFormFieldType = useMemo(() => { - return { - field_type: 'number', - name: 'quantity', - required: true, - value: props.item.quantity, - onValueChange: (value: any) => { - props.changeFn(props.idx, 'quantity', value); - } - }; - }, [props]); - return ( @@ -636,14 +625,18 @@ function BuildAllocateLineRow({ /> - { + setQuantity(value === '' ? 0 : value); + props.changeFn(props.rowId, 'quantity', value); + }} error={props.rowErrors?.quantity?.message} /> - props.removeFn(props.idx)} /> + props.removeFn(props.rowId)} /> ); @@ -671,6 +664,16 @@ export function useAllocateStockToBuildForm({ undefined ); + // Memoize the line items once to avoid re-rendering + const buildLines = useMemo(() => { + return lineItems.map((item) => { + return { + id: item.pk, + ...item + }; + }); + }, [lineItems]); + const buildAllocateFields: ApiFormFieldSet = useMemo(() => { const fields: ApiFormFieldSet = { items: { @@ -687,10 +690,10 @@ export function useAllocateStockToBuildForm({ modelRenderer: (row: TableFieldRowProps) => { // Find the matching record from the passed 'lineItems' const record = - lineItems.find((item) => item.pk == row.item.build_line) ?? {}; + buildLines.find((item) => item.pk == row.item.build_line) ?? {}; return ( { return { + id: item.pk, build_line: item.pk, stock_item: undefined, quantity: Math.max( @@ -788,6 +792,8 @@ function BuildConsumeItemRow({ props: TableFieldRowProps; record: any; }) { + const [quantity, setQuantity] = useState(props.item?.quantity ?? 0); + return ( @@ -803,21 +809,18 @@ function BuildConsumeItemRow({ {record.quantity} - { - props.changeFn(props.idx, 'quantity', value); - } + { + setQuantity(value === '' ? 0 : value); + props.changeFn(props.rowId, 'quantity', value); }} error={props.rowErrors?.quantity?.message} /> - props.removeFn(props.idx)} /> + props.removeFn(props.rowId)} /> ); @@ -853,7 +856,7 @@ export function useConsumeBuildItemsForm({ ); return ( - + ); } }, @@ -872,6 +875,7 @@ export function useConsumeBuildItemsForm({ initialData: { items: allocatedItems.map((item) => { return { + id: item.pk, build_item: item.pk, quantity: item.quantity }; @@ -916,7 +920,7 @@ function BuildConsumeLineRow({ /> - props.removeFn(props.idx)} /> + props.removeFn(props.rowId)} /> ); @@ -954,7 +958,7 @@ export function useConsumeBuildLinesForm({ ); return ( - + ); } }, @@ -969,9 +973,11 @@ export function useConsumeBuildLinesForm({ successMessage: null, onFormSuccess: onFormSuccess, fields: consumeFields, + size: '80%', initialData: { lines: filteredLines.map((item) => { return { + id: item.pk, build_line: item.pk }; }) diff --git a/src/frontend/src/forms/PurchaseOrderForms.tsx b/src/frontend/src/forms/PurchaseOrderForms.tsx index 3dce873eb6..aaf4fa83d4 100644 --- a/src/frontend/src/forms/PurchaseOrderForms.tsx +++ b/src/frontend/src/forms/PurchaseOrderForms.tsx @@ -45,6 +45,7 @@ import type { } from '@lib/types/Forms'; import { TableFieldExtraRow, + TableFieldQuantityInput, type TableFieldRowProps } from '../components/forms/fields/TableField'; import { Thumbnail } from '../components/images/Thumbnail'; @@ -348,11 +349,11 @@ function LineItemFormRow({ }>) { // Barcode Modal state const [opened, { open, close }] = useDisclosure(false, { - onClose: () => props.changeFn(props.idx, 'barcode', undefined) + onClose: () => props.changeFn(props.rowId, 'barcode', undefined) }); const [locationOpen, locationHandlers] = useDisclosure(false, { - onClose: () => props.changeFn(props.idx, 'location', undefined) + onClose: () => props.changeFn(props.rowId, 'location', undefined) }); // Is this a trackable part? @@ -365,7 +366,7 @@ function LineItemFormRow({ useEffect(() => { if (!!record.destination) { - props.changeFn(props.idx, 'location', record.destination); + props.changeFn(props.rowId, 'location', record.destination); locationHandlers.open(); } }, [record.destination]); @@ -375,7 +376,7 @@ function LineItemFormRow({ isEnabled: () => batchOpen, onGenerate: (value: any) => { if (value) { - props.changeFn(props.idx, 'batch_code', value); + props.changeFn(props.rowId, 'batch_code', value); } } }); @@ -387,19 +388,19 @@ function LineItemFormRow({ const [packagingOpen, packagingHandlers] = useDisclosure(false, { onClose: () => { - props.changeFn(props.idx, 'packaging', undefined); + props.changeFn(props.rowId, 'packaging', undefined); } }); const [noteOpen, noteHandlers] = useDisclosure(false, { onClose: () => { - props.changeFn(props.idx, 'note', undefined); + props.changeFn(props.rowId, 'note', undefined); } }); const [batchOpen, batchHandlers] = useDisclosure(false, { onClose: () => { - props.changeFn(props.idx, 'batch_code', undefined); + props.changeFn(props.rowId, 'batch_code', undefined); }, onOpen: () => { // Generate a new batch code @@ -412,7 +413,7 @@ function LineItemFormRow({ const [serialOpen, serialHandlers] = useDisclosure(false, { onClose: () => { - props.changeFn(props.idx, 'serial_numbers', undefined); + props.changeFn(props.rowId, 'serial_numbers', undefined); }, onOpen: () => { // Generate new serial numbers @@ -422,7 +423,7 @@ function LineItemFormRow({ quantity: props.item.quantity }); } else { - props.changeFn(props.idx, 'serial_numbers', undefined); + props.changeFn(props.rowId, 'serial_numbers', undefined); } } }); @@ -433,20 +434,20 @@ function LineItemFormRow({ const defaultExpiry = record.part_detail?.default_expiry; if (defaultExpiry !== undefined && defaultExpiry > 0) { props.changeFn( - props.idx, + props.rowId, 'expiry_date', dayjs().add(defaultExpiry, 'day').format('YYYY-MM-DD') ); } }, onClose: () => { - props.changeFn(props.idx, 'expiry_date', undefined); + props.changeFn(props.rowId, 'expiry_date', undefined); } }); // Status value const [statusOpen, statusHandlers] = useDisclosure(false, { - onClose: () => props.changeFn(props.idx, 'status', undefined) + onClose: () => props.changeFn(props.rowId, 'status', undefined) }); // Barcode value @@ -455,7 +456,7 @@ function LineItemFormRow({ // Change form value when state is altered useEffect(() => { - props.changeFn(props.idx, 'barcode', barcode); + props.changeFn(props.rowId, 'barcode', barcode); }, [barcode]); // Update location field description on state change @@ -574,15 +575,14 @@ function LineItemFormRow({ /> - { - props.changeFn(props.idx, 'quantity', value); - serialNumberGenerator.update({ quantity: value }); - } + { + props.changeFn(props.rowId, 'quantity', value); + serialNumberGenerator.update({ + quantity: value === '' ? undefined : value + }); }} error={props.rowErrors?.quantity?.message} /> @@ -678,7 +678,7 @@ function LineItemFormRow({ - props.removeFn(props.idx)} /> + props.removeFn(props.rowId)} /> {locationOpen && ( @@ -697,7 +697,7 @@ function LineItemFormRow({ structural: false }, onValueChange: (value) => { - props.changeFn(props.idx, 'location', value); + props.changeFn(props.rowId, 'location', value); }, description: locationDescription, value: props.item.location, @@ -719,7 +719,7 @@ function LineItemFormRow({ tooltip={t`Store at default location`} onClick={() => props.changeFn( - props.idx, + props.rowId, 'location', record.part_detail?.default_location ?? record.part_detail?.category_default_location @@ -733,7 +733,11 @@ function LineItemFormRow({ icon={} tooltip={t`Store at line item destination `} onClick={() => - props.changeFn(props.idx, 'location', record.destination) + props.changeFn( + props.rowId, + 'location', + record.destination + ) } tooltipAlignment='top' /> @@ -746,7 +750,7 @@ function LineItemFormRow({ tooltip={t`Store with already received stock`} onClick={() => props.changeFn( - props.idx, + props.rowId, 'location', record.destination_detail.pk ) @@ -762,7 +766,7 @@ function LineItemFormRow({ { - props.changeFn(props.idx, 'batch_code', value); + props.changeFn(props.rowId, 'batch_code', value); }} fieldName='batch_code' fieldDefinition={{ @@ -776,7 +780,7 @@ function LineItemFormRow({ - props.changeFn(props.idx, 'serial_numbers', value) + props.changeFn(props.rowId, 'serial_numbers', value) } fieldName='serial_numbers' fieldDefinition={{ @@ -794,7 +798,7 @@ function LineItemFormRow({ - props.changeFn(props.idx, 'expiry_date', value) + props.changeFn(props.rowId, 'expiry_date', value) } fieldName='expiry_date' fieldDefinition={{ @@ -808,7 +812,9 @@ function LineItemFormRow({ )} props.changeFn(props.idx, 'packaging', value)} + onValueChange={(value) => + props.changeFn(props.rowId, 'packaging', value) + } fieldName='packaging' fieldDefinition={{ field_type: 'string', @@ -821,7 +827,7 @@ function LineItemFormRow({ visible={statusOpen} defaultValue={10} fieldName='status' - onValueChange={(value) => props.changeFn(props.idx, 'status', value)} + onValueChange={(value) => props.changeFn(props.rowId, 'status', value)} fieldDefinition={{ field_type: 'choice', api_url: apiUrl(ApiEndpoints.stock_status), @@ -833,7 +839,7 @@ function LineItemFormRow({ props.changeFn(props.idx, 'note', value)} + onValueChange={(value) => props.changeFn(props.rowId, 'note', value)} fieldDefinition={{ field_type: 'string', label: t`Note` @@ -862,13 +868,20 @@ export function useReceiveLineItems(props: LineItemsForm) { [] ); - const records = Object.fromEntries( - props.items.map((item) => [item.pk, item]) - ); + const records = useMemo(() => { + return Object.fromEntries(props.items.map((item) => [item.pk, item])); + }, [props.items]); - const filteredItems = props.items.filter( - (elem) => elem.quantity !== elem.received - ); + const filteredItems = useMemo(() => { + return props.items + .filter((elem) => elem.quantity !== elem.received) + .map((elem) => { + return { + id: elem.pk, + ...elem + }; + }); + }, [props.items]); const fields: ApiFormFieldSet = useMemo(() => { return { @@ -878,8 +891,9 @@ export function useReceiveLineItems(props: LineItemsForm) { }, items: { field_type: 'table', - value: filteredItems.map((elem, idx) => { + value: filteredItems.map((elem) => { return { + id: elem.pk, line_item: elem.pk, location: elem.destination ?? elem.destination_detail?.pk ?? null, quantity: elem.quantity - elem.received, @@ -902,7 +916,7 @@ export function useReceiveLineItems(props: LineItemsForm) { props={row} record={record} statuses={stockStatusCodes} - key={record.pk} + key={row.rowId} /> ); }, @@ -921,7 +935,7 @@ export function useReceiveLineItems(props: LineItemsForm) { } } }; - }, [filteredItems, props, stockStatusCodes]); + }, [filteredItems, records, props, stockStatusCodes]); return useCreateApiFormModal({ ...props.formProps, diff --git a/src/frontend/src/forms/ReturnOrderForms.tsx b/src/frontend/src/forms/ReturnOrderForms.tsx index 3377ff1f22..0fe1866b7b 100644 --- a/src/frontend/src/forms/ReturnOrderForms.tsx +++ b/src/frontend/src/forms/ReturnOrderForms.tsx @@ -199,7 +199,7 @@ function ReturnOrderLineItemFormRow({ label: t`Status`, choices: statusOptions, onValueChange: (value) => { - props.changeFn(props.idx, 'status', value); + props.changeFn(props.rowId, 'status', value); } }} defaultValue={record.item_detail?.status} @@ -207,7 +207,7 @@ function ReturnOrderLineItemFormRow({ /> - props.removeFn(props.idx)} /> + props.removeFn(props.rowId)} /> @@ -226,6 +226,7 @@ export function useReceiveReturnOrderLineItems( field_type: 'table', value: props.items.map((item: any) => { return { + id: item.pk, item: item.pk }; }), @@ -236,7 +237,7 @@ export function useReceiveReturnOrderLineItems( ); }, diff --git a/src/frontend/src/forms/SalesOrderForms.tsx b/src/frontend/src/forms/SalesOrderForms.tsx index a863a51852..163861db24 100644 --- a/src/frontend/src/forms/SalesOrderForms.tsx +++ b/src/frontend/src/forms/SalesOrderForms.tsx @@ -25,7 +25,10 @@ import type { ApiFormFieldType } from '@lib/types/Forms'; import dayjs from 'dayjs'; -import type { TableFieldRowProps } from '../components/forms/fields/TableField'; +import { + TableFieldQuantityInput, + type TableFieldRowProps +} from '../components/forms/fields/TableField'; import useBackgroundTask from '../hooks/UseBackgroundTask'; import { useCreateApiFormModal, useEditApiFormModal } from '../hooks/UseForm'; import { useGlobalSettingsState } from '../states/SettingsStates'; @@ -328,41 +331,33 @@ function SalesOrderAllocateLineRow({ value: props.item.stock_item, name: 'stock_item', onValueChange: (value: any, instance: any) => { - props.changeFn(props.idx, 'stock_item', value); + props.changeFn(props.rowId, 'stock_item', value); // Update the allocated quantity based on the selected stock item if (instance) { const available = instance.quantity - instance.allocated; const required = record.quantity - record.allocated; - let quantity = props.item?.quantity ?? 0; + let q = props.item?.quantity ?? 0; - quantity = Math.max(quantity, required); - quantity = Math.min(quantity, available); + q = Math.max(q, required); + q = Math.min(q, available); - if (quantity != props.item.quantity) { - props.changeFn(props.idx, 'quantity', quantity); + if (q != props.item?.quantity) { + setQuantity(q); + props.changeFn(props.rowId, 'quantity', q); } } } }; }, [sourceLocation, record, props]); - // Statically defined field for selecting the allocation quantity - const quantityField: ApiFormFieldType = useMemo(() => { - return { - field_type: 'number', - name: 'quantity', - required: true, - value: props.item.quantity, - onValueChange: (value: any) => { - props.changeFn(props.idx, 'quantity', value); - } - }; - }, [props]); + const [quantity, setQuantity] = useState( + props.item?.quantity ?? '' + ); return ( - + @@ -381,14 +376,18 @@ function SalesOrderAllocateLineRow({ /> - { + setQuantity(value); + props.changeFn(props.rowId, 'quantity', value); + }} error={props.rowErrors?.quantity?.message} /> - props.removeFn(props.idx)} /> + props.removeFn(props.rowId)} /> ); @@ -443,7 +442,7 @@ export function useAllocateToSalesOrderForm({ return ( { return { + id: item.pk, line_item: item.pk, quantity: 0, stock_item: null diff --git a/src/frontend/src/forms/StockForms.tsx b/src/frontend/src/forms/StockForms.tsx index a0de3608b2..c8c253fd66 100644 --- a/src/frontend/src/forms/StockForms.tsx +++ b/src/frontend/src/forms/StockForms.tsx @@ -41,15 +41,16 @@ import { useCallback, useEffect, useMemo, + useRef, useState } from 'react'; import { useFormContext } from 'react-hook-form'; import { useNavigate } from 'react-router-dom'; import { api } from '../App'; import RemoveRowButton from '../components/buttons/RemoveRowButton'; -import { StandaloneField } from '../components/forms/StandaloneField'; import { TableFieldExtraRow, + TableFieldQuantityInput, type TableFieldRowProps } from '../components/forms/fields/TableField'; import { Thumbnail } from '../components/images/Thumbnail'; @@ -553,19 +554,106 @@ function moveToDefault( }); } -type StockAdjustmentItemWithRecord = { - obj: any; -} & StockAdjustmentItem; +/* + * Memoize a list of stock items for use in a stock operations modal. + * These items may be provided directly, or fetched from the API + * + * @param opened - Is the underlying modal opened or closed? + * @param items - Optional list of stock items to use directly + * @param category - Optional category ID to filter stock items + * @param location - Optional location ID to filter stock items + * @param part - Optional part ID to filter stock items + * @param filters - Optional additional filters to apply to the stock item query + */ +function useStockItems({ + opened, + items, + filters +}: Readonly<{ + opened: boolean; + items?: any[] | any; + filters?: { [key: string]: any }; +}>) { + const query = useQuery({ + enabled: opened, + queryKey: ['stockItems', filters], + queryFn: async () => { + if (items !== undefined) { + return Array.isArray(items) ? items : [items]; + } -type TableFieldRefreshFn = (idx: number) => void; -type TableFieldChangeFn = (idx: number, key: string, value: any) => void; + if (!opened) { + return []; + } -type StockRow = { - item: StockAdjustmentItemWithRecord; - idx: number; - changeFn: TableFieldChangeFn; - removeFn: TableFieldRefreshFn; -}; + // Fetch via the API + const url = apiUrl(ApiEndpoints.stock_item_list); + + return api + .get(url, { + params: { + ...filters, + part_detail: true, + location_detail: true, + cascade: false + } + }) + .then((response) => response.data ?? []); + } + }); + + return useMemo(() => { + if (!opened) { + return []; + } + + return query.data ?? []; + }, [opened, query.data]); +} + +function ReturnStockMoveButton({ + record, + quantity, + onRemove, + returnStock +}: { + record: any; + quantity: StockItemQuantity; + onRemove: () => void; + returnStock: boolean; +}) { + const form = useFormContext(); + + return ( + + moveToDefault( + record, + quantity, + onRemove, + returnStock + ? { + title: t`Confirm Stock Return`, + onConfirm: (location: number) => { + form.setValue('location', location, { + shouldDirty: true, + shouldValidate: true + }); + } + } + : undefined + ) + } + icon={} + tooltip={t`Move to default location`} + tooltipAlignment='top' + disabled={ + !record.part_detail?.default_location && + !record.part_detail?.category_default_location + } + /> + ); +} function StockOperationsRow({ props, @@ -588,7 +676,7 @@ function StockOperationsRow({ returnStock?: boolean; record?: any; }) { - const form = useFormContext(); + const rowId = props.rowId; const statusOptions: ApiFormFieldChoice[] = useMemo(() => { return ( @@ -607,37 +695,54 @@ function StockOperationsRow({ const [status, setStatus] = useState(undefined); - const removeAndRefresh = () => { - props.removeFn(props.idx); - }; + const [packagingOpen, packagingHandlers] = useDisclosure(false); + const [statusOpen, statusHandlers] = useDisclosure(false); + const hasMountedPackagingRef = useRef(false); + const hasMountedStatusRef = useRef(false); - const callChangeFn = (idx: number, key: string, value: any) => { - setTimeout(() => props.changeFn(idx, key, value), 0); - }; - - const [packagingOpen, packagingHandlers] = useDisclosure(false, { - onOpen: () => { - if (transfer) { - callChangeFn(props.idx, 'packaging', record?.packaging || undefined); - } - }, - onClose: () => { - if (transfer) { - callChangeFn(props.idx, 'packaging', undefined); - } + useEffect(() => { + if (!transfer) { + return; } - }); - const [statusOpen, statusHandlers] = useDisclosure(false, { - onOpen: () => { + if (!hasMountedPackagingRef.current) { + hasMountedPackagingRef.current = true; + return; + } + + props.changeFn( + rowId, + 'packaging', + packagingOpen ? record?.packaging || undefined : undefined + ); + }, [transfer, packagingOpen, rowId, record?.packaging, props.changeFn]); + + useEffect(() => { + if (!changeStatus) { + return; + } + + if (!hasMountedStatusRef.current) { + hasMountedStatusRef.current = true; + return; + } + + if (statusOpen) { setStatus(record?.status_custom_key || record?.status || undefined); - props.changeFn(props.idx, 'status', record?.status || undefined); - }, - onClose: () => { - setStatus(undefined); - callChangeFn(props.idx, 'status', undefined); + props.changeFn(rowId, 'status', record?.status || undefined); + return; } - }); + + setStatus(undefined); + props.changeFn(rowId, 'status', undefined); + }, [ + changeStatus, + statusOpen, + rowId, + record?.status, + record?.status_custom_key, + props.changeFn + ]); const stockString: string = useMemo(() => { if (!record) { @@ -652,10 +757,15 @@ function StockOperationsRow({ }, [record]); return !record ? ( -
{t`Loading...`}
+ + {t`Loading...`} + ) : ( <> - + @@ -689,15 +799,12 @@ function StockOperationsRow({ {!merge && ( - { - setQuantity(value); - props.changeFn(props.idx, 'quantity', value); - } + { + setQuantity(value); + props.changeFn(rowId, 'quantity', value); }} error={props.rowErrors?.quantity?.message} /> @@ -705,35 +812,30 @@ function StockOperationsRow({ )} - {transfer && ( - - moveToDefault( - record, - props.item.quantity, - removeAndRefresh, - returnStock - ? { - title: t`Confirm Stock Return`, - onConfirm: (location: number) => { - form.setValue('location', location, { - shouldDirty: true, - shouldValidate: true - }); - } - } - : undefined - ) - } - icon={} - tooltip={t`Move to default location`} - tooltipAlignment='top' - disabled={ - !record.part_detail?.default_location && - !record.part_detail?.category_default_location - } - /> - )} + {transfer && + (returnStock ? ( + props.removeFn(rowId)} + returnStock={returnStock} + /> + ) : ( + + moveToDefault(record, props.item.quantity, () => + props.removeFn(rowId) + ) + } + icon={} + tooltip={t`Move to default location`} + tooltipAlignment='top' + disabled={ + !record.part_detail?.default_location && + !record.part_detail?.category_default_location + } + /> + ))} {changeStatus && ( } tooltip={t`Merge into existing stock`} onClick={() => - callChangeFn(props.idx, 'merge', !props.item?.merge) + props.changeFn(rowId, 'merge', !props.item?.merge) } variant={props.item?.merge ? 'filled' : 'transparent'} /> )} - props.removeFn(props.idx)} /> + props.removeFn(rowId)} /> @@ -772,7 +874,7 @@ function StockOperationsRow({ visible={statusOpen} onValueChange={(value: any) => { setStatus(value); - props.changeFn(props.idx, 'status', value || undefined); + props.changeFn(rowId, 'status', value || undefined); }} fieldName='status' fieldDefinition={{ @@ -788,7 +890,7 @@ function StockOperationsRow({ { - props.changeFn(props.idx, 'packaging', value || undefined); + props.changeFn(rowId, 'packaging', value || undefined); }} fieldName='packaging' fieldDefinition={{ @@ -814,15 +916,15 @@ type StockAdjustmentItem = { }; function mapAdjustmentItems(items: any[], mergeDefault?: boolean) { - const mappedItems: StockAdjustmentItemWithRecord[] = items.map((elem) => { + const mappedItems: StockAdjustmentItem[] = items.map((elem) => { return { + id: elem.pk, pk: elem.pk, quantity: elem.quantity, batch: elem.batch || undefined, status: elem.status || undefined, packaging: elem.packaging || undefined, - merge: elem.merge ?? mergeDefault ?? false, - obj: elem + merge: elem.merge ?? mergeDefault ?? false }; }); @@ -856,7 +958,7 @@ function stockTransferFields( changeStatus setMax transferMerge - key={record.pk} + key={row.rowId} record={record} /> ); @@ -901,7 +1003,7 @@ function stockReturnFields(items: any[]): ApiFormFieldSet { return ( [item.pk, item])); + // Only include items which are not serialized (serial number field is empty) + const validItems = items.filter((item) => !item.serial && item.quantity > 0); - const initialValue = mapAdjustmentItems(items).map((elem) => { + const records = Object.fromEntries(validItems.map((item) => [item.pk, item])); + + const initialValue = mapAdjustmentItems(validItems).map((elem) => { return { ...elem, quantity: 0 @@ -970,7 +1075,7 @@ function stockRemoveFields(items: any[]): ApiFormFieldSet { setMax changeStatus add - key={record.pk} + key={row.rowId} record={record} /> ); @@ -995,9 +1100,12 @@ function stockAddFields(items: any[]): ApiFormFieldSet { return {}; } - const records = Object.fromEntries(items.map((item) => [item.pk, item])); + // Only include items which are not serialized (serial number field is empty) + const validItems = items.filter((item) => !item.serial); - const initialValue = mapAdjustmentItems(items).map((elem) => { + const records = Object.fromEntries(validItems.map((item) => [item.pk, item])); + + const initialValue = mapAdjustmentItems(validItems).map((elem) => { return { ...elem, quantity: 0 @@ -1016,7 +1124,7 @@ function stockAddFields(items: any[]): ApiFormFieldSet { changeStatus props={row} add - key={record.pk} + key={row.rowId} record={record} /> ); @@ -1037,16 +1145,14 @@ function stockAddFields(items: any[]): ApiFormFieldSet { } function stockCountFields(items: any[]): ApiFormFieldSet { - if (!items) { - return {}; - } + const records = Object.fromEntries( + items?.map((item) => [item.pk, item]) ?? [] + ); - const records = Object.fromEntries(items.map((item) => [item.pk, item])); - - const initialValue = mapAdjustmentItems(items); + const initialValue = items ? mapAdjustmentItems(items) : []; // Extract all location values from the items - const locations = [...new Set(items.map((item) => item.location))]; + const locations = [...new Set(items?.map((item) => item.location))]; const fields: ApiFormFieldSet = { items: { @@ -1057,8 +1163,8 @@ function stockCountFields(items: any[]): ApiFormFieldSet { ); }, @@ -1105,7 +1211,7 @@ function stockChangeStatusFields(items: any[]): ApiFormFieldSet { return ( @@ -1133,19 +1239,22 @@ function stockMergeFields(items: any[]): ApiFormFieldSet { return {}; } - const records = Object.fromEntries(items.map((item) => [item.pk, item])); + // Only include items which are not serialized (serial number field is empty) + const validItems = items.filter((item) => !item.serial); + + const records = Object.fromEntries(validItems.map((item) => [item.pk, item])); // Extract all non-null location values from the items const locationValues = [ ...new Set( - items.filter((item) => item.location).map((item) => item.location) + validItems.filter((item) => item.location).map((item) => item.location) ) ]; // Extract all non-null default location values from the items const defaultLocationValues = [ ...new Set( - items + validItems .filter((item) => item.part_detail?.default_location) .map((item) => item.part_detail?.default_location) ) @@ -1162,17 +1271,16 @@ function stockMergeFields(items: any[]): ApiFormFieldSet { const fields: ApiFormFieldSet = { items: { field_type: 'table', - value: items.map((elem) => { + value: validItems.map((elem) => { return { - item: elem.pk, - obj: elem + item: elem.pk }; }), modelRenderer: (row: TableFieldRowProps) => { return ( { return { - item: elem.pk, - obj: elem + item: elem.pk }; }), modelRenderer: (row: TableFieldRowProps) => { return ( @@ -1265,7 +1372,7 @@ function stockDeleteFields(items: any[]): ApiFormFieldSet { return ( @@ -1293,8 +1400,6 @@ type apiModalFunc = (props: ApiFormModalProps) => { function useStockOperationModal({ items, - pk, - model, refresh, fieldGenerator, endpoint, @@ -1305,9 +1410,7 @@ function useStockOperationModal({ modalFunc = useCreateApiFormModal }: { items?: object; - pk?: number; filters?: any; - model: ModelType | string; refresh: () => void; fieldGenerator: (items: any[]) => ApiFormFieldSet; endpoint: ApiEndpoints; @@ -1316,51 +1419,19 @@ function useStockOperationModal({ successMessage?: string; modalFunc?: apiModalFunc; }) { - const baseParams: any = { - part_detail: true, - location_detail: true, - cascade: false - }; - - const params = useMemo(() => { - const query_params: any = { - ...baseParams, - ...(filters ?? {}) - }; - - query_params[model] = - pk === undefined && model === 'location' ? 'null' : pk; - - return query_params; - }, [baseParams, filters, model, pk]); - const [opened, setOpened] = useState(false); - const { data } = useQuery({ - queryKey: ['stockitems', opened, model, pk, items, params], - queryFn: async () => { - if (items) { - // If a list of items is provided, use that directly - return Array.isArray(items) ? items : [items]; - } - - if (!pk || !opened) { - return []; - } - - const url = apiUrl(ApiEndpoints.stock_item_list); - - return api - .get(url, { - params: params - }) - .then((response) => response.data ?? []); - } + const stockItems = useStockItems({ + opened: opened, + items: items, + filters: filters }); - const fields = useMemo(() => { - return fieldGenerator(data); - }, [data]); + // Rebuild the "fields" object + const fields = useMemo( + () => fieldGenerator(stockItems), + [fieldGenerator, stockItems] + ); return modalFunc({ url: endpoint, @@ -1447,9 +1518,14 @@ export function useReturnStockItem(props: StockOperationProps) { } export function useCountStockItem(props: StockOperationProps) { + const fieldGenerator = useCallback( + (items: any[]) => stockCountFields(items), + [] + ); + return useStockOperationModal({ ...props, - fieldGenerator: stockCountFields, + fieldGenerator: fieldGenerator, endpoint: ApiEndpoints.stock_count, title: t`Count Stock`, successMessage: t`Stock counted`, diff --git a/src/frontend/src/forms/TransferOrderForms.tsx b/src/frontend/src/forms/TransferOrderForms.tsx index 638028603d..a80915e393 100644 --- a/src/frontend/src/forms/TransferOrderForms.tsx +++ b/src/frontend/src/forms/TransferOrderForms.tsx @@ -6,7 +6,10 @@ import { IconCalendar, IconUsers } from '@tabler/icons-react'; import { useMemo, useState } from 'react'; import RemoveRowButton from '../components/buttons/RemoveRowButton'; import { StandaloneField } from '../components/forms/StandaloneField'; -import type { TableFieldRowProps } from '../components/forms/fields/TableField'; +import { + TableFieldQuantityInput, + type TableFieldRowProps +} from '../components/forms/fields/TableField'; import { useCreateApiFormModal } from '../hooks/UseForm'; import { useGlobalSettingsState } from '../states/SettingsStates'; import { RenderPartColumn } from '../tables/ColumnRenderers'; @@ -110,6 +113,10 @@ function TransferOrderAllocateLineRow({ record: any; sourceLocation?: number | null; }>) { + const [quantity, setQuantity] = useState( + props.item?.quantity ?? '' + ); + // Statically defined field for selecting the stock item const stockItemField: ApiFormFieldType = useMemo(() => { return { @@ -128,41 +135,27 @@ function TransferOrderAllocateLineRow({ value: props.item.stock_item, name: 'stock_item', onValueChange: (value: any, instance: any) => { - props.changeFn(props.idx, 'stock_item', value); + props.changeFn(props.rowId, 'stock_item', value); // Update the allocated quantity based on the selected stock item if (instance) { const available = instance.quantity - instance.allocated; const required = record.quantity - record.allocated; - let quantity = props.item?.quantity ?? 0; + let q = props.item?.quantity ?? 0; - quantity = Math.max(quantity, required); - quantity = Math.min(quantity, available); + q = Math.max(q, required); + q = Math.min(q, available); - if (quantity != props.item.quantity) { - props.changeFn(props.idx, 'quantity', quantity); - } + setQuantity(q); + props.changeFn(props.rowId, 'quantity', q); } } }; - }, [sourceLocation, record, props]); - - // Statically defined field for selecting the allocation quantity - const quantityField: ApiFormFieldType = useMemo(() => { - return { - field_type: 'number', - name: 'quantity', - required: true, - value: props.item.quantity, - onValueChange: (value: any) => { - props.changeFn(props.idx, 'quantity', value); - } - }; - }, [props]); + }, [sourceLocation, record, quantity, props.changeFn]); return ( - + @@ -181,14 +174,18 @@ function TransferOrderAllocateLineRow({ /> - { + setQuantity(value === '' ? 0 : value); + props.changeFn(props.rowId, 'quantity', value); + }} error={props.rowErrors?.quantity?.message} /> - props.removeFn(props.idx)} /> + props.removeFn(props.rowId)} /> ); @@ -209,6 +206,16 @@ export function useAllocateToTransferOrderForm({ sourceLocationId || null ); + // Memoize the line items to prevent re-rendering + const lines = useMemo(() => { + return lineItems.map((item) => { + return { + id: item.pk, + ...item + }; + }); + }, [lineItems]); + const fields: ApiFormFieldSet = useMemo(() => { return { // Non-submitted field to select the source location @@ -237,11 +244,11 @@ export function useAllocateToTransferOrderForm({ ], modelRenderer: (row: TableFieldRowProps) => { const record = - lineItems.find((item) => item.pk == row.item.line_item) ?? {}; + lines.find((item) => item.pk == row.item.line_item) ?? {}; return ( { return { + id: item.pk, line_item: item.pk, quantity: 0, stock_item: null diff --git a/src/frontend/src/functions/debug.tsx b/src/frontend/src/functions/debug.tsx new file mode 100644 index 0000000000..bba89f3cea --- /dev/null +++ b/src/frontend/src/functions/debug.tsx @@ -0,0 +1,36 @@ +/** Various debugging helper functions for development */ + +import { useEffect, useRef } from 'react'; + +/** + * A custom hook that logs the previous and current props of a component whenever it updates. + */ +export function useWhyDidYouUpdate(name: string, props: any) { + const previousProps = useRef({}); + + useEffect(() => { + console.error( + 'useWhyDidYouUpdate should not be used in production code. It is intended for debugging purposes only.' + ); + + if (previousProps.current) { + const allKeys = Object.keys({ ...previousProps.current, ...props }); + const changedProps: any = {}; + + allKeys.forEach((key) => { + if ((previousProps as any).current[key] !== props[key]) { + (changedProps as any)[key] = { + from: (previousProps as any).current[key], + to: props[key] + }; + } + }); + + if (Object.keys(changedProps).length > 0) { + console.log(`[${name}] Changed props:`, changedProps); + } + } + + previousProps.current = props; + }); +} diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index 38194069b6..5349cd3f1c 100644 --- a/src/frontend/src/pages/part/PartDetail.tsx +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -1064,10 +1064,9 @@ export default function PartDetail() { const stockOperationProps: StockOperationProps = useMemo(() => { return { - pk: part.pk, - model: ModelType.part, refresh: refreshInstance, filters: { + part: part.pk, in_stock: true } }; diff --git a/src/frontend/src/pages/stock/LocationDetail.tsx b/src/frontend/src/pages/stock/LocationDetail.tsx index 0b13ba9f23..f9bdbad8fa 100644 --- a/src/frontend/src/pages/stock/LocationDetail.tsx +++ b/src/frontend/src/pages/stock/LocationDetail.tsx @@ -349,10 +349,9 @@ export default function Stock() { const stockOperationProps: StockOperationProps = useMemo(() => { return { - pk: location.pk, - model: 'location', refresh: refreshInstance, filters: { + location: location.pk, in_stock: true } }; diff --git a/src/frontend/src/pages/stock/StockDetail.tsx b/src/frontend/src/pages/stock/StockDetail.tsx index c2e700a3b8..5e118b4c6a 100644 --- a/src/frontend/src/pages/stock/StockDetail.tsx +++ b/src/frontend/src/pages/stock/StockDetail.tsx @@ -805,7 +805,6 @@ export default function StockDetail() { const stockOperationProps: StockOperationProps = useMemo(() => { return { items: [stockitem], - model: ModelType.stockitem, refresh: () => { const location = stockitem?.location; refreshInstancePromise().then((response) => { @@ -830,6 +829,8 @@ export default function StockDetail() { formProps: stockOperationProps, delete: false, changeBatch: false, + add: !stockitem.serial, + remove: !stockitem.serial, assign: !!stockitem.in_stock && stockitem.part_detail?.salable, return: !!stockitem.consumed_by || !!stockitem.customer, merge: false diff --git a/src/frontend/src/states/LocalState.tsx b/src/frontend/src/states/LocalState.tsx index 5b043d44c0..d8b40983bc 100644 --- a/src/frontend/src/states/LocalState.tsx +++ b/src/frontend/src/states/LocalState.tsx @@ -181,6 +181,6 @@ export function patchUser(key: 'language' | 'theme' | 'widgets', val: any) { if (uid) { api.patch(apiUrl(ApiEndpoints.user_me_profile), { [key]: val }); } else { - console.log('user not logged in, not patching'); + console.warn('user not logged in, not patching'); } } diff --git a/src/frontend/src/tables/build/BuildAllocatedStockTable.tsx b/src/frontend/src/tables/build/BuildAllocatedStockTable.tsx index ca70958099..0addd40f7e 100644 --- a/src/frontend/src/tables/build/BuildAllocatedStockTable.tsx +++ b/src/frontend/src/tables/build/BuildAllocatedStockTable.tsx @@ -233,7 +233,6 @@ export default function BuildAllocatedStockTable({ return { items: stockItems, - model: ModelType.stockitem, refresh: table.refreshTable }; }, [table.selectedRecords, table.refreshTable]); diff --git a/src/frontend/src/tables/build/BuildOutputTable.tsx b/src/frontend/src/tables/build/BuildOutputTable.tsx index 42d3cac02a..9f8df93b17 100644 --- a/src/frontend/src/tables/build/BuildOutputTable.tsx +++ b/src/frontend/src/tables/build/BuildOutputTable.tsx @@ -309,7 +309,10 @@ export default function BuildOutputTable({ allocated += allocation.quantity; }); - if (allocated >= item.bom_item_detail.quantity) { + if ( + item.bom_item_detail?.quantity && + allocated >= item.bom_item_detail.quantity + ) { fullyAllocatedCount += 1; } }); @@ -510,7 +513,6 @@ export default function BuildOutputTable({ const stockOperationProps: StockOperationProps = useMemo(() => { return { items: table.selectedRecords, - model: ModelType.stockitem, refresh: table.refreshTable, filters: {} }; diff --git a/src/frontend/src/tables/sales/SalesOrderAllocationTable.tsx b/src/frontend/src/tables/sales/SalesOrderAllocationTable.tsx index f8f10d8c6a..4fc03e9f68 100644 --- a/src/frontend/src/tables/sales/SalesOrderAllocationTable.tsx +++ b/src/frontend/src/tables/sales/SalesOrderAllocationTable.tsx @@ -285,7 +285,6 @@ export default function SalesOrderAllocationTable({ return { items: stockItems, - model: ModelType.stockitem, refresh: table.refreshTable }; }, [table.selectedRecords, table.refreshTable]); diff --git a/src/frontend/src/tables/settings/SelectionListDrawer.tsx b/src/frontend/src/tables/settings/SelectionListDrawer.tsx index 8b0100ae1a..4f3ab3d133 100644 --- a/src/frontend/src/tables/settings/SelectionListDrawer.tsx +++ b/src/frontend/src/tables/settings/SelectionListDrawer.tsx @@ -105,14 +105,12 @@ function SelectionListEntriesTable({ return [ RowEditAction({ onClick: () => { - console.log('record:', record); setSelectedEntry(record.id); editEntry.open(); } }), RowDeleteAction({ onClick: () => { - console.log('record:', record); setSelectedEntry(record.id); deleteEntry.open(); } diff --git a/src/frontend/src/tables/stock/StockItemTable.tsx b/src/frontend/src/tables/stock/StockItemTable.tsx index 45b3d1f639..e296fdadd3 100644 --- a/src/frontend/src/tables/stock/StockItemTable.tsx +++ b/src/frontend/src/tables/stock/StockItemTable.tsx @@ -399,7 +399,6 @@ export function StockItemTable({ const stockOperationProps: StockOperationProps = useMemo(() => { return { items: table.selectedRecords, - model: ModelType.stockitem, refresh: () => { table.clearSelectedRecords(); table.refreshTable(); diff --git a/src/frontend/src/tables/stock/TransferOrderAllocationTable.tsx b/src/frontend/src/tables/stock/TransferOrderAllocationTable.tsx index b1fc696ab1..4d51bafdff 100644 --- a/src/frontend/src/tables/stock/TransferOrderAllocationTable.tsx +++ b/src/frontend/src/tables/stock/TransferOrderAllocationTable.tsx @@ -224,7 +224,6 @@ export default function TransferOrderAllocationTable({ return { items: stockItems, - model: ModelType.stockitem, refresh: table.refreshTable }; }, [table.selectedRecords, table.refreshTable]); diff --git a/src/frontend/tests/pages/pui_dashboard.spec.ts b/src/frontend/tests/pages/pui_dashboard.spec.ts index eca6c9a004..435d8e29cf 100644 --- a/src/frontend/tests/pages/pui_dashboard.spec.ts +++ b/src/frontend/tests/pages/pui_dashboard.spec.ts @@ -159,8 +159,6 @@ test('Dashboard - Preserve widget sizes', async ({ browser }) => { for (const [bp, items] of Object.entries(await readLayouts(page))) { const entry = (items as any[]).find((i) => i?.i === 'ovr-so'); - console.log('entry:', bp, entry); - expect(entry?.w, `${bp}: ovr-so missing or wrong w`).toBe(TARGET_W); expect(entry?.h, `${bp}: ovr-so missing or wrong h`).toBe(TARGET_H); } From fb280d80e718e74ddaf3d62567c71293f5ceae9d Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 30 Jun 2026 18:54:35 +1000 Subject: [PATCH 7/8] Fix for STOCK_COUNT event (#12281) * Fix for STOCK_COUNT event - Prevent double emission of event type string * adjust unit test --- src/backend/InvenTree/stock/models.py | 7 ++----- src/backend/InvenTree/stock/tests.py | 6 ++---- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/src/backend/InvenTree/stock/models.py b/src/backend/InvenTree/stock/models.py index 39a476d9c8..fe7286674f 100644 --- a/src/backend/InvenTree/stock/models.py +++ b/src/backend/InvenTree/stock/models.py @@ -2663,7 +2663,7 @@ class StockItem( return True @transaction.atomic - def stocktake(self, count, user, **kwargs): + def stocktake(self, count, user, **kwargs) -> None: """Perform item stocktake. Arguments: @@ -2678,7 +2678,7 @@ class StockItem( try: count = Decimal(count) except InvalidOperation: - return False + return if count < 0: return False @@ -2729,13 +2729,10 @@ class StockItem( trigger_event( StockEvents.ITEM_COUNTED, - 'stockitem.counted', id=self.id, quantity=1 if self.serialized else float(self.quantity), ) - return True - @transaction.atomic def add_stock(self, quantity, user, **kwargs): """Add a specified quantity of stock to this item. diff --git a/src/backend/InvenTree/stock/tests.py b/src/backend/InvenTree/stock/tests.py index 999fc030b0..c0715bda64 100644 --- a/src/backend/InvenTree/stock/tests.py +++ b/src/backend/InvenTree/stock/tests.py @@ -378,10 +378,8 @@ class StockTest(StockTestBase): self.assertEqual(it.status, StockStatus.OK.value) # Next, perform a valid stocktake - self.assertTrue( - it.stocktake( - 100, None, notes='test stocktake', status=StockStatus.DAMAGED.value - ) + it.stocktake( + 100, None, notes='test stocktake', status=StockStatus.DAMAGED.value ) it.refresh_from_db() From 304c54530cd9d29588a3df850b76fa4b0ad25b76 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 30 Jun 2026 19:28:37 +1000 Subject: [PATCH 8/8] Allow stock adjustment from "category" detail view (#12256) --- .../src/pages/part/CategoryDetail.tsx | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/frontend/src/pages/part/CategoryDetail.tsx b/src/frontend/src/pages/part/CategoryDetail.tsx index 9d0077e9c8..77c2a8bf13 100644 --- a/src/frontend/src/pages/part/CategoryDetail.tsx +++ b/src/frontend/src/pages/part/CategoryDetail.tsx @@ -16,6 +16,7 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ModelType } from '@lib/enums/ModelType'; import { UserRoles } from '@lib/enums/Roles'; import { getDetailUrl } from '@lib/functions/Navigation'; +import type { StockOperationProps } from '@lib/types/Forms'; import type { PanelType } from '@lib/types/Panel'; import { useLocalStorage } from '@mantine/hooks'; import AdminButton from '../../components/buttons/AdminButton'; @@ -43,6 +44,7 @@ import { useEditApiFormModal } from '../../hooks/UseForm'; import { useInstance } from '../../hooks/UseInstance'; +import { useStockAdjustActions } from '../../hooks/UseStockAdjustActions'; import { useUserSettingsState } from '../../states/SettingsStates'; import { useUserState } from '../../states/UserState'; import ParametricPartTable from '../../tables/part/ParametricPartTable'; @@ -82,6 +84,28 @@ export default function CategoryDetail() { } }); + const stockOperationProps: StockOperationProps = useMemo(() => { + return { + refresh: refreshInstance, + filters: { + category: category.pk, + in_stock: true + } + }; + }, [category]); + + const stockAdjustActions = useStockAdjustActions({ + formProps: stockOperationProps, + enabled: true, + add: false, + remove: false, + changeStatus: false, + changeBatch: false, + delete: false, + merge: false, + assign: false + }); + const detailsPanel = useMemo(() => { if (id && instanceQuery.isFetching) { return ; @@ -241,6 +265,7 @@ export default function CategoryDetail() { refreshInstance(); }} />, + stockAdjustActions.dropdown, ]; - }, [id, user, category.pk, category.starred]); + }, [id, user, category.pk, category.starred, stockAdjustActions.dropdown]); const [partsView, setPartsView] = useLocalStorage({ key: 'category-parts-view', @@ -367,6 +392,7 @@ export default function CategoryDetail() { <> {editCategory.modal} {deleteCategory.modal} + {stockAdjustActions.modals.map((modal) => modal.modal)}