mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-27 19:16: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
|
||||
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' });
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user