2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-14 11:05:41 +00:00

[UI] Fix serial and batch code generators (#9772)

* Tweak stock item form

- Fix batch code placeholder
- Fix serial number placeholder

* Tweak build output form

* More cleanup

* Fix for PurchaseOrderForm

* Refactoring placeholder values
This commit is contained in:
Oliver
2025-06-13 20:02:30 +10:00
committed by GitHub
parent 5bf94acc1a
commit b465900344
9 changed files with 136 additions and 157 deletions

View File

@ -197,7 +197,6 @@ Configuration of stock item options
| Name | Description | Default | Units |
| ---- | ----------- | ------- | ----- |
{{ globalsetting("SERIAL_NUMBER_GLOBALLY_UNIQUE") }}
{{ globalsetting("SERIAL_NUMBER_AUTOFILL") }}
{{ globalsetting("STOCK_DELETE_DEPLETED_DEFAULT") }}
{{ globalsetting("STOCK_BATCH_CODE_TEMPLATE") }}
{{ globalsetting("STOCK_ENABLE_EXPIRY") }}

View File

@ -640,12 +640,6 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'default': False,
'validator': bool,
},
'SERIAL_NUMBER_AUTOFILL': {
'name': _('Autofill Serial Numbers'),
'description': _('Autofill serial numbers in forms'),
'default': False,
'validator': bool,
},
'STOCK_DELETE_DEPLETED_DEFAULT': {
'name': _('Delete Depleted Stock'),
'description': _('Determines default behavior when a stock item is depleted'),

View File

@ -25,8 +25,10 @@ import {
import { ProgressBar } from '../components/items/ProgressBar';
import { StatusRenderer } from '../components/render/StatusRenderer';
import { useCreateApiFormModal } from '../hooks/UseForm';
import { useBatchCodeGenerator } from '../hooks/UseGenerator';
import { useSerialNumberPlaceholder } from '../hooks/UsePlaceholder';
import {
useBatchCodeGenerator,
useSerialNumberGenerator
} from '../hooks/UseGenerator';
import { useGlobalSettingsState } from '../states/SettingsState';
import { PartColumn } from '../tables/ColumnRenderers';
@ -44,9 +46,9 @@ export function useBuildOrderFields({
const [batchCode, setBatchCode] = useState<string>('');
const batchGenerator = useBatchCodeGenerator((value: any) => {
if (!batchCode) {
setBatchCode(value);
const batchGenerator = useBatchCodeGenerator({
onGenerate: (value: any) => {
setBatchCode((batch: any) => batch || value);
}
});
@ -96,6 +98,9 @@ export function useBuildOrderFields({
icon: <IconTruckDelivery />
},
batch: {
placeholder:
batchGenerator.result &&
`${t`Next batch code`}: ${batchGenerator.result}`,
value: batchCode,
onValueChange: (value: any) => setBatchCode(value)
},
@ -143,7 +148,7 @@ export function useBuildOrderFields({
}
return fields;
}, [create, destination, batchCode, globalSettings]);
}, [create, destination, batchCode, batchGenerator.result, globalSettings]);
}
export function useBuildOrderOutputFields({
@ -170,10 +175,17 @@ export function useBuildOrderOutputFields({
setQuantity(Math.max(0, build_quantity - build_complete));
}, [build]);
const serialPlaceholder = useSerialNumberPlaceholder({
partId: build.part_detail?.pk,
key: 'build-output',
enabled: build.part_detail?.trackable
const serialGenerator = useSerialNumberGenerator({
initialQuery: {
part: build.part || build.part_detail?.pk
}
});
const batchGenerator = useBatchCodeGenerator({
initialQuery: {
part: build.part || build.part_detail?.pk,
quantity: build.quantity
}
});
return useMemo(() => {
@ -186,9 +198,15 @@ export function useBuildOrderOutputFields({
},
serial_numbers: {
hidden: !trackable,
placeholder: serialPlaceholder
placeholder:
serialGenerator.result &&
`${t`Next serial number`}: ${serialGenerator.result}`
},
batch_code: {
placeholder:
batchGenerator.result &&
`${t`Next batch code`}: ${batchGenerator.result}`
},
batch_code: {},
location: {
value: location,
onValueChange: (value: any) => {
@ -199,7 +217,7 @@ export function useBuildOrderOutputFields({
hidden: !trackable
}
};
}, [quantity, serialPlaceholder, trackable]);
}, [quantity, batchGenerator.result, serialGenerator.result, trackable]);
}
function BuildOutputFormRow({

View File

@ -295,16 +295,20 @@ function LineItemFormRow({
}, [record.destination]);
// Batch code generator
const batchCodeGenerator = useBatchCodeGenerator((value: any) => {
if (value) {
props.changeFn(props.idx, 'batch_code', value);
const batchCodeGenerator = useBatchCodeGenerator({
onGenerate: (value: any) => {
if (value) {
props.changeFn(props.idx, 'batch_code', value);
}
}
});
// Serial number generator
const serialNumberGenerator = useSerialNumberGenerator((value: any) => {
if (value) {
props.changeFn(props.idx, 'serial_numbers', value);
const serialNumberGenerator = useSerialNumberGenerator({
onGenerate: (value: any) => {
if (value) {
props.changeFn(props.idx, 'serial_numbers', value);
}
}
});
@ -478,8 +482,10 @@ function LineItemFormRow({
fieldDefinition={{
field_type: 'number',
value: props.item.quantity,
onValueChange: (value) =>
props.changeFn(props.idx, 'quantity', value)
onValueChange: (value) => {
props.changeFn(props.idx, 'quantity', value);
serialNumberGenerator.update({ quantity: value });
}
}}
error={props.rowErrors?.quantity?.message}
/>

View File

@ -56,7 +56,6 @@ import {
useBatchCodeGenerator,
useSerialNumberGenerator
} from '../hooks/UseGenerator';
import { useSerialNumberPlaceholder } from '../hooks/UsePlaceholder';
import { useGlobalSettingsState } from '../states/SettingsState';
import { StatusFilterOptions } from '../tables/Filter';
@ -79,35 +78,20 @@ export function useStockFields({
const [supplierPart, setSupplierPart] = useState<number | null>(null);
const [nextBatchCode, setNextBatchCode] = useState<string>('');
const [nextSerialNumber, setNextSerialNumber] = useState<string>('');
const [expiryDate, setExpiryDate] = useState<string | null>(null);
const batchGenerator = useBatchCodeGenerator((value: any) => {
if (value) {
setNextBatchCode(`${t`Next batch code`}: ${value}`);
} else {
setNextBatchCode('');
const batchGenerator = useBatchCodeGenerator({
initialQuery: {
part: partInstance?.pk || partId
}
});
const serialGenerator = useSerialNumberGenerator((value: any) => {
if (value) {
setNextSerialNumber(`${t`Next serial number`}: ${value}`);
} else {
setNextSerialNumber('');
const serialGenerator = useSerialNumberGenerator({
initialQuery: {
part: partInstance?.pk || partId
}
});
useEffect(() => {
if (partInstance?.pk) {
// Update the generators whenever the part ID changes
batchGenerator.update({ part: partInstance.pk });
serialGenerator.update({ part: partInstance.pk });
}
}, [partInstance.pk]);
return useMemo(() => {
const fields: ApiFormFieldSet = {
part: {
@ -181,16 +165,23 @@ export function useStockFields({
description: t`Enter serial numbers for new stock (or leave blank)`,
required: false,
hidden: !create,
placeholder: nextSerialNumber
placeholder:
serialGenerator.result &&
`${t`Next serial number`}: ${serialGenerator.result}`
},
serial: {
placeholder:
serialGenerator.result &&
`${t`Next serial number`}: ${serialGenerator.result}`,
hidden:
create ||
partInstance.trackable == false ||
(stockItem?.quantity != undefined && stockItem?.quantity != 1)
},
batch: {
placeholder: nextBatchCode
placeholder:
batchGenerator.result &&
`${t`Next batch code`}: ${batchGenerator.result}`
},
status_custom_key: {
label: t`Stock Status`
@ -234,8 +225,8 @@ export function useStockFields({
partId,
globalSettings,
supplierPart,
nextSerialNumber,
nextBatchCode,
serialGenerator.result,
batchGenerator.result,
create
]);
}
@ -332,21 +323,23 @@ export function useStockItemSerializeFields({
partId: number;
trackable: boolean;
}) {
const snPlaceholder = useSerialNumberPlaceholder({
partId: partId,
key: 'stock-item-serialize',
enabled: trackable
const serialGenerator = useSerialNumberGenerator({
initialQuery: {
part: partId
}
});
return useMemo(() => {
return {
quantity: {},
serial_numbers: {
placeholder: snPlaceholder
placeholder:
serialGenerator.result &&
`${t`Next serial number`}: ${serialGenerator.result}`
},
destination: {}
};
}, [snPlaceholder]);
}, [serialGenerator.result]);
}
function StockItemDefaultMove({

View File

@ -6,6 +6,13 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { apiUrl } from '@lib/functions/Api';
import { api } from '../App';
export type GeneratorProps = {
endpoint: ApiEndpoints;
key: string;
initialQuery?: Record<string, any>;
onGenerate?: (value: any) => void;
};
export type GeneratorState = {
query: Record<string, any>;
result: any;
@ -17,11 +24,7 @@ export type GeneratorState = {
* We can pass additional parameters to the query, and update the query as needed.
* Each update calls a new query to the API, and the result is stored in the state.
*/
export function useGenerator(
endpoint: ApiEndpoints,
key: string,
onGenerate?: (value: any) => void
): GeneratorState {
export function useGenerator(props: GeneratorProps): GeneratorState {
// Track the result
const [result, setResult] = useState<any>(null);
@ -29,7 +32,7 @@ export function useGenerator(
const [query, setQuery] = useState<Record<string, any>>({});
// Prevent rapid updates
const [debouncedQuery] = useDebouncedValue<Record<string, any>>(query, 250);
const [debouncedQuery] = useDebouncedValue<Record<string, any>>(query, 100);
// Callback to update the generator query
const update = useCallback(
@ -42,27 +45,44 @@ export function useGenerator(
...params
}));
}
queryGenerator.refetch();
},
[]
);
// API query handler
const queryGenerator = useQuery({
enabled: false,
queryKey: ['generator', key, endpoint, debouncedQuery],
enabled: true,
queryKey: [
'generator',
props.key,
props.endpoint,
props.initialQuery,
debouncedQuery
],
refetchOnMount: false,
refetchOnWindowFocus: false,
queryFn: async () => {
return api.post(apiUrl(endpoint), debouncedQuery).then((response) => {
const value = response?.data[key];
setResult(value);
const generatorQuery = {
...debouncedQuery,
...(props.initialQuery ?? {})
};
if (onGenerate) {
onGenerate(value);
}
return api
.post(apiUrl(props.endpoint), generatorQuery)
.then((response) => {
const value = response?.data[props.key];
setResult(value);
return response;
});
props.onGenerate?.(value);
return response;
})
.catch((error) => {
console.error(
`Error generating ${props.key} @ ${props.endpoint}:`,
error
);
});
}
});
@ -74,19 +94,33 @@ export function useGenerator(
}
// Generate a batch code with provided data
export function useBatchCodeGenerator(onGenerate: (value: any) => void) {
return useGenerator(
ApiEndpoints.generate_batch_code,
'batch_code',
onGenerate
);
export function useBatchCodeGenerator({
initialQuery,
onGenerate
}: {
initialQuery?: Record<string, any>;
onGenerate?: (value: any) => void;
}): GeneratorState {
return useGenerator({
endpoint: ApiEndpoints.generate_batch_code,
key: 'batch_code',
initialQuery: initialQuery,
onGenerate: onGenerate
});
}
// Generate a serial number with provided data
export function useSerialNumberGenerator(onGenerate: (value: any) => void) {
return useGenerator(
ApiEndpoints.generate_serial_number,
'serial_number',
onGenerate
);
export function useSerialNumberGenerator({
initialQuery,
onGenerate
}: {
initialQuery?: Record<string, any>;
onGenerate?: (value: any) => void;
}): GeneratorState {
return useGenerator({
endpoint: ApiEndpoints.generate_serial_number,
key: 'serial_number',
initialQuery: initialQuery,
onGenerate: onGenerate
});
}

View File

@ -1,66 +0,0 @@
import { t } from '@lingui/core/macro';
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { apiUrl } from '@lib/functions/Api';
import { api } from '../App';
/**
* Hook for generating a placeholder text for a serial number input
*
* This hook fetches the latest serial number information for a given part and generates a placeholder string.
*
* @param partId The ID of the part to fetch serial number information for
* @param key A unique key to identify the query
* @param enabled Whether the query should be enabled
*/
export function useSerialNumberPlaceholder({
partId,
key,
enabled = true
}: {
partId: number;
key: string;
enabled?: boolean;
}): string | undefined {
// Fetch serial number information (if available)
const snQuery = useQuery({
queryKey: ['serial-placeholder', key, partId],
enabled: enabled ?? true,
queryFn: async () => {
if (!partId) {
return null;
}
const url = apiUrl(ApiEndpoints.part_serial_numbers, partId);
return api
.get(url)
.then((response) => {
if (response.status === 200) {
return response.data;
} else {
return null;
}
})
.catch(() => {
return null;
});
}
});
const placeholder = useMemo(() => {
if (!enabled) {
return undefined;
} else if (snQuery.data?.next) {
return `${t`Next serial number`}: ${snQuery.data.next}`;
} else if (snQuery.data?.latest) {
return `${t`Latest serial number`}: ${snQuery.data.latest}`;
} else {
return undefined;
}
}, [enabled, snQuery.data]);
return placeholder;
}

View File

@ -222,7 +222,6 @@ export default function SystemSettings() {
<GlobalSettingList
keys={[
'SERIAL_NUMBER_GLOBALLY_UNIQUE',
'SERIAL_NUMBER_AUTOFILL',
'STOCK_DELETE_DEPLETED_DEFAULT',
'STOCK_BATCH_CODE_TEMPLATE',
'STOCK_ENABLE_EXPIRY',

View File

@ -167,6 +167,8 @@ test('Build Order - Build Outputs', async ({ browser }) => {
.getByLabel('text-field-serial_numbers')
.getAttribute('placeholder');
expect(placeholder).toContain('Next serial number');
let sn = 1;
if (!!placeholder && placeholder.includes('Next serial number')) {