mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 21:25:42 +00:00 
			
		
		
		
	[UI] Serial Number Navigation (#9505)
* Add checkClose function to forms - Allow custom check for whether form should be closed * Add form to jump to serial number * Tweak stock detail display * Remove dead field (might fix later, but it's hard with the current API) * Add some icons * Enhance extract_int functionality * Add API endpoint for "next" and "previous" serials for a given stock item * Add serial number navigation on stock item page * Add playwright tests * Bump API version * Fix for serial number clipping * Another tweak
This commit is contained in:
		| @@ -1,13 +1,16 @@ | ||||
| """InvenTree API version information.""" | ||||
|  | ||||
| # InvenTree API version | ||||
| INVENTREE_API_VERSION = 336 | ||||
| INVENTREE_API_VERSION = 337 | ||||
|  | ||||
| """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" | ||||
|  | ||||
|  | ||||
| INVENTREE_API_TEXT = """ | ||||
|  | ||||
| v337 -> 2025-04-15 : https://github.com/inventree/InvenTree/pull/9505 | ||||
|     - Adds API endpoint with extra serial number information for a given StockItem object | ||||
|  | ||||
| v336 -> 2025-04-10 : https://github.com/inventree/InvenTree/pull/9492 | ||||
|     - Fixed query and response serialization for units_all and version_text | ||||
|     - Fixed LicenseView and VersionInformation serialization | ||||
|   | ||||
| @@ -32,18 +32,67 @@ from .settings import MEDIA_URL, STATIC_URL | ||||
|  | ||||
| logger = structlog.get_logger('inventree') | ||||
|  | ||||
| INT_CLIP_MAX = 0x7FFFFFFF | ||||
|  | ||||
| def extract_int(reference, clip=0x7FFFFFFF, allow_negative=False): | ||||
|     """Extract an integer out of reference.""" | ||||
|  | ||||
| def extract_int( | ||||
|     reference, clip=INT_CLIP_MAX, try_hex=False, allow_negative=False | ||||
| ) -> int: | ||||
|     """Extract an integer out of provided string. | ||||
|  | ||||
|     Arguments: | ||||
|         reference: Input string to extract integer from | ||||
|         clip: Maximum value to return (default = 0x7FFFFFFF) | ||||
|         try_hex: Attempt to parse as hex if integer conversion fails (default = False) | ||||
|         allow_negative: Allow negative values (default = False) | ||||
|     """ | ||||
|     # Default value if we cannot convert to an integer | ||||
|     ref_int = 0 | ||||
|  | ||||
|     def do_clip(value: int, clip: int, allow_negative: bool) -> int: | ||||
|         """Perform clipping on the provided value. | ||||
|  | ||||
|         Arguments: | ||||
|             value: Value to clip | ||||
|             clip: Maximum value to clip to | ||||
|             allow_negative: Allow negative values (default = False) | ||||
|         """ | ||||
|         if clip is None: | ||||
|             return value | ||||
|  | ||||
|         clip = min(clip, INT_CLIP_MAX) | ||||
|  | ||||
|         if value > clip: | ||||
|             return clip | ||||
|         elif value < -clip: | ||||
|             return -clip | ||||
|  | ||||
|         if not allow_negative: | ||||
|             value = abs(value) | ||||
|  | ||||
|         return value | ||||
|  | ||||
|     reference = str(reference).strip() | ||||
|  | ||||
|     # Ignore empty string | ||||
|     if len(reference) == 0: | ||||
|         return 0 | ||||
|  | ||||
|     # Try naive integer conversion first | ||||
|     try: | ||||
|         ref_int = int(reference) | ||||
|         return do_clip(ref_int, clip, allow_negative) | ||||
|     except ValueError: | ||||
|         pass | ||||
|  | ||||
|     # Hex? | ||||
|     if try_hex or reference.startswith('0x'): | ||||
|         try: | ||||
|             ref_int = int(reference, base=16) | ||||
|             return do_clip(ref_int, clip, allow_negative) | ||||
|         except ValueError: | ||||
|             pass | ||||
|  | ||||
|     # Look at the start of the string - can it be "integerized"? | ||||
|     result = re.match(r'^(\d+)', reference) | ||||
|  | ||||
| @@ -66,11 +115,7 @@ def extract_int(reference, clip=0x7FFFFFFF, allow_negative=False): | ||||
|  | ||||
|     # Ensure that the returned values are within the range that can be stored in an IntegerField | ||||
|     # Note: This will result in large values being "clipped" | ||||
|     if clip is not None: | ||||
|         if ref_int > clip: | ||||
|             ref_int = clip | ||||
|         elif ref_int < -clip: | ||||
|             ref_int = -clip | ||||
|     ref_int = do_clip(ref_int, clip, allow_negative) | ||||
|  | ||||
|     if not allow_negative and ref_int < 0: | ||||
|         ref_int = abs(ref_int) | ||||
|   | ||||
| @@ -1227,6 +1227,17 @@ class StockDetail(StockApiMixin, RetrieveUpdateDestroyAPI): | ||||
|     """API detail endpoint for a single StockItem instance.""" | ||||
|  | ||||
|  | ||||
| class StockItemSerialNumbers(RetrieveAPI): | ||||
|     """View extra serial number information for a given stock item. | ||||
|  | ||||
|     Provides information on the "previous" and "next" stock items, | ||||
|     based on the serial number of the given stock item. | ||||
|     """ | ||||
|  | ||||
|     queryset = StockItem.objects.all() | ||||
|     serializer_class = StockSerializers.StockItemSerialNumbersSerializer | ||||
|  | ||||
|  | ||||
| class StockItemTestResultMixin: | ||||
|     """Mixin class for the StockItemTestResult API endpoints.""" | ||||
|  | ||||
| @@ -1615,6 +1626,11 @@ stock_api_urls = [ | ||||
|                 StockItemUninstall.as_view(), | ||||
|                 name='api-stock-item-uninstall', | ||||
|             ), | ||||
|             path( | ||||
|                 'serial-numbers/', | ||||
|                 StockItemSerialNumbers.as_view(), | ||||
|                 name='api-stock-item-serial-numbers', | ||||
|             ), | ||||
|             path('', StockDetail.as_view(), name='api-stock-detail'), | ||||
|         ]), | ||||
|     ), | ||||
|   | ||||
| @@ -692,6 +692,16 @@ class StockItem( | ||||
|  | ||||
|         return None | ||||
|  | ||||
|     @property | ||||
|     def get_next_stock_item(self): | ||||
|         """Return the 'next' stock item (based on serial number).""" | ||||
|         return self.get_next_serialized_item() | ||||
|  | ||||
|     @property | ||||
|     def get_previous_stock_item(self): | ||||
|         """Return the 'previous' stock item (based on serial number).""" | ||||
|         return self.get_next_serialized_item(reverse=True) | ||||
|  | ||||
|     def save(self, *args, **kwargs): | ||||
|         """Save this StockItem to the database. | ||||
|  | ||||
|   | ||||
| @@ -32,7 +32,11 @@ from generic.states.fields import InvenTreeCustomStatusSerializerMixin | ||||
| from importer.registry import register_importer | ||||
| from InvenTree.mixins import DataImportExportSerializerMixin | ||||
| from InvenTree.ready import isGeneratingSchema | ||||
| from InvenTree.serializers import InvenTreeCurrencySerializer, InvenTreeDecimalField | ||||
| from InvenTree.serializers import ( | ||||
|     InvenTreeCurrencySerializer, | ||||
|     InvenTreeDecimalField, | ||||
|     InvenTreeModelSerializer, | ||||
| ) | ||||
| from users.serializers import UserSerializer | ||||
|  | ||||
| from .models import ( | ||||
| @@ -1808,3 +1812,23 @@ class StockTransferSerializer(StockAdjustmentSerializer): | ||||
|                 stock_item.move( | ||||
|                     location, notes, request.user, quantity=quantity, **kwargs | ||||
|                 ) | ||||
|  | ||||
|  | ||||
| class StockItemSerialNumbersSerializer(InvenTreeModelSerializer): | ||||
|     """Serializer for extra serial number information about a stock item.""" | ||||
|  | ||||
|     class Meta: | ||||
|         """Metaclass options.""" | ||||
|  | ||||
|         model = StockItem | ||||
|         fields = ['next', 'previous'] | ||||
|  | ||||
|     next = StockItemSerializer( | ||||
|         read_only=True, source='get_next_stock_item', label=_('Next Serial Number') | ||||
|     ) | ||||
|  | ||||
|     previous = StockItemSerializer( | ||||
|         read_only=True, | ||||
|         source='get_previous_stock_item', | ||||
|         label=_('Previous Serial Number'), | ||||
|     ) | ||||
|   | ||||
| @@ -19,6 +19,7 @@ import { | ||||
|   FormProvider, | ||||
|   type SubmitErrorHandler, | ||||
|   type SubmitHandler, | ||||
|   type UseFormReturn, | ||||
|   useForm | ||||
| } from 'react-hook-form'; | ||||
| import { useNavigate } from 'react-router-dom'; | ||||
| @@ -71,6 +72,7 @@ export interface ApiFormAction { | ||||
|  * @param onFormSuccess : A callback function to call when the form is submitted successfully. | ||||
|  * @param onFormError : A callback function to call when the form is submitted with errors. | ||||
|  * @param processFormData : A callback function to process the form data before submission | ||||
|  * @param checkClose: A callback function to check if the form can be closed after submission | ||||
|  * @param modelType : Define a model type for this form | ||||
|  * @param follow : Boolean, follow the result of the form (if possible) | ||||
|  * @param table : Table to update on success (if provided) | ||||
| @@ -94,9 +96,10 @@ export interface ApiFormProps { | ||||
|   preFormSuccess?: string; | ||||
|   postFormContent?: JSX.Element; | ||||
|   successMessage?: string | null; | ||||
|   onFormSuccess?: (data: any) => void; | ||||
|   onFormError?: (response: any) => void; | ||||
|   processFormData?: (data: any) => any; | ||||
|   onFormSuccess?: (data: any, form: UseFormReturn) => void; | ||||
|   onFormError?: (response: any, form: UseFormReturn) => void; | ||||
|   processFormData?: (data: any, form: UseFormReturn) => any; | ||||
|   checkClose?: (data: any, form: UseFormReturn) => boolean; | ||||
|   table?: TableState; | ||||
|   modelType?: ModelType; | ||||
|   follow?: boolean; | ||||
| @@ -414,7 +417,7 @@ export function ApiForm({ | ||||
|  | ||||
|     // Optionally pre-process the data before submitting it | ||||
|     if (props.processFormData) { | ||||
|       data = props.processFormData(data); | ||||
|       data = props.processFormData(data, form); | ||||
|     } | ||||
|  | ||||
|     const jsonData = { ...data }; | ||||
| @@ -474,7 +477,7 @@ export function ApiForm({ | ||||
|  | ||||
|             if (props.onFormSuccess) { | ||||
|               // A custom callback hook is provided | ||||
|               props.onFormSuccess(response.data); | ||||
|               props.onFormSuccess(response.data, form); | ||||
|             } | ||||
|  | ||||
|             if (props.follow && props.modelType && response.data?.pk) { | ||||
| @@ -507,7 +510,7 @@ export function ApiForm({ | ||||
|           default: | ||||
|             // Unexpected state on form success | ||||
|             invalidResponse(response.status); | ||||
|             props.onFormError?.(response); | ||||
|             props.onFormError?.(response, form); | ||||
|             break; | ||||
|         } | ||||
|  | ||||
| @@ -572,18 +575,18 @@ export function ApiForm({ | ||||
|  | ||||
|               processErrors(error.response.data); | ||||
|               setNonFieldErrors(_nonFieldErrors); | ||||
|               props.onFormError?.(error); | ||||
|               props.onFormError?.(error, form); | ||||
|  | ||||
|               break; | ||||
|             default: | ||||
|               // Unexpected state on form error | ||||
|               invalidResponse(error.response.status); | ||||
|               props.onFormError?.(error); | ||||
|               props.onFormError?.(error, form); | ||||
|               break; | ||||
|           } | ||||
|         } else { | ||||
|           showTimeoutNotification(); | ||||
|           props.onFormError?.(error); | ||||
|           props.onFormError?.(error, form); | ||||
|         } | ||||
|  | ||||
|         return error; | ||||
| @@ -592,7 +595,7 @@ export function ApiForm({ | ||||
|  | ||||
|   const onFormError = useCallback<SubmitErrorHandler<FieldValues>>( | ||||
|     (error: any) => { | ||||
|       props.onFormError?.(error); | ||||
|       props.onFormError?.(error, form); | ||||
|     }, | ||||
|     [props.onFormError] | ||||
|   ); | ||||
|   | ||||
| @@ -152,6 +152,7 @@ export enum ApiEndpoints { | ||||
|   stock_uninstall = 'stock/:id/uninstall/', | ||||
|   stock_serialize = 'stock/:id/serialize/', | ||||
|   stock_return = 'stock/:id/return/', | ||||
|   stock_serial_info = 'stock/:id/serial-numbers/', | ||||
|  | ||||
|   // Generator API endpoints | ||||
|   generate_batch_code = 'generate/batch-code/', | ||||
|   | ||||
| @@ -14,6 +14,7 @@ import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; | ||||
| import { Suspense, useEffect, useMemo, useState } from 'react'; | ||||
|  | ||||
| import dayjs from 'dayjs'; | ||||
| import { useNavigate } from 'react-router-dom'; | ||||
| import { api } from '../App'; | ||||
| import { ActionButton } from '../components/buttons/ActionButton'; | ||||
| import RemoveRowButton from '../components/buttons/RemoveRowButton'; | ||||
| @@ -33,8 +34,10 @@ import { StatusRenderer } from '../components/render/StatusRenderer'; | ||||
| import { ApiEndpoints } from '../enums/ApiEndpoints'; | ||||
| import { ModelType } from '../enums/ModelType'; | ||||
| import { InvenTreeIcon } from '../functions/icons'; | ||||
| import { getDetailUrl } from '../functions/urls'; | ||||
| import { | ||||
|   type ApiFormModalProps, | ||||
|   useApiFormModal, | ||||
|   useCreateApiFormModal, | ||||
|   useDeleteApiFormModal | ||||
| } from '../hooks/UseForm'; | ||||
| @@ -1296,3 +1299,56 @@ export function useTestResultFields({ | ||||
|     includeTestStation | ||||
|   ]); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Modal form for finding a particular stock item by serial number | ||||
|  */ | ||||
| export function useFindSerialNumberForm({ | ||||
|   partId | ||||
| }: { | ||||
|   partId: number; | ||||
| }) { | ||||
|   const navigate = useNavigate(); | ||||
|  | ||||
|   return useApiFormModal({ | ||||
|     url: apiUrl(ApiEndpoints.stock_item_list), | ||||
|     fetchInitialData: false, | ||||
|     method: 'GET', | ||||
|     title: t`Find Serial Number`, | ||||
|     fields: { | ||||
|       serial: {}, | ||||
|       part_tree: { | ||||
|         value: partId, | ||||
|         hidden: true, | ||||
|         field_type: 'integer' | ||||
|       } | ||||
|     }, | ||||
|     checkClose: (data, form) => { | ||||
|       if (data.length == 0) { | ||||
|         form.setError('serial', { message: t`No matching items` }); | ||||
|         return false; | ||||
|       } | ||||
|  | ||||
|       if (data.length > 1) { | ||||
|         form.setError('serial', { | ||||
|           message: t`Multiple matching items` | ||||
|         }); | ||||
|         return false; | ||||
|       } | ||||
|  | ||||
|       if (data[0].pk) { | ||||
|         return true; | ||||
|       } else { | ||||
|         form.setError('serial', { | ||||
|           message: t`Invalid response from server` | ||||
|         }); | ||||
|         return false; | ||||
|       } | ||||
|     }, | ||||
|     onFormSuccess: (data) => { | ||||
|       if (data.length == 1 && data[0].pk) { | ||||
|         navigate(getDetailUrl(ModelType.stockitem, data[0].pk)); | ||||
|       } | ||||
|     } | ||||
|   }); | ||||
| } | ||||
|   | ||||
| @@ -77,6 +77,7 @@ import { | ||||
|   IconQuestionMark, | ||||
|   IconRefresh, | ||||
|   IconRulerMeasure, | ||||
|   IconSearch, | ||||
|   IconSettings, | ||||
|   IconShoppingCart, | ||||
|   IconShoppingCartHeart, | ||||
| @@ -254,7 +255,8 @@ const icons = { | ||||
|   success: IconCircleCheck, | ||||
|   plugin: IconPlug, | ||||
|   history: IconHistory, | ||||
|   dashboard: IconLayoutDashboard | ||||
|   dashboard: IconLayoutDashboard, | ||||
|   search: IconSearch | ||||
| }; | ||||
|  | ||||
| export type InvenTreeIconType = keyof typeof icons; | ||||
|   | ||||
| @@ -44,12 +44,14 @@ export function useApiFormModal(props: ApiFormModalProps) { | ||||
|           } | ||||
|         } | ||||
|       ], | ||||
|       onFormSuccess: (data) => { | ||||
|         modalClose.current(); | ||||
|         props.onFormSuccess?.(data); | ||||
|       onFormSuccess: (data, form) => { | ||||
|         if (props.checkClose?.(data, form) ?? true) { | ||||
|           modalClose.current(); | ||||
|         } | ||||
|         props.onFormSuccess?.(data, form); | ||||
|       }, | ||||
|       onFormError: (error: any) => { | ||||
|         props.onFormError?.(error); | ||||
|       onFormError: (error: any, form) => { | ||||
|         props.onFormError?.(error, form); | ||||
|       } | ||||
|     }), | ||||
|     [props] | ||||
|   | ||||
| @@ -20,6 +20,7 @@ import { | ||||
|   IconListTree, | ||||
|   IconLock, | ||||
|   IconPackages, | ||||
|   IconSearch, | ||||
|   IconShoppingCart, | ||||
|   IconStack2, | ||||
|   IconTestPipe, | ||||
| @@ -71,6 +72,7 @@ import { usePartFields } from '../../forms/PartForms'; | ||||
| import { | ||||
|   type StockOperationProps, | ||||
|   useCountStockItem, | ||||
|   useFindSerialNumberForm, | ||||
|   useTransferStockItem | ||||
| } from '../../forms/StockForms'; | ||||
| import { InvenTreeIcon } from '../../functions/icons'; | ||||
| @@ -879,6 +881,8 @@ export default function PartDetail() { | ||||
|     parts: [part] | ||||
|   }); | ||||
|  | ||||
|   const findBySerialNumber = useFindSerialNumberForm({ partId: part.pk }); | ||||
|  | ||||
|   const partActions = useMemo(() => { | ||||
|     return [ | ||||
|       <AdminButton model={ModelType.part} id={part.pk} />, | ||||
| @@ -940,6 +944,13 @@ export default function PartDetail() { | ||||
|             onClick: () => { | ||||
|               orderPartsWizard.openWizard(); | ||||
|             } | ||||
|           }, | ||||
|           { | ||||
|             name: t`Search`, | ||||
|             tooltip: t`Search by serial number`, | ||||
|             hidden: !part.trackable, | ||||
|             icon: <IconSearch />, | ||||
|             onClick: findBySerialNumber.open | ||||
|           } | ||||
|         ]} | ||||
|       />, | ||||
| @@ -976,6 +987,7 @@ export default function PartDetail() { | ||||
|       {duplicatePart.modal} | ||||
|       {editPart.modal} | ||||
|       {deletePart.modal} | ||||
|       {findBySerialNumber.modal} | ||||
|       {orderPartsWizard.wizard} | ||||
|       <InstanceDetail | ||||
|         status={requestStatus} | ||||
|   | ||||
| @@ -1,12 +1,26 @@ | ||||
| import { t } from '@lingui/core/macro'; | ||||
| import { Accordion, Alert, Grid, Skeleton, Stack } from '@mantine/core'; | ||||
| import { | ||||
|   Accordion, | ||||
|   Alert, | ||||
|   Button, | ||||
|   Grid, | ||||
|   Group, | ||||
|   Skeleton, | ||||
|   Space, | ||||
|   Stack, | ||||
|   Text, | ||||
|   Tooltip | ||||
| } from '@mantine/core'; | ||||
| import { | ||||
|   IconArrowLeft, | ||||
|   IconArrowRight, | ||||
|   IconBookmark, | ||||
|   IconBoxPadding, | ||||
|   IconChecklist, | ||||
|   IconHistory, | ||||
|   IconInfoCircle, | ||||
|   IconPackages, | ||||
|   IconSearch, | ||||
|   IconShoppingCart, | ||||
|   IconSitemap | ||||
| } from '@tabler/icons-react'; | ||||
| @@ -14,6 +28,7 @@ import { useQuery } from '@tanstack/react-query'; | ||||
| import { type ReactNode, useMemo, useState } from 'react'; | ||||
| import { useNavigate, useParams } from 'react-router-dom'; | ||||
|  | ||||
| import { ActionButton } from '../../components/buttons/ActionButton'; | ||||
| import AdminButton from '../../components/buttons/AdminButton'; | ||||
| import { PrintingActions } from '../../components/buttons/PrintingActions'; | ||||
| import { | ||||
| @@ -52,6 +67,7 @@ import { | ||||
|   useAddStockItem, | ||||
|   useAssignStockItem, | ||||
|   useCountStockItem, | ||||
|   useFindSerialNumberForm, | ||||
|   useRemoveStockItem, | ||||
|   useStockFields, | ||||
|   useStockItemSerializeFields, | ||||
| @@ -108,6 +124,16 @@ export default function StockDetail() { | ||||
|     } | ||||
|   }); | ||||
|  | ||||
|   const { instance: serialNumbers, instanceQuery: serialNumbersQuery } = | ||||
|     useInstance({ | ||||
|       endpoint: ApiEndpoints.stock_serial_info, | ||||
|       pk: id | ||||
|     }); | ||||
|  | ||||
|   const findBySerialNumber = useFindSerialNumberForm({ | ||||
|     partId: stockitem.part | ||||
|   }); | ||||
|  | ||||
|   const detailsPanel = useMemo(() => { | ||||
|     const data = { ...stockitem }; | ||||
|     const part = stockitem?.part_detail ?? {}; | ||||
| @@ -150,13 +176,6 @@ export default function StockDetail() { | ||||
|           !stockitem.status_custom_key || | ||||
|           stockitem.status_custom_key == stockitem.status | ||||
|       }, | ||||
|       { | ||||
|         type: 'text', | ||||
|         name: 'tests', | ||||
|         label: t`Completed Tests`, | ||||
|         icon: 'progress', | ||||
|         hidden: !part?.testable | ||||
|       }, | ||||
|       { | ||||
|         type: 'text', | ||||
|         name: 'updated', | ||||
| @@ -176,14 +195,69 @@ export default function StockDetail() { | ||||
|     const tr: DetailsField[] = [ | ||||
|       { | ||||
|         type: 'text', | ||||
|         name: 'quantity', | ||||
|         label: t`Quantity` | ||||
|         name: 'serial', | ||||
|         label: t`Serial Number`, | ||||
|         hidden: !stockitem.serial, | ||||
|         value_formatter: () => ( | ||||
|           <Group gap='xs' justify='space-apart'> | ||||
|             <Text>{stockitem.serial}</Text> | ||||
|             <Space flex={10} /> | ||||
|             <Group gap={2} justify='right'> | ||||
|               {serialNumbers.previous?.pk && ( | ||||
|                 <Tooltip label={t`Previous serial number`} position='top'> | ||||
|                   <Button | ||||
|                     p={3} | ||||
|                     aria-label='previous-serial-number' | ||||
|                     leftSection={<IconArrowLeft />} | ||||
|                     variant='transparent' | ||||
|                     size='sm' | ||||
|                     onClick={() => { | ||||
|                       navigate( | ||||
|                         getDetailUrl( | ||||
|                           ModelType.stockitem, | ||||
|                           serialNumbers.previous.pk | ||||
|                         ) | ||||
|                       ); | ||||
|                     }} | ||||
|                   > | ||||
|                     {serialNumbers.previous.serial} | ||||
|                   </Button> | ||||
|                 </Tooltip> | ||||
|               )} | ||||
|               <ActionButton | ||||
|                 icon={<IconSearch size={18} />} | ||||
|                 tooltip={t`Find serial number`} | ||||
|                 tooltipAlignment='top' | ||||
|                 variant='transparent' | ||||
|                 onClick={findBySerialNumber.open} | ||||
|               /> | ||||
|               {serialNumbers.next?.pk && ( | ||||
|                 <Tooltip label={t`Next serial number`} position='top'> | ||||
|                   <Button | ||||
|                     p={3} | ||||
|                     aria-label='next-serial-number' | ||||
|                     rightSection={<IconArrowRight />} | ||||
|                     variant='transparent' | ||||
|                     size='sm' | ||||
|                     onClick={() => { | ||||
|                       navigate( | ||||
|                         getDetailUrl(ModelType.stockitem, serialNumbers.next.pk) | ||||
|                       ); | ||||
|                     }} | ||||
|                   > | ||||
|                     {serialNumbers.next.serial} | ||||
|                   </Button> | ||||
|                 </Tooltip> | ||||
|               )} | ||||
|             </Group> | ||||
|           </Group> | ||||
|         ) | ||||
|       }, | ||||
|       { | ||||
|         type: 'text', | ||||
|         name: 'serial', | ||||
|         label: t`Serial Number`, | ||||
|         hidden: !stockitem.serial | ||||
|         name: 'quantity', | ||||
|         label: t`Quantity`, | ||||
|         hidden: !!stockitem.serial && stockitem.quantity == 1 | ||||
|       }, | ||||
|       { | ||||
|         type: 'text', | ||||
| @@ -365,7 +439,13 @@ export default function StockDetail() { | ||||
|         <DetailsTable fields={br} item={data} /> | ||||
|       </ItemDetailsGrid> | ||||
|     ); | ||||
|   }, [stockitem, instanceQuery.isFetching, enableExpiry]); | ||||
|   }, [ | ||||
|     stockitem, | ||||
|     serialNumbers, | ||||
|     serialNumbersQuery.isFetching, | ||||
|     instanceQuery.isFetching, | ||||
|     enableExpiry | ||||
|   ]); | ||||
|  | ||||
|   const showBuildAllocations: boolean = useMemo(() => { | ||||
|     // Determine if "build allocations" should be shown for this stock item | ||||
| @@ -539,6 +619,8 @@ export default function StockDetail() { | ||||
|     showBuildAllocations, | ||||
|     showInstalledItems, | ||||
|     stockitem, | ||||
|     serialNumbers, | ||||
|     serialNumbersQuery, | ||||
|     id, | ||||
|     user | ||||
|   ]); | ||||
| @@ -890,62 +972,67 @@ export default function StockDetail() { | ||||
|   }, [stockitem, instanceQuery, enableExpiry]); | ||||
|  | ||||
|   return ( | ||||
|     <InstanceDetail | ||||
|       requiredRole={UserRoles.stock} | ||||
|       status={requestStatus} | ||||
|       loading={instanceQuery.isFetching} | ||||
|     > | ||||
|       <Stack> | ||||
|         {user.hasViewRole(UserRoles.stock_location) && ( | ||||
|           <NavigationTree | ||||
|             title={t`Stock Locations`} | ||||
|             modelType={ModelType.stocklocation} | ||||
|             endpoint={ApiEndpoints.stock_location_tree} | ||||
|             opened={treeOpen} | ||||
|             onClose={() => setTreeOpen(false)} | ||||
|             selectedId={stockitem?.location} | ||||
|           /> | ||||
|         )} | ||||
|         <PageDetail | ||||
|           title={t`Stock Item`} | ||||
|           subtitle={stockitem.part_detail?.full_name} | ||||
|           imageUrl={stockitem.part_detail?.thumbnail} | ||||
|           editAction={editStockItem.open} | ||||
|           editEnabled={user.hasChangePermission(ModelType.stockitem)} | ||||
|           badges={stockBadges} | ||||
|           breadcrumbs={ | ||||
|             user.hasViewRole(UserRoles.stock_location) ? breadcrumbs : undefined | ||||
|           } | ||||
|           lastCrumb={[ | ||||
|             { | ||||
|               name: stockitem.name, | ||||
|               url: `/stock/item/${stockitem.pk}/` | ||||
|     <> | ||||
|       {findBySerialNumber.modal} | ||||
|       <InstanceDetail | ||||
|         requiredRole={UserRoles.stock} | ||||
|         status={requestStatus} | ||||
|         loading={instanceQuery.isFetching} | ||||
|       > | ||||
|         <Stack> | ||||
|           {user.hasViewRole(UserRoles.stock_location) && ( | ||||
|             <NavigationTree | ||||
|               title={t`Stock Locations`} | ||||
|               modelType={ModelType.stocklocation} | ||||
|               endpoint={ApiEndpoints.stock_location_tree} | ||||
|               opened={treeOpen} | ||||
|               onClose={() => setTreeOpen(false)} | ||||
|               selectedId={stockitem?.location} | ||||
|             /> | ||||
|           )} | ||||
|           <PageDetail | ||||
|             title={t`Stock Item`} | ||||
|             subtitle={stockitem.part_detail?.full_name} | ||||
|             imageUrl={stockitem.part_detail?.thumbnail} | ||||
|             editAction={editStockItem.open} | ||||
|             editEnabled={user.hasChangePermission(ModelType.stockitem)} | ||||
|             badges={stockBadges} | ||||
|             breadcrumbs={ | ||||
|               user.hasViewRole(UserRoles.stock_location) | ||||
|                 ? breadcrumbs | ||||
|                 : undefined | ||||
|             } | ||||
|           ]} | ||||
|           breadcrumbAction={() => { | ||||
|             setTreeOpen(true); | ||||
|           }} | ||||
|           actions={stockActions} | ||||
|         /> | ||||
|         <PanelGroup | ||||
|           pageKey='stockitem' | ||||
|           panels={stockPanels} | ||||
|           model={ModelType.stockitem} | ||||
|           id={stockitem.pk} | ||||
|           instance={stockitem} | ||||
|         /> | ||||
|         {editStockItem.modal} | ||||
|         {duplicateStockItem.modal} | ||||
|         {deleteStockItem.modal} | ||||
|         {countStockItem.modal} | ||||
|         {addStockItem.modal} | ||||
|         {removeStockItem.modal} | ||||
|         {transferStockItem.modal} | ||||
|         {serializeStockItem.modal} | ||||
|         {returnStockItem.modal} | ||||
|         {assignToCustomer.modal} | ||||
|         {orderPartsWizard.wizard} | ||||
|       </Stack> | ||||
|     </InstanceDetail> | ||||
|             lastCrumb={[ | ||||
|               { | ||||
|                 name: stockitem.name, | ||||
|                 url: `/stock/item/${stockitem.pk}/` | ||||
|               } | ||||
|             ]} | ||||
|             breadcrumbAction={() => { | ||||
|               setTreeOpen(true); | ||||
|             }} | ||||
|             actions={stockActions} | ||||
|           /> | ||||
|           <PanelGroup | ||||
|             pageKey='stockitem' | ||||
|             panels={stockPanels} | ||||
|             model={ModelType.stockitem} | ||||
|             id={stockitem.pk} | ||||
|             instance={stockitem} | ||||
|           /> | ||||
|           {editStockItem.modal} | ||||
|           {duplicateStockItem.modal} | ||||
|           {deleteStockItem.modal} | ||||
|           {countStockItem.modal} | ||||
|           {addStockItem.modal} | ||||
|           {removeStockItem.modal} | ||||
|           {transferStockItem.modal} | ||||
|           {serializeStockItem.modal} | ||||
|           {returnStockItem.modal} | ||||
|           {assignToCustomer.modal} | ||||
|           {orderPartsWizard.wizard} | ||||
|         </Stack> | ||||
|       </InstanceDetail> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
|   | ||||
| @@ -165,6 +165,33 @@ test('Stock - Serial Numbers', async ({ browser }) => { | ||||
|   await page.getByRole('button', { name: 'Cancel' }).click(); | ||||
| }); | ||||
|  | ||||
| // Test navigation by serial number | ||||
| test('Stock - Serial Navigation', async ({ browser }) => { | ||||
|   const page = await doCachedLogin(browser, { url: 'part/79/details' }); | ||||
|  | ||||
|   await page.getByLabel('action-menu-stock-actions').click(); | ||||
|   await page.getByLabel('action-menu-stock-actions-search').click(); | ||||
|   await page.getByLabel('text-field-serial').fill('359'); | ||||
|   await page.getByRole('button', { name: 'Submit' }).click(); | ||||
|  | ||||
|   // Start at serial 359 | ||||
|   await page.getByText('359', { exact: true }).first().waitFor(); | ||||
|   await page.getByLabel('next-serial-number').waitFor(); | ||||
|   await page.getByLabel('previous-serial-number').click(); | ||||
|  | ||||
|   // Navigate to serial 358 | ||||
|   await page.getByText('358', { exact: true }).first().waitFor(); | ||||
|  | ||||
|   await page.getByLabel('action-button-find-serial').click(); | ||||
|   await page.getByLabel('text-field-serial').fill('200'); | ||||
|   await page.getByRole('button', { name: 'Submit' }).click(); | ||||
|  | ||||
|   await page.getByText('Serial Number: 200').waitFor(); | ||||
|   await page.getByText('200', { exact: true }).first().waitFor(); | ||||
|   await page.getByText('199', { exact: true }).first().waitFor(); | ||||
|   await page.getByText('201', { exact: true }).first().waitFor(); | ||||
| }); | ||||
|  | ||||
| test('Stock - Serialize', async ({ browser }) => { | ||||
|   const page = await doCachedLogin(browser, { url: 'stock/item/232/details' }); | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user