2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-09-13 14:11:37 +00:00

[UI] Enhance stock item rendering (#10143)

* Enhance stock item rendering

- Show location in forms
- Allow differentiation between items

* Unit test fixes

- Account for longer query time in docker mode

* Cast to string

* Improved rendering
This commit is contained in:
Oliver
2025-08-07 15:29:57 +10:00
committed by GitHub
parent 1e6ffbde30
commit 61c8a8f703
5 changed files with 163 additions and 28 deletions

View File

@@ -8,6 +8,7 @@ export interface InstanceRenderInterface {
link?: boolean; link?: boolean;
navigate?: any; navigate?: any;
showSecondary?: boolean; showSecondary?: boolean;
extra?: Record<string, any>;
} }
type EnumDictionary<T extends string | symbol | number, U> = { type EnumDictionary<T extends string | symbol | number, U> = {

View File

@@ -1,5 +1,14 @@
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { Alert, Anchor, Group, Skeleton, Space, Text } from '@mantine/core'; import {
Alert,
Anchor,
Group,
type MantineSize,
Paper,
Skeleton,
Space,
Text
} from '@mantine/core';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { type ReactNode, useCallback } from 'react'; import { type ReactNode, useCallback } from 'react';
@@ -158,8 +167,8 @@ export function RenderInlineModel({
showSecondary = true, showSecondary = true,
tooltip tooltip
}: Readonly<{ }: Readonly<{
primary: string; primary: ReactNode;
secondary?: string; secondary?: ReactNode;
showSecondary?: boolean; showSecondary?: boolean;
prefix?: ReactNode; prefix?: ReactNode;
suffix?: ReactNode; suffix?: ReactNode;
@@ -180,15 +189,25 @@ export function RenderInlineModel({
[url, navigate] [url, navigate]
); );
const primaryText = shortenString({ if (typeof primary === 'string') {
str: primary, primary = shortenString({
len: 50 str: primary,
}); len: 50
});
const secondaryText = shortenString({ primary = <Text size='sm'>{primary}</Text>;
str: secondary, }
len: 75
}); if (typeof secondary === 'string') {
secondary = shortenString({
str: secondary,
len: 75
});
if (secondary.toString()?.length > 0) {
secondary = <InlineSecondaryBadge text={secondary.toString()} />;
}
}
return ( return (
<Group gap='xs' justify='space-between' wrap='nowrap' title={tooltip}> <Group gap='xs' justify='space-between' wrap='nowrap' title={tooltip}>
@@ -197,12 +216,12 @@ export function RenderInlineModel({
{image && <Thumbnail src={image} size={18} />} {image && <Thumbnail src={image} size={18} />}
{url ? ( {url ? (
<Anchor href='' onClick={(event: any) => onClick(event)}> <Anchor href='' onClick={(event: any) => onClick(event)}>
<Text size='sm'>{primaryText}</Text> {primary}
</Anchor> </Anchor>
) : ( ) : (
<Text size='sm'>{primaryText}</Text> primary
)} )}
{showSecondary && secondary && <Text size='xs'>{secondaryText}</Text>} {showSecondary && secondary && secondary}
</Group> </Group>
{suffix && ( {suffix && (
<> <>
@@ -222,3 +241,29 @@ export function UnknownRenderer({
const model_name = model ? model.toString() : 'undefined'; const model_name = model ? model.toString() : 'undefined';
return <Alert color='red' title={t`Unknown model: ${model_name}`} />; return <Alert color='red' title={t`Unknown model: ${model_name}`} />;
} }
/**
* Render a "badge like" component with a text label
*/
export function InlineSecondaryBadge({
text,
title,
size = 'xs'
}: {
text: string;
title?: string;
size?: MantineSize;
}): ReactNode {
return (
<Paper p={2} withBorder style={{ backgroundColor: 'transparent' }}>
<Group gap='xs'>
{title && (
<Text size={size} title={title}>
{title}:
</Text>
)}
<Text size={size ?? 'xs'}>{text}</Text>
</Group>
</Paper>
);
}

View File

@@ -1,10 +1,12 @@
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { Badge } from '@mantine/core'; import { Badge, Group, Text } from '@mantine/core';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { ModelType } from '@lib/enums/ModelType'; import { ModelType } from '@lib/enums/ModelType';
import { formatDecimal } from '@lib/functions/Formatting'; import { formatDecimal } from '@lib/functions/Formatting';
import { getDetailUrl } from '@lib/functions/Navigation'; import { getDetailUrl } from '@lib/functions/Navigation';
import { shortenString } from '../../functions/tables';
import { TableHoverCard } from '../../tables/TableHoverCard';
import { ApiIcon } from '../items/ApiIcon'; import { ApiIcon } from '../items/ApiIcon';
import { type InstanceRenderInterface, RenderInlineModel } from './Instance'; import { type InstanceRenderInterface, RenderInlineModel } from './Instance';
@@ -58,6 +60,24 @@ export function RenderPartCategory(
): ReactNode { ): ReactNode {
const { instance } = props; const { instance } = props;
const suffix: ReactNode = (
<Group gap='xs'>
<TableHoverCard
value=''
position='bottom-end'
zIndex={10000}
icon='sitemap'
title={t`Category`}
extra={[<Text>{instance.pathstring}</Text>]}
/>
</Group>
);
const category = shortenString({
str: instance.pathstring,
len: 50
});
return ( return (
<RenderInlineModel <RenderInlineModel
{...props} {...props}
@@ -68,8 +88,9 @@ export function RenderPartCategory(
{instance.icon && <ApiIcon name={instance.icon} />} {instance.icon && <ApiIcon name={instance.icon} />}
</> </>
} }
primary={instance.pathstring} primary={category}
secondary={instance.description} secondary={instance.description}
suffix={suffix}
url={ url={
props.link props.link
? getDetailUrl(ModelType.partcategory, instance.pk) ? getDetailUrl(ModelType.partcategory, instance.pk)

View File

@@ -1,12 +1,18 @@
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { Text } from '@mantine/core'; import { Group, Text } from '@mantine/core';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { ModelType } from '@lib/enums/ModelType'; import { ModelType } from '@lib/enums/ModelType';
import { formatDecimal } from '@lib/functions/Formatting'; import { formatDecimal } from '@lib/functions/Formatting';
import { getDetailUrl } from '@lib/functions/Navigation'; import { getDetailUrl } from '@lib/functions/Navigation';
import { shortenString } from '../../functions/tables';
import { TableHoverCard } from '../../tables/TableHoverCard';
import { ApiIcon } from '../items/ApiIcon'; import { ApiIcon } from '../items/ApiIcon';
import { type InstanceRenderInterface, RenderInlineModel } from './Instance'; import {
InlineSecondaryBadge,
type InstanceRenderInterface,
RenderInlineModel
} from './Instance';
/** /**
* Inline rendering of a single StockLocation instance * Inline rendering of a single StockLocation instance
@@ -16,6 +22,24 @@ export function RenderStockLocation(
): ReactNode { ): ReactNode {
const { instance } = props; const { instance } = props;
const suffix: ReactNode = (
<Group gap='xs'>
<TableHoverCard
value=''
position='bottom-end'
zIndex={10000}
icon='sitemap'
title={t`Location`}
extra={[<Text>{instance.pathstring}</Text>]}
/>
</Group>
);
const location = shortenString({
str: instance.pathstring,
len: 50
});
return ( return (
<RenderInlineModel <RenderInlineModel
{...props} {...props}
@@ -26,8 +50,9 @@ export function RenderStockLocation(
{instance.icon && <ApiIcon name={instance.icon} />} {instance.icon && <ApiIcon name={instance.icon} />}
</> </>
} }
primary={instance.pathstring} primary={location}
secondary={instance.description} secondary={instance.description}
suffix={suffix}
url={ url={
props.link props.link
? getDetailUrl(ModelType.stocklocation, instance.pk) ? getDetailUrl(ModelType.stocklocation, instance.pk)
@@ -69,18 +94,44 @@ export function RenderStockItem(
quantity_string = `${t`Quantity`}: ${formatDecimal(instance.quantity)}`; quantity_string = `${t`Quantity`}: ${formatDecimal(instance.quantity)}`;
} }
let batch_string = ''; const showLocation: boolean = props.extra?.show_location !== false;
const location: any = props.instance?.location_detail;
if (!!instance.batch) { // Form the "secondary" text to display
batch_string = `${t`Batch`}: ${instance.batch}`; const secondary: ReactNode = (
} <Group gap='xs' style={{ paddingLeft: '5px' }}>
{showLocation && location?.name && (
<InlineSecondaryBadge title={t`Location`} text={location.name} />
)}
{instance.batch && (
<InlineSecondaryBadge title={t`Batch`} text={instance.batch} />
)}
</Group>
);
// Form the "suffix" text to display
const suffix: ReactNode = (
<Group gap='xs'>
<Text size='xs'>{quantity_string}</Text>
{location && (
<TableHoverCard
value=''
position='bottom-end'
zIndex={10000}
icon='sitemap'
title={t`Location`}
extra={[<Text>{location.pathstring}</Text>]}
/>
)}
</Group>
);
return ( return (
<RenderInlineModel <RenderInlineModel
{...props} {...props}
primary={instance.part_detail?.full_name} primary={instance.part_detail?.full_name}
secondary={batch_string} secondary={secondary}
suffix={<Text size='xs'>{quantity_string}</Text>} suffix={suffix}
image={instance.part_detail?.thumbnail || instance.part_detail?.image} image={instance.part_detail?.thumbnail || instance.part_detail?.image}
url={ url={
props.link ? getDetailUrl(ModelType.stockitem, instance.pk) : undefined props.link ? getDetailUrl(ModelType.stockitem, instance.pk) : undefined

View File

@@ -1,5 +1,12 @@
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { Divider, Group, HoverCard, Stack, Text } from '@mantine/core'; import {
Divider,
type FloatingPosition,
Group,
HoverCard,
Stack,
Text
} from '@mantine/core';
import { type ReactNode, useMemo } from 'react'; import { type ReactNode, useMemo } from 'react';
import type { InvenTreeIconType } from '@lib/types/Icons'; import type { InvenTreeIconType } from '@lib/types/Icons';
@@ -15,13 +22,17 @@ export function TableHoverCard({
extra, // The extra information to display extra, // The extra information to display
title, // The title of the hovercard title, // The title of the hovercard
icon, // The icon to display icon, // The icon to display
iconColor // The icon color iconColor, // The icon color
position, // The position of the hovercard
zIndex // Optional z-index for the hovercard
}: Readonly<{ }: Readonly<{
value: any; value: any;
extra?: ReactNode; extra?: ReactNode;
title?: string; title?: string;
icon?: keyof InvenTreeIconType; icon?: keyof InvenTreeIconType;
iconColor?: string; iconColor?: string;
position?: FloatingPosition;
zIndex?: string | number;
}>) { }>) {
const extraItems: ReactNode = useMemo(() => { const extraItems: ReactNode = useMemo(() => {
if (Array.isArray(extra)) { if (Array.isArray(extra)) {
@@ -47,7 +58,13 @@ export function TableHoverCard({
} }
return ( return (
<HoverCard withinPortal={true} closeDelay={20} openDelay={250}> <HoverCard
withinPortal={true}
closeDelay={20}
openDelay={250}
position={position}
zIndex={zIndex}
>
<HoverCard.Target> <HoverCard.Target>
<Group gap='xs' justify='space-between' wrap='nowrap'> <Group gap='xs' justify='space-between' wrap='nowrap'>
{value} {value}