mirror of
https://github.com/inventree/InvenTree.git
synced 2026-07-05 06:32:55 +00:00
[UI] Table field refactor (#12274)
* Use callback funcs * Don't use idx to identify rows * Add debug function for finding why a component re-rendered * Do not pass 'control' through to each row * Prevent unnecessary re-rendering of table rows * Adjust order of operations for hooks * Keep props hidden * Use lightweight NumberInput * Use NumberInput elsewhere * Add comment * use rowId instead of idx * Generic row memos * Compare errors too * Fix for BomItemSubstituteRow * Adjust more forms * memoize quantity * Memoize build lines * Fix re-rendering issues for build allocation * Fix for useConsumeBuildLinesForm * Fix for transfer order table * Fix useReceiveLineItems * Remove memoized pattern * Fix row keys * Cleanup * Create useStockItems hook for memoizing items * Refactoring * More refactoring * Remove obj reference - preventing shallow comparison from working * Add error message to useWhyDidYouUpdate * Cleanup * Cleanup dead code * Adjust modal width * Change attr name * Remove autoFillFilters prop * Adjustments for serialized stock * Fix typing * Bump frontend version * Adjustments for playwright testing * Fix ref issue * Remove debug entry * Update CHANGELOG.md * Reintroduce index to table header * Refactor common component
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -247,7 +247,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 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
@@ -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
|
||||
};
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
|
||||
@@ -805,7 +805,6 @@ export default function StockDetail() {
|
||||
const stockOperationProps: StockOperationProps = useMemo(() => {
|
||||
return {
|
||||
items: [stockitem],
|
||||
model: ModelType.stockitem,
|
||||
refresh: () => {
|
||||
const location = stockitem?.location;
|
||||
refreshInstancePromise().then((response) => {
|
||||
@@ -830,6 +829,8 @@ export default function StockDetail() {
|
||||
formProps: stockOperationProps,
|
||||
delete: false,
|
||||
changeBatch: false,
|
||||
add: !stockitem.serial,
|
||||
remove: !stockitem.serial,
|
||||
assign: !!stockitem.in_stock && stockitem.part_detail?.salable,
|
||||
return: !!stockitem.consumed_by || !!stockitem.customer,
|
||||
merge: false
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +233,6 @@ export default function BuildAllocatedStockTable({
|
||||
|
||||
return {
|
||||
items: stockItems,
|
||||
model: ModelType.stockitem,
|
||||
refresh: table.refreshTable
|
||||
};
|
||||
}, [table.selectedRecords, table.refreshTable]);
|
||||
|
||||
@@ -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: {}
|
||||
};
|
||||
|
||||
@@ -285,7 +285,6 @@ export default function SalesOrderAllocationTable({
|
||||
|
||||
return {
|
||||
items: stockItems,
|
||||
model: ModelType.stockitem,
|
||||
refresh: table.refreshTable
|
||||
};
|
||||
}, [table.selectedRecords, table.refreshTable]);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -399,7 +399,6 @@ export function StockItemTable({
|
||||
const stockOperationProps: StockOperationProps = useMemo(() => {
|
||||
return {
|
||||
items: table.selectedRecords,
|
||||
model: ModelType.stockitem,
|
||||
refresh: () => {
|
||||
table.clearSelectedRecords();
|
||||
table.refreshTable();
|
||||
|
||||
@@ -224,7 +224,6 @@ export default function TransferOrderAllocationTable({
|
||||
|
||||
return {
|
||||
items: stockItems,
|
||||
model: ModelType.stockitem,
|
||||
refresh: table.refreshTable
|
||||
};
|
||||
}, [table.selectedRecords, table.refreshTable]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user