Merge branch 'master' into fix-issue-10769

This commit is contained in:
Alonso Lopez 2026-06-30 15:15:11 -07:00
commit 844e08814f
132 changed files with 41732 additions and 40158 deletions

View File

@ -31,7 +31,7 @@ jobs:
steps: steps:
- name: Checkout Code - name: Checkout Code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false

View File

@ -39,7 +39,7 @@ jobs:
docker: ${{ steps.filter.outputs.docker }} docker: ${{ steps.filter.outputs.docker }}
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # pin@v4.0.1 - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # pin@v4.0.1
@ -67,7 +67,7 @@ jobs:
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Test Docker Image - name: Test Docker Image
@ -139,7 +139,7 @@ jobs:
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Run Migration Tests - name: Run Migration Tests
@ -163,7 +163,7 @@ jobs:
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Set Up Python ${{ env.python_version }} - name: Set Up Python ${{ env.python_version }}

View File

@ -46,7 +46,7 @@ jobs:
force: ${{ steps.force.outputs.force }} force: ${{ steps.force.outputs.force }}
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # pin@v4.0.1 - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # pin@v4.0.1
@ -69,7 +69,7 @@ jobs:
timeout-minutes: 60 timeout-minutes: 60
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Environment Setup - name: Environment Setup
@ -123,7 +123,7 @@ jobs:
VITE_COVERAGE: false VITE_COVERAGE: false
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Environment Setup - name: Environment Setup
@ -205,7 +205,7 @@ jobs:
VITE_COVERAGE: true VITE_COVERAGE: true
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Environment Setup - name: Environment Setup
@ -273,7 +273,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false

View File

@ -48,7 +48,7 @@ jobs:
outputs: outputs:
server: ${{ steps.filter.outputs.server }} server: ${{ steps.filter.outputs.server }}
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # pin@v4.0.1 - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # pin@v4.0.1
@ -76,7 +76,7 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
fetch-depth: 0 fetch-depth: 0
persist-credentials: false persist-credentials: false

View File

@ -44,7 +44,7 @@ jobs:
submit-performance: ${{ steps.runner-perf.outputs.submit-performance }} submit-performance: ${{ steps.runner-perf.outputs.submit-performance }}
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # pin@v4.0.1 - 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' 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: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Set up Python ${{ env.python_version }} - 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' if: needs.paths-filter.outputs.server == 'true' || needs.paths-filter.outputs.requirements == 'true' || needs.paths-filter.outputs.force == 'true'
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Environment Setup - name: Environment Setup
@ -152,7 +152,7 @@ jobs:
steps: steps:
- name: Checkout Code - name: Checkout Code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Set up Python ${{ env.python_version }} - name: Set up Python ${{ env.python_version }}
@ -190,7 +190,7 @@ jobs:
version: ${{ steps.version.outputs.version }} version: ${{ steps.version.outputs.version }}
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Environment Setup - name: Environment Setup
@ -222,7 +222,7 @@ jobs:
echo "Downloaded api.yaml" echo "Downloaded api.yaml"
- name: Running OpenAPI Spec diff action - name: Running OpenAPI Spec diff action
id: breaking_changes id: breaking_changes
uses: oasdiff/oasdiff-action/diff@5fbe96ede8d0c53aeadef122d7a0abb79152d493 # pin@main uses: oasdiff/oasdiff-action/diff@3e9d440d37f468355457604348009f50e0cddbf3 # v0.1.4
with: with:
base: "api.yaml" base: "api.yaml"
revision: "src/backend/InvenTree/schema.yml" revision: "src/backend/InvenTree/schema.yml"
@ -275,7 +275,7 @@ jobs:
version: ${{ needs.schema.outputs.version }} version: ${{ needs.schema.outputs.version }}
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
name: Checkout Code name: Checkout Code
with: with:
repository: inventree/schema repository: inventree/schema
@ -332,7 +332,7 @@ jobs:
node_version: '>=24' node_version: '>=24'
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Environment Setup - name: Environment Setup
@ -363,7 +363,7 @@ jobs:
pip install . pip install .
if: needs.paths-filter.outputs.submit-performance == 'true' if: needs.paths-filter.outputs.submit-performance == 'true'
- name: Performance Reporting - 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 # 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' if: github.repository == 'inventree/InvenTree' && needs.paths-filter.outputs.submit-performance == 'true'
with: with:
@ -390,7 +390,7 @@ jobs:
python_version: ${{ matrix.python_version }} python_version: ${{ matrix.python_version }}
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Environment Setup - name: Environment Setup
@ -441,7 +441,7 @@ jobs:
INVENTREE_AUTO_UPDATE: true INVENTREE_AUTO_UPDATE: true
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Environment Setup - name: Environment Setup
@ -454,7 +454,7 @@ jobs:
env: env:
node_version: '>=24' node_version: '>=24'
- name: Performance Reporting - name: Performance Reporting
uses: CodSpeedHQ/action@c145068895e045cc725ee76fcd2307624b65c3af # pin@v4.17.5 uses: CodSpeedHQ/action@63f3e98b61959fe67f146a3ff022e4136fe9bb9c # v4.17.6
with: with:
mode: walltime mode: walltime
run: inv dev.test --pytest run: inv dev.test --pytest
@ -492,7 +492,7 @@ jobs:
- 6379:6379 - 6379:6379
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Environment Setup - name: Environment Setup
@ -541,7 +541,7 @@ jobs:
- 3306:3306 - 3306:3306
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Environment Setup - name: Environment Setup
@ -584,7 +584,7 @@ jobs:
- 5432:5432 - 5432:5432
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Environment Setup - name: Environment Setup
@ -618,7 +618,7 @@ jobs:
INVENTREE_PLUGINS_ENABLED: false INVENTREE_PLUGINS_ENABLED: false
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
name: Checkout Code name: Checkout Code
@ -674,8 +674,8 @@ jobs:
security-events: write security-events: write
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Run zizmor 🌈 - name: Run zizmor 🌈
uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6 uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7

View File

@ -20,7 +20,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps: steps:
- name: Checkout Code - name: Checkout Code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Version Check - name: Version Check
@ -45,7 +45,7 @@ jobs:
artifact-metadata: write artifact-metadata: write
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Environment Setup - name: Environment Setup
@ -115,7 +115,7 @@ jobs:
INVENTREE_DEBUG: true INVENTREE_DEBUG: true
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Environment Setup - name: Environment Setup
@ -156,7 +156,7 @@ jobs:
- debian:13 - debian:13
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
fetch-depth: 0 fetch-depth: 0
persist-credentials: false persist-credentials: false
@ -216,7 +216,7 @@ jobs:
pip install --require-hashes -r contrib/dev_reqs/requirements.txt pip install --require-hashes -r contrib/dev_reqs/requirements.txt
python3 .github/scripts/version_check.py python3 .github/scripts/version_check.py
- name: Package - current release channel - name: Package - current release channel
uses: pkgr/action/package@c5666febcd31750da6428042193fc5b2fb765435 # pin@main uses: pkgr/action/package@c5666febcd31750da6428042193fc5b2fb765435 # main
id: package id: package
with: with:
target: ${{ matrix.target }} target: ${{ matrix.target }}
@ -234,7 +234,7 @@ jobs:
INVENTREE_CONFIG_FILE=/opt/inventree/config.yaml INVENTREE_CONFIG_FILE=/opt/inventree/config.yaml
APP_REPO=inventree/InvenTree APP_REPO=inventree/InvenTree
- name: Publish to go.packager.io - current release channel - name: Publish to go.packager.io - current release channel
uses: pkgr/action/publish@3bce081ae512c5020856e237d37b3f5479d4aa71 # pin@main uses: pkgr/action/publish@c5666febcd31750da6428042193fc5b2fb765435 # main
with: with:
target: ${{ matrix.target }} target: ${{ matrix.target }}
token: ${{ secrets.PACKAGER_RELEASE_TOKEN }} token: ${{ secrets.PACKAGER_RELEASE_TOKEN }}
@ -249,7 +249,7 @@ jobs:
PACKAGE_NAME: ${{ matrix.target }}-${{ steps.setup.outputs.version }}.tar.gz PACKAGE_NAME: ${{ matrix.target }}-${{ steps.setup.outputs.version }}.tar.gz
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Package - stable release channel - name: Package - stable release channel
uses: pkgr/action/package@c5666febcd31750da6428042193fc5b2fb765435 # pin@main uses: pkgr/action/package@c5666febcd31750da6428042193fc5b2fb765435 # main
id: package-stable id: package-stable
with: with:
target: ${{ matrix.target }} target: ${{ matrix.target }}
@ -267,7 +267,7 @@ jobs:
INVENTREE_CONFIG_FILE=/opt/inventree/config.yaml INVENTREE_CONFIG_FILE=/opt/inventree/config.yaml
APP_REPO=inventree/InvenTree APP_REPO=inventree/InvenTree
- name: Publish to go.packager.io - stable release channel - name: Publish to go.packager.io - stable release channel
uses: pkgr/action/publish@3bce081ae512c5020856e237d37b3f5479d4aa71 # pin@main uses: pkgr/action/publish@c5666febcd31750da6428042193fc5b2fb765435 # main
with: with:
target: ${{ matrix.target }} target: ${{ matrix.target }}
token: ${{ secrets.PACKAGER_RELEASE_TOKEN }} token: ${{ secrets.PACKAGER_RELEASE_TOKEN }}

View File

@ -32,7 +32,7 @@ jobs:
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false

View File

@ -32,7 +32,7 @@ jobs:
steps: steps:
- name: Checkout Code - name: Checkout Code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Environment Setup - name: Environment Setup

View File

@ -17,6 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### 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 ### Removed
## 1.4.0 - 2026-06-24 ## 1.4.0 - 2026-06-24

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -2663,7 +2663,7 @@ class StockItem(
return True return True
@transaction.atomic @transaction.atomic
def stocktake(self, count, user, **kwargs): def stocktake(self, count, user, **kwargs) -> None:
"""Perform item stocktake. """Perform item stocktake.
Arguments: Arguments:
@ -2678,7 +2678,7 @@ class StockItem(
try: try:
count = Decimal(count) count = Decimal(count)
except InvalidOperation: except InvalidOperation:
return False return
if count < 0: if count < 0:
return False return False
@ -2706,8 +2706,8 @@ class StockItem(
) )
tracking_info['old_status_logical'] = old_status_logical tracking_info['old_status_logical'] = old_status_logical
if self.updateQuantity(count): if self.serialized or self.updateQuantity(count):
tracking_info['quantity'] = float(count) tracking_info['quantity'] = 1 if self.serialized else float(count)
self.stocktake_date = InvenTree.helpers.current_date() self.stocktake_date = InvenTree.helpers.current_date()
self.stocktake_user = user self.stocktake_user = user
@ -2727,14 +2727,11 @@ class StockItem(
deltas=tracking_info, deltas=tracking_info,
) )
trigger_event( trigger_event(
StockEvents.ITEM_COUNTED, StockEvents.ITEM_COUNTED,
'stockitem.counted', id=self.id,
id=self.id, quantity=1 if self.serialized else float(self.quantity),
quantity=float(self.quantity), )
)
return True
@transaction.atomic @transaction.atomic
def add_stock(self, quantity, user, **kwargs): def add_stock(self, quantity, user, **kwargs):

View File

@ -1770,7 +1770,7 @@ class StockCountSerializer(StockAdjustmentSerializer):
fields = ['items', 'notes', 'location'] fields = ['items', 'notes', 'location']
location = serializers.PrimaryKeyRelatedField( location = serializers.PrimaryKeyRelatedField(
queryset=StockLocation.objects.filter(structural=False), queryset=StockLocation.objects.filter(),
many=False, many=False,
required=False, required=False,
allow_null=True, allow_null=True,
@ -1778,6 +1778,15 @@ class StockCountSerializer(StockAdjustmentSerializer):
help_text=_('Set stock location for counted items (optional)'), help_text=_('Set stock location for counted items (optional)'),
) )
def validate_location(self, location):
"""Validate the provided location."""
if location and location.structural:
raise ValidationError(
_('Structural locations cannot be assigned stock items')
)
return location
def save(self): def save(self):
"""Count stock.""" """Count stock."""
request = self.context['request'] request = self.context['request']
@ -1874,7 +1883,7 @@ class StockTransferSerializer(StockAdjustmentSerializer):
items = StockAdjustmentItemSerializer(many=True, require_non_zero=True) items = StockAdjustmentItemSerializer(many=True, require_non_zero=True)
location = serializers.PrimaryKeyRelatedField( location = serializers.PrimaryKeyRelatedField(
queryset=StockLocation.objects.filter(structural=False), queryset=StockLocation.objects.filter(),
many=False, many=False,
required=True, required=True,
allow_null=False, allow_null=False,
@ -1882,6 +1891,15 @@ class StockTransferSerializer(StockAdjustmentSerializer):
help_text=_('Destination stock location'), help_text=_('Destination stock location'),
) )
def validate_location(self, location):
"""Validate the provided location."""
if location and location.structural:
raise ValidationError(
_('Structural locations cannot be assigned stock items')
)
return location
def save(self): def save(self):
"""Transfer stock.""" """Transfer stock."""
request = self.context['request'] request = self.context['request']
@ -1965,7 +1983,7 @@ class StockReturnSerializer(StockAdjustmentSerializer):
) )
location = serializers.PrimaryKeyRelatedField( location = serializers.PrimaryKeyRelatedField(
queryset=StockLocation.objects.filter(structural=False), queryset=StockLocation.objects.filter(),
many=False, many=False,
required=True, required=True,
allow_null=False, allow_null=False,
@ -1973,6 +1991,15 @@ class StockReturnSerializer(StockAdjustmentSerializer):
help_text=_('Destination stock location'), help_text=_('Destination stock location'),
) )
def validate_location(self, location):
"""Validate the provided location."""
if location and location.structural:
raise ValidationError(
_('Structural locations cannot be assigned stock items')
)
return location
merge = serializers.BooleanField( merge = serializers.BooleanField(
default=False, default=False,
required=False, required=False,

View File

@ -2289,6 +2289,32 @@ class StocktakeTest(StockAPITestCase):
self.assertEqual(response.data['items'][0]['pk'], 1234) self.assertEqual(response.data['items'][0]['pk'], 1234)
self.assertEqual(response.data['items'][0]['quantity'], target[endpoint]) 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): def test_transfer(self):
"""Test stock transfers.""" """Test stock transfers."""
stock_item = StockItem.objects.get(pk=1234) stock_item = StockItem.objects.get(pk=1234)
@ -2409,7 +2435,10 @@ class StocktakeTest(StockAPITestCase):
{'items': [{'pk': item_a.pk, 'quantity': 1}], 'location': structural.pk}, {'items': [{'pk': item_a.pk, 'quantity': 1}], 'location': structural.pk},
expected_code=400, expected_code=400,
) )
self.assertIn('does not exist', str(response.data['location'])) self.assertIn(
'Structural locations cannot be assigned stock items',
str(response.data['location']),
)
class StockTransferMergeTest(StockAPITestCase): class StockTransferMergeTest(StockAPITestCase):

View File

@ -378,10 +378,8 @@ class StockTest(StockTestBase):
self.assertEqual(it.status, StockStatus.OK.value) self.assertEqual(it.status, StockStatus.OK.value)
# Next, perform a valid stocktake # Next, perform a valid stocktake
self.assertTrue( it.stocktake(
it.stocktake( 100, None, notes='test stocktake', status=StockStatus.DAMAGED.value
100, None, notes='test stocktake', status=StockStatus.DAMAGED.value
)
) )
it.refresh_from_db() it.refresh_from_db()

View File

@ -221,8 +221,6 @@ export interface BulkEditApiFormModalProps extends ApiFormModalProps {
export type StockOperationProps = { export type StockOperationProps = {
items?: any[]; items?: any[];
pk?: number;
filters?: any; filters?: any;
model: ModelType.stockitem | 'location' | ModelType.part;
refresh: () => void; refresh: () => void;
}; };

View File

@ -1,7 +1,7 @@
{ {
"name": "@inventreedb/ui", "name": "@inventreedb/ui",
"description": "UI components for the InvenTree project", "description": "UI components for the InvenTree project",
"version": "1.4.6", "version": "1.5.0",
"private": false, "private": false,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",

View File

@ -455,6 +455,12 @@ export function ApiForm({
} }
}) })
.then((response) => { .then((response) => {
const followPk = Array.isArray(response.data)
? response.data.length === 1
? response.data[0]?.pk
: undefined
: response.data?.pk;
switch (response.status) { switch (response.status) {
case 200: case 200:
case 201: case 201:
@ -469,12 +475,12 @@ export function ApiForm({
if ( if (
props.follow && props.follow &&
props.modelType && props.modelType &&
response.data?.pk && followPk &&
!keepOpenRef.current !keepOpenRef.current
) { ) {
// If we want to automatically follow the returned data // If we want to automatically follow the returned data
if (!!navigate && !keepOpenRef.current) { if (!!navigate && !keepOpenRef.current) {
navigate(getDetailUrl(props.modelType, response.data?.pk)); navigate(getDetailUrl(props.modelType, followPk));
} }
} else if (props.table) { } else if (props.table) {
// If we want to automatically update or reload a linked table // If we want to automatically update or reload a linked table

View File

@ -4,6 +4,9 @@ import { useId } from '@mantine/hooks';
import { useCallback, useEffect, useMemo } from 'react'; import { useCallback, useEffect, useMemo } from 'react';
import { type Control, type FieldValues, useController } from 'react-hook-form'; import { type Control, type FieldValues, useController } from 'react-hook-form';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { apiUrl } from '@lib/functions/Api';
import type { ApiFormFieldSet, ApiFormFieldType } from '@lib/types/Forms'; import type { ApiFormFieldSet, ApiFormFieldType } from '@lib/types/Forms';
import { IconFileUpload } from '@tabler/icons-react'; import { IconFileUpload } from '@tabler/icons-react';
import type { NavigateFunction } from 'react-router-dom'; import type { NavigateFunction } from 'react-router-dom';
@ -19,6 +22,7 @@ import { RelatedModelField } from './RelatedModelField';
import { TableField } from './TableField'; import { TableField } from './TableField';
import TagsField from './TagsField'; import TagsField from './TagsField';
import TextField from './TextField'; import TextField from './TextField';
import { TreeField } from './TreeField';
/** /**
* Render an individual form field * Render an individual form field
@ -121,14 +125,48 @@ export function ApiFormField({
const fieldInstance = useMemo(() => { const fieldInstance = useMemo(() => {
switch (fieldDefinition.field_type) { switch (fieldDefinition.field_type) {
case 'related field': case 'related field':
return ( if (
<RelatedModelField fieldDefinition.api_url === apiUrl(ApiEndpoints.stock_location_list)
definition={fieldDefinition} ) {
controller={controller} // Redirect location fields to the appropriate tree field
fieldName={fieldName} return (
navigate={navigate} <TreeField
/> controller={controller}
); definition={fieldDefinition}
fieldName={fieldName}
endpoint={ApiEndpoints.stock_location_tree}
childIdentifier='sublocations'
genericPlaceholder={t`Select location`}
model={ModelType.stocklocation}
navigate={navigate}
/>
);
} else if (
fieldDefinition.api_url === apiUrl(ApiEndpoints.category_list)
) {
// Redirect category fields to the appropriate tree field
return (
<TreeField
controller={controller}
definition={fieldDefinition}
fieldName={fieldName}
endpoint={ApiEndpoints.category_tree}
childIdentifier='subcategories'
genericPlaceholder={t`Select category`}
model={ModelType.partcategory}
navigate={navigate}
/>
);
} else {
return (
<RelatedModelField
definition={fieldDefinition}
controller={controller}
fieldName={fieldName}
navigate={navigate}
/>
);
}
case 'email': case 'email':
case 'url': case 'url':
case 'string': case 'string':
@ -247,7 +285,9 @@ export function ApiFormField({
<TableField <TableField
definition={fieldDefinition} definition={fieldDefinition}
fieldName={fieldName} fieldName={fieldName}
control={controller} value={value}
onChange={field.onChange}
error={error}
/> />
); );
case 'tags': case 'tags':

View File

@ -419,6 +419,7 @@ export function RelatedModelField({
...definition, ...definition,
addCreateFields: undefined, addCreateFields: undefined,
autoFill: undefined, autoFill: undefined,
autoFillFilters: undefined,
modelRenderer: undefined, modelRenderer: undefined,
onValueChange: undefined, onValueChange: undefined,
adjustFilters: undefined, adjustFilters: undefined,

View File

@ -1,9 +1,23 @@
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { Trans } from '@lingui/react/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 { IconExclamationCircle } from '@tabler/icons-react';
import { type ReactNode, useCallback, useEffect, useMemo } from 'react'; import {
import type { FieldValues, UseControllerReturn } from 'react-hook-form'; type ReactNode,
memo,
useCallback,
useEffect,
useMemo,
useRef
} from 'react';
import { AddItemButton } from '@lib/components/AddItemButton'; import { AddItemButton } from '@lib/components/AddItemButton';
import { identifierString } from '@lib/functions/Conversion'; import { identifierString } from '@lib/functions/Conversion';
@ -13,35 +27,34 @@ import { StandaloneField } from '../StandaloneField';
export interface TableFieldRowProps { export interface TableFieldRowProps {
item: any; item: any;
idx: number; rowId: string | number;
rowErrors: any; rowErrors: any;
control: UseControllerReturn<FieldValues, any>; changeFn: (rowId: number | string, key: string, value: any) => void;
changeFn: (idx: number, key: string, value: any) => void; removeFn: (rowId: number | string) => void;
removeFn: (idx: number) => void;
} }
function TableFieldRow({ function TableFieldRow({
item, item,
idx, rowId,
errors, rowErrors,
definition, modelRenderer,
control, columnCount,
changeFn, changeFn,
removeFn removeFn
}: Readonly<{ }: Readonly<{
item: any; item: any;
idx: number; rowId: string | number;
errors: any; rowErrors: any;
definition: ApiFormFieldType; modelRenderer?: ApiFormFieldType['modelRenderer'];
control: UseControllerReturn<FieldValues, any>; columnCount?: number;
changeFn: (idx: number, key: string, value: any) => void; changeFn: (rowId: number | string, key: string, value: any) => void;
removeFn: (idx: number) => void; removeFn: (rowId: number | string) => void;
}>) { }>) {
// Table fields require render function // Table fields require render function
if (!definition.modelRenderer) { if (!modelRenderer) {
return ( return (
<Table.Tr key='table-row-no-renderer'> <Table.Tr key='table-row-no-renderer'>
<Table.Td colSpan={definition.headers?.length}> <Table.Td colSpan={columnCount}>
<Alert color='red' title={t`Error`} icon={<IconExclamationCircle />}> <Alert color='red' title={t`Error`} icon={<IconExclamationCircle />}>
{t`modelRenderer entry required for tables`} {t`modelRenderer entry required for tables`}
</Alert> </Alert>
@ -50,16 +63,60 @@ function TableFieldRow({
); );
} }
return definition.modelRenderer({ return modelRenderer({
item: item, item: item,
idx: idx, rowId: rowId,
rowErrors: errors, rowErrors: rowErrors,
control: control,
changeFn: changeFn, changeFn: changeFn,
removeFn: removeFn 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({ export function TableFieldErrorWrapper({
props, props,
errorKey, errorKey,
@ -83,33 +140,131 @@ export function TableFieldErrorWrapper({
); );
} }
export function TableField({ function TableFieldComponent({
definition, definition,
fieldName, fieldName,
control value,
onChange,
error
}: Readonly<{ }: Readonly<{
definition: ApiFormFieldType; definition: ApiFormFieldType;
fieldName: string; fieldName: string;
control: UseControllerReturn<FieldValues, any>; value: any;
onChange: (value: any) => void;
error?: any;
}>) { }>) {
const { const valueRef = useRef(value);
field, const onChangeRef = useRef(onChange);
fieldState: { error } const rowIndexByIdRef = useRef(new Map<string | number, number>());
} = control; const generatedRowIdsRef = useRef(new WeakMap<object, string>());
const { value } = field; const generatedRowIdCounterRef = useRef(0);
const onRowFieldChange = (idx: number, key: string, value: any) => { const getRowIdentifier = useCallback(
const val = field.value; (item: any, idx: number): string | number => {
val[idx][key] = value; 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 existingGeneratedId = generatedRowIdsRef.current.get(item);
const val = field.value;
val.splice(idx, 1); if (existingGeneratedId) {
field.onChange(val); 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<string | number, number>();
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 // Extract errors associated with the current row
const rowErrors: any = useCallback( const rowErrors: any = useCallback(
@ -125,7 +280,7 @@ export function TableField({
<Table <Table
highlightOnHover highlightOnHover
striped striped
aria-label={`table-field-${field.name}`} aria-label={`table-field-${fieldName}`}
style={{ width: '100%' }} style={{ width: '100%' }}
> >
<Table.Thead> <Table.Thead>
@ -146,14 +301,16 @@ export function TableField({
<Table.Tbody> <Table.Tbody>
{(value?.length ?? 0) > 0 ? ( {(value?.length ?? 0) > 0 ? (
value.map((item: any, idx: number) => { value.map((item: any, idx: number) => {
const rowId = getRowIdentifier(item, idx);
return ( return (
<TableFieldRow <MemoizedTableFieldRow
key={`table-row-${idx}`} key={`table-row-${rowId}`}
item={item} item={item}
idx={idx} rowId={rowId}
errors={rowErrors(idx)} rowErrors={rowErrors(idx)}
control={control} modelRenderer={definition.modelRenderer}
definition={definition} columnCount={definition.headers?.length}
changeFn={onRowFieldChange} changeFn={onRowFieldChange}
removeFn={removeRow} removeFn={removeRow}
/> />
@ -189,9 +346,7 @@ export function TableField({
if (definition.addRow === undefined) return; if (definition.addRow === undefined) return;
const ret = definition.addRow(); const ret = definition.addRow();
if (ret) { if (ret) {
const val = field.value; onChange([...(value ?? []), ret]);
val.push(ret);
field.onChange(val);
} }
}} }}
/> />
@ -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. * 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 * - Each "row" can display an extra row of information below the main row
@ -224,10 +392,16 @@ export function TableFieldExtraRow({
emptyValue?: any; emptyValue?: any;
onValueChange: (value: any) => void; onValueChange: (value: any) => void;
}) { }) {
const hasMounted = useRef(false);
// Callback whenever the visibility of the sub-field changes // Callback whenever the visibility of the sub-field changes
// Skip the initial mount — the value was never set, nothing to reset
useEffect(() => { useEffect(() => {
if (!hasMounted.current) {
hasMounted.current = true;
return;
}
if (!visible) { if (!visible) {
// If the sub-field is hidden, reset the value to the "empty" value
onValueChange(emptyValue); onValueChange(emptyValue);
} }
}, [visible]); }, [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 (
<NumberInput
radius='sm'
aria-label='number-field-quantity'
min={min}
step={1}
decimalScale={10}
value={value}
onChange={(v: number | string) => {
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}
/>
);
}

View File

@ -0,0 +1,499 @@
import { t } from '@lingui/core/macro';
import {
ActionIcon,
Group,
Input,
Text,
type TreeNodeData,
TreeSelect
} from '@mantine/core';
import { useDebouncedValue, useId } from '@mantine/hooks';
import {
IconChevronDown,
IconChevronRight,
IconLink,
IconX
} from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { FieldValues, UseControllerReturn } from 'react-hook-form';
import type { NavigateFunction } from 'react-router-dom';
import type { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelInformationDict } from '@lib/enums/ModelInformation';
import type { ModelType } from '@lib/enums/ModelType';
import { apiUrl } from '@lib/functions/Api';
import { cancelEvent } from '@lib/functions/Events';
import { getDetailUrl, navigateToLink } from '@lib/index';
import type { ApiFormFieldType } from '@lib/types/Forms';
import { useApi } from '../../../contexts/ApiContext';
import {
useGlobalSettingsState,
useUserSettingsState
} from '../../../states/SettingsStates';
import { ScanButton } from '../../buttons/ScanButton';
import { ApiIcon } from '../../items/ApiIcon';
import Expand from '../../items/Expand';
import { ModelHoverCard } from '../../render/ModelHoverCard';
/**
* A form field that renders a hierarchical tree selector backed by a tree API
* endpoint. Supports server-side search, lazy child loading, and (when a model
* type is provided) barcode scanning and a hover-card navigate link.
*/
export function TreeField({
controller,
definition,
fieldName,
endpoint,
childIdentifier,
genericPlaceholder,
model,
navigate
}: Readonly<{
controller: UseControllerReturn<FieldValues, any>;
definition: ApiFormFieldType;
fieldName: string;
endpoint: ApiEndpoints;
childIdentifier: string;
genericPlaceholder?: string;
model?: ModelType;
navigate?: NavigateFunction | null;
}>) {
const api = useApi();
const inputId = useId();
const globalSettings = useGlobalSettingsState();
const userSettings = useUserSettingsState();
const {
field,
fieldState: { error }
} = controller;
// Keep the selected pk in sync with form state so we can always request
// the selected node (and its ancestors) for label hydration.
const selectedValue = useMemo(
() => (field.value != null ? Number(field.value) : null),
[field.value]
);
// Track dropdown state to separate server-side search text from the
// read-only display label shown when the field is closed.
const dropdownOpen = useRef(false);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
// Track the pk whose ancestor path was last auto-expanded.
const expandedForValue = useRef<number | null>(null);
const [searchValue, setSearchValue] = useState('');
const [debouncedSearch] = useDebouncedValue(searchValue, 300);
const [expandedValues, setExpandedValues] = useState<string[]>([]);
const [nodes, setNodes] = useState<any[]>([]);
const query = useQuery({
enabled:
!definition.disabled &&
!definition.hidden &&
(isDropdownOpen || selectedValue != null),
queryKey: [
'tree-field',
fieldName,
endpoint,
isDropdownOpen,
debouncedSearch,
selectedValue
],
queryFn: () =>
api
.get(apiUrl(endpoint), {
params: {
...definition.filters,
ordering: 'level',
search: debouncedSearch || undefined,
// Include the selected node and its ancestors in the initial response
// so the node label is available before the user interacts with the field.
max_level: debouncedSearch ? undefined : 0,
expand_to: debouncedSearch
? undefined
: (selectedValue ?? undefined)
}
})
.then((res) => res.data ?? [])
});
useEffect(() => {
setNodes(query.data ?? []);
}, [query.data]);
// O(1) lookup for raw node data inside renderNode
const nodeMap = useMemo(() => {
const map: Record<string, any> = {};
for (const n of nodes) map[n.pk.toString()] = n;
return map;
}, [nodes]);
// Expand all returned nodes when a search is active so users can see all matches.
// On the first browse-mode load, expand the ancestors of the initial value so
// the tree shows the path to the currently-selected node.
useEffect(() => {
if (debouncedSearch) {
setExpandedValues(nodes.map((n: any) => n.pk.toString()));
return;
}
if (
selectedValue != null &&
expandedForValue.current !== selectedValue &&
nodes.length > 0
) {
expandedForValue.current = selectedValue;
const map: Record<number, any> = {};
for (const n of nodes) map[n.pk] = n;
const toExpand: string[] = [];
let cur = map[selectedValue];
while (cur?.parent) {
toExpand.push(String(cur.parent));
cur = map[cur.parent];
}
setExpandedValues(toExpand);
return;
}
if (selectedValue == null) {
expandedForValue.current = null;
}
}, [debouncedSearch, nodes, selectedValue]);
// Convert the flat API response (sorted by level) into the nested TreeNodeData structure.
// `children` is intentionally left undefined on leaf nodes: Mantine's flatten logic uses
// Array.isArray(node.children) to detect loaded children, so an empty [] would make every
// node look like a parent. Instead we set node.hasChildren from the server-side count field
// (childIdentifier) and only attach a children array when a child is actually encountered.
const treeData: TreeNodeData[] = useMemo(() => {
const map: Record<number, any> = {};
const tree: TreeNodeData[] = [];
const sorted = [...nodes].sort((a, b) => a.level - b.level);
for (const raw of sorted) {
const node: any = {
value: raw.pk.toString(),
label: raw.name as string,
hasChildren: (raw[childIdentifier] ?? 0) > 0
};
map[raw.pk] = node;
if (!raw.parent) {
tree.push(node);
} else if (map[raw.parent]) {
if (!map[raw.parent].children) {
map[raw.parent].children = [];
}
map[raw.parent].children.push(node);
} else {
// Keep orphaned nodes visible so selected labels can still resolve
// if the API response omits an ancestor.
tree.push(node);
}
}
return tree;
}, [nodes, childIdentifier]);
const onChange = useCallback(
(val: string | null) => {
const pk = val ? Number.parseInt(val, 10) : null;
const raw = val ? (nodeMap[val] ?? {}) : {};
field.onChange(pk);
definition.onValueChange?.(pk, raw);
},
[field, definition, nodeMap]
);
const selectValue = useMemo(
() => (field.value != null ? field.value.toString() : null),
[field.value]
);
const selectedLabel = useMemo(() => {
if (selectValue == null) return '';
const node = nodeMap[selectValue];
return node?.pathstring ?? node?.name ?? selectValue;
}, [nodeMap, selectValue]);
const inputSearchValue = isDropdownOpen ? searchValue : selectedLabel;
const refreshChildren = useCallback(
async (nodeValue: string) => {
const pk = Number.parseInt(nodeValue, 10);
if (Number.isNaN(pk)) return;
const node = nodeMap[nodeValue];
if (!node) return;
const response = await api.get(apiUrl(endpoint), {
params: {
...definition.filters,
ordering: 'level',
parent: pk,
max_level: (node.level ?? 0) + 1
}
});
const children: any[] = response.data ?? [];
setNodes((prev) => {
const base = prev.filter((n) => n.parent !== pk);
const byPk = new Map<number, any>();
for (const n of base) {
byPk.set(n.pk, n);
}
for (const child of children) {
byPk.set(child.pk, child);
}
if (children.length === 0) {
const parentNode = byPk.get(pk);
if (parentNode) {
byPk.set(pk, { ...parentNode, [childIdentifier]: 0 });
}
}
return Array.from(byPk.values());
});
},
[api, endpoint, nodeMap, childIdentifier]
);
const toggleExpanded = useCallback(
(nodeValue: string) => {
setExpandedValues((prev) => {
const isExpanded = prev.includes(nodeValue);
if (!isExpanded) {
void refreshChildren(nodeValue);
return [...prev, nodeValue];
}
return prev.filter((v) => v !== nodeValue);
});
},
[refreshChildren]
);
// --- Navigate / hovercard ---
const detailUrl = useMemo(() => {
if (!model || !selectedValue) return '';
return getDetailUrl(model, selectedValue, true);
}, [model, selectedValue]);
const handleNavigate = useCallback(
(e: any) => {
if (navigate && detailUrl) navigateToLink(detailUrl, navigate, e);
},
[navigate, detailUrl]
);
// When a navigate model is present and a value is selected, swap out
// Mantine's built-in clear button for a custom right section that holds
// both a clear button and a link to the model detail page (with tooltip).
const showNavigateSection = Boolean(model && selectedValue);
const navigateRightSection = showNavigateSection ? (
<Group gap={2} wrap='nowrap' style={{ paddingRight: 4 }}>
{!definition.required && selectValue && (
<ActionIcon
variant='transparent'
size='xs'
color='dimmed'
aria-label={t`Clear`}
onClick={(e: any) => {
cancelEvent(e);
onChange(null);
}}
>
<IconX size={12} />
</ActionIcon>
)}
<ModelHoverCard model={model} pk={selectedValue} navigate={navigate}>
<ActionIcon
variant='transparent'
size='xs'
component='a'
href={detailUrl || '#'}
target='_blank'
aria-label={t`View details`}
onClick={handleNavigate}
>
<IconLink size={12} />
</ActionIcon>
</ModelHoverCard>
</Group>
) : undefined;
// --- Barcode scanning ---
const modelInfo = useMemo(
() => (model ? ModelInformationDict[model] : null),
[model]
);
const addBarcodeField = useMemo(() => {
if (!modelInfo?.supports_barcode) return false;
if (!globalSettings.isSet('BARCODE_ENABLE')) return false;
if (!userSettings.isSet('BARCODE_IN_FORM_FIELDS')) return false;
return true;
}, [modelInfo, globalSettings, userSettings]);
const onBarcodeScan = useCallback(
(_barcode: string, response: any) => {
if (!model) return;
const modelData = response?.[model] ?? null;
if (modelData?.pk) {
field.onChange(modelData.pk);
definition.onValueChange?.(modelData.pk, modelData);
}
},
[model, field, definition]
);
// --- Render ---
return (
<Input.Wrapper
label={definition.label}
description={definition.description}
required={definition.required}
error={definition.error ?? error?.message}
id={inputId}
>
<Group wrap='nowrap' gap={3}>
<Expand>
<TreeSelect
id={inputId}
data={treeData}
aria-label={`tree-field-${fieldName}`}
value={selectValue}
searchValue={inputSearchValue}
onChange={onChange}
onSearchChange={(val) => {
if (dropdownOpen.current) setSearchValue(val);
}}
searchable
filter={() => true}
clearable={showNavigateSection ? false : !definition.required}
rightSection={navigateRightSection}
rightSectionPointerEvents={showNavigateSection ? 'all' : undefined}
rightSectionWidth={
showNavigateSection ? (definition.required ? 28 : 52) : undefined
}
expandedValues={expandedValues}
onExpandedChange={setExpandedValues}
onDropdownOpen={() => {
dropdownOpen.current = true;
setIsDropdownOpen(true);
}}
onDropdownClose={() => {
dropdownOpen.current = false;
setIsDropdownOpen(false);
setSearchValue('');
}}
placeholder={
isDropdownOpen && selectedLabel
? selectedLabel
: (definition.placeholder ?? genericPlaceholder ?? t`Select...`)
}
disabled={definition.disabled}
comboboxProps={{ withinPortal: true }}
maxDropdownHeight={300}
nothingFoundMessage={
query.isFetching ? t`Loading...` : t`No results found`
}
renderNode={({ node, expanded, hasChildren, selected }) => {
const raw = nodeMap[node.value];
return (
<Group
justify='space-between'
gap='xs'
wrap='nowrap'
style={{ flex: 1 }}
>
<Group gap={4} wrap='nowrap'>
{/* Chevron rendered manually so renderNode can coexist with
expand behavior. stopPropagation prevents the
Combobox.Option from selecting the node when the user
clicks the expand toggle. */}
<span
role={hasChildren ? 'button' : undefined}
tabIndex={hasChildren ? 0 : undefined}
style={{
display: 'inline-flex',
width: 16,
alignItems: 'center',
flexShrink: 0,
cursor: hasChildren ? 'pointer' : 'default'
}}
aria-label={expanded ? t`Collapse` : t`Expand`}
onClick={
hasChildren
? (event: any) => {
cancelEvent(event);
toggleExpanded(node.value);
}
: undefined
}
onKeyDown={
hasChildren
? (event: any) => {
if (event.key === 'Enter' || event.key === ' ') {
cancelEvent(event);
toggleExpanded(node.value);
}
}
: undefined
}
>
{hasChildren &&
(expanded ? (
<IconChevronDown size={14} />
) : (
<IconChevronRight size={14} />
))}
</span>
{raw?.icon && <ApiIcon name={raw.icon} size={14} />}
<Text
size='sm'
fw={selected ? 600 : undefined}
fs={raw?.structural ? 'italic' : undefined}
c={raw?.structural ? 'dimmed' : undefined}
>
{raw?.name ?? String(node.label)}
</Text>
</Group>
{raw?.description && (
<Text
size='xs'
c='dimmed'
ta='right'
truncate
flex={1}
maw='50%'
>
{raw.description}
</Text>
)}
</Group>
);
}}
/>
</Expand>
{addBarcodeField && (
<ScanButton modelType={model} onScanSuccess={onBarcodeScan} />
)}
</Group>
</Input.Wrapper>
);
}

View File

@ -172,15 +172,6 @@ export default function ImporterDataSelector({
description: fieldDef.help_text, description: fieldDef.help_text,
filters: filters filters: filters
}; };
console.log('Defined Field:', field);
console.log({
...fieldDef,
...customField,
field_type: fieldDef.type,
description: fieldDef.help_text,
filters: filters
});
} }
} }

View File

@ -0,0 +1,81 @@
import { t } from '@lingui/core/macro';
import {
ActionIcon,
Anchor,
Group,
HoverCard,
Stack,
Text
} from '@mantine/core';
import type { ReactNode } from 'react';
import type { NavigateFunction } from 'react-router-dom';
import { ModelInformationDict } from '@lib/enums/ModelInformation';
import type { ModelType } from '@lib/enums/ModelType';
import { getDetailUrl, navigateToLink } from '@lib/index';
import { IconLink } from '@tabler/icons-react';
/**
* Wraps children in a HoverCard showing the model label, pk, and a "View
* details" link for the given instance. Renders children directly (no card)
* when model or pk is absent.
*/
export function ModelHoverCard({
children,
model,
pk,
navigate
}: {
children: ReactNode;
model: ModelType | undefined;
pk: number | null | undefined;
navigate?: NavigateFunction | null;
}) {
const modelInfo = model ? ModelInformationDict[model] : undefined;
if (!modelInfo || !pk) {
return <>{children}</>;
}
const detailUrl = getDetailUrl(model!, pk, true);
return (
<HoverCard
position='top-end'
withinPortal
openDelay={500}
closeDelay={100}
zIndex={99999}
>
<HoverCard.Target>
<span>{children}</span>
</HoverCard.Target>
<HoverCard.Dropdown>
<Stack gap='xs'>
<Group justify='space-between'>
<Text size='sm' fw='bold'>
{modelInfo.label()}
</Text>
<Text size='xs'>{`[${t`ID`}: ${pk}]`}</Text>
</Group>
{detailUrl && (
<Anchor
href={detailUrl}
target='_blank'
onClick={(event) => {
if (navigate) navigateToLink(detailUrl, navigate, event);
}}
>
<Group gap='xs' wrap='nowrap'>
<ActionIcon variant='transparent' size='xs'>
<IconLink />
</ActionIcon>
<Text size='sm'>{t`View details`}</Text>
</Group>
</Anchor>
)}
</Stack>
</HoverCard.Dropdown>
</HoverCard>
);
}

View File

@ -128,12 +128,7 @@ export function RenderPartCategory(
<RenderInlineModel <RenderInlineModel
{...props} {...props}
tooltip={instance.pathstring} tooltip={instance.pathstring}
prefix={ prefix={instance.icon && <ApiIcon name={instance.icon} />}
<>
{instance.level > 0 && `${'- '.repeat(instance.level)}`}
{instance.icon && <ApiIcon name={instance.icon} />}
</>
}
primary={category} primary={category}
suffix={suffix} suffix={suffix}
url={ url={

View File

@ -49,12 +49,7 @@ export function RenderStockLocation(
<RenderInlineModel <RenderInlineModel
{...props} {...props}
tooltip={instance.pathstring} tooltip={instance.pathstring}
prefix={ prefix={instance.icon && <ApiIcon name={instance.icon} />}
<>
{instance.level > 0 && `${'- '.repeat(instance.level)}`}
{instance.icon && <ApiIcon name={instance.icon} />}
</>
}
primary={location} primary={location}
suffix={suffix} suffix={suffix}
url={ url={

View File

@ -75,7 +75,7 @@ function BomItemSubstituteRow({
api api
.delete(apiUrl(ApiEndpoints.bom_substitute_list, record.pk)) .delete(apiUrl(ApiEndpoints.bom_substitute_list, record.pk))
.then(() => { .then(() => {
props.removeFn(props.idx); props.removeFn(props.rowId);
}) })
.catch((err) => { .catch((err) => {
showApiErrorMessage({ showApiErrorMessage({
@ -116,7 +116,7 @@ export function useEditBomSubstitutesForm(props: BomItemSubstituteFormProps) {
modelRenderer: (row: TableFieldRowProps) => { modelRenderer: (row: TableFieldRowProps) => {
const record = substitutes.find((r) => r.pk == row.item.pk); const record = substitutes.find((r) => r.pk == row.item.pk);
return record ? ( return record ? (
<BomItemSubstituteRow props={row} record={record} /> <BomItemSubstituteRow key={row.rowId} props={row} record={record} />
) : null; ) : null;
}, },
headers: [ headers: [

View File

@ -21,6 +21,7 @@ import { apiUrl } from '@lib/functions/Api';
import type { ApiFormFieldSet, ApiFormFieldType } from '@lib/types/Forms'; import type { ApiFormFieldSet, ApiFormFieldType } from '@lib/types/Forms';
import { import {
TableFieldErrorWrapper, TableFieldErrorWrapper,
TableFieldQuantityInput,
type TableFieldRowProps type TableFieldRowProps
} from '../components/forms/fields/TableField'; } from '../components/forms/fields/TableField';
import { StatusRenderer } from '../components/render/StatusRenderer'; import { StatusRenderer } from '../components/render/StatusRenderer';
@ -272,15 +273,10 @@ function BuildOutputFormRow({
// Non-serialized output - quantity can be changed // Non-serialized output - quantity can be changed
return ( return (
<StandaloneField <TableFieldQuantityInput
fieldName='quantity' value={props.item.quantity ?? ''}
fieldDefinition={{ onChange={(value) => {
field_type: 'number', props.changeFn(props.rowId, 'quantity', value);
required: true,
value: props.item.quantity,
onValueChange: (value: any) => {
props.changeFn(props.idx, 'quantity', value);
}
}} }}
error={props.rowErrors?.quantity?.message} error={props.rowErrors?.quantity?.message}
/> />
@ -310,7 +306,7 @@ function BuildOutputFormRow({
/>{' '} />{' '}
</Table.Td> </Table.Td>
<Table.Td style={{ width: '1%', whiteSpace: 'nowrap' }}> <Table.Td style={{ width: '1%', whiteSpace: 'nowrap' }}>
<RemoveRowButton onClick={() => props.removeFn(props.idx)} /> <RemoveRowButton onClick={() => props.removeFn(props.rowId)} />
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
</> </>
@ -344,6 +340,7 @@ export function useCompleteBuildOutputsForm({
const buildOutputs = useMemo(() => { const buildOutputs = useMemo(() => {
return outputs.map((output: any) => { return outputs.map((output: any) => {
return { return {
id: output.pk,
output: output.pk, output: output.pk,
quantity: output.quantity quantity: output.quantity
}; };
@ -358,7 +355,7 @@ export function useCompleteBuildOutputsForm({
modelRenderer: (row: TableFieldRowProps) => { modelRenderer: (row: TableFieldRowProps) => {
const record = outputs.find((output) => output.pk == row.item.output); const record = outputs.find((output) => output.pk == row.item.output);
return ( return (
<BuildOutputFormRow props={row} record={record} key={record.pk} /> <BuildOutputFormRow props={row} record={record} key={row.rowId} />
); );
}, },
headers: [ headers: [
@ -426,6 +423,7 @@ export function useScrapBuildOutputsForm({
const buildOutputs = useMemo(() => { const buildOutputs = useMemo(() => {
return outputs.map((output: any) => { return outputs.map((output: any) => {
return { return {
id: output.pk,
output: output.pk, output: output.pk,
quantity: output.quantity quantity: output.quantity
}; };
@ -440,7 +438,7 @@ export function useScrapBuildOutputsForm({
modelRenderer: (row: TableFieldRowProps) => { modelRenderer: (row: TableFieldRowProps) => {
const record = outputs.find((output) => output.pk == row.item.output); const record = outputs.find((output) => output.pk == row.item.output);
return ( return (
<BuildOutputFormRow props={row} record={record} key={record.pk} /> <BuildOutputFormRow props={row} record={record} key={row.rowId} />
); );
}, },
headers: [ headers: [
@ -497,6 +495,7 @@ export function useCancelBuildOutputsForm({
const buildOutputs = useMemo(() => { const buildOutputs = useMemo(() => {
return outputs.map((output: any) => { return outputs.map((output: any) => {
return { return {
id: output.pk,
output: output.pk output: output.pk
}; };
}); });
@ -562,6 +561,8 @@ function BuildAllocateLineRow({
record: any; record: any;
sourceLocation: number | undefined; sourceLocation: number | undefined;
}>) { }>) {
const [quantity, setQuantity] = useState<number>(props.item?.quantity ?? 0);
const stockField: ApiFormFieldType = useMemo(() => { const stockField: ApiFormFieldType = useMemo(() => {
return { return {
field_type: 'related field', field_type: 'related field',
@ -585,7 +586,7 @@ function BuildAllocateLineRow({
value: props.item.stock_item, value: props.item.stock_item,
name: 'stock_item', name: 'stock_item',
onValueChange: (value: any, instance: any) => { 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 // Update the allocated quantity based on the selected stock item
if (instance) { if (instance) {
@ -593,7 +594,7 @@ function BuildAllocateLineRow({
if (available < props.item.quantity) { if (available < props.item.quantity) {
props.changeFn( props.changeFn(
props.idx, props.rowId,
'quantity', 'quantity',
Math.min(props.item.quantity, available) Math.min(props.item.quantity, available)
); );
@ -603,18 +604,6 @@ function BuildAllocateLineRow({
}; };
}, [record, props]); }, [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 ( return (
<Table.Tr key={`table-row-${record.pk}`}> <Table.Tr key={`table-row-${record.pk}`}>
<Table.Td> <Table.Td>
@ -639,14 +628,18 @@ function BuildAllocateLineRow({
/> />
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<StandaloneField <TableFieldQuantityInput
fieldName='quantity' min={0}
fieldDefinition={quantityField} value={quantity}
onChange={(value) => {
setQuantity(value === '' ? 0 : value);
props.changeFn(props.rowId, 'quantity', value);
}}
error={props.rowErrors?.quantity?.message} error={props.rowErrors?.quantity?.message}
/> />
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<RemoveRowButton onClick={() => props.removeFn(props.idx)} /> <RemoveRowButton onClick={() => props.removeFn(props.rowId)} />
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
); );
@ -674,6 +667,16 @@ export function useAllocateStockToBuildForm({
undefined 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 buildAllocateFields: ApiFormFieldSet = useMemo(() => {
const fields: ApiFormFieldSet = { const fields: ApiFormFieldSet = {
items: { items: {
@ -690,10 +693,10 @@ export function useAllocateStockToBuildForm({
modelRenderer: (row: TableFieldRowProps) => { modelRenderer: (row: TableFieldRowProps) => {
// Find the matching record from the passed 'lineItems' // Find the matching record from the passed 'lineItems'
const record = const record =
lineItems.find((item) => item.pk == row.item.build_line) ?? {}; buildLines.find((item) => item.pk == row.item.build_line) ?? {};
return ( return (
<BuildAllocateLineRow <BuildAllocateLineRow
key={row.idx} key={row.rowId}
output={output} output={output}
props={row} props={row}
record={record} record={record}
@ -770,6 +773,7 @@ export function useAllocateStockToBuildForm({
}) })
.map((item) => { .map((item) => {
return { return {
id: item.pk,
build_line: item.pk, build_line: item.pk,
stock_item: undefined, stock_item: undefined,
quantity: Math.max( quantity: Math.max(
@ -791,6 +795,8 @@ function BuildConsumeItemRow({
props: TableFieldRowProps; props: TableFieldRowProps;
record: any; record: any;
}) { }) {
const [quantity, setQuantity] = useState<number>(props.item?.quantity ?? 0);
return ( return (
<Table.Tr key={`table-row-${record.pk}`}> <Table.Tr key={`table-row-${record.pk}`}>
<Table.Td> <Table.Td>
@ -806,21 +812,18 @@ function BuildConsumeItemRow({
</Table.Td> </Table.Td>
<Table.Td>{record.quantity}</Table.Td> <Table.Td>{record.quantity}</Table.Td>
<Table.Td> <Table.Td>
<StandaloneField <TableFieldQuantityInput
fieldName='quantity' min={0}
fieldDefinition={{ value={quantity}
field_type: 'number', onChange={(value) => {
required: true, setQuantity(value === '' ? 0 : value);
value: props.item.quantity, props.changeFn(props.rowId, 'quantity', value);
onValueChange: (value: any) => {
props.changeFn(props.idx, 'quantity', value);
}
}} }}
error={props.rowErrors?.quantity?.message} error={props.rowErrors?.quantity?.message}
/> />
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<RemoveRowButton onClick={() => props.removeFn(props.idx)} /> <RemoveRowButton onClick={() => props.removeFn(props.rowId)} />
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
); );
@ -856,7 +859,7 @@ export function useConsumeBuildItemsForm({
); );
return ( return (
<BuildConsumeItemRow key={row.idx} props={row} record={record} /> <BuildConsumeItemRow key={row.rowId} props={row} record={record} />
); );
} }
}, },
@ -875,6 +878,7 @@ export function useConsumeBuildItemsForm({
initialData: { initialData: {
items: allocatedItems.map((item) => { items: allocatedItems.map((item) => {
return { return {
id: item.pk,
build_item: item.pk, build_item: item.pk,
quantity: item.quantity quantity: item.quantity
}; };
@ -919,7 +923,7 @@ function BuildConsumeLineRow({
/> />
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<RemoveRowButton onClick={() => props.removeFn(props.idx)} /> <RemoveRowButton onClick={() => props.removeFn(props.rowId)} />
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
); );
@ -957,7 +961,7 @@ export function useConsumeBuildLinesForm({
); );
return ( return (
<BuildConsumeLineRow key={row.idx} props={row} record={record} /> <BuildConsumeLineRow key={row.rowId} props={row} record={record} />
); );
} }
}, },
@ -972,9 +976,11 @@ export function useConsumeBuildLinesForm({
successMessage: null, successMessage: null,
onFormSuccess: onFormSuccess, onFormSuccess: onFormSuccess,
fields: consumeFields, fields: consumeFields,
size: '80%',
initialData: { initialData: {
lines: filteredLines.map((item) => { lines: filteredLines.map((item) => {
return { return {
id: item.pk,
build_line: item.pk build_line: item.pk
}; };
}) })

View File

@ -45,6 +45,7 @@ import type {
} from '@lib/types/Forms'; } from '@lib/types/Forms';
import { import {
TableFieldExtraRow, TableFieldExtraRow,
TableFieldQuantityInput,
type TableFieldRowProps type TableFieldRowProps
} from '../components/forms/fields/TableField'; } from '../components/forms/fields/TableField';
import { Thumbnail } from '../components/images/Thumbnail'; import { Thumbnail } from '../components/images/Thumbnail';
@ -348,11 +349,11 @@ function LineItemFormRow({
}>) { }>) {
// Barcode Modal state // Barcode Modal state
const [opened, { open, close }] = useDisclosure(false, { 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, { const [locationOpen, locationHandlers] = useDisclosure(false, {
onClose: () => props.changeFn(props.idx, 'location', undefined) onClose: () => props.changeFn(props.rowId, 'location', undefined)
}); });
// Is this a trackable part? // Is this a trackable part?
@ -365,7 +366,7 @@ function LineItemFormRow({
useEffect(() => { useEffect(() => {
if (!!record.destination) { if (!!record.destination) {
props.changeFn(props.idx, 'location', record.destination); props.changeFn(props.rowId, 'location', record.destination);
locationHandlers.open(); locationHandlers.open();
} }
}, [record.destination]); }, [record.destination]);
@ -375,7 +376,7 @@ function LineItemFormRow({
isEnabled: () => batchOpen, isEnabled: () => batchOpen,
onGenerate: (value: any) => { onGenerate: (value: any) => {
if (value) { 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, { const [packagingOpen, packagingHandlers] = useDisclosure(false, {
onClose: () => { onClose: () => {
props.changeFn(props.idx, 'packaging', undefined); props.changeFn(props.rowId, 'packaging', undefined);
} }
}); });
const [noteOpen, noteHandlers] = useDisclosure(false, { const [noteOpen, noteHandlers] = useDisclosure(false, {
onClose: () => { onClose: () => {
props.changeFn(props.idx, 'note', undefined); props.changeFn(props.rowId, 'note', undefined);
} }
}); });
const [batchOpen, batchHandlers] = useDisclosure(false, { const [batchOpen, batchHandlers] = useDisclosure(false, {
onClose: () => { onClose: () => {
props.changeFn(props.idx, 'batch_code', undefined); props.changeFn(props.rowId, 'batch_code', undefined);
}, },
onOpen: () => { onOpen: () => {
// Generate a new batch code // Generate a new batch code
@ -412,7 +413,7 @@ function LineItemFormRow({
const [serialOpen, serialHandlers] = useDisclosure(false, { const [serialOpen, serialHandlers] = useDisclosure(false, {
onClose: () => { onClose: () => {
props.changeFn(props.idx, 'serial_numbers', undefined); props.changeFn(props.rowId, 'serial_numbers', undefined);
}, },
onOpen: () => { onOpen: () => {
// Generate new serial numbers // Generate new serial numbers
@ -422,7 +423,7 @@ function LineItemFormRow({
quantity: props.item.quantity quantity: props.item.quantity
}); });
} else { } 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; const defaultExpiry = record.part_detail?.default_expiry;
if (defaultExpiry !== undefined && defaultExpiry > 0) { if (defaultExpiry !== undefined && defaultExpiry > 0) {
props.changeFn( props.changeFn(
props.idx, props.rowId,
'expiry_date', 'expiry_date',
dayjs().add(defaultExpiry, 'day').format('YYYY-MM-DD') dayjs().add(defaultExpiry, 'day').format('YYYY-MM-DD')
); );
} }
}, },
onClose: () => { onClose: () => {
props.changeFn(props.idx, 'expiry_date', undefined); props.changeFn(props.rowId, 'expiry_date', undefined);
} }
}); });
// Status value // Status value
const [statusOpen, statusHandlers] = useDisclosure(false, { const [statusOpen, statusHandlers] = useDisclosure(false, {
onClose: () => props.changeFn(props.idx, 'status', undefined) onClose: () => props.changeFn(props.rowId, 'status', undefined)
}); });
// Barcode value // Barcode value
@ -455,7 +456,7 @@ function LineItemFormRow({
// Change form value when state is altered // Change form value when state is altered
useEffect(() => { useEffect(() => {
props.changeFn(props.idx, 'barcode', barcode); props.changeFn(props.rowId, 'barcode', barcode);
}, [barcode]); }, [barcode]);
// Update location field description on state change // Update location field description on state change
@ -574,15 +575,14 @@ function LineItemFormRow({
/> />
</Table.Td> </Table.Td>
<Table.Td style={{ whiteSpace: 'nowrap' }}> <Table.Td style={{ whiteSpace: 'nowrap' }}>
<StandaloneField <TableFieldQuantityInput
fieldName='quantity' min={0}
fieldDefinition={{ value={props.item.quantity ?? ''}
field_type: 'number', onChange={(value) => {
value: props.item.quantity, props.changeFn(props.rowId, 'quantity', value);
onValueChange: (value) => { serialNumberGenerator.update({
props.changeFn(props.idx, 'quantity', value); quantity: value === '' ? undefined : value
serialNumberGenerator.update({ quantity: value }); });
}
}} }}
error={props.rowErrors?.quantity?.message} error={props.rowErrors?.quantity?.message}
/> />
@ -678,7 +678,7 @@ function LineItemFormRow({
</Flex> </Flex>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<RemoveRowButton onClick={() => props.removeFn(props.idx)} /> <RemoveRowButton onClick={() => props.removeFn(props.rowId)} />
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
{locationOpen && ( {locationOpen && (
@ -697,7 +697,7 @@ function LineItemFormRow({
structural: false structural: false
}, },
onValueChange: (value) => { onValueChange: (value) => {
props.changeFn(props.idx, 'location', value); props.changeFn(props.rowId, 'location', value);
}, },
description: locationDescription, description: locationDescription,
value: props.item.location, value: props.item.location,
@ -719,7 +719,7 @@ function LineItemFormRow({
tooltip={t`Store at default location`} tooltip={t`Store at default location`}
onClick={() => onClick={() =>
props.changeFn( props.changeFn(
props.idx, props.rowId,
'location', 'location',
record.part_detail?.default_location ?? record.part_detail?.default_location ??
record.part_detail?.category_default_location record.part_detail?.category_default_location
@ -733,7 +733,11 @@ function LineItemFormRow({
icon={<InvenTreeIcon icon='destination' />} icon={<InvenTreeIcon icon='destination' />}
tooltip={t`Store at line item destination `} tooltip={t`Store at line item destination `}
onClick={() => onClick={() =>
props.changeFn(props.idx, 'location', record.destination) props.changeFn(
props.rowId,
'location',
record.destination
)
} }
tooltipAlignment='top' tooltipAlignment='top'
/> />
@ -746,7 +750,7 @@ function LineItemFormRow({
tooltip={t`Store with already received stock`} tooltip={t`Store with already received stock`}
onClick={() => onClick={() =>
props.changeFn( props.changeFn(
props.idx, props.rowId,
'location', 'location',
record.destination_detail.pk record.destination_detail.pk
) )
@ -762,7 +766,7 @@ function LineItemFormRow({
<TableFieldExtraRow <TableFieldExtraRow
visible={batchOpen} visible={batchOpen}
onValueChange={(value) => { onValueChange={(value) => {
props.changeFn(props.idx, 'batch_code', value); props.changeFn(props.rowId, 'batch_code', value);
}} }}
fieldName='batch_code' fieldName='batch_code'
fieldDefinition={{ fieldDefinition={{
@ -776,7 +780,7 @@ function LineItemFormRow({
<TableFieldExtraRow <TableFieldExtraRow
visible={serialOpen} visible={serialOpen}
onValueChange={(value) => onValueChange={(value) =>
props.changeFn(props.idx, 'serial_numbers', value) props.changeFn(props.rowId, 'serial_numbers', value)
} }
fieldName='serial_numbers' fieldName='serial_numbers'
fieldDefinition={{ fieldDefinition={{
@ -794,7 +798,7 @@ function LineItemFormRow({
<TableFieldExtraRow <TableFieldExtraRow
visible={expiryDateOpen} visible={expiryDateOpen}
onValueChange={(value) => onValueChange={(value) =>
props.changeFn(props.idx, 'expiry_date', value) props.changeFn(props.rowId, 'expiry_date', value)
} }
fieldName='expiry_date' fieldName='expiry_date'
fieldDefinition={{ fieldDefinition={{
@ -808,7 +812,9 @@ function LineItemFormRow({
)} )}
<TableFieldExtraRow <TableFieldExtraRow
visible={packagingOpen} visible={packagingOpen}
onValueChange={(value) => props.changeFn(props.idx, 'packaging', value)} onValueChange={(value) =>
props.changeFn(props.rowId, 'packaging', value)
}
fieldName='packaging' fieldName='packaging'
fieldDefinition={{ fieldDefinition={{
field_type: 'string', field_type: 'string',
@ -821,7 +827,7 @@ function LineItemFormRow({
visible={statusOpen} visible={statusOpen}
defaultValue={10} defaultValue={10}
fieldName='status' fieldName='status'
onValueChange={(value) => props.changeFn(props.idx, 'status', value)} onValueChange={(value) => props.changeFn(props.rowId, 'status', value)}
fieldDefinition={{ fieldDefinition={{
field_type: 'choice', field_type: 'choice',
api_url: apiUrl(ApiEndpoints.stock_status), api_url: apiUrl(ApiEndpoints.stock_status),
@ -833,7 +839,7 @@ function LineItemFormRow({
<TableFieldExtraRow <TableFieldExtraRow
visible={noteOpen} visible={noteOpen}
fieldName='note' fieldName='note'
onValueChange={(value) => props.changeFn(props.idx, 'note', value)} onValueChange={(value) => props.changeFn(props.rowId, 'note', value)}
fieldDefinition={{ fieldDefinition={{
field_type: 'string', field_type: 'string',
label: t`Note` label: t`Note`
@ -862,13 +868,20 @@ export function useReceiveLineItems(props: LineItemsForm) {
[] []
); );
const records = Object.fromEntries( const records = useMemo(() => {
props.items.map((item) => [item.pk, item]) return Object.fromEntries(props.items.map((item) => [item.pk, item]));
); }, [props.items]);
const filteredItems = props.items.filter( const filteredItems = useMemo(() => {
(elem) => elem.quantity !== elem.received return props.items
); .filter((elem) => elem.quantity !== elem.received)
.map((elem) => {
return {
id: elem.pk,
...elem
};
});
}, [props.items]);
const fields: ApiFormFieldSet = useMemo(() => { const fields: ApiFormFieldSet = useMemo(() => {
return { return {
@ -878,8 +891,9 @@ export function useReceiveLineItems(props: LineItemsForm) {
}, },
items: { items: {
field_type: 'table', field_type: 'table',
value: filteredItems.map((elem, idx) => { value: filteredItems.map((elem) => {
return { return {
id: elem.pk,
line_item: elem.pk, line_item: elem.pk,
location: elem.destination ?? elem.destination_detail?.pk ?? null, location: elem.destination ?? elem.destination_detail?.pk ?? null,
quantity: elem.quantity - elem.received, quantity: elem.quantity - elem.received,
@ -902,7 +916,7 @@ export function useReceiveLineItems(props: LineItemsForm) {
props={row} props={row}
record={record} record={record}
statuses={stockStatusCodes} 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({ return useCreateApiFormModal({
...props.formProps, ...props.formProps,

View File

@ -199,7 +199,7 @@ function ReturnOrderLineItemFormRow({
label: t`Status`, label: t`Status`,
choices: statusOptions, choices: statusOptions,
onValueChange: (value) => { onValueChange: (value) => {
props.changeFn(props.idx, 'status', value); props.changeFn(props.rowId, 'status', value);
} }
}} }}
defaultValue={record.item_detail?.status} defaultValue={record.item_detail?.status}
@ -207,7 +207,7 @@ function ReturnOrderLineItemFormRow({
/> />
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<RemoveRowButton onClick={() => props.removeFn(props.idx)} /> <RemoveRowButton onClick={() => props.removeFn(props.rowId)} />
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
</> </>
@ -226,6 +226,7 @@ export function useReceiveReturnOrderLineItems(
field_type: 'table', field_type: 'table',
value: props.items.map((item: any) => { value: props.items.map((item: any) => {
return { return {
id: item.pk,
item: item.pk item: item.pk
}; };
}), }),
@ -236,7 +237,7 @@ export function useReceiveReturnOrderLineItems(
<ReturnOrderLineItemFormRow <ReturnOrderLineItemFormRow
props={row} props={row}
record={record} record={record}
key={record.pk} key={row.rowId}
/> />
); );
}, },

View File

@ -26,7 +26,10 @@ import type {
ApiFormFieldType ApiFormFieldType
} from '@lib/types/Forms'; } from '@lib/types/Forms';
import dayjs from 'dayjs'; 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 useBackgroundTask from '../hooks/UseBackgroundTask';
import { useCreateApiFormModal, useEditApiFormModal } from '../hooks/UseForm'; import { useCreateApiFormModal, useEditApiFormModal } from '../hooks/UseForm';
import { useGlobalSettingsState } from '../states/SettingsStates'; import { useGlobalSettingsState } from '../states/SettingsStates';
@ -332,41 +335,33 @@ function SalesOrderAllocateLineRow({
value: props.item.stock_item, value: props.item.stock_item,
name: 'stock_item', name: 'stock_item',
onValueChange: (value: any, instance: any) => { 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 // Update the allocated quantity based on the selected stock item
if (instance) { if (instance) {
const available = instance.quantity - instance.allocated; const available = instance.quantity - instance.allocated;
const required = record.quantity - record.allocated; const required = record.quantity - record.allocated;
let quantity = props.item?.quantity ?? 0; let q = props.item?.quantity ?? 0;
quantity = Math.max(quantity, required); q = Math.max(q, required);
quantity = Math.min(quantity, available); q = Math.min(q, available);
if (quantity != props.item.quantity) { if (q != props.item?.quantity) {
props.changeFn(props.idx, 'quantity', quantity); setQuantity(q);
props.changeFn(props.rowId, 'quantity', q);
} }
} }
} }
}; };
}, [sourceLocation, record, props]); }, [sourceLocation, record, props]);
// Statically defined field for selecting the allocation quantity const [quantity, setQuantity] = useState<number | ''>(
const quantityField: ApiFormFieldType = useMemo(() => { props.item?.quantity ?? ''
return { );
field_type: 'number',
name: 'quantity',
required: true,
value: props.item.quantity,
onValueChange: (value: any) => {
props.changeFn(props.idx, 'quantity', value);
}
};
}, [props]);
return ( return (
<Table.Tr key={`table-row-${props.idx}-${record.pk}`}> <Table.Tr key={`table-row-${props.rowId}-${record.pk}`}>
<Table.Td> <Table.Td>
<RenderPartColumn part={record.part_detail} /> <RenderPartColumn part={record.part_detail} />
</Table.Td> </Table.Td>
@ -385,14 +380,18 @@ function SalesOrderAllocateLineRow({
/> />
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<StandaloneField <TableFieldQuantityInput
fieldName='quantity' min={0}
fieldDefinition={quantityField} value={quantity}
onChange={(value) => {
setQuantity(value);
props.changeFn(props.rowId, 'quantity', value);
}}
error={props.rowErrors?.quantity?.message} error={props.rowErrors?.quantity?.message}
/> />
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<RemoveRowButton onClick={() => props.removeFn(props.idx)} /> <RemoveRowButton onClick={() => props.removeFn(props.rowId)} />
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
); );
@ -447,7 +446,7 @@ export function useAllocateToSalesOrderForm({
return ( return (
<SalesOrderAllocateLineRow <SalesOrderAllocateLineRow
key={`table-row-${row.idx}-${record.pk}`} key={row.rowId}
props={row} props={row}
record={record} record={record}
sourceLocation={sourceLocation} sourceLocation={sourceLocation}
@ -478,6 +477,7 @@ export function useAllocateToSalesOrderForm({
initialData: { initialData: {
items: lineItems.map((item) => { items: lineItems.map((item) => {
return { return {
id: item.pk,
line_item: item.pk, line_item: item.pk,
quantity: 0, quantity: 0,
stock_item: null stock_item: null

View File

@ -41,15 +41,16 @@ import {
useCallback, useCallback,
useEffect, useEffect,
useMemo, useMemo,
useRef,
useState useState
} from 'react'; } from 'react';
import { useFormContext } from 'react-hook-form'; import { useFormContext } from 'react-hook-form';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { api } from '../App'; import { api } from '../App';
import RemoveRowButton from '../components/buttons/RemoveRowButton'; import RemoveRowButton from '../components/buttons/RemoveRowButton';
import { StandaloneField } from '../components/forms/StandaloneField';
import { import {
TableFieldExtraRow, TableFieldExtraRow,
TableFieldQuantityInput,
type TableFieldRowProps type TableFieldRowProps
} from '../components/forms/fields/TableField'; } from '../components/forms/fields/TableField';
import { Thumbnail } from '../components/images/Thumbnail'; import { Thumbnail } from '../components/images/Thumbnail';
@ -553,19 +554,106 @@ function moveToDefault(
}); });
} }
type StockAdjustmentItemWithRecord = { /*
obj: any; * Memoize a list of stock items for use in a stock operations modal.
} & StockAdjustmentItem; * 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; if (!opened) {
type TableFieldChangeFn = (idx: number, key: string, value: any) => void; return [];
}
type StockRow = { // Fetch via the API
item: StockAdjustmentItemWithRecord; const url = apiUrl(ApiEndpoints.stock_item_list);
idx: number;
changeFn: TableFieldChangeFn; return api
removeFn: TableFieldRefreshFn; .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 (
<ActionButton
onClick={() =>
moveToDefault(
record,
quantity,
onRemove,
returnStock
? {
title: t`Confirm Stock Return`,
onConfirm: (location: number) => {
form.setValue('location', location, {
shouldDirty: true,
shouldValidate: true
});
}
}
: undefined
)
}
icon={<InvenTreeIcon icon='default_location' />}
tooltip={t`Move to default location`}
tooltipAlignment='top'
disabled={
!record.part_detail?.default_location &&
!record.part_detail?.category_default_location
}
/>
);
}
function StockOperationsRow({ function StockOperationsRow({
props, props,
@ -588,7 +676,7 @@ function StockOperationsRow({
returnStock?: boolean; returnStock?: boolean;
record?: any; record?: any;
}) { }) {
const form = useFormContext(); const rowId = props.rowId;
const statusOptions: ApiFormFieldChoice[] = useMemo(() => { const statusOptions: ApiFormFieldChoice[] = useMemo(() => {
return ( return (
@ -607,37 +695,54 @@ function StockOperationsRow({
const [status, setStatus] = useState<number | undefined>(undefined); const [status, setStatus] = useState<number | undefined>(undefined);
const removeAndRefresh = () => { const [packagingOpen, packagingHandlers] = useDisclosure(false);
props.removeFn(props.idx); const [statusOpen, statusHandlers] = useDisclosure(false);
}; const hasMountedPackagingRef = useRef(false);
const hasMountedStatusRef = useRef(false);
const callChangeFn = (idx: number, key: string, value: any) => { useEffect(() => {
setTimeout(() => props.changeFn(idx, key, value), 0); if (!transfer) {
}; return;
const [packagingOpen, packagingHandlers] = useDisclosure(false, {
onOpen: () => {
if (transfer) {
callChangeFn(props.idx, 'packaging', record?.packaging || undefined);
}
},
onClose: () => {
if (transfer) {
callChangeFn(props.idx, 'packaging', undefined);
}
} }
});
const [statusOpen, statusHandlers] = useDisclosure(false, { if (!hasMountedPackagingRef.current) {
onOpen: () => { 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); setStatus(record?.status_custom_key || record?.status || undefined);
props.changeFn(props.idx, 'status', record?.status || undefined); props.changeFn(rowId, 'status', record?.status || undefined);
}, return;
onClose: () => {
setStatus(undefined);
callChangeFn(props.idx, 'status', undefined);
} }
});
setStatus(undefined);
props.changeFn(rowId, 'status', undefined);
}, [
changeStatus,
statusOpen,
rowId,
record?.status,
record?.status_custom_key,
props.changeFn
]);
const stockString: string = useMemo(() => { const stockString: string = useMemo(() => {
if (!record) { if (!record) {
@ -652,10 +757,15 @@ function StockOperationsRow({
}, [record]); }, [record]);
return !record ? ( return !record ? (
<div>{t`Loading...`}</div> <Table.Tr>
<Table.Td colSpan={6}>{t`Loading...`}</Table.Td>
</Table.Tr>
) : ( ) : (
<> <>
<Table.Tr> <Table.Tr
aria-label={`stock-op-row-${rowId}`}
key={`stock-op-row-${rowId}`}
>
<Table.Td> <Table.Td>
<Stack gap='xs'> <Stack gap='xs'>
<Flex gap='sm' align='center'> <Flex gap='sm' align='center'>
@ -689,15 +799,12 @@ function StockOperationsRow({
</Table.Td> </Table.Td>
{!merge && ( {!merge && (
<Table.Td> <Table.Td>
<StandaloneField <TableFieldQuantityInput
fieldName='quantity' min={0}
fieldDefinition={{ value={quantity ?? ''}
field_type: 'number', onChange={(value) => {
value: quantity, setQuantity(value);
onValueChange: (value: any) => { props.changeFn(rowId, 'quantity', value);
setQuantity(value);
props.changeFn(props.idx, 'quantity', value);
}
}} }}
error={props.rowErrors?.quantity?.message} error={props.rowErrors?.quantity?.message}
/> />
@ -705,35 +812,30 @@ function StockOperationsRow({
)} )}
<Table.Td> <Table.Td>
<Flex gap='3px'> <Flex gap='3px'>
{transfer && ( {transfer &&
<ActionButton (returnStock ? (
onClick={() => <ReturnStockMoveButton
moveToDefault( record={record}
record, quantity={props.item.quantity}
props.item.quantity, onRemove={() => props.removeFn(rowId)}
removeAndRefresh, returnStock={returnStock}
returnStock />
? { ) : (
title: t`Confirm Stock Return`, <ActionButton
onConfirm: (location: number) => { onClick={() =>
form.setValue('location', location, { moveToDefault(record, props.item.quantity, () =>
shouldDirty: true, props.removeFn(rowId)
shouldValidate: true )
}); }
} icon={<InvenTreeIcon icon='default_location' />}
} tooltip={t`Move to default location`}
: undefined tooltipAlignment='top'
) disabled={
} !record.part_detail?.default_location &&
icon={<InvenTreeIcon icon='default_location' />} !record.part_detail?.category_default_location
tooltip={t`Move to default location`} }
tooltipAlignment='top' />
disabled={ ))}
!record.part_detail?.default_location &&
!record.part_detail?.category_default_location
}
/>
)}
{changeStatus && ( {changeStatus && (
<ActionButton <ActionButton
size='sm' size='sm'
@ -758,12 +860,12 @@ function StockOperationsRow({
icon={<InvenTreeIcon icon='merge' />} icon={<InvenTreeIcon icon='merge' />}
tooltip={t`Merge into existing stock`} tooltip={t`Merge into existing stock`}
onClick={() => onClick={() =>
callChangeFn(props.idx, 'merge', !props.item?.merge) props.changeFn(rowId, 'merge', !props.item?.merge)
} }
variant={props.item?.merge ? 'filled' : 'transparent'} variant={props.item?.merge ? 'filled' : 'transparent'}
/> />
)} )}
<RemoveRowButton onClick={() => props.removeFn(props.idx)} /> <RemoveRowButton onClick={() => props.removeFn(rowId)} />
</Flex> </Flex>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
@ -772,7 +874,7 @@ function StockOperationsRow({
visible={statusOpen} visible={statusOpen}
onValueChange={(value: any) => { onValueChange={(value: any) => {
setStatus(value); setStatus(value);
props.changeFn(props.idx, 'status', value || undefined); props.changeFn(rowId, 'status', value || undefined);
}} }}
fieldName='status' fieldName='status'
fieldDefinition={{ fieldDefinition={{
@ -788,7 +890,7 @@ function StockOperationsRow({
<TableFieldExtraRow <TableFieldExtraRow
visible={transfer && packagingOpen} visible={transfer && packagingOpen}
onValueChange={(value: any) => { onValueChange={(value: any) => {
props.changeFn(props.idx, 'packaging', value || undefined); props.changeFn(rowId, 'packaging', value || undefined);
}} }}
fieldName='packaging' fieldName='packaging'
fieldDefinition={{ fieldDefinition={{
@ -814,15 +916,15 @@ type StockAdjustmentItem = {
}; };
function mapAdjustmentItems(items: any[], mergeDefault?: boolean) { function mapAdjustmentItems(items: any[], mergeDefault?: boolean) {
const mappedItems: StockAdjustmentItemWithRecord[] = items.map((elem) => { const mappedItems: StockAdjustmentItem[] = items.map((elem) => {
return { return {
id: elem.pk,
pk: elem.pk, pk: elem.pk,
quantity: elem.quantity, quantity: elem.quantity,
batch: elem.batch || undefined, batch: elem.batch || undefined,
status: elem.status || undefined, status: elem.status || undefined,
packaging: elem.packaging || undefined, packaging: elem.packaging || undefined,
merge: elem.merge ?? mergeDefault ?? false, merge: elem.merge ?? mergeDefault ?? false
obj: elem
}; };
}); });
@ -856,7 +958,7 @@ function stockTransferFields(
changeStatus changeStatus
setMax setMax
transferMerge transferMerge
key={record.pk} key={row.rowId}
record={record} record={record}
/> />
); );
@ -901,7 +1003,7 @@ function stockReturnFields(items: any[]): ApiFormFieldSet {
return ( return (
<StockOperationsRow <StockOperationsRow
props={row} props={row}
key={record.pk} key={row.rowId}
record={record} record={record}
transfer transfer
returnStock returnStock
@ -948,9 +1050,12 @@ function stockRemoveFields(items: any[]): ApiFormFieldSet {
return {}; 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 && 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 { return {
...elem, ...elem,
quantity: 0 quantity: 0
@ -970,7 +1075,7 @@ function stockRemoveFields(items: any[]): ApiFormFieldSet {
setMax setMax
changeStatus changeStatus
add add
key={record.pk} key={row.rowId}
record={record} record={record}
/> />
); );
@ -995,9 +1100,12 @@ function stockAddFields(items: any[]): ApiFormFieldSet {
return {}; 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 { return {
...elem, ...elem,
quantity: 0 quantity: 0
@ -1016,7 +1124,7 @@ function stockAddFields(items: any[]): ApiFormFieldSet {
changeStatus changeStatus
props={row} props={row}
add add
key={record.pk} key={row.rowId}
record={record} record={record}
/> />
); );
@ -1037,16 +1145,14 @@ function stockAddFields(items: any[]): ApiFormFieldSet {
} }
function stockCountFields(items: any[]): ApiFormFieldSet { function stockCountFields(items: any[]): ApiFormFieldSet {
if (!items) { const records = Object.fromEntries(
return {}; items?.map((item) => [item.pk, item]) ?? []
} );
const records = Object.fromEntries(items.map((item) => [item.pk, item])); const initialValue = items ? mapAdjustmentItems(items) : [];
const initialValue = mapAdjustmentItems(items);
// Extract all location values from the 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 = { const fields: ApiFormFieldSet = {
items: { items: {
@ -1057,8 +1163,8 @@ function stockCountFields(items: any[]): ApiFormFieldSet {
<StockOperationsRow <StockOperationsRow
props={row} props={row}
changeStatus changeStatus
key={row.item.pk} key={row.rowId}
record={records[row.item.pk]} record={records[row.item?.pk]}
/> />
); );
}, },
@ -1105,7 +1211,7 @@ function stockChangeStatusFields(items: any[]): ApiFormFieldSet {
return ( return (
<StockOperationsRow <StockOperationsRow
props={row} props={row}
key={row.item} key={row.rowId}
merge merge
record={records[row.item]} record={records[row.item]}
/> />
@ -1133,19 +1239,22 @@ function stockMergeFields(items: any[]): ApiFormFieldSet {
return {}; 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 // Extract all non-null location values from the items
const locationValues = [ const locationValues = [
...new Set( ...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 // Extract all non-null default location values from the items
const defaultLocationValues = [ const defaultLocationValues = [
...new Set( ...new Set(
items validItems
.filter((item) => item.part_detail?.default_location) .filter((item) => item.part_detail?.default_location)
.map((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 = { const fields: ApiFormFieldSet = {
items: { items: {
field_type: 'table', field_type: 'table',
value: items.map((elem) => { value: validItems.map((elem) => {
return { return {
item: elem.pk, item: elem.pk
obj: elem
}; };
}), }),
modelRenderer: (row: TableFieldRowProps) => { modelRenderer: (row: TableFieldRowProps) => {
return ( return (
<StockOperationsRow <StockOperationsRow
props={row} props={row}
key={row.item.item} key={row.rowId}
merge merge
changeStatus changeStatus
record={records[row.item.item]} record={records[row.item.item]}
@ -1213,15 +1321,14 @@ function stockAssignFields(items: any[]): ApiFormFieldSet {
field_type: 'table', field_type: 'table',
value: items.map((elem) => { value: items.map((elem) => {
return { return {
item: elem.pk, item: elem.pk
obj: elem
}; };
}), }),
modelRenderer: (row: TableFieldRowProps) => { modelRenderer: (row: TableFieldRowProps) => {
return ( return (
<StockOperationsRow <StockOperationsRow
props={row} props={row}
key={row.item.item} key={row.rowId}
merge merge
record={records[row.item.item]} record={records[row.item.item]}
/> />
@ -1265,7 +1372,7 @@ function stockDeleteFields(items: any[]): ApiFormFieldSet {
return ( return (
<StockOperationsRow <StockOperationsRow
props={row} props={row}
key={record.pk} key={row.rowId}
merge merge
record={record} record={record}
/> />
@ -1293,8 +1400,6 @@ type apiModalFunc = (props: ApiFormModalProps) => {
function useStockOperationModal({ function useStockOperationModal({
items, items,
pk,
model,
refresh, refresh,
fieldGenerator, fieldGenerator,
endpoint, endpoint,
@ -1305,9 +1410,7 @@ function useStockOperationModal({
modalFunc = useCreateApiFormModal modalFunc = useCreateApiFormModal
}: { }: {
items?: object; items?: object;
pk?: number;
filters?: any; filters?: any;
model: ModelType | string;
refresh: () => void; refresh: () => void;
fieldGenerator: (items: any[]) => ApiFormFieldSet; fieldGenerator: (items: any[]) => ApiFormFieldSet;
endpoint: ApiEndpoints; endpoint: ApiEndpoints;
@ -1316,51 +1419,19 @@ function useStockOperationModal({
successMessage?: string; successMessage?: string;
modalFunc?: apiModalFunc; 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<boolean>(false); const [opened, setOpened] = useState<boolean>(false);
const { data } = useQuery({ const stockItems = useStockItems({
queryKey: ['stockitems', opened, model, pk, items, params], opened: opened,
queryFn: async () => { items: items,
if (items) { filters: filters
// 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 fields = useMemo(() => { // Rebuild the "fields" object
return fieldGenerator(data); const fields = useMemo(
}, [data]); () => fieldGenerator(stockItems),
[fieldGenerator, stockItems]
);
return modalFunc({ return modalFunc({
url: endpoint, url: endpoint,
@ -1447,9 +1518,14 @@ export function useReturnStockItem(props: StockOperationProps) {
} }
export function useCountStockItem(props: StockOperationProps) { export function useCountStockItem(props: StockOperationProps) {
const fieldGenerator = useCallback(
(items: any[]) => stockCountFields(items),
[]
);
return useStockOperationModal({ return useStockOperationModal({
...props, ...props,
fieldGenerator: stockCountFields, fieldGenerator: fieldGenerator,
endpoint: ApiEndpoints.stock_count, endpoint: ApiEndpoints.stock_count,
title: t`Count Stock`, title: t`Count Stock`,
successMessage: t`Stock counted`, successMessage: t`Stock counted`,

View File

@ -6,7 +6,10 @@ import { IconCalendar, IconUsers } from '@tabler/icons-react';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import RemoveRowButton from '../components/buttons/RemoveRowButton'; import RemoveRowButton from '../components/buttons/RemoveRowButton';
import { StandaloneField } from '../components/forms/StandaloneField'; 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 { useCreateApiFormModal } from '../hooks/UseForm';
import { useGlobalSettingsState } from '../states/SettingsStates'; import { useGlobalSettingsState } from '../states/SettingsStates';
import { RenderPartColumn } from '../tables/ColumnRenderers'; import { RenderPartColumn } from '../tables/ColumnRenderers';
@ -110,6 +113,10 @@ function TransferOrderAllocateLineRow({
record: any; record: any;
sourceLocation?: number | null; sourceLocation?: number | null;
}>) { }>) {
const [quantity, setQuantity] = useState<number | ''>(
props.item?.quantity ?? ''
);
// Statically defined field for selecting the stock item // Statically defined field for selecting the stock item
const stockItemField: ApiFormFieldType = useMemo(() => { const stockItemField: ApiFormFieldType = useMemo(() => {
return { return {
@ -128,41 +135,27 @@ function TransferOrderAllocateLineRow({
value: props.item.stock_item, value: props.item.stock_item,
name: 'stock_item', name: 'stock_item',
onValueChange: (value: any, instance: any) => { 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 // Update the allocated quantity based on the selected stock item
if (instance) { if (instance) {
const available = instance.quantity - instance.allocated; const available = instance.quantity - instance.allocated;
const required = record.quantity - record.allocated; const required = record.quantity - record.allocated;
let quantity = props.item?.quantity ?? 0; let q = props.item?.quantity ?? 0;
quantity = Math.max(quantity, required); q = Math.max(q, required);
quantity = Math.min(quantity, available); q = Math.min(q, available);
if (quantity != props.item.quantity) { setQuantity(q);
props.changeFn(props.idx, 'quantity', quantity); props.changeFn(props.rowId, 'quantity', q);
}
} }
} }
}; };
}, [sourceLocation, record, props]); }, [sourceLocation, record, quantity, props.changeFn]);
// 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]);
return ( return (
<Table.Tr key={`table-row-${props.idx}-${record.pk}`}> <Table.Tr key={`table-row-${props.rowId}`}>
<Table.Td> <Table.Td>
<RenderPartColumn part={record.part_detail} /> <RenderPartColumn part={record.part_detail} />
</Table.Td> </Table.Td>
@ -181,14 +174,18 @@ function TransferOrderAllocateLineRow({
/> />
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<StandaloneField <TableFieldQuantityInput
fieldName='quantity' min={0}
fieldDefinition={quantityField} value={quantity}
onChange={(value) => {
setQuantity(value === '' ? 0 : value);
props.changeFn(props.rowId, 'quantity', value);
}}
error={props.rowErrors?.quantity?.message} error={props.rowErrors?.quantity?.message}
/> />
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<RemoveRowButton onClick={() => props.removeFn(props.idx)} /> <RemoveRowButton onClick={() => props.removeFn(props.rowId)} />
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
); );
@ -209,6 +206,16 @@ export function useAllocateToTransferOrderForm({
sourceLocationId || null 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(() => { const fields: ApiFormFieldSet = useMemo(() => {
return { return {
// Non-submitted field to select the source location // Non-submitted field to select the source location
@ -237,11 +244,11 @@ export function useAllocateToTransferOrderForm({
], ],
modelRenderer: (row: TableFieldRowProps) => { modelRenderer: (row: TableFieldRowProps) => {
const record = const record =
lineItems.find((item) => item.pk == row.item.line_item) ?? {}; lines.find((item) => item.pk == row.item.line_item) ?? {};
return ( return (
<TransferOrderAllocateLineRow <TransferOrderAllocateLineRow
key={`table-row-${row.idx}-${record.pk}`} key={row.rowId}
props={row} props={row}
record={record} record={record}
sourceLocation={sourceLocation} sourceLocation={sourceLocation}
@ -263,6 +270,7 @@ export function useAllocateToTransferOrderForm({
initialData: { initialData: {
items: lineItems.map((item) => { items: lineItems.map((item) => {
return { return {
id: item.pk,
line_item: item.pk, line_item: item.pk,
quantity: 0, quantity: 0,
stock_item: null stock_item: null

View File

@ -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;
});
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More