Merge branch 'master' into fix-issue-10769
This commit is contained in:
commit
844e08814f
|
|
@ -31,7 +31,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ jobs:
|
|||
docker: ${{ steps.filter.outputs.docker }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # pin@v4.0.1
|
||||
|
|
@ -67,7 +67,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Test Docker Image
|
||||
|
|
@ -139,7 +139,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Run Migration Tests
|
||||
|
|
@ -163,7 +163,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set Up Python ${{ env.python_version }}
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ jobs:
|
|||
force: ${{ steps.force.outputs.force }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # pin@v4.0.1
|
||||
|
|
@ -69,7 +69,7 @@ jobs:
|
|||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Environment Setup
|
||||
|
|
@ -123,7 +123,7 @@ jobs:
|
|||
VITE_COVERAGE: false
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Environment Setup
|
||||
|
|
@ -205,7 +205,7 @@ jobs:
|
|||
VITE_COVERAGE: true
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Environment Setup
|
||||
|
|
@ -273,7 +273,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ jobs:
|
|||
outputs:
|
||||
server: ${{ steps.filter.outputs.server }}
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # pin@v4.0.1
|
||||
|
|
@ -76,7 +76,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ jobs:
|
|||
submit-performance: ${{ steps.runner-perf.outputs.submit-performance }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # pin@v4.0.1
|
||||
|
|
@ -108,7 +108,7 @@ jobs:
|
|||
if: needs.paths-filter.outputs.cicd == 'true' || needs.paths-filter.outputs.server == 'true' || needs.paths-filter.outputs.frontend == 'true' || needs.paths-filter.outputs.requirements == 'true' || needs.paths-filter.outputs.force == 'true'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python ${{ env.python_version }}
|
||||
|
|
@ -130,7 +130,7 @@ jobs:
|
|||
if: needs.paths-filter.outputs.server == 'true' || needs.paths-filter.outputs.requirements == 'true' || needs.paths-filter.outputs.force == 'true'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Environment Setup
|
||||
|
|
@ -152,7 +152,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python ${{ env.python_version }}
|
||||
|
|
@ -190,7 +190,7 @@ jobs:
|
|||
version: ${{ steps.version.outputs.version }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Environment Setup
|
||||
|
|
@ -222,7 +222,7 @@ jobs:
|
|||
echo "Downloaded api.yaml"
|
||||
- name: Running OpenAPI Spec diff action
|
||||
id: breaking_changes
|
||||
uses: oasdiff/oasdiff-action/diff@5fbe96ede8d0c53aeadef122d7a0abb79152d493 # pin@main
|
||||
uses: oasdiff/oasdiff-action/diff@3e9d440d37f468355457604348009f50e0cddbf3 # v0.1.4
|
||||
with:
|
||||
base: "api.yaml"
|
||||
revision: "src/backend/InvenTree/schema.yml"
|
||||
|
|
@ -275,7 +275,7 @@ jobs:
|
|||
version: ${{ needs.schema.outputs.version }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
name: Checkout Code
|
||||
with:
|
||||
repository: inventree/schema
|
||||
|
|
@ -332,7 +332,7 @@ jobs:
|
|||
node_version: '>=24'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Environment Setup
|
||||
|
|
@ -363,7 +363,7 @@ jobs:
|
|||
pip install .
|
||||
if: needs.paths-filter.outputs.submit-performance == 'true'
|
||||
- name: Performance Reporting
|
||||
uses: CodSpeedHQ/action@c145068895e045cc725ee76fcd2307624b65c3af # pin@v4.17.5
|
||||
uses: CodSpeedHQ/action@63f3e98b61959fe67f146a3ff022e4136fe9bb9c # v4.17.6
|
||||
# check if we are in inventree/inventree - reporting only works in that OIDC context
|
||||
if: github.repository == 'inventree/InvenTree' && needs.paths-filter.outputs.submit-performance == 'true'
|
||||
with:
|
||||
|
|
@ -390,7 +390,7 @@ jobs:
|
|||
python_version: ${{ matrix.python_version }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Environment Setup
|
||||
|
|
@ -441,7 +441,7 @@ jobs:
|
|||
INVENTREE_AUTO_UPDATE: true
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Environment Setup
|
||||
|
|
@ -454,7 +454,7 @@ jobs:
|
|||
env:
|
||||
node_version: '>=24'
|
||||
- name: Performance Reporting
|
||||
uses: CodSpeedHQ/action@c145068895e045cc725ee76fcd2307624b65c3af # pin@v4.17.5
|
||||
uses: CodSpeedHQ/action@63f3e98b61959fe67f146a3ff022e4136fe9bb9c # v4.17.6
|
||||
with:
|
||||
mode: walltime
|
||||
run: inv dev.test --pytest
|
||||
|
|
@ -492,7 +492,7 @@ jobs:
|
|||
- 6379:6379
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Environment Setup
|
||||
|
|
@ -541,7 +541,7 @@ jobs:
|
|||
- 3306:3306
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Environment Setup
|
||||
|
|
@ -584,7 +584,7 @@ jobs:
|
|||
- 5432:5432
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Environment Setup
|
||||
|
|
@ -618,7 +618,7 @@ jobs:
|
|||
INVENTREE_PLUGINS_ENABLED: false
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
name: Checkout Code
|
||||
|
|
@ -674,8 +674,8 @@ jobs:
|
|||
security-events: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Run zizmor 🌈
|
||||
uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
|
||||
uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ jobs:
|
|||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Version Check
|
||||
|
|
@ -45,7 +45,7 @@ jobs:
|
|||
artifact-metadata: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Environment Setup
|
||||
|
|
@ -115,7 +115,7 @@ jobs:
|
|||
INVENTREE_DEBUG: true
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Environment Setup
|
||||
|
|
@ -156,7 +156,7 @@ jobs:
|
|||
- debian:13
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
|
@ -216,7 +216,7 @@ jobs:
|
|||
pip install --require-hashes -r contrib/dev_reqs/requirements.txt
|
||||
python3 .github/scripts/version_check.py
|
||||
- name: Package - current release channel
|
||||
uses: pkgr/action/package@c5666febcd31750da6428042193fc5b2fb765435 # pin@main
|
||||
uses: pkgr/action/package@c5666febcd31750da6428042193fc5b2fb765435 # main
|
||||
id: package
|
||||
with:
|
||||
target: ${{ matrix.target }}
|
||||
|
|
@ -234,7 +234,7 @@ jobs:
|
|||
INVENTREE_CONFIG_FILE=/opt/inventree/config.yaml
|
||||
APP_REPO=inventree/InvenTree
|
||||
- name: Publish to go.packager.io - current release channel
|
||||
uses: pkgr/action/publish@3bce081ae512c5020856e237d37b3f5479d4aa71 # pin@main
|
||||
uses: pkgr/action/publish@c5666febcd31750da6428042193fc5b2fb765435 # main
|
||||
with:
|
||||
target: ${{ matrix.target }}
|
||||
token: ${{ secrets.PACKAGER_RELEASE_TOKEN }}
|
||||
|
|
@ -249,7 +249,7 @@ jobs:
|
|||
PACKAGE_NAME: ${{ matrix.target }}-${{ steps.setup.outputs.version }}.tar.gz
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Package - stable release channel
|
||||
uses: pkgr/action/package@c5666febcd31750da6428042193fc5b2fb765435 # pin@main
|
||||
uses: pkgr/action/package@c5666febcd31750da6428042193fc5b2fb765435 # main
|
||||
id: package-stable
|
||||
with:
|
||||
target: ${{ matrix.target }}
|
||||
|
|
@ -267,7 +267,7 @@ jobs:
|
|||
INVENTREE_CONFIG_FILE=/opt/inventree/config.yaml
|
||||
APP_REPO=inventree/InvenTree
|
||||
- name: Publish to go.packager.io - stable release channel
|
||||
uses: pkgr/action/publish@3bce081ae512c5020856e237d37b3f5479d4aa71 # pin@main
|
||||
uses: pkgr/action/publish@c5666febcd31750da6428042193fc5b2fb765435 # main
|
||||
with:
|
||||
target: ${{ matrix.target }}
|
||||
token: ${{ secrets.PACKAGER_RELEASE_TOKEN }}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Environment Setup
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
### Changed
|
||||
|
||||
- [#12274](https://github.com/inventree/InvenTree/pull/12274) fixes a bug in rendering the "table field" component in frontend forms. Any plugins which make use of the "table field" component in their forms may be affected by this change, and will need to update their form definitions accordingly.
|
||||
|
||||
### Removed
|
||||
|
||||
## 1.4.0 - 2026-06-24
|
||||
|
|
|
|||
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
|
|
@ -2663,7 +2663,7 @@ class StockItem(
|
|||
return True
|
||||
|
||||
@transaction.atomic
|
||||
def stocktake(self, count, user, **kwargs):
|
||||
def stocktake(self, count, user, **kwargs) -> None:
|
||||
"""Perform item stocktake.
|
||||
|
||||
Arguments:
|
||||
|
|
@ -2678,7 +2678,7 @@ class StockItem(
|
|||
try:
|
||||
count = Decimal(count)
|
||||
except InvalidOperation:
|
||||
return False
|
||||
return
|
||||
|
||||
if count < 0:
|
||||
return False
|
||||
|
|
@ -2706,8 +2706,8 @@ class StockItem(
|
|||
)
|
||||
tracking_info['old_status_logical'] = old_status_logical
|
||||
|
||||
if self.updateQuantity(count):
|
||||
tracking_info['quantity'] = float(count)
|
||||
if self.serialized or self.updateQuantity(count):
|
||||
tracking_info['quantity'] = 1 if self.serialized else float(count)
|
||||
|
||||
self.stocktake_date = InvenTree.helpers.current_date()
|
||||
self.stocktake_user = user
|
||||
|
|
@ -2729,13 +2729,10 @@ class StockItem(
|
|||
|
||||
trigger_event(
|
||||
StockEvents.ITEM_COUNTED,
|
||||
'stockitem.counted',
|
||||
id=self.id,
|
||||
quantity=float(self.quantity),
|
||||
quantity=1 if self.serialized else float(self.quantity),
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@transaction.atomic
|
||||
def add_stock(self, quantity, user, **kwargs):
|
||||
"""Add a specified quantity of stock to this item.
|
||||
|
|
|
|||
|
|
@ -1770,7 +1770,7 @@ class StockCountSerializer(StockAdjustmentSerializer):
|
|||
fields = ['items', 'notes', 'location']
|
||||
|
||||
location = serializers.PrimaryKeyRelatedField(
|
||||
queryset=StockLocation.objects.filter(structural=False),
|
||||
queryset=StockLocation.objects.filter(),
|
||||
many=False,
|
||||
required=False,
|
||||
allow_null=True,
|
||||
|
|
@ -1778,6 +1778,15 @@ class StockCountSerializer(StockAdjustmentSerializer):
|
|||
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):
|
||||
"""Count stock."""
|
||||
request = self.context['request']
|
||||
|
|
@ -1874,7 +1883,7 @@ class StockTransferSerializer(StockAdjustmentSerializer):
|
|||
items = StockAdjustmentItemSerializer(many=True, require_non_zero=True)
|
||||
|
||||
location = serializers.PrimaryKeyRelatedField(
|
||||
queryset=StockLocation.objects.filter(structural=False),
|
||||
queryset=StockLocation.objects.filter(),
|
||||
many=False,
|
||||
required=True,
|
||||
allow_null=False,
|
||||
|
|
@ -1882,6 +1891,15 @@ class StockTransferSerializer(StockAdjustmentSerializer):
|
|||
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):
|
||||
"""Transfer stock."""
|
||||
request = self.context['request']
|
||||
|
|
@ -1965,7 +1983,7 @@ class StockReturnSerializer(StockAdjustmentSerializer):
|
|||
)
|
||||
|
||||
location = serializers.PrimaryKeyRelatedField(
|
||||
queryset=StockLocation.objects.filter(structural=False),
|
||||
queryset=StockLocation.objects.filter(),
|
||||
many=False,
|
||||
required=True,
|
||||
allow_null=False,
|
||||
|
|
@ -1973,6 +1991,15 @@ class StockReturnSerializer(StockAdjustmentSerializer):
|
|||
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(
|
||||
default=False,
|
||||
required=False,
|
||||
|
|
|
|||
|
|
@ -2289,6 +2289,32 @@ class StocktakeTest(StockAPITestCase):
|
|||
self.assertEqual(response.data['items'][0]['pk'], 1234)
|
||||
self.assertEqual(response.data['items'][0]['quantity'], target[endpoint])
|
||||
|
||||
def test_count_serialized(self):
|
||||
"""Test that counting a serialized stock item correctly updates stocktake_date."""
|
||||
import datetime
|
||||
|
||||
# Fixture item pk=501 is a serialized item (serial=1)
|
||||
item = StockItem.objects.get(pk=501)
|
||||
self.assertTrue(item.serialized)
|
||||
self.assertEqual(item.quantity, 1)
|
||||
|
||||
# Clear any existing stocktake date so we can verify it gets set
|
||||
item.stocktake_date = None
|
||||
item.save()
|
||||
|
||||
url = reverse('api-stock-count')
|
||||
|
||||
# Count the serialized item — quantity must be 1
|
||||
data = {'items': [{'pk': item.pk, 'quantity': 1}]}
|
||||
response = self.post(url, data, expected_code=201)
|
||||
|
||||
self.assertEqual(response.data['items'][0]['pk'], item.pk)
|
||||
self.assertEqual(response.data['items'][0]['quantity'], '1.00000')
|
||||
|
||||
# stocktake_date must have been set to today
|
||||
item.refresh_from_db()
|
||||
self.assertEqual(item.stocktake_date, datetime.date.today())
|
||||
|
||||
def test_transfer(self):
|
||||
"""Test stock transfers."""
|
||||
stock_item = StockItem.objects.get(pk=1234)
|
||||
|
|
@ -2409,7 +2435,10 @@ class StocktakeTest(StockAPITestCase):
|
|||
{'items': [{'pk': item_a.pk, 'quantity': 1}], 'location': structural.pk},
|
||||
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):
|
||||
|
|
|
|||
|
|
@ -378,11 +378,9 @@ class StockTest(StockTestBase):
|
|||
self.assertEqual(it.status, StockStatus.OK.value)
|
||||
|
||||
# Next, perform a valid stocktake
|
||||
self.assertTrue(
|
||||
it.stocktake(
|
||||
100, None, notes='test stocktake', status=StockStatus.DAMAGED.value
|
||||
)
|
||||
)
|
||||
|
||||
it.refresh_from_db()
|
||||
self.assertEqual(it.quantity, 100)
|
||||
|
|
|
|||
|
|
@ -221,8 +221,6 @@ export interface BulkEditApiFormModalProps extends ApiFormModalProps {
|
|||
|
||||
export type StockOperationProps = {
|
||||
items?: any[];
|
||||
pk?: number;
|
||||
filters?: any;
|
||||
model: ModelType.stockitem | 'location' | ModelType.part;
|
||||
refresh: () => void;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@inventreedb/ui",
|
||||
"description": "UI components for the InvenTree project",
|
||||
"version": "1.4.6",
|
||||
"version": "1.5.0",
|
||||
"private": false,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
|
|
|||
|
|
@ -455,6 +455,12 @@ export function ApiForm({
|
|||
}
|
||||
})
|
||||
.then((response) => {
|
||||
const followPk = Array.isArray(response.data)
|
||||
? response.data.length === 1
|
||||
? response.data[0]?.pk
|
||||
: undefined
|
||||
: response.data?.pk;
|
||||
|
||||
switch (response.status) {
|
||||
case 200:
|
||||
case 201:
|
||||
|
|
@ -469,12 +475,12 @@ export function ApiForm({
|
|||
if (
|
||||
props.follow &&
|
||||
props.modelType &&
|
||||
response.data?.pk &&
|
||||
followPk &&
|
||||
!keepOpenRef.current
|
||||
) {
|
||||
// If we want to automatically follow the returned data
|
||||
if (!!navigate && !keepOpenRef.current) {
|
||||
navigate(getDetailUrl(props.modelType, response.data?.pk));
|
||||
navigate(getDetailUrl(props.modelType, followPk));
|
||||
}
|
||||
} else if (props.table) {
|
||||
// If we want to automatically update or reload a linked table
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ import { useId } from '@mantine/hooks';
|
|||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
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 { IconFileUpload } from '@tabler/icons-react';
|
||||
import type { NavigateFunction } from 'react-router-dom';
|
||||
|
|
@ -19,6 +22,7 @@ import { RelatedModelField } from './RelatedModelField';
|
|||
import { TableField } from './TableField';
|
||||
import TagsField from './TagsField';
|
||||
import TextField from './TextField';
|
||||
import { TreeField } from './TreeField';
|
||||
|
||||
/**
|
||||
* Render an individual form field
|
||||
|
|
@ -121,6 +125,39 @@ export function ApiFormField({
|
|||
const fieldInstance = useMemo(() => {
|
||||
switch (fieldDefinition.field_type) {
|
||||
case 'related field':
|
||||
if (
|
||||
fieldDefinition.api_url === apiUrl(ApiEndpoints.stock_location_list)
|
||||
) {
|
||||
// Redirect location fields to the appropriate tree field
|
||||
return (
|
||||
<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}
|
||||
|
|
@ -129,6 +166,7 @@ export function ApiFormField({
|
|||
navigate={navigate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'email':
|
||||
case 'url':
|
||||
case 'string':
|
||||
|
|
@ -247,7 +285,9 @@ export function ApiFormField({
|
|||
<TableField
|
||||
definition={fieldDefinition}
|
||||
fieldName={fieldName}
|
||||
control={controller}
|
||||
value={value}
|
||||
onChange={field.onChange}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
case 'tags':
|
||||
|
|
|
|||
|
|
@ -419,6 +419,7 @@ export function RelatedModelField({
|
|||
...definition,
|
||||
addCreateFields: undefined,
|
||||
autoFill: undefined,
|
||||
autoFillFilters: undefined,
|
||||
modelRenderer: undefined,
|
||||
onValueChange: undefined,
|
||||
adjustFilters: undefined,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,23 @@
|
|||
import { t } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Alert, Container, Group, Stack, Table, Text } from '@mantine/core';
|
||||
import {
|
||||
Alert,
|
||||
Container,
|
||||
Group,
|
||||
NumberInput,
|
||||
Stack,
|
||||
Table,
|
||||
Text
|
||||
} from '@mantine/core';
|
||||
import { IconExclamationCircle } from '@tabler/icons-react';
|
||||
import { type ReactNode, useCallback, useEffect, useMemo } from 'react';
|
||||
import type { FieldValues, UseControllerReturn } from 'react-hook-form';
|
||||
import {
|
||||
type ReactNode,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef
|
||||
} from 'react';
|
||||
|
||||
import { AddItemButton } from '@lib/components/AddItemButton';
|
||||
import { identifierString } from '@lib/functions/Conversion';
|
||||
|
|
@ -13,35 +27,34 @@ import { StandaloneField } from '../StandaloneField';
|
|||
|
||||
export interface TableFieldRowProps {
|
||||
item: any;
|
||||
idx: number;
|
||||
rowId: string | number;
|
||||
rowErrors: any;
|
||||
control: UseControllerReturn<FieldValues, any>;
|
||||
changeFn: (idx: number, key: string, value: any) => void;
|
||||
removeFn: (idx: number) => void;
|
||||
changeFn: (rowId: number | string, key: string, value: any) => void;
|
||||
removeFn: (rowId: number | string) => void;
|
||||
}
|
||||
|
||||
function TableFieldRow({
|
||||
item,
|
||||
idx,
|
||||
errors,
|
||||
definition,
|
||||
control,
|
||||
rowId,
|
||||
rowErrors,
|
||||
modelRenderer,
|
||||
columnCount,
|
||||
changeFn,
|
||||
removeFn
|
||||
}: Readonly<{
|
||||
item: any;
|
||||
idx: number;
|
||||
errors: any;
|
||||
definition: ApiFormFieldType;
|
||||
control: UseControllerReturn<FieldValues, any>;
|
||||
changeFn: (idx: number, key: string, value: any) => void;
|
||||
removeFn: (idx: number) => void;
|
||||
rowId: string | number;
|
||||
rowErrors: any;
|
||||
modelRenderer?: ApiFormFieldType['modelRenderer'];
|
||||
columnCount?: number;
|
||||
changeFn: (rowId: number | string, key: string, value: any) => void;
|
||||
removeFn: (rowId: number | string) => void;
|
||||
}>) {
|
||||
// Table fields require render function
|
||||
if (!definition.modelRenderer) {
|
||||
if (!modelRenderer) {
|
||||
return (
|
||||
<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 />}>
|
||||
{t`modelRenderer entry required for tables`}
|
||||
</Alert>
|
||||
|
|
@ -50,16 +63,60 @@ function TableFieldRow({
|
|||
);
|
||||
}
|
||||
|
||||
return definition.modelRenderer({
|
||||
return modelRenderer({
|
||||
item: item,
|
||||
idx: idx,
|
||||
rowErrors: errors,
|
||||
control: control,
|
||||
rowId: rowId,
|
||||
rowErrors: rowErrors,
|
||||
changeFn: changeFn,
|
||||
removeFn: removeFn
|
||||
});
|
||||
}
|
||||
|
||||
// Memoize each table field row, so that we don't re-render the entire table when a single row is updated
|
||||
function areShallowEqual(previousValue: any, nextValue: any): boolean {
|
||||
if (previousValue === nextValue) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!previousValue || !nextValue) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof previousValue !== 'object' || typeof nextValue !== 'object') {
|
||||
return previousValue === nextValue;
|
||||
}
|
||||
|
||||
const previousKeys = Object.keys(previousValue);
|
||||
const nextKeys = Object.keys(nextValue);
|
||||
|
||||
if (previousKeys.length !== nextKeys.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const key of previousKeys) {
|
||||
if (previousValue[key] !== nextValue[key]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const MemoizedTableFieldRow = memo(
|
||||
TableFieldRow,
|
||||
(previousProps, nextProps) => {
|
||||
return (
|
||||
previousProps.rowId === nextProps.rowId &&
|
||||
areShallowEqual(previousProps.item, nextProps.item) &&
|
||||
areShallowEqual(previousProps.rowErrors, nextProps.rowErrors) &&
|
||||
previousProps.modelRenderer === nextProps.modelRenderer &&
|
||||
previousProps.changeFn === nextProps.changeFn &&
|
||||
previousProps.removeFn === nextProps.removeFn &&
|
||||
previousProps.columnCount === nextProps.columnCount
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export function TableFieldErrorWrapper({
|
||||
props,
|
||||
errorKey,
|
||||
|
|
@ -83,33 +140,131 @@ export function TableFieldErrorWrapper({
|
|||
);
|
||||
}
|
||||
|
||||
export function TableField({
|
||||
function TableFieldComponent({
|
||||
definition,
|
||||
fieldName,
|
||||
control
|
||||
value,
|
||||
onChange,
|
||||
error
|
||||
}: Readonly<{
|
||||
definition: ApiFormFieldType;
|
||||
fieldName: string;
|
||||
control: UseControllerReturn<FieldValues, any>;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
error?: any;
|
||||
}>) {
|
||||
const {
|
||||
field,
|
||||
fieldState: { error }
|
||||
} = control;
|
||||
const { value } = field;
|
||||
const valueRef = useRef(value);
|
||||
const onChangeRef = useRef(onChange);
|
||||
const rowIndexByIdRef = useRef(new Map<string | number, number>());
|
||||
const generatedRowIdsRef = useRef(new WeakMap<object, string>());
|
||||
const generatedRowIdCounterRef = useRef(0);
|
||||
|
||||
const onRowFieldChange = (idx: number, key: string, value: any) => {
|
||||
const val = field.value;
|
||||
val[idx][key] = value;
|
||||
const getRowIdentifier = useCallback(
|
||||
(item: any, idx: number): string | number => {
|
||||
if (item && typeof item === 'object') {
|
||||
const intrinsicId = item.pk ?? item.item ?? item.id ?? item.uuid;
|
||||
|
||||
field.onChange(val);
|
||||
if (intrinsicId !== undefined && intrinsicId !== null) {
|
||||
return intrinsicId;
|
||||
}
|
||||
|
||||
const existingGeneratedId = generatedRowIdsRef.current.get(item);
|
||||
|
||||
if (existingGeneratedId) {
|
||||
return existingGeneratedId;
|
||||
}
|
||||
|
||||
generatedRowIdCounterRef.current += 1;
|
||||
const generatedId = `table-row-generated-${generatedRowIdCounterRef.current}`;
|
||||
generatedRowIdsRef.current.set(item, generatedId);
|
||||
|
||||
return generatedId;
|
||||
}
|
||||
|
||||
return item ?? idx;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Keep refs in sync with latest values without introducing them as deps
|
||||
valueRef.current = value;
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
useEffect(() => {
|
||||
const nextRowIndexById = new Map<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
|
||||
};
|
||||
}
|
||||
|
||||
const removeRow = (idx: number) => {
|
||||
const val = field.value;
|
||||
val.splice(idx, 1);
|
||||
field.onChange(val);
|
||||
};
|
||||
valueRef.current = nextValue;
|
||||
onChangeRef.current(nextValue);
|
||||
},
|
||||
[resolveRowIndex]
|
||||
);
|
||||
|
||||
const removeRow = useCallback(
|
||||
(identifier: number | string) => {
|
||||
const idx = resolveRowIndex(identifier);
|
||||
|
||||
if (idx === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentValue = valueRef.current;
|
||||
|
||||
if (!Array.isArray(currentValue)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextValue = [...currentValue];
|
||||
nextValue.splice(idx, 1);
|
||||
|
||||
onChangeRef.current(nextValue);
|
||||
},
|
||||
[resolveRowIndex]
|
||||
);
|
||||
|
||||
// Extract errors associated with the current row
|
||||
const rowErrors: any = useCallback(
|
||||
|
|
@ -125,7 +280,7 @@ export function TableField({
|
|||
<Table
|
||||
highlightOnHover
|
||||
striped
|
||||
aria-label={`table-field-${field.name}`}
|
||||
aria-label={`table-field-${fieldName}`}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Table.Thead>
|
||||
|
|
@ -146,14 +301,16 @@ export function TableField({
|
|||
<Table.Tbody>
|
||||
{(value?.length ?? 0) > 0 ? (
|
||||
value.map((item: any, idx: number) => {
|
||||
const rowId = getRowIdentifier(item, idx);
|
||||
|
||||
return (
|
||||
<TableFieldRow
|
||||
key={`table-row-${idx}`}
|
||||
<MemoizedTableFieldRow
|
||||
key={`table-row-${rowId}`}
|
||||
item={item}
|
||||
idx={idx}
|
||||
errors={rowErrors(idx)}
|
||||
control={control}
|
||||
definition={definition}
|
||||
rowId={rowId}
|
||||
rowErrors={rowErrors(idx)}
|
||||
modelRenderer={definition.modelRenderer}
|
||||
columnCount={definition.headers?.length}
|
||||
changeFn={onRowFieldChange}
|
||||
removeFn={removeRow}
|
||||
/>
|
||||
|
|
@ -189,9 +346,7 @@ export function TableField({
|
|||
if (definition.addRow === undefined) return;
|
||||
const ret = definition.addRow();
|
||||
if (ret) {
|
||||
const val = field.value;
|
||||
val.push(ret);
|
||||
field.onChange(val);
|
||||
onChange([...(value ?? []), ret]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
|
@ -203,6 +358,19 @@ export function TableField({
|
|||
);
|
||||
}
|
||||
|
||||
export const TableField = memo(
|
||||
TableFieldComponent,
|
||||
(previousProps, nextProps) => {
|
||||
return (
|
||||
previousProps.definition === nextProps.definition &&
|
||||
previousProps.fieldName === nextProps.fieldName &&
|
||||
areShallowEqual(previousProps.value, nextProps.value) &&
|
||||
areShallowEqual(previousProps.error, nextProps.error) &&
|
||||
previousProps.onChange === nextProps.onChange
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
/*
|
||||
* Display an "extra" row below the main table row, for additional information.
|
||||
* - Each "row" can display an extra row of information below the main row
|
||||
|
|
@ -224,10 +392,16 @@ export function TableFieldExtraRow({
|
|||
emptyValue?: any;
|
||||
onValueChange: (value: any) => void;
|
||||
}) {
|
||||
const hasMounted = useRef(false);
|
||||
|
||||
// Callback whenever the visibility of the sub-field changes
|
||||
// Skip the initial mount — the value was never set, nothing to reset
|
||||
useEffect(() => {
|
||||
if (!hasMounted.current) {
|
||||
hasMounted.current = true;
|
||||
return;
|
||||
}
|
||||
if (!visible) {
|
||||
// If the sub-field is hidden, reset the value to the "empty" value
|
||||
onValueChange(emptyValue);
|
||||
}
|
||||
}, [visible]);
|
||||
|
|
@ -262,3 +436,37 @@ export function TableFieldExtraRow({
|
|||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function TableFieldQuantityInput({
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
min
|
||||
}: {
|
||||
value: number | '';
|
||||
onChange: (value: number | '') => void;
|
||||
error?: string;
|
||||
min?: number;
|
||||
}) {
|
||||
return (
|
||||
<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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -172,15 +172,6 @@ export default function ImporterDataSelector({
|
|||
description: fieldDef.help_text,
|
||||
filters: filters
|
||||
};
|
||||
|
||||
console.log('Defined Field:', field);
|
||||
console.log({
|
||||
...fieldDef,
|
||||
...customField,
|
||||
field_type: fieldDef.type,
|
||||
description: fieldDef.help_text,
|
||||
filters: filters
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -128,12 +128,7 @@ export function RenderPartCategory(
|
|||
<RenderInlineModel
|
||||
{...props}
|
||||
tooltip={instance.pathstring}
|
||||
prefix={
|
||||
<>
|
||||
{instance.level > 0 && `${'- '.repeat(instance.level)}`}
|
||||
{instance.icon && <ApiIcon name={instance.icon} />}
|
||||
</>
|
||||
}
|
||||
prefix={instance.icon && <ApiIcon name={instance.icon} />}
|
||||
primary={category}
|
||||
suffix={suffix}
|
||||
url={
|
||||
|
|
|
|||
|
|
@ -49,12 +49,7 @@ export function RenderStockLocation(
|
|||
<RenderInlineModel
|
||||
{...props}
|
||||
tooltip={instance.pathstring}
|
||||
prefix={
|
||||
<>
|
||||
{instance.level > 0 && `${'- '.repeat(instance.level)}`}
|
||||
{instance.icon && <ApiIcon name={instance.icon} />}
|
||||
</>
|
||||
}
|
||||
prefix={instance.icon && <ApiIcon name={instance.icon} />}
|
||||
primary={location}
|
||||
suffix={suffix}
|
||||
url={
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ function BomItemSubstituteRow({
|
|||
api
|
||||
.delete(apiUrl(ApiEndpoints.bom_substitute_list, record.pk))
|
||||
.then(() => {
|
||||
props.removeFn(props.idx);
|
||||
props.removeFn(props.rowId);
|
||||
})
|
||||
.catch((err) => {
|
||||
showApiErrorMessage({
|
||||
|
|
@ -116,7 +116,7 @@ export function useEditBomSubstitutesForm(props: BomItemSubstituteFormProps) {
|
|||
modelRenderer: (row: TableFieldRowProps) => {
|
||||
const record = substitutes.find((r) => r.pk == row.item.pk);
|
||||
return record ? (
|
||||
<BomItemSubstituteRow props={row} record={record} />
|
||||
<BomItemSubstituteRow key={row.rowId} props={row} record={record} />
|
||||
) : null;
|
||||
},
|
||||
headers: [
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { apiUrl } from '@lib/functions/Api';
|
|||
import type { ApiFormFieldSet, ApiFormFieldType } from '@lib/types/Forms';
|
||||
import {
|
||||
TableFieldErrorWrapper,
|
||||
TableFieldQuantityInput,
|
||||
type TableFieldRowProps
|
||||
} from '../components/forms/fields/TableField';
|
||||
import { StatusRenderer } from '../components/render/StatusRenderer';
|
||||
|
|
@ -272,15 +273,10 @@ function BuildOutputFormRow({
|
|||
|
||||
// Non-serialized output - quantity can be changed
|
||||
return (
|
||||
<StandaloneField
|
||||
fieldName='quantity'
|
||||
fieldDefinition={{
|
||||
field_type: 'number',
|
||||
required: true,
|
||||
value: props.item.quantity,
|
||||
onValueChange: (value: any) => {
|
||||
props.changeFn(props.idx, 'quantity', value);
|
||||
}
|
||||
<TableFieldQuantityInput
|
||||
value={props.item.quantity ?? ''}
|
||||
onChange={(value) => {
|
||||
props.changeFn(props.rowId, 'quantity', value);
|
||||
}}
|
||||
error={props.rowErrors?.quantity?.message}
|
||||
/>
|
||||
|
|
@ -310,7 +306,7 @@ function BuildOutputFormRow({
|
|||
/>{' '}
|
||||
</Table.Td>
|
||||
<Table.Td style={{ width: '1%', whiteSpace: 'nowrap' }}>
|
||||
<RemoveRowButton onClick={() => props.removeFn(props.idx)} />
|
||||
<RemoveRowButton onClick={() => props.removeFn(props.rowId)} />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
</>
|
||||
|
|
@ -344,6 +340,7 @@ export function useCompleteBuildOutputsForm({
|
|||
const buildOutputs = useMemo(() => {
|
||||
return outputs.map((output: any) => {
|
||||
return {
|
||||
id: output.pk,
|
||||
output: output.pk,
|
||||
quantity: output.quantity
|
||||
};
|
||||
|
|
@ -358,7 +355,7 @@ export function useCompleteBuildOutputsForm({
|
|||
modelRenderer: (row: TableFieldRowProps) => {
|
||||
const record = outputs.find((output) => output.pk == row.item.output);
|
||||
return (
|
||||
<BuildOutputFormRow props={row} record={record} key={record.pk} />
|
||||
<BuildOutputFormRow props={row} record={record} key={row.rowId} />
|
||||
);
|
||||
},
|
||||
headers: [
|
||||
|
|
@ -426,6 +423,7 @@ export function useScrapBuildOutputsForm({
|
|||
const buildOutputs = useMemo(() => {
|
||||
return outputs.map((output: any) => {
|
||||
return {
|
||||
id: output.pk,
|
||||
output: output.pk,
|
||||
quantity: output.quantity
|
||||
};
|
||||
|
|
@ -440,7 +438,7 @@ export function useScrapBuildOutputsForm({
|
|||
modelRenderer: (row: TableFieldRowProps) => {
|
||||
const record = outputs.find((output) => output.pk == row.item.output);
|
||||
return (
|
||||
<BuildOutputFormRow props={row} record={record} key={record.pk} />
|
||||
<BuildOutputFormRow props={row} record={record} key={row.rowId} />
|
||||
);
|
||||
},
|
||||
headers: [
|
||||
|
|
@ -497,6 +495,7 @@ export function useCancelBuildOutputsForm({
|
|||
const buildOutputs = useMemo(() => {
|
||||
return outputs.map((output: any) => {
|
||||
return {
|
||||
id: output.pk,
|
||||
output: output.pk
|
||||
};
|
||||
});
|
||||
|
|
@ -562,6 +561,8 @@ function BuildAllocateLineRow({
|
|||
record: any;
|
||||
sourceLocation: number | undefined;
|
||||
}>) {
|
||||
const [quantity, setQuantity] = useState<number>(props.item?.quantity ?? 0);
|
||||
|
||||
const stockField: ApiFormFieldType = useMemo(() => {
|
||||
return {
|
||||
field_type: 'related field',
|
||||
|
|
@ -585,7 +586,7 @@ function BuildAllocateLineRow({
|
|||
value: props.item.stock_item,
|
||||
name: 'stock_item',
|
||||
onValueChange: (value: any, instance: any) => {
|
||||
props.changeFn(props.idx, 'stock_item', value);
|
||||
props.changeFn(props.rowId, 'stock_item', value);
|
||||
|
||||
// Update the allocated quantity based on the selected stock item
|
||||
if (instance) {
|
||||
|
|
@ -593,7 +594,7 @@ function BuildAllocateLineRow({
|
|||
|
||||
if (available < props.item.quantity) {
|
||||
props.changeFn(
|
||||
props.idx,
|
||||
props.rowId,
|
||||
'quantity',
|
||||
Math.min(props.item.quantity, available)
|
||||
);
|
||||
|
|
@ -603,18 +604,6 @@ function BuildAllocateLineRow({
|
|||
};
|
||||
}, [record, props]);
|
||||
|
||||
const quantityField: ApiFormFieldType = useMemo(() => {
|
||||
return {
|
||||
field_type: 'number',
|
||||
name: 'quantity',
|
||||
required: true,
|
||||
value: props.item.quantity,
|
||||
onValueChange: (value: any) => {
|
||||
props.changeFn(props.idx, 'quantity', value);
|
||||
}
|
||||
};
|
||||
}, [props]);
|
||||
|
||||
return (
|
||||
<Table.Tr key={`table-row-${record.pk}`}>
|
||||
<Table.Td>
|
||||
|
|
@ -639,14 +628,18 @@ function BuildAllocateLineRow({
|
|||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<StandaloneField
|
||||
fieldName='quantity'
|
||||
fieldDefinition={quantityField}
|
||||
<TableFieldQuantityInput
|
||||
min={0}
|
||||
value={quantity}
|
||||
onChange={(value) => {
|
||||
setQuantity(value === '' ? 0 : value);
|
||||
props.changeFn(props.rowId, 'quantity', value);
|
||||
}}
|
||||
error={props.rowErrors?.quantity?.message}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<RemoveRowButton onClick={() => props.removeFn(props.idx)} />
|
||||
<RemoveRowButton onClick={() => props.removeFn(props.rowId)} />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
|
|
@ -674,6 +667,16 @@ export function useAllocateStockToBuildForm({
|
|||
undefined
|
||||
);
|
||||
|
||||
// Memoize the line items once to avoid re-rendering
|
||||
const buildLines = useMemo(() => {
|
||||
return lineItems.map((item) => {
|
||||
return {
|
||||
id: item.pk,
|
||||
...item
|
||||
};
|
||||
});
|
||||
}, [lineItems]);
|
||||
|
||||
const buildAllocateFields: ApiFormFieldSet = useMemo(() => {
|
||||
const fields: ApiFormFieldSet = {
|
||||
items: {
|
||||
|
|
@ -690,10 +693,10 @@ export function useAllocateStockToBuildForm({
|
|||
modelRenderer: (row: TableFieldRowProps) => {
|
||||
// Find the matching record from the passed 'lineItems'
|
||||
const record =
|
||||
lineItems.find((item) => item.pk == row.item.build_line) ?? {};
|
||||
buildLines.find((item) => item.pk == row.item.build_line) ?? {};
|
||||
return (
|
||||
<BuildAllocateLineRow
|
||||
key={row.idx}
|
||||
key={row.rowId}
|
||||
output={output}
|
||||
props={row}
|
||||
record={record}
|
||||
|
|
@ -770,6 +773,7 @@ export function useAllocateStockToBuildForm({
|
|||
})
|
||||
.map((item) => {
|
||||
return {
|
||||
id: item.pk,
|
||||
build_line: item.pk,
|
||||
stock_item: undefined,
|
||||
quantity: Math.max(
|
||||
|
|
@ -791,6 +795,8 @@ function BuildConsumeItemRow({
|
|||
props: TableFieldRowProps;
|
||||
record: any;
|
||||
}) {
|
||||
const [quantity, setQuantity] = useState<number>(props.item?.quantity ?? 0);
|
||||
|
||||
return (
|
||||
<Table.Tr key={`table-row-${record.pk}`}>
|
||||
<Table.Td>
|
||||
|
|
@ -806,21 +812,18 @@ function BuildConsumeItemRow({
|
|||
</Table.Td>
|
||||
<Table.Td>{record.quantity}</Table.Td>
|
||||
<Table.Td>
|
||||
<StandaloneField
|
||||
fieldName='quantity'
|
||||
fieldDefinition={{
|
||||
field_type: 'number',
|
||||
required: true,
|
||||
value: props.item.quantity,
|
||||
onValueChange: (value: any) => {
|
||||
props.changeFn(props.idx, 'quantity', value);
|
||||
}
|
||||
<TableFieldQuantityInput
|
||||
min={0}
|
||||
value={quantity}
|
||||
onChange={(value) => {
|
||||
setQuantity(value === '' ? 0 : value);
|
||||
props.changeFn(props.rowId, 'quantity', value);
|
||||
}}
|
||||
error={props.rowErrors?.quantity?.message}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<RemoveRowButton onClick={() => props.removeFn(props.idx)} />
|
||||
<RemoveRowButton onClick={() => props.removeFn(props.rowId)} />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
|
|
@ -856,7 +859,7 @@ export function useConsumeBuildItemsForm({
|
|||
);
|
||||
|
||||
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: {
|
||||
items: allocatedItems.map((item) => {
|
||||
return {
|
||||
id: item.pk,
|
||||
build_item: item.pk,
|
||||
quantity: item.quantity
|
||||
};
|
||||
|
|
@ -919,7 +923,7 @@ function BuildConsumeLineRow({
|
|||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<RemoveRowButton onClick={() => props.removeFn(props.idx)} />
|
||||
<RemoveRowButton onClick={() => props.removeFn(props.rowId)} />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
|
|
@ -957,7 +961,7 @@ export function useConsumeBuildLinesForm({
|
|||
);
|
||||
|
||||
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,
|
||||
onFormSuccess: onFormSuccess,
|
||||
fields: consumeFields,
|
||||
size: '80%',
|
||||
initialData: {
|
||||
lines: filteredLines.map((item) => {
|
||||
return {
|
||||
id: item.pk,
|
||||
build_line: item.pk
|
||||
};
|
||||
})
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ import type {
|
|||
} from '@lib/types/Forms';
|
||||
import {
|
||||
TableFieldExtraRow,
|
||||
TableFieldQuantityInput,
|
||||
type TableFieldRowProps
|
||||
} from '../components/forms/fields/TableField';
|
||||
import { Thumbnail } from '../components/images/Thumbnail';
|
||||
|
|
@ -348,11 +349,11 @@ function LineItemFormRow({
|
|||
}>) {
|
||||
// Barcode Modal state
|
||||
const [opened, { open, close }] = useDisclosure(false, {
|
||||
onClose: () => props.changeFn(props.idx, 'barcode', undefined)
|
||||
onClose: () => props.changeFn(props.rowId, 'barcode', undefined)
|
||||
});
|
||||
|
||||
const [locationOpen, locationHandlers] = useDisclosure(false, {
|
||||
onClose: () => props.changeFn(props.idx, 'location', undefined)
|
||||
onClose: () => props.changeFn(props.rowId, 'location', undefined)
|
||||
});
|
||||
|
||||
// Is this a trackable part?
|
||||
|
|
@ -365,7 +366,7 @@ function LineItemFormRow({
|
|||
|
||||
useEffect(() => {
|
||||
if (!!record.destination) {
|
||||
props.changeFn(props.idx, 'location', record.destination);
|
||||
props.changeFn(props.rowId, 'location', record.destination);
|
||||
locationHandlers.open();
|
||||
}
|
||||
}, [record.destination]);
|
||||
|
|
@ -375,7 +376,7 @@ function LineItemFormRow({
|
|||
isEnabled: () => batchOpen,
|
||||
onGenerate: (value: any) => {
|
||||
if (value) {
|
||||
props.changeFn(props.idx, 'batch_code', value);
|
||||
props.changeFn(props.rowId, 'batch_code', value);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -387,19 +388,19 @@ function LineItemFormRow({
|
|||
|
||||
const [packagingOpen, packagingHandlers] = useDisclosure(false, {
|
||||
onClose: () => {
|
||||
props.changeFn(props.idx, 'packaging', undefined);
|
||||
props.changeFn(props.rowId, 'packaging', undefined);
|
||||
}
|
||||
});
|
||||
|
||||
const [noteOpen, noteHandlers] = useDisclosure(false, {
|
||||
onClose: () => {
|
||||
props.changeFn(props.idx, 'note', undefined);
|
||||
props.changeFn(props.rowId, 'note', undefined);
|
||||
}
|
||||
});
|
||||
|
||||
const [batchOpen, batchHandlers] = useDisclosure(false, {
|
||||
onClose: () => {
|
||||
props.changeFn(props.idx, 'batch_code', undefined);
|
||||
props.changeFn(props.rowId, 'batch_code', undefined);
|
||||
},
|
||||
onOpen: () => {
|
||||
// Generate a new batch code
|
||||
|
|
@ -412,7 +413,7 @@ function LineItemFormRow({
|
|||
|
||||
const [serialOpen, serialHandlers] = useDisclosure(false, {
|
||||
onClose: () => {
|
||||
props.changeFn(props.idx, 'serial_numbers', undefined);
|
||||
props.changeFn(props.rowId, 'serial_numbers', undefined);
|
||||
},
|
||||
onOpen: () => {
|
||||
// Generate new serial numbers
|
||||
|
|
@ -422,7 +423,7 @@ function LineItemFormRow({
|
|||
quantity: props.item.quantity
|
||||
});
|
||||
} else {
|
||||
props.changeFn(props.idx, 'serial_numbers', undefined);
|
||||
props.changeFn(props.rowId, 'serial_numbers', undefined);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -433,20 +434,20 @@ function LineItemFormRow({
|
|||
const defaultExpiry = record.part_detail?.default_expiry;
|
||||
if (defaultExpiry !== undefined && defaultExpiry > 0) {
|
||||
props.changeFn(
|
||||
props.idx,
|
||||
props.rowId,
|
||||
'expiry_date',
|
||||
dayjs().add(defaultExpiry, 'day').format('YYYY-MM-DD')
|
||||
);
|
||||
}
|
||||
},
|
||||
onClose: () => {
|
||||
props.changeFn(props.idx, 'expiry_date', undefined);
|
||||
props.changeFn(props.rowId, 'expiry_date', undefined);
|
||||
}
|
||||
});
|
||||
|
||||
// Status value
|
||||
const [statusOpen, statusHandlers] = useDisclosure(false, {
|
||||
onClose: () => props.changeFn(props.idx, 'status', undefined)
|
||||
onClose: () => props.changeFn(props.rowId, 'status', undefined)
|
||||
});
|
||||
|
||||
// Barcode value
|
||||
|
|
@ -455,7 +456,7 @@ function LineItemFormRow({
|
|||
|
||||
// Change form value when state is altered
|
||||
useEffect(() => {
|
||||
props.changeFn(props.idx, 'barcode', barcode);
|
||||
props.changeFn(props.rowId, 'barcode', barcode);
|
||||
}, [barcode]);
|
||||
|
||||
// Update location field description on state change
|
||||
|
|
@ -574,15 +575,14 @@ function LineItemFormRow({
|
|||
/>
|
||||
</Table.Td>
|
||||
<Table.Td style={{ whiteSpace: 'nowrap' }}>
|
||||
<StandaloneField
|
||||
fieldName='quantity'
|
||||
fieldDefinition={{
|
||||
field_type: 'number',
|
||||
value: props.item.quantity,
|
||||
onValueChange: (value) => {
|
||||
props.changeFn(props.idx, 'quantity', value);
|
||||
serialNumberGenerator.update({ quantity: value });
|
||||
}
|
||||
<TableFieldQuantityInput
|
||||
min={0}
|
||||
value={props.item.quantity ?? ''}
|
||||
onChange={(value) => {
|
||||
props.changeFn(props.rowId, 'quantity', value);
|
||||
serialNumberGenerator.update({
|
||||
quantity: value === '' ? undefined : value
|
||||
});
|
||||
}}
|
||||
error={props.rowErrors?.quantity?.message}
|
||||
/>
|
||||
|
|
@ -678,7 +678,7 @@ function LineItemFormRow({
|
|||
</Flex>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<RemoveRowButton onClick={() => props.removeFn(props.idx)} />
|
||||
<RemoveRowButton onClick={() => props.removeFn(props.rowId)} />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
{locationOpen && (
|
||||
|
|
@ -697,7 +697,7 @@ function LineItemFormRow({
|
|||
structural: false
|
||||
},
|
||||
onValueChange: (value) => {
|
||||
props.changeFn(props.idx, 'location', value);
|
||||
props.changeFn(props.rowId, 'location', value);
|
||||
},
|
||||
description: locationDescription,
|
||||
value: props.item.location,
|
||||
|
|
@ -719,7 +719,7 @@ function LineItemFormRow({
|
|||
tooltip={t`Store at default location`}
|
||||
onClick={() =>
|
||||
props.changeFn(
|
||||
props.idx,
|
||||
props.rowId,
|
||||
'location',
|
||||
record.part_detail?.default_location ??
|
||||
record.part_detail?.category_default_location
|
||||
|
|
@ -733,7 +733,11 @@ function LineItemFormRow({
|
|||
icon={<InvenTreeIcon icon='destination' />}
|
||||
tooltip={t`Store at line item destination `}
|
||||
onClick={() =>
|
||||
props.changeFn(props.idx, 'location', record.destination)
|
||||
props.changeFn(
|
||||
props.rowId,
|
||||
'location',
|
||||
record.destination
|
||||
)
|
||||
}
|
||||
tooltipAlignment='top'
|
||||
/>
|
||||
|
|
@ -746,7 +750,7 @@ function LineItemFormRow({
|
|||
tooltip={t`Store with already received stock`}
|
||||
onClick={() =>
|
||||
props.changeFn(
|
||||
props.idx,
|
||||
props.rowId,
|
||||
'location',
|
||||
record.destination_detail.pk
|
||||
)
|
||||
|
|
@ -762,7 +766,7 @@ function LineItemFormRow({
|
|||
<TableFieldExtraRow
|
||||
visible={batchOpen}
|
||||
onValueChange={(value) => {
|
||||
props.changeFn(props.idx, 'batch_code', value);
|
||||
props.changeFn(props.rowId, 'batch_code', value);
|
||||
}}
|
||||
fieldName='batch_code'
|
||||
fieldDefinition={{
|
||||
|
|
@ -776,7 +780,7 @@ function LineItemFormRow({
|
|||
<TableFieldExtraRow
|
||||
visible={serialOpen}
|
||||
onValueChange={(value) =>
|
||||
props.changeFn(props.idx, 'serial_numbers', value)
|
||||
props.changeFn(props.rowId, 'serial_numbers', value)
|
||||
}
|
||||
fieldName='serial_numbers'
|
||||
fieldDefinition={{
|
||||
|
|
@ -794,7 +798,7 @@ function LineItemFormRow({
|
|||
<TableFieldExtraRow
|
||||
visible={expiryDateOpen}
|
||||
onValueChange={(value) =>
|
||||
props.changeFn(props.idx, 'expiry_date', value)
|
||||
props.changeFn(props.rowId, 'expiry_date', value)
|
||||
}
|
||||
fieldName='expiry_date'
|
||||
fieldDefinition={{
|
||||
|
|
@ -808,7 +812,9 @@ function LineItemFormRow({
|
|||
)}
|
||||
<TableFieldExtraRow
|
||||
visible={packagingOpen}
|
||||
onValueChange={(value) => props.changeFn(props.idx, 'packaging', value)}
|
||||
onValueChange={(value) =>
|
||||
props.changeFn(props.rowId, 'packaging', value)
|
||||
}
|
||||
fieldName='packaging'
|
||||
fieldDefinition={{
|
||||
field_type: 'string',
|
||||
|
|
@ -821,7 +827,7 @@ function LineItemFormRow({
|
|||
visible={statusOpen}
|
||||
defaultValue={10}
|
||||
fieldName='status'
|
||||
onValueChange={(value) => props.changeFn(props.idx, 'status', value)}
|
||||
onValueChange={(value) => props.changeFn(props.rowId, 'status', value)}
|
||||
fieldDefinition={{
|
||||
field_type: 'choice',
|
||||
api_url: apiUrl(ApiEndpoints.stock_status),
|
||||
|
|
@ -833,7 +839,7 @@ function LineItemFormRow({
|
|||
<TableFieldExtraRow
|
||||
visible={noteOpen}
|
||||
fieldName='note'
|
||||
onValueChange={(value) => props.changeFn(props.idx, 'note', value)}
|
||||
onValueChange={(value) => props.changeFn(props.rowId, 'note', value)}
|
||||
fieldDefinition={{
|
||||
field_type: 'string',
|
||||
label: t`Note`
|
||||
|
|
@ -862,13 +868,20 @@ export function useReceiveLineItems(props: LineItemsForm) {
|
|||
[]
|
||||
);
|
||||
|
||||
const records = Object.fromEntries(
|
||||
props.items.map((item) => [item.pk, item])
|
||||
);
|
||||
const records = useMemo(() => {
|
||||
return Object.fromEntries(props.items.map((item) => [item.pk, item]));
|
||||
}, [props.items]);
|
||||
|
||||
const filteredItems = props.items.filter(
|
||||
(elem) => elem.quantity !== elem.received
|
||||
);
|
||||
const filteredItems = useMemo(() => {
|
||||
return props.items
|
||||
.filter((elem) => elem.quantity !== elem.received)
|
||||
.map((elem) => {
|
||||
return {
|
||||
id: elem.pk,
|
||||
...elem
|
||||
};
|
||||
});
|
||||
}, [props.items]);
|
||||
|
||||
const fields: ApiFormFieldSet = useMemo(() => {
|
||||
return {
|
||||
|
|
@ -878,8 +891,9 @@ export function useReceiveLineItems(props: LineItemsForm) {
|
|||
},
|
||||
items: {
|
||||
field_type: 'table',
|
||||
value: filteredItems.map((elem, idx) => {
|
||||
value: filteredItems.map((elem) => {
|
||||
return {
|
||||
id: elem.pk,
|
||||
line_item: elem.pk,
|
||||
location: elem.destination ?? elem.destination_detail?.pk ?? null,
|
||||
quantity: elem.quantity - elem.received,
|
||||
|
|
@ -902,7 +916,7 @@ export function useReceiveLineItems(props: LineItemsForm) {
|
|||
props={row}
|
||||
record={record}
|
||||
statuses={stockStatusCodes}
|
||||
key={record.pk}
|
||||
key={row.rowId}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
|
@ -921,7 +935,7 @@ export function useReceiveLineItems(props: LineItemsForm) {
|
|||
}
|
||||
}
|
||||
};
|
||||
}, [filteredItems, props, stockStatusCodes]);
|
||||
}, [filteredItems, records, props, stockStatusCodes]);
|
||||
|
||||
return useCreateApiFormModal({
|
||||
...props.formProps,
|
||||
|
|
|
|||
|
|
@ -199,7 +199,7 @@ function ReturnOrderLineItemFormRow({
|
|||
label: t`Status`,
|
||||
choices: statusOptions,
|
||||
onValueChange: (value) => {
|
||||
props.changeFn(props.idx, 'status', value);
|
||||
props.changeFn(props.rowId, 'status', value);
|
||||
}
|
||||
}}
|
||||
defaultValue={record.item_detail?.status}
|
||||
|
|
@ -207,7 +207,7 @@ function ReturnOrderLineItemFormRow({
|
|||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<RemoveRowButton onClick={() => props.removeFn(props.idx)} />
|
||||
<RemoveRowButton onClick={() => props.removeFn(props.rowId)} />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
</>
|
||||
|
|
@ -226,6 +226,7 @@ export function useReceiveReturnOrderLineItems(
|
|||
field_type: 'table',
|
||||
value: props.items.map((item: any) => {
|
||||
return {
|
||||
id: item.pk,
|
||||
item: item.pk
|
||||
};
|
||||
}),
|
||||
|
|
@ -236,7 +237,7 @@ export function useReceiveReturnOrderLineItems(
|
|||
<ReturnOrderLineItemFormRow
|
||||
props={row}
|
||||
record={record}
|
||||
key={record.pk}
|
||||
key={row.rowId}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -26,7 +26,10 @@ import type {
|
|||
ApiFormFieldType
|
||||
} from '@lib/types/Forms';
|
||||
import dayjs from 'dayjs';
|
||||
import type { TableFieldRowProps } from '../components/forms/fields/TableField';
|
||||
import {
|
||||
TableFieldQuantityInput,
|
||||
type TableFieldRowProps
|
||||
} from '../components/forms/fields/TableField';
|
||||
import useBackgroundTask from '../hooks/UseBackgroundTask';
|
||||
import { useCreateApiFormModal, useEditApiFormModal } from '../hooks/UseForm';
|
||||
import { useGlobalSettingsState } from '../states/SettingsStates';
|
||||
|
|
@ -332,41 +335,33 @@ function SalesOrderAllocateLineRow({
|
|||
value: props.item.stock_item,
|
||||
name: 'stock_item',
|
||||
onValueChange: (value: any, instance: any) => {
|
||||
props.changeFn(props.idx, 'stock_item', value);
|
||||
props.changeFn(props.rowId, 'stock_item', value);
|
||||
|
||||
// Update the allocated quantity based on the selected stock item
|
||||
if (instance) {
|
||||
const available = instance.quantity - instance.allocated;
|
||||
const required = record.quantity - record.allocated;
|
||||
|
||||
let quantity = props.item?.quantity ?? 0;
|
||||
let q = props.item?.quantity ?? 0;
|
||||
|
||||
quantity = Math.max(quantity, required);
|
||||
quantity = Math.min(quantity, available);
|
||||
q = Math.max(q, required);
|
||||
q = Math.min(q, available);
|
||||
|
||||
if (quantity != props.item.quantity) {
|
||||
props.changeFn(props.idx, 'quantity', quantity);
|
||||
if (q != props.item?.quantity) {
|
||||
setQuantity(q);
|
||||
props.changeFn(props.rowId, 'quantity', q);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [sourceLocation, record, props]);
|
||||
|
||||
// Statically defined field for selecting the allocation quantity
|
||||
const quantityField: ApiFormFieldType = useMemo(() => {
|
||||
return {
|
||||
field_type: 'number',
|
||||
name: 'quantity',
|
||||
required: true,
|
||||
value: props.item.quantity,
|
||||
onValueChange: (value: any) => {
|
||||
props.changeFn(props.idx, 'quantity', value);
|
||||
}
|
||||
};
|
||||
}, [props]);
|
||||
const [quantity, setQuantity] = useState<number | ''>(
|
||||
props.item?.quantity ?? ''
|
||||
);
|
||||
|
||||
return (
|
||||
<Table.Tr key={`table-row-${props.idx}-${record.pk}`}>
|
||||
<Table.Tr key={`table-row-${props.rowId}-${record.pk}`}>
|
||||
<Table.Td>
|
||||
<RenderPartColumn part={record.part_detail} />
|
||||
</Table.Td>
|
||||
|
|
@ -385,14 +380,18 @@ function SalesOrderAllocateLineRow({
|
|||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<StandaloneField
|
||||
fieldName='quantity'
|
||||
fieldDefinition={quantityField}
|
||||
<TableFieldQuantityInput
|
||||
min={0}
|
||||
value={quantity}
|
||||
onChange={(value) => {
|
||||
setQuantity(value);
|
||||
props.changeFn(props.rowId, 'quantity', value);
|
||||
}}
|
||||
error={props.rowErrors?.quantity?.message}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<RemoveRowButton onClick={() => props.removeFn(props.idx)} />
|
||||
<RemoveRowButton onClick={() => props.removeFn(props.rowId)} />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
|
|
@ -447,7 +446,7 @@ export function useAllocateToSalesOrderForm({
|
|||
|
||||
return (
|
||||
<SalesOrderAllocateLineRow
|
||||
key={`table-row-${row.idx}-${record.pk}`}
|
||||
key={row.rowId}
|
||||
props={row}
|
||||
record={record}
|
||||
sourceLocation={sourceLocation}
|
||||
|
|
@ -478,6 +477,7 @@ export function useAllocateToSalesOrderForm({
|
|||
initialData: {
|
||||
items: lineItems.map((item) => {
|
||||
return {
|
||||
id: item.pk,
|
||||
line_item: item.pk,
|
||||
quantity: 0,
|
||||
stock_item: null
|
||||
|
|
|
|||
|
|
@ -41,15 +41,16 @@ import {
|
|||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../App';
|
||||
import RemoveRowButton from '../components/buttons/RemoveRowButton';
|
||||
import { StandaloneField } from '../components/forms/StandaloneField';
|
||||
import {
|
||||
TableFieldExtraRow,
|
||||
TableFieldQuantityInput,
|
||||
type TableFieldRowProps
|
||||
} from '../components/forms/fields/TableField';
|
||||
import { Thumbnail } from '../components/images/Thumbnail';
|
||||
|
|
@ -553,19 +554,106 @@ function moveToDefault(
|
|||
});
|
||||
}
|
||||
|
||||
type StockAdjustmentItemWithRecord = {
|
||||
obj: any;
|
||||
} & StockAdjustmentItem;
|
||||
/*
|
||||
* Memoize a list of stock items for use in a stock operations modal.
|
||||
* These items may be provided directly, or fetched from the API
|
||||
*
|
||||
* @param opened - Is the underlying modal opened or closed?
|
||||
* @param items - Optional list of stock items to use directly
|
||||
* @param category - Optional category ID to filter stock items
|
||||
* @param location - Optional location ID to filter stock items
|
||||
* @param part - Optional part ID to filter stock items
|
||||
* @param filters - Optional additional filters to apply to the stock item query
|
||||
*/
|
||||
function useStockItems({
|
||||
opened,
|
||||
items,
|
||||
filters
|
||||
}: Readonly<{
|
||||
opened: boolean;
|
||||
items?: any[] | any;
|
||||
filters?: { [key: string]: any };
|
||||
}>) {
|
||||
const query = useQuery({
|
||||
enabled: opened,
|
||||
queryKey: ['stockItems', filters],
|
||||
queryFn: async () => {
|
||||
if (items !== undefined) {
|
||||
return Array.isArray(items) ? items : [items];
|
||||
}
|
||||
|
||||
type TableFieldRefreshFn = (idx: number) => void;
|
||||
type TableFieldChangeFn = (idx: number, key: string, value: any) => void;
|
||||
if (!opened) {
|
||||
return [];
|
||||
}
|
||||
|
||||
type StockRow = {
|
||||
item: StockAdjustmentItemWithRecord;
|
||||
idx: number;
|
||||
changeFn: TableFieldChangeFn;
|
||||
removeFn: TableFieldRefreshFn;
|
||||
};
|
||||
// Fetch via the API
|
||||
const url = apiUrl(ApiEndpoints.stock_item_list);
|
||||
|
||||
return api
|
||||
.get(url, {
|
||||
params: {
|
||||
...filters,
|
||||
part_detail: true,
|
||||
location_detail: true,
|
||||
cascade: false
|
||||
}
|
||||
})
|
||||
.then((response) => response.data ?? []);
|
||||
}
|
||||
});
|
||||
|
||||
return useMemo(() => {
|
||||
if (!opened) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return query.data ?? [];
|
||||
}, [opened, query.data]);
|
||||
}
|
||||
|
||||
function ReturnStockMoveButton({
|
||||
record,
|
||||
quantity,
|
||||
onRemove,
|
||||
returnStock
|
||||
}: {
|
||||
record: any;
|
||||
quantity: StockItemQuantity;
|
||||
onRemove: () => void;
|
||||
returnStock: boolean;
|
||||
}) {
|
||||
const form = useFormContext();
|
||||
|
||||
return (
|
||||
<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({
|
||||
props,
|
||||
|
|
@ -588,7 +676,7 @@ function StockOperationsRow({
|
|||
returnStock?: boolean;
|
||||
record?: any;
|
||||
}) {
|
||||
const form = useFormContext();
|
||||
const rowId = props.rowId;
|
||||
|
||||
const statusOptions: ApiFormFieldChoice[] = useMemo(() => {
|
||||
return (
|
||||
|
|
@ -607,37 +695,54 @@ function StockOperationsRow({
|
|||
|
||||
const [status, setStatus] = useState<number | undefined>(undefined);
|
||||
|
||||
const removeAndRefresh = () => {
|
||||
props.removeFn(props.idx);
|
||||
};
|
||||
const [packagingOpen, packagingHandlers] = useDisclosure(false);
|
||||
const [statusOpen, statusHandlers] = useDisclosure(false);
|
||||
const hasMountedPackagingRef = useRef(false);
|
||||
const hasMountedStatusRef = useRef(false);
|
||||
|
||||
const callChangeFn = (idx: number, key: string, value: any) => {
|
||||
setTimeout(() => props.changeFn(idx, key, value), 0);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!transfer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [packagingOpen, packagingHandlers] = useDisclosure(false, {
|
||||
onOpen: () => {
|
||||
if (transfer) {
|
||||
callChangeFn(props.idx, 'packaging', record?.packaging || undefined);
|
||||
if (!hasMountedPackagingRef.current) {
|
||||
hasMountedPackagingRef.current = true;
|
||||
return;
|
||||
}
|
||||
},
|
||||
onClose: () => {
|
||||
if (transfer) {
|
||||
callChangeFn(props.idx, 'packaging', undefined);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const [statusOpen, statusHandlers] = useDisclosure(false, {
|
||||
onOpen: () => {
|
||||
props.changeFn(
|
||||
rowId,
|
||||
'packaging',
|
||||
packagingOpen ? record?.packaging || undefined : undefined
|
||||
);
|
||||
}, [transfer, packagingOpen, rowId, record?.packaging, props.changeFn]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!changeStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasMountedStatusRef.current) {
|
||||
hasMountedStatusRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (statusOpen) {
|
||||
setStatus(record?.status_custom_key || record?.status || undefined);
|
||||
props.changeFn(props.idx, 'status', record?.status || undefined);
|
||||
},
|
||||
onClose: () => {
|
||||
setStatus(undefined);
|
||||
callChangeFn(props.idx, 'status', undefined);
|
||||
props.changeFn(rowId, 'status', record?.status || undefined);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
setStatus(undefined);
|
||||
props.changeFn(rowId, 'status', undefined);
|
||||
}, [
|
||||
changeStatus,
|
||||
statusOpen,
|
||||
rowId,
|
||||
record?.status,
|
||||
record?.status_custom_key,
|
||||
props.changeFn
|
||||
]);
|
||||
|
||||
const stockString: string = useMemo(() => {
|
||||
if (!record) {
|
||||
|
|
@ -652,10 +757,15 @@ function StockOperationsRow({
|
|||
}, [record]);
|
||||
|
||||
return !record ? (
|
||||
<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>
|
||||
<Stack gap='xs'>
|
||||
<Flex gap='sm' align='center'>
|
||||
|
|
@ -689,15 +799,12 @@ function StockOperationsRow({
|
|||
</Table.Td>
|
||||
{!merge && (
|
||||
<Table.Td>
|
||||
<StandaloneField
|
||||
fieldName='quantity'
|
||||
fieldDefinition={{
|
||||
field_type: 'number',
|
||||
value: quantity,
|
||||
onValueChange: (value: any) => {
|
||||
<TableFieldQuantityInput
|
||||
min={0}
|
||||
value={quantity ?? ''}
|
||||
onChange={(value) => {
|
||||
setQuantity(value);
|
||||
props.changeFn(props.idx, 'quantity', value);
|
||||
}
|
||||
props.changeFn(rowId, 'quantity', value);
|
||||
}}
|
||||
error={props.rowErrors?.quantity?.message}
|
||||
/>
|
||||
|
|
@ -705,24 +812,19 @@ function StockOperationsRow({
|
|||
)}
|
||||
<Table.Td>
|
||||
<Flex gap='3px'>
|
||||
{transfer && (
|
||||
{transfer &&
|
||||
(returnStock ? (
|
||||
<ReturnStockMoveButton
|
||||
record={record}
|
||||
quantity={props.item.quantity}
|
||||
onRemove={() => props.removeFn(rowId)}
|
||||
returnStock={returnStock}
|
||||
/>
|
||||
) : (
|
||||
<ActionButton
|
||||
onClick={() =>
|
||||
moveToDefault(
|
||||
record,
|
||||
props.item.quantity,
|
||||
removeAndRefresh,
|
||||
returnStock
|
||||
? {
|
||||
title: t`Confirm Stock Return`,
|
||||
onConfirm: (location: number) => {
|
||||
form.setValue('location', location, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true
|
||||
});
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
moveToDefault(record, props.item.quantity, () =>
|
||||
props.removeFn(rowId)
|
||||
)
|
||||
}
|
||||
icon={<InvenTreeIcon icon='default_location' />}
|
||||
|
|
@ -733,7 +835,7 @@ function StockOperationsRow({
|
|||
!record.part_detail?.category_default_location
|
||||
}
|
||||
/>
|
||||
)}
|
||||
))}
|
||||
{changeStatus && (
|
||||
<ActionButton
|
||||
size='sm'
|
||||
|
|
@ -758,12 +860,12 @@ function StockOperationsRow({
|
|||
icon={<InvenTreeIcon icon='merge' />}
|
||||
tooltip={t`Merge into existing stock`}
|
||||
onClick={() =>
|
||||
callChangeFn(props.idx, 'merge', !props.item?.merge)
|
||||
props.changeFn(rowId, 'merge', !props.item?.merge)
|
||||
}
|
||||
variant={props.item?.merge ? 'filled' : 'transparent'}
|
||||
/>
|
||||
)}
|
||||
<RemoveRowButton onClick={() => props.removeFn(props.idx)} />
|
||||
<RemoveRowButton onClick={() => props.removeFn(rowId)} />
|
||||
</Flex>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
|
|
@ -772,7 +874,7 @@ function StockOperationsRow({
|
|||
visible={statusOpen}
|
||||
onValueChange={(value: any) => {
|
||||
setStatus(value);
|
||||
props.changeFn(props.idx, 'status', value || undefined);
|
||||
props.changeFn(rowId, 'status', value || undefined);
|
||||
}}
|
||||
fieldName='status'
|
||||
fieldDefinition={{
|
||||
|
|
@ -788,7 +890,7 @@ function StockOperationsRow({
|
|||
<TableFieldExtraRow
|
||||
visible={transfer && packagingOpen}
|
||||
onValueChange={(value: any) => {
|
||||
props.changeFn(props.idx, 'packaging', value || undefined);
|
||||
props.changeFn(rowId, 'packaging', value || undefined);
|
||||
}}
|
||||
fieldName='packaging'
|
||||
fieldDefinition={{
|
||||
|
|
@ -814,15 +916,15 @@ type StockAdjustmentItem = {
|
|||
};
|
||||
|
||||
function mapAdjustmentItems(items: any[], mergeDefault?: boolean) {
|
||||
const mappedItems: StockAdjustmentItemWithRecord[] = items.map((elem) => {
|
||||
const mappedItems: StockAdjustmentItem[] = items.map((elem) => {
|
||||
return {
|
||||
id: elem.pk,
|
||||
pk: elem.pk,
|
||||
quantity: elem.quantity,
|
||||
batch: elem.batch || undefined,
|
||||
status: elem.status || undefined,
|
||||
packaging: elem.packaging || undefined,
|
||||
merge: elem.merge ?? mergeDefault ?? false,
|
||||
obj: elem
|
||||
merge: elem.merge ?? mergeDefault ?? false
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -856,7 +958,7 @@ function stockTransferFields(
|
|||
changeStatus
|
||||
setMax
|
||||
transferMerge
|
||||
key={record.pk}
|
||||
key={row.rowId}
|
||||
record={record}
|
||||
/>
|
||||
);
|
||||
|
|
@ -901,7 +1003,7 @@ function stockReturnFields(items: any[]): ApiFormFieldSet {
|
|||
return (
|
||||
<StockOperationsRow
|
||||
props={row}
|
||||
key={record.pk}
|
||||
key={row.rowId}
|
||||
record={record}
|
||||
transfer
|
||||
returnStock
|
||||
|
|
@ -948,9 +1050,12 @@ function stockRemoveFields(items: any[]): ApiFormFieldSet {
|
|||
return {};
|
||||
}
|
||||
|
||||
const records = Object.fromEntries(items.map((item) => [item.pk, item]));
|
||||
// Only include items which are not serialized (serial number field is empty)
|
||||
const validItems = items.filter((item) => !item.serial && item.quantity > 0);
|
||||
|
||||
const initialValue = mapAdjustmentItems(items).map((elem) => {
|
||||
const records = Object.fromEntries(validItems.map((item) => [item.pk, item]));
|
||||
|
||||
const initialValue = mapAdjustmentItems(validItems).map((elem) => {
|
||||
return {
|
||||
...elem,
|
||||
quantity: 0
|
||||
|
|
@ -970,7 +1075,7 @@ function stockRemoveFields(items: any[]): ApiFormFieldSet {
|
|||
setMax
|
||||
changeStatus
|
||||
add
|
||||
key={record.pk}
|
||||
key={row.rowId}
|
||||
record={record}
|
||||
/>
|
||||
);
|
||||
|
|
@ -995,9 +1100,12 @@ function stockAddFields(items: any[]): ApiFormFieldSet {
|
|||
return {};
|
||||
}
|
||||
|
||||
const records = Object.fromEntries(items.map((item) => [item.pk, item]));
|
||||
// Only include items which are not serialized (serial number field is empty)
|
||||
const validItems = items.filter((item) => !item.serial);
|
||||
|
||||
const initialValue = mapAdjustmentItems(items).map((elem) => {
|
||||
const records = Object.fromEntries(validItems.map((item) => [item.pk, item]));
|
||||
|
||||
const initialValue = mapAdjustmentItems(validItems).map((elem) => {
|
||||
return {
|
||||
...elem,
|
||||
quantity: 0
|
||||
|
|
@ -1016,7 +1124,7 @@ function stockAddFields(items: any[]): ApiFormFieldSet {
|
|||
changeStatus
|
||||
props={row}
|
||||
add
|
||||
key={record.pk}
|
||||
key={row.rowId}
|
||||
record={record}
|
||||
/>
|
||||
);
|
||||
|
|
@ -1037,16 +1145,14 @@ function stockAddFields(items: any[]): ApiFormFieldSet {
|
|||
}
|
||||
|
||||
function stockCountFields(items: any[]): ApiFormFieldSet {
|
||||
if (!items) {
|
||||
return {};
|
||||
}
|
||||
const records = Object.fromEntries(
|
||||
items?.map((item) => [item.pk, item]) ?? []
|
||||
);
|
||||
|
||||
const records = Object.fromEntries(items.map((item) => [item.pk, item]));
|
||||
|
||||
const initialValue = mapAdjustmentItems(items);
|
||||
const initialValue = items ? mapAdjustmentItems(items) : [];
|
||||
|
||||
// Extract all location values from the items
|
||||
const locations = [...new Set(items.map((item) => item.location))];
|
||||
const locations = [...new Set(items?.map((item) => item.location))];
|
||||
|
||||
const fields: ApiFormFieldSet = {
|
||||
items: {
|
||||
|
|
@ -1057,8 +1163,8 @@ function stockCountFields(items: any[]): ApiFormFieldSet {
|
|||
<StockOperationsRow
|
||||
props={row}
|
||||
changeStatus
|
||||
key={row.item.pk}
|
||||
record={records[row.item.pk]}
|
||||
key={row.rowId}
|
||||
record={records[row.item?.pk]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
|
@ -1105,7 +1211,7 @@ function stockChangeStatusFields(items: any[]): ApiFormFieldSet {
|
|||
return (
|
||||
<StockOperationsRow
|
||||
props={row}
|
||||
key={row.item}
|
||||
key={row.rowId}
|
||||
merge
|
||||
record={records[row.item]}
|
||||
/>
|
||||
|
|
@ -1133,19 +1239,22 @@ function stockMergeFields(items: any[]): ApiFormFieldSet {
|
|||
return {};
|
||||
}
|
||||
|
||||
const records = Object.fromEntries(items.map((item) => [item.pk, item]));
|
||||
// Only include items which are not serialized (serial number field is empty)
|
||||
const validItems = items.filter((item) => !item.serial);
|
||||
|
||||
const records = Object.fromEntries(validItems.map((item) => [item.pk, item]));
|
||||
|
||||
// Extract all non-null location values from the items
|
||||
const locationValues = [
|
||||
...new Set(
|
||||
items.filter((item) => item.location).map((item) => item.location)
|
||||
validItems.filter((item) => item.location).map((item) => item.location)
|
||||
)
|
||||
];
|
||||
|
||||
// Extract all non-null default location values from the items
|
||||
const defaultLocationValues = [
|
||||
...new Set(
|
||||
items
|
||||
validItems
|
||||
.filter((item) => item.part_detail?.default_location)
|
||||
.map((item) => item.part_detail?.default_location)
|
||||
)
|
||||
|
|
@ -1162,17 +1271,16 @@ function stockMergeFields(items: any[]): ApiFormFieldSet {
|
|||
const fields: ApiFormFieldSet = {
|
||||
items: {
|
||||
field_type: 'table',
|
||||
value: items.map((elem) => {
|
||||
value: validItems.map((elem) => {
|
||||
return {
|
||||
item: elem.pk,
|
||||
obj: elem
|
||||
item: elem.pk
|
||||
};
|
||||
}),
|
||||
modelRenderer: (row: TableFieldRowProps) => {
|
||||
return (
|
||||
<StockOperationsRow
|
||||
props={row}
|
||||
key={row.item.item}
|
||||
key={row.rowId}
|
||||
merge
|
||||
changeStatus
|
||||
record={records[row.item.item]}
|
||||
|
|
@ -1213,15 +1321,14 @@ function stockAssignFields(items: any[]): ApiFormFieldSet {
|
|||
field_type: 'table',
|
||||
value: items.map((elem) => {
|
||||
return {
|
||||
item: elem.pk,
|
||||
obj: elem
|
||||
item: elem.pk
|
||||
};
|
||||
}),
|
||||
modelRenderer: (row: TableFieldRowProps) => {
|
||||
return (
|
||||
<StockOperationsRow
|
||||
props={row}
|
||||
key={row.item.item}
|
||||
key={row.rowId}
|
||||
merge
|
||||
record={records[row.item.item]}
|
||||
/>
|
||||
|
|
@ -1265,7 +1372,7 @@ function stockDeleteFields(items: any[]): ApiFormFieldSet {
|
|||
return (
|
||||
<StockOperationsRow
|
||||
props={row}
|
||||
key={record.pk}
|
||||
key={row.rowId}
|
||||
merge
|
||||
record={record}
|
||||
/>
|
||||
|
|
@ -1293,8 +1400,6 @@ type apiModalFunc = (props: ApiFormModalProps) => {
|
|||
|
||||
function useStockOperationModal({
|
||||
items,
|
||||
pk,
|
||||
model,
|
||||
refresh,
|
||||
fieldGenerator,
|
||||
endpoint,
|
||||
|
|
@ -1305,9 +1410,7 @@ function useStockOperationModal({
|
|||
modalFunc = useCreateApiFormModal
|
||||
}: {
|
||||
items?: object;
|
||||
pk?: number;
|
||||
filters?: any;
|
||||
model: ModelType | string;
|
||||
refresh: () => void;
|
||||
fieldGenerator: (items: any[]) => ApiFormFieldSet;
|
||||
endpoint: ApiEndpoints;
|
||||
|
|
@ -1316,51 +1419,19 @@ function useStockOperationModal({
|
|||
successMessage?: string;
|
||||
modalFunc?: apiModalFunc;
|
||||
}) {
|
||||
const baseParams: any = {
|
||||
part_detail: true,
|
||||
location_detail: true,
|
||||
cascade: false
|
||||
};
|
||||
|
||||
const params = useMemo(() => {
|
||||
const query_params: any = {
|
||||
...baseParams,
|
||||
...(filters ?? {})
|
||||
};
|
||||
|
||||
query_params[model] =
|
||||
pk === undefined && model === 'location' ? 'null' : pk;
|
||||
|
||||
return query_params;
|
||||
}, [baseParams, filters, model, pk]);
|
||||
|
||||
const [opened, setOpened] = useState<boolean>(false);
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['stockitems', opened, model, pk, items, params],
|
||||
queryFn: async () => {
|
||||
if (items) {
|
||||
// If a list of items is provided, use that directly
|
||||
return Array.isArray(items) ? items : [items];
|
||||
}
|
||||
|
||||
if (!pk || !opened) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const url = apiUrl(ApiEndpoints.stock_item_list);
|
||||
|
||||
return api
|
||||
.get(url, {
|
||||
params: params
|
||||
})
|
||||
.then((response) => response.data ?? []);
|
||||
}
|
||||
const stockItems = useStockItems({
|
||||
opened: opened,
|
||||
items: items,
|
||||
filters: filters
|
||||
});
|
||||
|
||||
const fields = useMemo(() => {
|
||||
return fieldGenerator(data);
|
||||
}, [data]);
|
||||
// Rebuild the "fields" object
|
||||
const fields = useMemo(
|
||||
() => fieldGenerator(stockItems),
|
||||
[fieldGenerator, stockItems]
|
||||
);
|
||||
|
||||
return modalFunc({
|
||||
url: endpoint,
|
||||
|
|
@ -1447,9 +1518,14 @@ export function useReturnStockItem(props: StockOperationProps) {
|
|||
}
|
||||
|
||||
export function useCountStockItem(props: StockOperationProps) {
|
||||
const fieldGenerator = useCallback(
|
||||
(items: any[]) => stockCountFields(items),
|
||||
[]
|
||||
);
|
||||
|
||||
return useStockOperationModal({
|
||||
...props,
|
||||
fieldGenerator: stockCountFields,
|
||||
fieldGenerator: fieldGenerator,
|
||||
endpoint: ApiEndpoints.stock_count,
|
||||
title: t`Count Stock`,
|
||||
successMessage: t`Stock counted`,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,10 @@ import { IconCalendar, IconUsers } from '@tabler/icons-react';
|
|||
import { useMemo, useState } from 'react';
|
||||
import RemoveRowButton from '../components/buttons/RemoveRowButton';
|
||||
import { StandaloneField } from '../components/forms/StandaloneField';
|
||||
import type { TableFieldRowProps } from '../components/forms/fields/TableField';
|
||||
import {
|
||||
TableFieldQuantityInput,
|
||||
type TableFieldRowProps
|
||||
} from '../components/forms/fields/TableField';
|
||||
import { useCreateApiFormModal } from '../hooks/UseForm';
|
||||
import { useGlobalSettingsState } from '../states/SettingsStates';
|
||||
import { RenderPartColumn } from '../tables/ColumnRenderers';
|
||||
|
|
@ -110,6 +113,10 @@ function TransferOrderAllocateLineRow({
|
|||
record: any;
|
||||
sourceLocation?: number | null;
|
||||
}>) {
|
||||
const [quantity, setQuantity] = useState<number | ''>(
|
||||
props.item?.quantity ?? ''
|
||||
);
|
||||
|
||||
// Statically defined field for selecting the stock item
|
||||
const stockItemField: ApiFormFieldType = useMemo(() => {
|
||||
return {
|
||||
|
|
@ -128,41 +135,27 @@ function TransferOrderAllocateLineRow({
|
|||
value: props.item.stock_item,
|
||||
name: 'stock_item',
|
||||
onValueChange: (value: any, instance: any) => {
|
||||
props.changeFn(props.idx, 'stock_item', value);
|
||||
props.changeFn(props.rowId, 'stock_item', value);
|
||||
|
||||
// Update the allocated quantity based on the selected stock item
|
||||
if (instance) {
|
||||
const available = instance.quantity - instance.allocated;
|
||||
const required = record.quantity - record.allocated;
|
||||
|
||||
let quantity = props.item?.quantity ?? 0;
|
||||
let q = props.item?.quantity ?? 0;
|
||||
|
||||
quantity = Math.max(quantity, required);
|
||||
quantity = Math.min(quantity, available);
|
||||
q = Math.max(q, required);
|
||||
q = Math.min(q, available);
|
||||
|
||||
if (quantity != props.item.quantity) {
|
||||
props.changeFn(props.idx, 'quantity', quantity);
|
||||
}
|
||||
setQuantity(q);
|
||||
props.changeFn(props.rowId, 'quantity', q);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [sourceLocation, record, props]);
|
||||
|
||||
// Statically defined field for selecting the allocation quantity
|
||||
const quantityField: ApiFormFieldType = useMemo(() => {
|
||||
return {
|
||||
field_type: 'number',
|
||||
name: 'quantity',
|
||||
required: true,
|
||||
value: props.item.quantity,
|
||||
onValueChange: (value: any) => {
|
||||
props.changeFn(props.idx, 'quantity', value);
|
||||
}
|
||||
};
|
||||
}, [props]);
|
||||
}, [sourceLocation, record, quantity, props.changeFn]);
|
||||
|
||||
return (
|
||||
<Table.Tr key={`table-row-${props.idx}-${record.pk}`}>
|
||||
<Table.Tr key={`table-row-${props.rowId}`}>
|
||||
<Table.Td>
|
||||
<RenderPartColumn part={record.part_detail} />
|
||||
</Table.Td>
|
||||
|
|
@ -181,14 +174,18 @@ function TransferOrderAllocateLineRow({
|
|||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<StandaloneField
|
||||
fieldName='quantity'
|
||||
fieldDefinition={quantityField}
|
||||
<TableFieldQuantityInput
|
||||
min={0}
|
||||
value={quantity}
|
||||
onChange={(value) => {
|
||||
setQuantity(value === '' ? 0 : value);
|
||||
props.changeFn(props.rowId, 'quantity', value);
|
||||
}}
|
||||
error={props.rowErrors?.quantity?.message}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<RemoveRowButton onClick={() => props.removeFn(props.idx)} />
|
||||
<RemoveRowButton onClick={() => props.removeFn(props.rowId)} />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
|
|
@ -209,6 +206,16 @@ export function useAllocateToTransferOrderForm({
|
|||
sourceLocationId || null
|
||||
);
|
||||
|
||||
// Memoize the line items to prevent re-rendering
|
||||
const lines = useMemo(() => {
|
||||
return lineItems.map((item) => {
|
||||
return {
|
||||
id: item.pk,
|
||||
...item
|
||||
};
|
||||
});
|
||||
}, [lineItems]);
|
||||
|
||||
const fields: ApiFormFieldSet = useMemo(() => {
|
||||
return {
|
||||
// Non-submitted field to select the source location
|
||||
|
|
@ -237,11 +244,11 @@ export function useAllocateToTransferOrderForm({
|
|||
],
|
||||
modelRenderer: (row: TableFieldRowProps) => {
|
||||
const record =
|
||||
lineItems.find((item) => item.pk == row.item.line_item) ?? {};
|
||||
lines.find((item) => item.pk == row.item.line_item) ?? {};
|
||||
|
||||
return (
|
||||
<TransferOrderAllocateLineRow
|
||||
key={`table-row-${row.idx}-${record.pk}`}
|
||||
key={row.rowId}
|
||||
props={row}
|
||||
record={record}
|
||||
sourceLocation={sourceLocation}
|
||||
|
|
@ -263,6 +270,7 @@ export function useAllocateToTransferOrderForm({
|
|||
initialData: {
|
||||
items: lineItems.map((item) => {
|
||||
return {
|
||||
id: item.pk,
|
||||
line_item: item.pk,
|
||||
quantity: 0,
|
||||
stock_item: null
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue