Merge branch 'master' into block-notes

This commit is contained in:
Oliver 2026-06-30 21:12:33 +10:00 committed by GitHub
commit cc6dc60640
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 841 additions and 458 deletions

View File

@ -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

View File

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

View File

@ -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

View File

@ -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

View File

@ -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

View File

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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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
@ -2727,14 +2727,11 @@ class StockItem(
deltas=tracking_info,
)
trigger_event(
StockEvents.ITEM_COUNTED,
'stockitem.counted',
id=self.id,
quantity=float(self.quantity),
)
return True
trigger_event(
StockEvents.ITEM_COUNTED,
id=self.id,
quantity=1 if self.serialized else float(self.quantity),
)
@transaction.atomic
def add_stock(self, quantity, user, **kwargs):

View File

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

View File

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

View File

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

View File

@ -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",

View File

@ -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

View File

@ -247,7 +247,9 @@ export function ApiFormField({
<TableField
definition={fieldDefinition}
fieldName={fieldName}
control={controller}
value={value}
onChange={field.onChange}
error={error}
/>
);
case 'tags':

View File

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

View File

@ -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 removeRow = (idx: number) => {
const val = field.value;
val.splice(idx, 1);
field.onChange(val);
};
const existingGeneratedId = generatedRowIdsRef.current.get(item);
if (existingGeneratedId) {
return existingGeneratedId;
}
generatedRowIdCounterRef.current += 1;
const generatedId = `table-row-generated-${generatedRowIdCounterRef.current}`;
generatedRowIdsRef.current.set(item, generatedId);
return generatedId;
}
return item ?? idx;
},
[]
);
// Keep refs in sync with latest values without introducing them as deps
valueRef.current = value;
onChangeRef.current = onChange;
useEffect(() => {
const nextRowIndexById = new Map<string | number, number>();
value?.forEach((item: any, idx: number) => {
nextRowIndexById.set(getRowIdentifier(item, idx), idx);
});
rowIndexByIdRef.current = nextRowIndexById;
}, [value, getRowIdentifier]);
const resolveRowIndex = useCallback((identifier: number | string) => {
const mappedIndex = rowIndexByIdRef.current.get(identifier);
if (mappedIndex !== undefined) {
return mappedIndex;
}
if (typeof identifier === 'number' && identifier >= 0) {
return identifier;
}
return undefined;
}, []);
const onRowFieldChange = useCallback(
(identifier: number | string, key: string, rowValue: any) => {
const idx = resolveRowIndex(identifier);
if (idx === undefined) {
return;
}
const currentValue = valueRef.current;
if (!Array.isArray(currentValue) || currentValue[idx] === undefined) {
return;
}
const nextValue = [...currentValue];
const currentRow = nextValue[idx];
if (currentRow && typeof currentRow === 'object') {
nextValue[idx] = {
...currentRow,
[key]: rowValue
};
}
valueRef.current = nextValue;
onChangeRef.current(nextValue);
},
[resolveRowIndex]
);
const removeRow = useCallback(
(identifier: number | string) => {
const idx = resolveRowIndex(identifier);
if (idx === undefined) {
return;
}
const currentValue = valueRef.current;
if (!Array.isArray(currentValue)) {
return;
}
const nextValue = [...currentValue];
nextValue.splice(idx, 1);
onChangeRef.current(nextValue);
},
[resolveRowIndex]
);
// Extract errors associated with the current row
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}
/>
);
}

View File

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

View File

@ -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: [

View File

@ -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',
@ -582,7 +583,7 @@ function BuildAllocateLineRow({
value: props.item.stock_item,
name: 'stock_item',
onValueChange: (value: any, instance: any) => {
props.changeFn(props.idx, 'stock_item', value);
props.changeFn(props.rowId, 'stock_item', value);
// Update the allocated quantity based on the selected stock item
if (instance) {
@ -590,7 +591,7 @@ function BuildAllocateLineRow({
if (available < props.item.quantity) {
props.changeFn(
props.idx,
props.rowId,
'quantity',
Math.min(props.item.quantity, available)
);
@ -600,18 +601,6 @@ function BuildAllocateLineRow({
};
}, [record, props]);
const quantityField: ApiFormFieldType = useMemo(() => {
return {
field_type: 'number',
name: 'quantity',
required: true,
value: props.item.quantity,
onValueChange: (value: any) => {
props.changeFn(props.idx, 'quantity', value);
}
};
}, [props]);
return (
<Table.Tr key={`table-row-${record.pk}`}>
<Table.Td>
@ -636,14 +625,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>
);
@ -671,6 +664,16 @@ export function useAllocateStockToBuildForm({
undefined
);
// Memoize the line items once to avoid re-rendering
const buildLines = useMemo(() => {
return lineItems.map((item) => {
return {
id: item.pk,
...item
};
});
}, [lineItems]);
const buildAllocateFields: ApiFormFieldSet = useMemo(() => {
const fields: ApiFormFieldSet = {
items: {
@ -687,10 +690,10 @@ export function useAllocateStockToBuildForm({
modelRenderer: (row: TableFieldRowProps) => {
// Find the matching record from the passed 'lineItems'
const record =
lineItems.find((item) => item.pk == row.item.build_line) ?? {};
buildLines.find((item) => item.pk == row.item.build_line) ?? {};
return (
<BuildAllocateLineRow
key={row.idx}
key={row.rowId}
output={output}
props={row}
record={record}
@ -767,6 +770,7 @@ export function useAllocateStockToBuildForm({
})
.map((item) => {
return {
id: item.pk,
build_line: item.pk,
stock_item: undefined,
quantity: Math.max(
@ -788,6 +792,8 @@ function BuildConsumeItemRow({
props: TableFieldRowProps;
record: any;
}) {
const [quantity, setQuantity] = useState<number>(props.item?.quantity ?? 0);
return (
<Table.Tr key={`table-row-${record.pk}`}>
<Table.Td>
@ -803,21 +809,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>
);
@ -853,7 +856,7 @@ export function useConsumeBuildItemsForm({
);
return (
<BuildConsumeItemRow key={row.idx} props={row} record={record} />
<BuildConsumeItemRow key={row.rowId} props={row} record={record} />
);
}
},
@ -872,6 +875,7 @@ export function useConsumeBuildItemsForm({
initialData: {
items: allocatedItems.map((item) => {
return {
id: item.pk,
build_item: item.pk,
quantity: item.quantity
};
@ -916,7 +920,7 @@ function BuildConsumeLineRow({
/>
</Table.Td>
<Table.Td>
<RemoveRowButton onClick={() => props.removeFn(props.idx)} />
<RemoveRowButton onClick={() => props.removeFn(props.rowId)} />
</Table.Td>
</Table.Tr>
);
@ -954,7 +958,7 @@ export function useConsumeBuildLinesForm({
);
return (
<BuildConsumeLineRow key={row.idx} props={row} record={record} />
<BuildConsumeLineRow key={row.rowId} props={row} record={record} />
);
}
},
@ -969,9 +973,11 @@ export function useConsumeBuildLinesForm({
successMessage: null,
onFormSuccess: onFormSuccess,
fields: consumeFields,
size: '80%',
initialData: {
lines: filteredLines.map((item) => {
return {
id: item.pk,
build_line: item.pk
};
})

View File

@ -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,

View File

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

View File

@ -25,7 +25,10 @@ import type {
ApiFormFieldType
} from '@lib/types/Forms';
import dayjs from 'dayjs';
import type { TableFieldRowProps } from '../components/forms/fields/TableField';
import {
TableFieldQuantityInput,
type TableFieldRowProps
} from '../components/forms/fields/TableField';
import useBackgroundTask from '../hooks/UseBackgroundTask';
import { useCreateApiFormModal, useEditApiFormModal } from '../hooks/UseForm';
import { useGlobalSettingsState } from '../states/SettingsStates';
@ -328,41 +331,33 @@ function SalesOrderAllocateLineRow({
value: props.item.stock_item,
name: 'stock_item',
onValueChange: (value: any, instance: any) => {
props.changeFn(props.idx, 'stock_item', value);
props.changeFn(props.rowId, 'stock_item', value);
// Update the allocated quantity based on the selected stock item
if (instance) {
const available = instance.quantity - instance.allocated;
const required = record.quantity - record.allocated;
let quantity = props.item?.quantity ?? 0;
let q = props.item?.quantity ?? 0;
quantity = Math.max(quantity, required);
quantity = Math.min(quantity, available);
q = Math.max(q, required);
q = Math.min(q, available);
if (quantity != props.item.quantity) {
props.changeFn(props.idx, 'quantity', quantity);
if (q != props.item?.quantity) {
setQuantity(q);
props.changeFn(props.rowId, 'quantity', q);
}
}
}
};
}, [sourceLocation, record, props]);
// Statically defined field for selecting the allocation quantity
const quantityField: ApiFormFieldType = useMemo(() => {
return {
field_type: 'number',
name: 'quantity',
required: true,
value: props.item.quantity,
onValueChange: (value: any) => {
props.changeFn(props.idx, 'quantity', value);
}
};
}, [props]);
const [quantity, setQuantity] = useState<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>
@ -381,14 +376,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>
);
@ -443,7 +442,7 @@ export function useAllocateToSalesOrderForm({
return (
<SalesOrderAllocateLineRow
key={`table-row-${row.idx}-${record.pk}`}
key={row.rowId}
props={row}
record={record}
sourceLocation={sourceLocation}
@ -474,6 +473,7 @@ export function useAllocateToSalesOrderForm({
initialData: {
items: lineItems.map((item) => {
return {
id: item.pk,
line_item: item.pk,
quantity: 0,
stock_item: null

View File

@ -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);
};
const [packagingOpen, packagingHandlers] = useDisclosure(false, {
onOpen: () => {
if (transfer) {
callChangeFn(props.idx, 'packaging', record?.packaging || undefined);
}
},
onClose: () => {
if (transfer) {
callChangeFn(props.idx, 'packaging', undefined);
}
useEffect(() => {
if (!transfer) {
return;
}
});
const [statusOpen, statusHandlers] = useDisclosure(false, {
onOpen: () => {
if (!hasMountedPackagingRef.current) {
hasMountedPackagingRef.current = true;
return;
}
props.changeFn(
rowId,
'packaging',
packagingOpen ? record?.packaging || undefined : undefined
);
}, [transfer, packagingOpen, rowId, record?.packaging, props.changeFn]);
useEffect(() => {
if (!changeStatus) {
return;
}
if (!hasMountedStatusRef.current) {
hasMountedStatusRef.current = true;
return;
}
if (statusOpen) {
setStatus(record?.status_custom_key || record?.status || undefined);
props.changeFn(props.idx, 'status', record?.status || undefined);
},
onClose: () => {
setStatus(undefined);
callChangeFn(props.idx, 'status', undefined);
props.changeFn(rowId, 'status', record?.status || undefined);
return;
}
});
setStatus(undefined);
props.changeFn(rowId, 'status', undefined);
}, [
changeStatus,
statusOpen,
rowId,
record?.status,
record?.status_custom_key,
props.changeFn
]);
const stockString: string = useMemo(() => {
if (!record) {
@ -652,10 +757,15 @@ function StockOperationsRow({
}, [record]);
return !record ? (
<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) => {
setQuantity(value);
props.changeFn(props.idx, 'quantity', value);
}
<TableFieldQuantityInput
min={0}
value={quantity ?? ''}
onChange={(value) => {
setQuantity(value);
props.changeFn(rowId, 'quantity', value);
}}
error={props.rowErrors?.quantity?.message}
/>
@ -705,35 +812,30 @@ function StockOperationsRow({
)}
<Table.Td>
<Flex gap='3px'>
{transfer && (
<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
)
}
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
}
/>
)}
{transfer &&
(returnStock ? (
<ReturnStockMoveButton
record={record}
quantity={props.item.quantity}
onRemove={() => props.removeFn(rowId)}
returnStock={returnStock}
/>
) : (
<ActionButton
onClick={() =>
moveToDefault(record, props.item.quantity, () =>
props.removeFn(rowId)
)
}
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
}
/>
))}
{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`,

View File

@ -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

View File

@ -0,0 +1,36 @@
/** Various debugging helper functions for development */
import { useEffect, useRef } from 'react';
/**
* A custom hook that logs the previous and current props of a component whenever it updates.
*/
export function useWhyDidYouUpdate(name: string, props: any) {
const previousProps = useRef({});
useEffect(() => {
console.error(
'useWhyDidYouUpdate should not be used in production code. It is intended for debugging purposes only.'
);
if (previousProps.current) {
const allKeys = Object.keys({ ...previousProps.current, ...props });
const changedProps: any = {};
allKeys.forEach((key) => {
if ((previousProps as any).current[key] !== props[key]) {
(changedProps as any)[key] = {
from: (previousProps as any).current[key],
to: props[key]
};
}
});
if (Object.keys(changedProps).length > 0) {
console.log(`[${name}] Changed props:`, changedProps);
}
}
previousProps.current = props;
});
}

View File

@ -16,6 +16,7 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { getDetailUrl } from '@lib/functions/Navigation';
import type { StockOperationProps } from '@lib/types/Forms';
import type { PanelType } from '@lib/types/Panel';
import { useLocalStorage } from '@mantine/hooks';
import AdminButton from '../../components/buttons/AdminButton';
@ -43,6 +44,7 @@ import {
useEditApiFormModal
} from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance';
import { useStockAdjustActions } from '../../hooks/UseStockAdjustActions';
import { useUserSettingsState } from '../../states/SettingsStates';
import { useUserState } from '../../states/UserState';
import ParametricPartTable from '../../tables/part/ParametricPartTable';
@ -82,6 +84,28 @@ export default function CategoryDetail() {
}
});
const stockOperationProps: StockOperationProps = useMemo(() => {
return {
refresh: refreshInstance,
filters: {
category: category.pk,
in_stock: true
}
};
}, [category]);
const stockAdjustActions = useStockAdjustActions({
formProps: stockOperationProps,
enabled: true,
add: false,
remove: false,
changeStatus: false,
changeBatch: false,
delete: false,
merge: false,
assign: false
});
const detailsPanel = useMemo(() => {
if (id && instanceQuery.isFetching) {
return <Skeleton />;
@ -241,6 +265,7 @@ export default function CategoryDetail() {
refreshInstance();
}}
/>,
stockAdjustActions.dropdown,
<OptionsActionDropdown
key='category-actions'
tooltip={t`Category Actions`}
@ -258,7 +283,7 @@ export default function CategoryDetail() {
]}
/>
];
}, [id, user, category.pk, category.starred]);
}, [id, user, category.pk, category.starred, stockAdjustActions.dropdown]);
const [partsView, setPartsView] = useLocalStorage<string>({
key: 'category-parts-view',
@ -367,6 +392,7 @@ export default function CategoryDetail() {
<>
{editCategory.modal}
{deleteCategory.modal}
{stockAdjustActions.modals.map((modal) => modal.modal)}
<InstanceDetail
query={instanceQuery}
requiredRole={UserRoles.part_category}

View File

@ -1064,10 +1064,9 @@ export default function PartDetail() {
const stockOperationProps: StockOperationProps = useMemo(() => {
return {
pk: part.pk,
model: ModelType.part,
refresh: refreshInstance,
filters: {
part: part.pk,
in_stock: true
}
};

View File

@ -349,10 +349,9 @@ export default function Stock() {
const stockOperationProps: StockOperationProps = useMemo(() => {
return {
pk: location.pk,
model: 'location',
refresh: refreshInstance,
filters: {
location: location.pk,
in_stock: true
}
};

View File

@ -275,7 +275,8 @@ export default function StockDetail() {
name: 'available_stock',
label: t`Available`,
unit: part?.units,
icon: 'stock'
icon: 'stock',
hidden: stockitem.in_stock == false
},
{
type: 'number',
@ -804,7 +805,6 @@ export default function StockDetail() {
const stockOperationProps: StockOperationProps = useMemo(() => {
return {
items: [stockitem],
model: ModelType.stockitem,
refresh: () => {
const location = stockitem?.location;
refreshInstancePromise().then((response) => {
@ -829,6 +829,8 @@ export default function StockDetail() {
formProps: stockOperationProps,
delete: false,
changeBatch: false,
add: !stockitem.serial,
remove: !stockitem.serial,
assign: !!stockitem.in_stock && stockitem.part_detail?.salable,
return: !!stockitem.consumed_by || !!stockitem.customer,
merge: false

View File

@ -181,6 +181,6 @@ export function patchUser(key: 'language' | 'theme' | 'widgets', val: any) {
if (uid) {
api.patch(apiUrl(ApiEndpoints.user_me_profile), { [key]: val });
} else {
console.log('user not logged in, not patching');
console.warn('user not logged in, not patching');
}
}

View File

@ -233,7 +233,6 @@ export default function BuildAllocatedStockTable({
return {
items: stockItems,
model: ModelType.stockitem,
refresh: table.refreshTable
};
}, [table.selectedRecords, table.refreshTable]);

View File

@ -309,7 +309,10 @@ export default function BuildOutputTable({
allocated += allocation.quantity;
});
if (allocated >= item.bom_item_detail.quantity) {
if (
item.bom_item_detail?.quantity &&
allocated >= item.bom_item_detail.quantity
) {
fullyAllocatedCount += 1;
}
});
@ -510,7 +513,6 @@ export default function BuildOutputTable({
const stockOperationProps: StockOperationProps = useMemo(() => {
return {
items: table.selectedRecords,
model: ModelType.stockitem,
refresh: table.refreshTable,
filters: {}
};

View File

@ -285,7 +285,6 @@ export default function SalesOrderAllocationTable({
return {
items: stockItems,
model: ModelType.stockitem,
refresh: table.refreshTable
};
}, [table.selectedRecords, table.refreshTable]);

View File

@ -105,14 +105,12 @@ function SelectionListEntriesTable({
return [
RowEditAction({
onClick: () => {
console.log('record:', record);
setSelectedEntry(record.id);
editEntry.open();
}
}),
RowDeleteAction({
onClick: () => {
console.log('record:', record);
setSelectedEntry(record.id);
deleteEntry.open();
}

View File

@ -1,6 +1,5 @@
import { t } from '@lingui/core/macro';
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ActionButton } from '@lib/components/ActionButton';
import { AddItemButton } from '@lib/components/AddItemButton';
@ -8,7 +7,6 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import { getDetailUrl } from '@lib/functions/Navigation';
import useTable from '@lib/hooks/UseTable';
import type { TableFilter } from '@lib/types/Filters';
import type { StockOperationProps } from '@lib/types/Forms';
@ -381,8 +379,6 @@ export function StockItemTable({
[settings]
);
const navigate = useNavigate();
const tableColumns = useMemo(
() =>
stockItemTableColumns({
@ -403,7 +399,6 @@ export function StockItemTable({
const stockOperationProps: StockOperationProps = useMemo(() => {
return {
items: table.selectedRecords,
model: ModelType.stockitem,
refresh: () => {
table.clearSelectedRecords();
table.refreshTable();
@ -433,11 +428,7 @@ export function StockItemTable({
},
follow: params.openNewStockItem ?? true,
table: table,
onFormSuccess: (response: any) => {
// Returns a list that may contain multiple serialized stock items
// Navigate to the first result
navigate(getDetailUrl(ModelType.stockitem, response[0].pk));
},
modelType: ModelType.stockitem,
successMessage: t`Stock item created`,
keepOpenOption: true
});

View File

@ -224,7 +224,6 @@ export default function TransferOrderAllocationTable({
return {
items: stockItems,
model: ModelType.stockitem,
refresh: table.refreshTable
};
}, [table.selectedRecords, table.refreshTable]);

View File

@ -159,8 +159,6 @@ test('Dashboard - Preserve widget sizes', async ({ browser }) => {
for (const [bp, items] of Object.entries(await readLayouts(page))) {
const entry = (items as any[]).find((i) => i?.i === 'ovr-so');
console.log('entry:', bp, entry);
expect(entry?.w, `${bp}: ovr-so missing or wrong w`).toBe(TARGET_W);
expect(entry?.h, `${bp}: ovr-so missing or wrong h`).toBe(TARGET_H);
}

View File

@ -90,7 +90,8 @@ export default defineConfig(({ command, mode }) => {
}
},
define: {
...__INVENTREE_VERSION_INFO__
...__INVENTREE_VERSION_INFO__,
'process.env.NODE_ENV': JSON.stringify(mode)
}
};
});