mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 13:15:43 +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:
		| @@ -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") }} | ||||
|   | ||||
| @@ -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'), | ||||
|   | ||||
| @@ -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({ | ||||
|   | ||||
| @@ -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} | ||||
|           /> | ||||
|   | ||||
| @@ -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({ | ||||
|   | ||||
| @@ -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 | ||||
|   }); | ||||
| } | ||||
|   | ||||
| @@ -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; | ||||
| } | ||||
| @@ -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', | ||||
|   | ||||
| @@ -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')) { | ||||
|   | ||||
		Reference in New Issue
	
	Block a user