mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-28 11:36:44 +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:
parent
8d44a0d330
commit
448d24de21
@ -1,13 +1,16 @@
|
|||||||
"""InvenTree API version information."""
|
"""InvenTree API version information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# 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."""
|
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||||
|
|
||||||
|
|
||||||
INVENTREE_API_TEXT = """
|
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
|
v336 -> 2025-04-10 : https://github.com/inventree/InvenTree/pull/9492
|
||||||
- Fixed query and response serialization for units_all and version_text
|
- Fixed query and response serialization for units_all and version_text
|
||||||
- Fixed LicenseView and VersionInformation serialization
|
- Fixed LicenseView and VersionInformation serialization
|
||||||
|
@ -32,18 +32,67 @@ from .settings import MEDIA_URL, STATIC_URL
|
|||||||
|
|
||||||
logger = structlog.get_logger('inventree')
|
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
|
# Default value if we cannot convert to an integer
|
||||||
ref_int = 0
|
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()
|
reference = str(reference).strip()
|
||||||
|
|
||||||
# Ignore empty string
|
# Ignore empty string
|
||||||
if len(reference) == 0:
|
if len(reference) == 0:
|
||||||
return 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"?
|
# Look at the start of the string - can it be "integerized"?
|
||||||
result = re.match(r'^(\d+)', reference)
|
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
|
# 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"
|
# Note: This will result in large values being "clipped"
|
||||||
if clip is not None:
|
ref_int = do_clip(ref_int, clip, allow_negative)
|
||||||
if ref_int > clip:
|
|
||||||
ref_int = clip
|
|
||||||
elif ref_int < -clip:
|
|
||||||
ref_int = -clip
|
|
||||||
|
|
||||||
if not allow_negative and ref_int < 0:
|
if not allow_negative and ref_int < 0:
|
||||||
ref_int = abs(ref_int)
|
ref_int = abs(ref_int)
|
||||||
|
@ -1227,6 +1227,17 @@ class StockDetail(StockApiMixin, RetrieveUpdateDestroyAPI):
|
|||||||
"""API detail endpoint for a single StockItem instance."""
|
"""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:
|
class StockItemTestResultMixin:
|
||||||
"""Mixin class for the StockItemTestResult API endpoints."""
|
"""Mixin class for the StockItemTestResult API endpoints."""
|
||||||
|
|
||||||
@ -1615,6 +1626,11 @@ stock_api_urls = [
|
|||||||
StockItemUninstall.as_view(),
|
StockItemUninstall.as_view(),
|
||||||
name='api-stock-item-uninstall',
|
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'),
|
path('', StockDetail.as_view(), name='api-stock-detail'),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
|
@ -692,6 +692,16 @@ class StockItem(
|
|||||||
|
|
||||||
return None
|
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):
|
def save(self, *args, **kwargs):
|
||||||
"""Save this StockItem to the database.
|
"""Save this StockItem to the database.
|
||||||
|
|
||||||
|
@ -32,7 +32,11 @@ from generic.states.fields import InvenTreeCustomStatusSerializerMixin
|
|||||||
from importer.registry import register_importer
|
from importer.registry import register_importer
|
||||||
from InvenTree.mixins import DataImportExportSerializerMixin
|
from InvenTree.mixins import DataImportExportSerializerMixin
|
||||||
from InvenTree.ready import isGeneratingSchema
|
from InvenTree.ready import isGeneratingSchema
|
||||||
from InvenTree.serializers import InvenTreeCurrencySerializer, InvenTreeDecimalField
|
from InvenTree.serializers import (
|
||||||
|
InvenTreeCurrencySerializer,
|
||||||
|
InvenTreeDecimalField,
|
||||||
|
InvenTreeModelSerializer,
|
||||||
|
)
|
||||||
from users.serializers import UserSerializer
|
from users.serializers import UserSerializer
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
@ -1808,3 +1812,23 @@ class StockTransferSerializer(StockAdjustmentSerializer):
|
|||||||
stock_item.move(
|
stock_item.move(
|
||||||
location, notes, request.user, quantity=quantity, **kwargs
|
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,
|
FormProvider,
|
||||||
type SubmitErrorHandler,
|
type SubmitErrorHandler,
|
||||||
type SubmitHandler,
|
type SubmitHandler,
|
||||||
|
type UseFormReturn,
|
||||||
useForm
|
useForm
|
||||||
} from 'react-hook-form';
|
} from 'react-hook-form';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 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 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 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 modelType : Define a model type for this form
|
||||||
* @param follow : Boolean, follow the result of the form (if possible)
|
* @param follow : Boolean, follow the result of the form (if possible)
|
||||||
* @param table : Table to update on success (if provided)
|
* @param table : Table to update on success (if provided)
|
||||||
@ -94,9 +96,10 @@ export interface ApiFormProps {
|
|||||||
preFormSuccess?: string;
|
preFormSuccess?: string;
|
||||||
postFormContent?: JSX.Element;
|
postFormContent?: JSX.Element;
|
||||||
successMessage?: string | null;
|
successMessage?: string | null;
|
||||||
onFormSuccess?: (data: any) => void;
|
onFormSuccess?: (data: any, form: UseFormReturn) => void;
|
||||||
onFormError?: (response: any) => void;
|
onFormError?: (response: any, form: UseFormReturn) => void;
|
||||||
processFormData?: (data: any) => any;
|
processFormData?: (data: any, form: UseFormReturn) => any;
|
||||||
|
checkClose?: (data: any, form: UseFormReturn) => boolean;
|
||||||
table?: TableState;
|
table?: TableState;
|
||||||
modelType?: ModelType;
|
modelType?: ModelType;
|
||||||
follow?: boolean;
|
follow?: boolean;
|
||||||
@ -414,7 +417,7 @@ export function ApiForm({
|
|||||||
|
|
||||||
// Optionally pre-process the data before submitting it
|
// Optionally pre-process the data before submitting it
|
||||||
if (props.processFormData) {
|
if (props.processFormData) {
|
||||||
data = props.processFormData(data);
|
data = props.processFormData(data, form);
|
||||||
}
|
}
|
||||||
|
|
||||||
const jsonData = { ...data };
|
const jsonData = { ...data };
|
||||||
@ -474,7 +477,7 @@ export function ApiForm({
|
|||||||
|
|
||||||
if (props.onFormSuccess) {
|
if (props.onFormSuccess) {
|
||||||
// A custom callback hook is provided
|
// A custom callback hook is provided
|
||||||
props.onFormSuccess(response.data);
|
props.onFormSuccess(response.data, form);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.follow && props.modelType && response.data?.pk) {
|
if (props.follow && props.modelType && response.data?.pk) {
|
||||||
@ -507,7 +510,7 @@ export function ApiForm({
|
|||||||
default:
|
default:
|
||||||
// Unexpected state on form success
|
// Unexpected state on form success
|
||||||
invalidResponse(response.status);
|
invalidResponse(response.status);
|
||||||
props.onFormError?.(response);
|
props.onFormError?.(response, form);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -572,18 +575,18 @@ export function ApiForm({
|
|||||||
|
|
||||||
processErrors(error.response.data);
|
processErrors(error.response.data);
|
||||||
setNonFieldErrors(_nonFieldErrors);
|
setNonFieldErrors(_nonFieldErrors);
|
||||||
props.onFormError?.(error);
|
props.onFormError?.(error, form);
|
||||||
|
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
// Unexpected state on form error
|
// Unexpected state on form error
|
||||||
invalidResponse(error.response.status);
|
invalidResponse(error.response.status);
|
||||||
props.onFormError?.(error);
|
props.onFormError?.(error, form);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
showTimeoutNotification();
|
showTimeoutNotification();
|
||||||
props.onFormError?.(error);
|
props.onFormError?.(error, form);
|
||||||
}
|
}
|
||||||
|
|
||||||
return error;
|
return error;
|
||||||
@ -592,7 +595,7 @@ export function ApiForm({
|
|||||||
|
|
||||||
const onFormError = useCallback<SubmitErrorHandler<FieldValues>>(
|
const onFormError = useCallback<SubmitErrorHandler<FieldValues>>(
|
||||||
(error: any) => {
|
(error: any) => {
|
||||||
props.onFormError?.(error);
|
props.onFormError?.(error, form);
|
||||||
},
|
},
|
||||||
[props.onFormError]
|
[props.onFormError]
|
||||||
);
|
);
|
||||||
|
@ -152,6 +152,7 @@ export enum ApiEndpoints {
|
|||||||
stock_uninstall = 'stock/:id/uninstall/',
|
stock_uninstall = 'stock/:id/uninstall/',
|
||||||
stock_serialize = 'stock/:id/serialize/',
|
stock_serialize = 'stock/:id/serialize/',
|
||||||
stock_return = 'stock/:id/return/',
|
stock_return = 'stock/:id/return/',
|
||||||
|
stock_serial_info = 'stock/:id/serial-numbers/',
|
||||||
|
|
||||||
// Generator API endpoints
|
// Generator API endpoints
|
||||||
generate_batch_code = 'generate/batch-code/',
|
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 { Suspense, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { api } from '../App';
|
import { api } from '../App';
|
||||||
import { ActionButton } from '../components/buttons/ActionButton';
|
import { ActionButton } from '../components/buttons/ActionButton';
|
||||||
import RemoveRowButton from '../components/buttons/RemoveRowButton';
|
import RemoveRowButton from '../components/buttons/RemoveRowButton';
|
||||||
@ -33,8 +34,10 @@ import { StatusRenderer } from '../components/render/StatusRenderer';
|
|||||||
import { ApiEndpoints } from '../enums/ApiEndpoints';
|
import { ApiEndpoints } from '../enums/ApiEndpoints';
|
||||||
import { ModelType } from '../enums/ModelType';
|
import { ModelType } from '../enums/ModelType';
|
||||||
import { InvenTreeIcon } from '../functions/icons';
|
import { InvenTreeIcon } from '../functions/icons';
|
||||||
|
import { getDetailUrl } from '../functions/urls';
|
||||||
import {
|
import {
|
||||||
type ApiFormModalProps,
|
type ApiFormModalProps,
|
||||||
|
useApiFormModal,
|
||||||
useCreateApiFormModal,
|
useCreateApiFormModal,
|
||||||
useDeleteApiFormModal
|
useDeleteApiFormModal
|
||||||
} from '../hooks/UseForm';
|
} from '../hooks/UseForm';
|
||||||
@ -1296,3 +1299,56 @@ export function useTestResultFields({
|
|||||||
includeTestStation
|
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,
|
IconQuestionMark,
|
||||||
IconRefresh,
|
IconRefresh,
|
||||||
IconRulerMeasure,
|
IconRulerMeasure,
|
||||||
|
IconSearch,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
IconShoppingCart,
|
IconShoppingCart,
|
||||||
IconShoppingCartHeart,
|
IconShoppingCartHeart,
|
||||||
@ -254,7 +255,8 @@ const icons = {
|
|||||||
success: IconCircleCheck,
|
success: IconCircleCheck,
|
||||||
plugin: IconPlug,
|
plugin: IconPlug,
|
||||||
history: IconHistory,
|
history: IconHistory,
|
||||||
dashboard: IconLayoutDashboard
|
dashboard: IconLayoutDashboard,
|
||||||
|
search: IconSearch
|
||||||
};
|
};
|
||||||
|
|
||||||
export type InvenTreeIconType = keyof typeof icons;
|
export type InvenTreeIconType = keyof typeof icons;
|
||||||
|
@ -44,12 +44,14 @@ export function useApiFormModal(props: ApiFormModalProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
onFormSuccess: (data) => {
|
onFormSuccess: (data, form) => {
|
||||||
modalClose.current();
|
if (props.checkClose?.(data, form) ?? true) {
|
||||||
props.onFormSuccess?.(data);
|
modalClose.current();
|
||||||
|
}
|
||||||
|
props.onFormSuccess?.(data, form);
|
||||||
},
|
},
|
||||||
onFormError: (error: any) => {
|
onFormError: (error: any, form) => {
|
||||||
props.onFormError?.(error);
|
props.onFormError?.(error, form);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
[props]
|
[props]
|
||||||
|
@ -20,6 +20,7 @@ import {
|
|||||||
IconListTree,
|
IconListTree,
|
||||||
IconLock,
|
IconLock,
|
||||||
IconPackages,
|
IconPackages,
|
||||||
|
IconSearch,
|
||||||
IconShoppingCart,
|
IconShoppingCart,
|
||||||
IconStack2,
|
IconStack2,
|
||||||
IconTestPipe,
|
IconTestPipe,
|
||||||
@ -71,6 +72,7 @@ import { usePartFields } from '../../forms/PartForms';
|
|||||||
import {
|
import {
|
||||||
type StockOperationProps,
|
type StockOperationProps,
|
||||||
useCountStockItem,
|
useCountStockItem,
|
||||||
|
useFindSerialNumberForm,
|
||||||
useTransferStockItem
|
useTransferStockItem
|
||||||
} from '../../forms/StockForms';
|
} from '../../forms/StockForms';
|
||||||
import { InvenTreeIcon } from '../../functions/icons';
|
import { InvenTreeIcon } from '../../functions/icons';
|
||||||
@ -879,6 +881,8 @@ export default function PartDetail() {
|
|||||||
parts: [part]
|
parts: [part]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const findBySerialNumber = useFindSerialNumberForm({ partId: part.pk });
|
||||||
|
|
||||||
const partActions = useMemo(() => {
|
const partActions = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
<AdminButton model={ModelType.part} id={part.pk} />,
|
<AdminButton model={ModelType.part} id={part.pk} />,
|
||||||
@ -940,6 +944,13 @@ export default function PartDetail() {
|
|||||||
onClick: () => {
|
onClick: () => {
|
||||||
orderPartsWizard.openWizard();
|
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}
|
{duplicatePart.modal}
|
||||||
{editPart.modal}
|
{editPart.modal}
|
||||||
{deletePart.modal}
|
{deletePart.modal}
|
||||||
|
{findBySerialNumber.modal}
|
||||||
{orderPartsWizard.wizard}
|
{orderPartsWizard.wizard}
|
||||||
<InstanceDetail
|
<InstanceDetail
|
||||||
status={requestStatus}
|
status={requestStatus}
|
||||||
|
@ -1,12 +1,26 @@
|
|||||||
import { t } from '@lingui/core/macro';
|
import { t } from '@lingui/core/macro';
|
||||||
import { Accordion, Alert, Grid, Skeleton, Stack } from '@mantine/core';
|
|
||||||
import {
|
import {
|
||||||
|
Accordion,
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Grid,
|
||||||
|
Group,
|
||||||
|
Skeleton,
|
||||||
|
Space,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Tooltip
|
||||||
|
} from '@mantine/core';
|
||||||
|
import {
|
||||||
|
IconArrowLeft,
|
||||||
|
IconArrowRight,
|
||||||
IconBookmark,
|
IconBookmark,
|
||||||
IconBoxPadding,
|
IconBoxPadding,
|
||||||
IconChecklist,
|
IconChecklist,
|
||||||
IconHistory,
|
IconHistory,
|
||||||
IconInfoCircle,
|
IconInfoCircle,
|
||||||
IconPackages,
|
IconPackages,
|
||||||
|
IconSearch,
|
||||||
IconShoppingCart,
|
IconShoppingCart,
|
||||||
IconSitemap
|
IconSitemap
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
@ -14,6 +28,7 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { type ReactNode, useMemo, useState } from 'react';
|
import { type ReactNode, useMemo, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { ActionButton } from '../../components/buttons/ActionButton';
|
||||||
import AdminButton from '../../components/buttons/AdminButton';
|
import AdminButton from '../../components/buttons/AdminButton';
|
||||||
import { PrintingActions } from '../../components/buttons/PrintingActions';
|
import { PrintingActions } from '../../components/buttons/PrintingActions';
|
||||||
import {
|
import {
|
||||||
@ -52,6 +67,7 @@ import {
|
|||||||
useAddStockItem,
|
useAddStockItem,
|
||||||
useAssignStockItem,
|
useAssignStockItem,
|
||||||
useCountStockItem,
|
useCountStockItem,
|
||||||
|
useFindSerialNumberForm,
|
||||||
useRemoveStockItem,
|
useRemoveStockItem,
|
||||||
useStockFields,
|
useStockFields,
|
||||||
useStockItemSerializeFields,
|
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 detailsPanel = useMemo(() => {
|
||||||
const data = { ...stockitem };
|
const data = { ...stockitem };
|
||||||
const part = stockitem?.part_detail ?? {};
|
const part = stockitem?.part_detail ?? {};
|
||||||
@ -150,13 +176,6 @@ export default function StockDetail() {
|
|||||||
!stockitem.status_custom_key ||
|
!stockitem.status_custom_key ||
|
||||||
stockitem.status_custom_key == stockitem.status
|
stockitem.status_custom_key == stockitem.status
|
||||||
},
|
},
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
name: 'tests',
|
|
||||||
label: t`Completed Tests`,
|
|
||||||
icon: 'progress',
|
|
||||||
hidden: !part?.testable
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
name: 'updated',
|
name: 'updated',
|
||||||
@ -176,14 +195,69 @@ export default function StockDetail() {
|
|||||||
const tr: DetailsField[] = [
|
const tr: DetailsField[] = [
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
name: 'quantity',
|
name: 'serial',
|
||||||
label: t`Quantity`
|
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',
|
type: 'text',
|
||||||
name: 'serial',
|
name: 'quantity',
|
||||||
label: t`Serial Number`,
|
label: t`Quantity`,
|
||||||
hidden: !stockitem.serial
|
hidden: !!stockitem.serial && stockitem.quantity == 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
@ -365,7 +439,13 @@ export default function StockDetail() {
|
|||||||
<DetailsTable fields={br} item={data} />
|
<DetailsTable fields={br} item={data} />
|
||||||
</ItemDetailsGrid>
|
</ItemDetailsGrid>
|
||||||
);
|
);
|
||||||
}, [stockitem, instanceQuery.isFetching, enableExpiry]);
|
}, [
|
||||||
|
stockitem,
|
||||||
|
serialNumbers,
|
||||||
|
serialNumbersQuery.isFetching,
|
||||||
|
instanceQuery.isFetching,
|
||||||
|
enableExpiry
|
||||||
|
]);
|
||||||
|
|
||||||
const showBuildAllocations: boolean = useMemo(() => {
|
const showBuildAllocations: boolean = useMemo(() => {
|
||||||
// Determine if "build allocations" should be shown for this stock item
|
// Determine if "build allocations" should be shown for this stock item
|
||||||
@ -539,6 +619,8 @@ export default function StockDetail() {
|
|||||||
showBuildAllocations,
|
showBuildAllocations,
|
||||||
showInstalledItems,
|
showInstalledItems,
|
||||||
stockitem,
|
stockitem,
|
||||||
|
serialNumbers,
|
||||||
|
serialNumbersQuery,
|
||||||
id,
|
id,
|
||||||
user
|
user
|
||||||
]);
|
]);
|
||||||
@ -890,62 +972,67 @@ export default function StockDetail() {
|
|||||||
}, [stockitem, instanceQuery, enableExpiry]);
|
}, [stockitem, instanceQuery, enableExpiry]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InstanceDetail
|
<>
|
||||||
requiredRole={UserRoles.stock}
|
{findBySerialNumber.modal}
|
||||||
status={requestStatus}
|
<InstanceDetail
|
||||||
loading={instanceQuery.isFetching}
|
requiredRole={UserRoles.stock}
|
||||||
>
|
status={requestStatus}
|
||||||
<Stack>
|
loading={instanceQuery.isFetching}
|
||||||
{user.hasViewRole(UserRoles.stock_location) && (
|
>
|
||||||
<NavigationTree
|
<Stack>
|
||||||
title={t`Stock Locations`}
|
{user.hasViewRole(UserRoles.stock_location) && (
|
||||||
modelType={ModelType.stocklocation}
|
<NavigationTree
|
||||||
endpoint={ApiEndpoints.stock_location_tree}
|
title={t`Stock Locations`}
|
||||||
opened={treeOpen}
|
modelType={ModelType.stocklocation}
|
||||||
onClose={() => setTreeOpen(false)}
|
endpoint={ApiEndpoints.stock_location_tree}
|
||||||
selectedId={stockitem?.location}
|
opened={treeOpen}
|
||||||
/>
|
onClose={() => setTreeOpen(false)}
|
||||||
)}
|
selectedId={stockitem?.location}
|
||||||
<PageDetail
|
/>
|
||||||
title={t`Stock Item`}
|
)}
|
||||||
subtitle={stockitem.part_detail?.full_name}
|
<PageDetail
|
||||||
imageUrl={stockitem.part_detail?.thumbnail}
|
title={t`Stock Item`}
|
||||||
editAction={editStockItem.open}
|
subtitle={stockitem.part_detail?.full_name}
|
||||||
editEnabled={user.hasChangePermission(ModelType.stockitem)}
|
imageUrl={stockitem.part_detail?.thumbnail}
|
||||||
badges={stockBadges}
|
editAction={editStockItem.open}
|
||||||
breadcrumbs={
|
editEnabled={user.hasChangePermission(ModelType.stockitem)}
|
||||||
user.hasViewRole(UserRoles.stock_location) ? breadcrumbs : undefined
|
badges={stockBadges}
|
||||||
}
|
breadcrumbs={
|
||||||
lastCrumb={[
|
user.hasViewRole(UserRoles.stock_location)
|
||||||
{
|
? breadcrumbs
|
||||||
name: stockitem.name,
|
: undefined
|
||||||
url: `/stock/item/${stockitem.pk}/`
|
|
||||||
}
|
}
|
||||||
]}
|
lastCrumb={[
|
||||||
breadcrumbAction={() => {
|
{
|
||||||
setTreeOpen(true);
|
name: stockitem.name,
|
||||||
}}
|
url: `/stock/item/${stockitem.pk}/`
|
||||||
actions={stockActions}
|
}
|
||||||
/>
|
]}
|
||||||
<PanelGroup
|
breadcrumbAction={() => {
|
||||||
pageKey='stockitem'
|
setTreeOpen(true);
|
||||||
panels={stockPanels}
|
}}
|
||||||
model={ModelType.stockitem}
|
actions={stockActions}
|
||||||
id={stockitem.pk}
|
/>
|
||||||
instance={stockitem}
|
<PanelGroup
|
||||||
/>
|
pageKey='stockitem'
|
||||||
{editStockItem.modal}
|
panels={stockPanels}
|
||||||
{duplicateStockItem.modal}
|
model={ModelType.stockitem}
|
||||||
{deleteStockItem.modal}
|
id={stockitem.pk}
|
||||||
{countStockItem.modal}
|
instance={stockitem}
|
||||||
{addStockItem.modal}
|
/>
|
||||||
{removeStockItem.modal}
|
{editStockItem.modal}
|
||||||
{transferStockItem.modal}
|
{duplicateStockItem.modal}
|
||||||
{serializeStockItem.modal}
|
{deleteStockItem.modal}
|
||||||
{returnStockItem.modal}
|
{countStockItem.modal}
|
||||||
{assignToCustomer.modal}
|
{addStockItem.modal}
|
||||||
{orderPartsWizard.wizard}
|
{removeStockItem.modal}
|
||||||
</Stack>
|
{transferStockItem.modal}
|
||||||
</InstanceDetail>
|
{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();
|
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 }) => {
|
test('Stock - Serialize', async ({ browser }) => {
|
||||||
const page = await doCachedLogin(browser, { url: 'stock/item/232/details' });
|
const page = await doCachedLogin(browser, { url: 'stock/item/232/details' });
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user