mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-18 04:55: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:
@ -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