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;
navigate?: any;
showSecondary?: boolean;
extra?: Record<string, any>;
}
type EnumDictionary<T extends string | symbol | number, U> = {

View File

@@ -1,5 +1,14 @@
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 { type ReactNode, useCallback } from 'react';
@@ -158,8 +167,8 @@ export function RenderInlineModel({
showSecondary = true,
tooltip
}: Readonly<{
primary: string;
secondary?: string;
primary: ReactNode;
secondary?: ReactNode;
showSecondary?: boolean;
prefix?: ReactNode;
suffix?: ReactNode;
@@ -180,16 +189,26 @@ export function RenderInlineModel({
[url, navigate]
);
const primaryText = shortenString({
if (typeof primary === 'string') {
primary = shortenString({
str: primary,
len: 50
});
const secondaryText = shortenString({
primary = <Text size='sm'>{primary}</Text>;
}
if (typeof secondary === 'string') {
secondary = shortenString({
str: secondary,
len: 75
});
if (secondary.toString()?.length > 0) {
secondary = <InlineSecondaryBadge text={secondary.toString()} />;
}
}
return (
<Group gap='xs' justify='space-between' wrap='nowrap' title={tooltip}>
<Group gap='xs' justify='left' wrap='nowrap'>
@@ -197,12 +216,12 @@ export function RenderInlineModel({
{image && <Thumbnail src={image} size={18} />}
{url ? (
<Anchor href='' onClick={(event: any) => onClick(event)}>
<Text size='sm'>{primaryText}</Text>
{primary}
</Anchor>
) : (
<Text size='sm'>{primaryText}</Text>
primary
)}
{showSecondary && secondary && <Text size='xs'>{secondaryText}</Text>}
{showSecondary && secondary && secondary}
</Group>
{suffix && (
<>
@@ -222,3 +241,29 @@ export function UnknownRenderer({
const model_name = model ? model.toString() : 'undefined';
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 { Badge } from '@mantine/core';
import { Badge, Group, Text } from '@mantine/core';
import type { ReactNode } from 'react';
import { ModelType } from '@lib/enums/ModelType';
import { formatDecimal } from '@lib/functions/Formatting';
import { getDetailUrl } from '@lib/functions/Navigation';
import { shortenString } from '../../functions/tables';
import { TableHoverCard } from '../../tables/TableHoverCard';
import { ApiIcon } from '../items/ApiIcon';
import { type InstanceRenderInterface, RenderInlineModel } from './Instance';
@@ -58,6 +60,24 @@ export function RenderPartCategory(
): ReactNode {
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 (
<RenderInlineModel
{...props}
@@ -68,8 +88,9 @@ export function RenderPartCategory(
{instance.icon && <ApiIcon name={instance.icon} />}
</>
}
primary={instance.pathstring}
primary={category}
secondary={instance.description}
suffix={suffix}
url={
props.link
? getDetailUrl(ModelType.partcategory, instance.pk)

View File

@@ -1,12 +1,18 @@
import { t } from '@lingui/core/macro';
import { Text } from '@mantine/core';
import { Group, Text } from '@mantine/core';
import type { ReactNode } from 'react';
import { ModelType } from '@lib/enums/ModelType';
import { formatDecimal } from '@lib/functions/Formatting';
import { getDetailUrl } from '@lib/functions/Navigation';
import { shortenString } from '../../functions/tables';
import { TableHoverCard } from '../../tables/TableHoverCard';
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
@@ -16,6 +22,24 @@ export function RenderStockLocation(
): ReactNode {
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 (
<RenderInlineModel
{...props}
@@ -26,8 +50,9 @@ export function RenderStockLocation(
{instance.icon && <ApiIcon name={instance.icon} />}
</>
}
primary={instance.pathstring}
primary={location}
secondary={instance.description}
suffix={suffix}
url={
props.link
? getDetailUrl(ModelType.stocklocation, instance.pk)
@@ -69,18 +94,44 @@ export function RenderStockItem(
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) {
batch_string = `${t`Batch`}: ${instance.batch}`;
}
// Form the "secondary" text to display
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 (
<RenderInlineModel
{...props}
primary={instance.part_detail?.full_name}
secondary={batch_string}
suffix={<Text size='xs'>{quantity_string}</Text>}
secondary={secondary}
suffix={suffix}
image={instance.part_detail?.thumbnail || instance.part_detail?.image}
url={
props.link ? getDetailUrl(ModelType.stockitem, instance.pk) : undefined

View File

@@ -1,5 +1,12 @@
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 { InvenTreeIconType } from '@lib/types/Icons';
@@ -15,13 +22,17 @@ export function TableHoverCard({
extra, // The extra information to display
title, // The title of the hovercard
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<{
value: any;
extra?: ReactNode;
title?: string;
icon?: keyof InvenTreeIconType;
iconColor?: string;
position?: FloatingPosition;
zIndex?: string | number;
}>) {
const extraItems: ReactNode = useMemo(() => {
if (Array.isArray(extra)) {
@@ -47,7 +58,13 @@ export function TableHoverCard({
}
return (
<HoverCard withinPortal={true} closeDelay={20} openDelay={250}>
<HoverCard
withinPortal={true}
closeDelay={20}
openDelay={250}
position={position}
zIndex={zIndex}
>
<HoverCard.Target>
<Group gap='xs' justify='space-between' wrap='nowrap'>
{value}