[PUI] Details Panel components (#6040)

* Add default_location to part filters

* Add Detail components

* Add Detail Image V1

* Remove piggyback change from different branch

* Remove unused code

* Add remove image modal

* Basic part image selection form

* Add Part Image selector Modal and fix PartThumb API pagination

* imports

* Add Image Upload modal

* Typescript and translation cleanup

* .

* Revert temporary workaround for existing_image

* Start adding fields

* .

* Modre fields and Icon manager

* Add most part detail fields

* .

* Final draft

* Remove unused TS

* More cleanup

* .

* Bump API version

* .

* Docstring oopsie
This commit is contained in:
Lavissa 2024-01-31 00:37:42 +01:00 committed by GitHub
parent 3bfde82394
commit fb71e847bb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1716 additions and 9 deletions

View File

@ -1,11 +1,14 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 164 INVENTREE_API_VERSION = 165
"""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 = """
v165 -> 2024-01-28 : https://github.com/inventree/InvenTree/pull/6040
- Adds supplier_part.name, part.creation_user, part.required_for_sales_order
v164 -> 2024-01-24 : https://github.com/inventree/InvenTree/pull/6343 v164 -> 2024-01-24 : https://github.com/inventree/InvenTree/pull/6343
- Adds "building" quantity to BuildLine API serializer - Adds "building" quantity to BuildLine API serializer

View File

@ -895,6 +895,11 @@ class SupplierPart(MetadataMixin, InvenTreeBarcodeMixin, common.models.MetaMixin
self.availability_updated = datetime.now() self.availability_updated = datetime.now()
self.save() self.save()
@property
def name(self):
"""Return string representation of own name."""
return str(self)
@property @property
def manufacturer_string(self): def manufacturer_string(self):
"""Format a MPN string for this SupplierPart. """Format a MPN string for this SupplierPart.

View File

@ -309,6 +309,7 @@ class SupplierPartSerializer(InvenTreeTagModelSerializer):
'manufacturer_part', 'manufacturer_part',
'manufacturer_part_detail', 'manufacturer_part_detail',
'MPN', 'MPN',
'name',
'note', 'note',
'pk', 'pk',
'barcode_hash', 'barcode_hash',
@ -395,6 +396,8 @@ class SupplierPartSerializer(InvenTreeTagModelSerializer):
source='manufacturer_part', part_detail=False, read_only=True source='manufacturer_part', part_detail=False, read_only=True
) )
name = serializers.CharField(read_only=True)
url = serializers.CharField(source='get_absolute_url', read_only=True) url = serializers.CharField(source='get_absolute_url', read_only=True)
# Date fields # Date fields

View File

@ -442,6 +442,15 @@ class PartThumbs(ListAPI):
queryset.values('image').annotate(count=Count('image')).order_by('-count') queryset.values('image').annotate(count=Count('image')).order_by('-count')
) )
page = self.paginate_queryset(data)
if page is not None:
serializer = self.get_serializer(page, many=True)
else:
serializer = self.get_serializer(data, many=True)
data = serializer.data
return Response(data) return Response(data)
filter_backends = [InvenTreeSearchFilter] filter_backends = [InvenTreeSearchFilter]

View File

@ -169,6 +169,26 @@ def annotate_build_order_allocations(reference: str = ''):
) )
def annotate_sales_order_requirements(reference: str = ''):
"""Annotate the total quantity of each part required for sales orders.
- Only interested in 'active' sales orders
- We are looking for any order lines which requires this part
- We are interested in 'quantity'-'shipped'
"""
# Order filter only returns incomplete shipments for open orders
order_filter = Q(order__status__in=SalesOrderStatusGroups.OPEN)
return Coalesce(
SubquerySum(f'{reference}sales_order_line_items__quantity', filter=order_filter)
- SubquerySum(
f'{reference}sales_order_line_items__shipped', filter=order_filter
),
Decimal(0),
output_field=models.DecimalField(),
)
def annotate_sales_order_allocations(reference: str = ''): def annotate_sales_order_allocations(reference: str = ''):
"""Annotate the total quantity of each part allocated to sales orders. """Annotate the total quantity of each part allocated to sales orders.

View File

@ -538,6 +538,7 @@ class PartSerializer(
'category_path', 'category_path',
'component', 'component',
'creation_date', 'creation_date',
'creation_user',
'default_expiry', 'default_expiry',
'default_location', 'default_location',
'default_supplier', 'default_supplier',
@ -575,6 +576,7 @@ class PartSerializer(
'in_stock', 'in_stock',
'ordering', 'ordering',
'required_for_build_orders', 'required_for_build_orders',
'required_for_sales_orders',
'stock_item_count', 'stock_item_count',
'suppliers', 'suppliers',
'total_in_stock', 'total_in_stock',
@ -715,7 +717,8 @@ class PartSerializer(
# Annotate with the total 'required for builds' quantity # Annotate with the total 'required for builds' quantity
queryset = queryset.annotate( queryset = queryset.annotate(
required_for_build_orders=part.filters.annotate_build_order_requirements() required_for_build_orders=part.filters.annotate_build_order_requirements(),
required_for_sales_orders=part.filters.annotate_sales_order_requirements(),
) )
return queryset return queryset
@ -738,6 +741,10 @@ class PartSerializer(
source='responsible_owner', source='responsible_owner',
) )
creation_user = serializers.PrimaryKeyRelatedField(
queryset=users.models.User.objects.all(), required=False, allow_null=True
)
# Annotated fields # Annotated fields
allocated_to_build_orders = serializers.FloatField(read_only=True) allocated_to_build_orders = serializers.FloatField(read_only=True)
allocated_to_sales_orders = serializers.FloatField(read_only=True) allocated_to_sales_orders = serializers.FloatField(read_only=True)
@ -745,6 +752,7 @@ class PartSerializer(
in_stock = serializers.FloatField(read_only=True) in_stock = serializers.FloatField(read_only=True)
ordering = serializers.FloatField(read_only=True) ordering = serializers.FloatField(read_only=True)
required_for_build_orders = serializers.IntegerField(read_only=True) required_for_build_orders = serializers.IntegerField(read_only=True)
required_for_sales_orders = serializers.IntegerField(read_only=True)
stock_item_count = serializers.IntegerField(read_only=True) stock_item_count = serializers.IntegerField(read_only=True)
suppliers = serializers.IntegerField(read_only=True) suppliers = serializers.IntegerField(read_only=True)
total_in_stock = serializers.FloatField(read_only=True) total_in_stock = serializers.FloatField(read_only=True)

View File

@ -812,7 +812,7 @@ class Owner(models.Model):
self.owner_type.name == 'user' self.owner_type.name == 'user'
and common_models.InvenTreeSetting.get_setting('DISPLAY_FULL_NAMES') and common_models.InvenTreeSetting.get_setting('DISPLAY_FULL_NAMES')
): ):
return self.owner.get_full_name() return self.owner.get_full_name() or str(self.owner)
return str(self.owner) return str(self.owner)
def label(self): def label(self):

View File

@ -1,4 +1,5 @@
import { ActionIcon, Group, Tooltip } from '@mantine/core'; import { ActionIcon, Group, Tooltip } from '@mantine/core';
import { FloatingPosition } from '@mantine/core/lib/Floating';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { notYetImplemented } from '../../functions/notifications'; import { notYetImplemented } from '../../functions/notifications';
@ -10,10 +11,11 @@ export type ActionButtonProps = {
color?: string; color?: string;
tooltip?: string; tooltip?: string;
variant?: string; variant?: string;
size?: number; size?: number | string;
disabled?: boolean; disabled?: boolean;
onClick?: any; onClick?: any;
hidden?: boolean; hidden?: boolean;
tooltipAlignment?: FloatingPosition;
}; };
/** /**
@ -28,7 +30,7 @@ export function ActionButton(props: ActionButtonProps) {
key={`tooltip-${props.key}`} key={`tooltip-${props.key}`}
disabled={!props.tooltip && !props.text} disabled={!props.tooltip && !props.text}
label={props.tooltip ?? props.text} label={props.tooltip ?? props.text}
position="left" position={props.tooltipAlignment ?? 'left'}
> >
<ActionIcon <ActionIcon
key={`action-icon-${props.key}`} key={`action-icon-${props.key}`}
@ -37,6 +39,7 @@ export function ActionButton(props: ActionButtonProps) {
color={props.color} color={props.color}
size={props.size} size={props.size}
onClick={props.onClick ?? notYetImplemented} onClick={props.onClick ?? notYetImplemented}
variant={props.variant}
> >
<Group spacing="xs" noWrap={true}> <Group spacing="xs" noWrap={true}>
{props.icon} {props.icon}

View File

@ -0,0 +1,358 @@
import { Trans, t } from '@lingui/macro';
import {
Button,
Group,
Image,
Modal,
Paper,
Text,
rem,
useMantineTheme
} from '@mantine/core';
import { Dropzone, FileWithPath, IMAGE_MIME_TYPE } from '@mantine/dropzone';
import { useDisclosure, useHover } from '@mantine/hooks';
import { modals } from '@mantine/modals';
import { useState } from 'react';
import { api } from '../../App';
import { UserRoles } from '../../enums/Roles';
import { InvenTreeIcon } from '../../functions/icons';
import { useUserState } from '../../states/UserState';
import { ActionButton } from '../buttons/ActionButton';
import { PartThumbTable } from '../tables/part/PartThumbTable';
import { ApiImage } from './ApiImage';
/**
* Props for detail image
*/
export type DetailImageProps = {
appRole: UserRoles;
src: string;
apiPath: string;
refresh: () => void;
imageActions?: DetailImageButtonProps;
pk: string;
};
/**
* Actions for Detail Images.
* If true, the button type will be visible
* @param {boolean} selectExisting - PART ONLY. Allows selecting existing images as part image
* @param {boolean} uploadFile - Allows uploading a new image
* @param {boolean} deleteFile - Allows deleting the current image
*/
export type DetailImageButtonProps = {
selectExisting?: boolean;
uploadFile?: boolean;
deleteFile?: boolean;
};
// Image is expected to be 1:1 square, so only 1 dimension is needed
const IMAGE_DIMENSION = 256;
// Image to display if instance has no image
const backup_image = '/static/img/blank_image.png';
/**
* Modal used for removing/deleting the current image relation
*/
const removeModal = (apiPath: string, setImage: (image: string) => void) =>
modals.openConfirmModal({
title: t`Remove Image`,
children: (
<Text size="sm">
<Trans>Remove the associated image from this item?</Trans>
</Text>
),
labels: { confirm: t`Remove`, cancel: t`Cancel` },
onConfirm: async () => {
await api.patch(apiPath, { image: null });
setImage(backup_image);
}
});
/**
* Modal used for uploading a new image
*/
function UploadModal({
apiPath,
setImage
}: {
apiPath: string;
setImage: (image: string) => void;
}) {
const [file1, setFile] = useState<FileWithPath | null>(null);
let uploading = false;
const theme = useMantineTheme();
// Components to show in the Dropzone when no file is selected
const noFileIdle = (
<Group>
<InvenTreeIcon icon="photo" iconProps={{ size: '3.2rem', stroke: 1.5 }} />
<div>
<Text size="xl" inline>
<Trans>Drag and drop to upload</Trans>
</Text>
<Text size="sm" color="dimmed" inline mt={7}>
<Trans>Click to select file(s)</Trans>
</Text>
</div>
</Group>
);
/**
* Generates components to display selected image in Dropzone
*/
const fileInfo = (file: FileWithPath) => {
const imageUrl = URL.createObjectURL(file);
const size = file.size / 1024 ** 2;
return (
<div
style={{
display: 'flex',
flexDirection: 'row',
gap: '15px',
flexGrow: '1'
}}
>
<Image
src={imageUrl}
imageProps={{ onLoad: () => URL.revokeObjectURL(imageUrl) }}
radius="sm"
height={75}
fit="contain"
style={{ flexBasis: '40%' }}
/>
<div style={{ flexBasis: '60%' }}>
<Text size="xl" inline style={{ wordBreak: 'break-all' }}>
{file.name}
</Text>
<Text size="sm" color="dimmed" inline mt={7}>
{size.toFixed(2)} MB
</Text>
</div>
</div>
);
};
/**
* Create FormData object and upload selected image
*/
const uploadImage = async (file: FileWithPath | null) => {
if (!file) {
return;
}
uploading = true;
const formData = new FormData();
formData.append('image', file, file.name);
const response = await api.patch(apiPath, formData);
if (response.data.image.includes(file.name)) {
setImage(response.data.image);
modals.closeAll();
}
};
const primaryColor =
theme.colors[theme.primaryColor][theme.colorScheme === 'dark' ? 4 : 6];
const redColor = theme.colors.red[theme.colorScheme === 'dark' ? 4 : 6];
return (
<Paper sx={{ height: '220px' }}>
<Dropzone
onDrop={(files) => setFile(files[0])}
maxFiles={1}
accept={IMAGE_MIME_TYPE}
loading={uploading}
>
<Group
position="center"
spacing="xl"
style={{ minHeight: rem(140), pointerEvents: 'none' }}
>
<Dropzone.Accept>
<InvenTreeIcon
icon="upload"
iconProps={{
size: '3.2rem',
stroke: 1.5,
color: primaryColor
}}
/>
</Dropzone.Accept>
<Dropzone.Reject>
<InvenTreeIcon
icon="reject"
iconProps={{
size: '3.2rem',
stroke: 1.5,
color: redColor
}}
/>
</Dropzone.Reject>
<Dropzone.Idle>{file1 ? fileInfo(file1) : noFileIdle}</Dropzone.Idle>
</Group>
</Dropzone>
<Paper
style={{
position: 'sticky',
bottom: '0',
left: '0',
right: '0',
height: '60px',
zIndex: 1,
display: 'flex',
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'flex-end',
gap: '10px'
}}
>
<Button
variant="outline"
disabled={!file1}
onClick={() => setFile(null)}
>
<Trans>Clear</Trans>
</Button>
<Button disabled={!file1} onClick={() => uploadImage(file1)}>
<Trans>Submit</Trans>
</Button>
</Paper>
</Paper>
);
}
/**
* Generate components for Action buttons used with the Details Image
*/
function ImageActionButtons({
actions = {},
visible,
apiPath,
hasImage,
pk,
setImage
}: {
actions?: DetailImageButtonProps;
visible: boolean;
apiPath: string;
hasImage: boolean;
pk: string;
setImage: (image: string) => void;
}) {
const [opened, { open, close }] = useDisclosure(false);
return (
<>
<Modal opened={opened} onClose={close} title={t`Select image`} size="70%">
<PartThumbTable pk={pk} close={close} setImage={setImage} />
</Modal>
{visible && (
<Group
spacing="xs"
style={{ zIndex: 2, position: 'absolute', top: '10px', left: '10px' }}
>
{actions.selectExisting && (
<ActionButton
icon={<InvenTreeIcon icon="select_image" />}
tooltip={t`Select from existing images`}
variant="outline"
size="lg"
tooltipAlignment="top"
onClick={open}
/>
)}
{actions.uploadFile && (
<ActionButton
icon={<InvenTreeIcon icon="upload" />}
tooltip={t`Upload new image`}
variant="outline"
size="lg"
tooltipAlignment="top"
onClick={() => {
modals.open({
title: t`Upload Image`,
children: (
<UploadModal apiPath={apiPath} setImage={setImage} />
)
});
}}
/>
)}
{actions.deleteFile && hasImage && (
<ActionButton
icon={
<InvenTreeIcon icon="delete" iconProps={{ color: 'red' }} />
}
tooltip={t`Delete image`}
variant="outline"
size="lg"
tooltipAlignment="top"
onClick={() => removeModal(apiPath, setImage)}
/>
)}
</Group>
)}
</>
);
}
/**
* Renders an image with action buttons for display on Details panels
*/
export function DetailsImage(props: DetailImageProps) {
// Displays a group of ActionButtons on hover
const { hovered, ref } = useHover();
const [img, setImg] = useState<string>(props.src ?? backup_image);
// Sets a new image, and triggers upstream instance refresh
const setAndRefresh = (image: string) => {
setImg(image);
props.refresh();
};
const permissions = useUserState();
return (
<>
<Paper
ref={ref}
style={{
position: 'relative',
width: `${IMAGE_DIMENSION}px`,
display: 'flex',
justifyContent: 'center',
alignItems: 'center'
}}
>
<ApiImage
src={img}
style={{ zIndex: 1 }}
height={IMAGE_DIMENSION}
width={IMAGE_DIMENSION}
onClick={() => {
modals.open({
children: <ApiImage src={img} />,
withCloseButton: false
});
}}
/>
{permissions.hasChangeRole(props.appRole) && (
<ImageActionButtons
visible={hovered}
actions={props.imageActions}
apiPath={props.apiPath}
hasImage={props.src ? true : false}
pk={props.pk}
setImage={setAndRefresh}
/>
)}
</Paper>
</>
);
}

View File

@ -13,17 +13,19 @@ export function Thumbnail({
src, src,
alt = t`Thumbnail`, alt = t`Thumbnail`,
size = 20, size = 20,
text text,
align
}: { }: {
src?: string | undefined; src?: string | undefined;
alt?: string; alt?: string;
size?: number; size?: number;
text?: ReactNode; text?: ReactNode;
align?: string;
}) { }) {
const backup_image = '/static/img/blank_image.png'; const backup_image = '/static/img/blank_image.png';
return ( return (
<Group align="left" spacing="xs" noWrap={true}> <Group align={align ?? 'left'} spacing="xs" noWrap={true}>
<ApiImage <ApiImage
src={src || backup_image} src={src || backup_image}
alt={alt} alt={alt}

View File

@ -22,7 +22,7 @@ export function ProgressBar(props: ProgressBarProps) {
}, [props]); }, [props]);
return ( return (
<Stack spacing={2}> <Stack spacing={2} style={{ flexGrow: 1, minWidth: '100px' }}>
{props.progressLabel && ( {props.progressLabel && (
<Text align="center" size="xs"> <Text align="center" size="xs">
{props.value} / {props.maximum} {props.value} / {props.maximum}

View File

@ -122,6 +122,7 @@ function BasePanelGroup({
<Tabs.Tab <Tabs.Tab
p="xs" p="xs"
value={panel.name} value={panel.name}
// icon={(<InvenTreeIcon icon={panel.name}/>)} // Enable when implementing Icon manager everywhere
icon={panel.icon} icon={panel.icon}
hidden={panel.hidden} hidden={panel.hidden}
> >

View File

@ -0,0 +1,470 @@
import { Trans, t } from '@lingui/macro';
import {
ActionIcon,
Anchor,
Badge,
CopyButton,
Group,
Skeleton,
Table,
Text,
Tooltip
} from '@mantine/core';
import { useSuspenseQuery } from '@tanstack/react-query';
import { Suspense } from 'react';
import { api } from '../../App';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { InvenTreeIcon } from '../../functions/icons';
import { apiUrl } from '../../states/ApiState';
import { useGlobalSettingsState } from '../../states/SettingsState';
import { ProgressBar } from '../items/ProgressBar';
export type PartIconsType = {
assembly: boolean;
template: boolean;
component: boolean;
trackable: boolean;
purchaseable: boolean;
saleable: boolean;
virtual: boolean;
active: boolean;
};
export type DetailsField =
| {
name: string;
label?: string;
badge?: BadgeType;
copy?: boolean;
value_formatter?: () => ValueFormatterReturn;
} & (StringDetailField | LinkDetailField | ProgressBarfield);
type BadgeType = 'owner' | 'user' | 'group';
type ValueFormatterReturn = string | number | null;
type StringDetailField = {
type: 'string' | 'text';
unit?: boolean;
};
type LinkDetailField = {
type: 'link';
} & (InternalLinkField | ExternalLinkField);
type InternalLinkField = {
path: ApiEndpoints;
dest: string;
};
type ExternalLinkField = {
external: true;
};
type ProgressBarfield = {
type: 'progressbar';
progress: number;
total: number;
};
type FieldValueType = string | number | undefined;
type FieldProps = {
field_data: any;
field_value: string | number;
unit?: string | null;
};
/**
* Fetches and wraps an InvenTreeIcon in a flex div
* @param icon name of icon
*
*/
function PartIcon(icon: string) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
<InvenTreeIcon icon={icon} />
</div>
);
}
/**
* Generates a table cell with Part icons.
* Only used for Part Model Details
*/
function PartIcons({
assembly,
template,
component,
trackable,
purchaseable,
saleable,
virtual,
active
}: PartIconsType) {
return (
<td colSpan={2}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
{!active && (
<Tooltip label={t`Part is not active`}>
<Badge color="red" variant="filled">
<div
style={{ display: 'flex', alignItems: 'center', gap: '5px' }}
>
<InvenTreeIcon icon="inactive" iconProps={{ size: 19 }} />{' '}
<Trans>Inactive</Trans>
</div>
</Badge>
</Tooltip>
)}
{template && (
<Tooltip
label={t`Part is a template part (variants can be made from this part)`}
children={PartIcon('template')}
/>
)}
{assembly && (
<Tooltip
label={t`Part can be assembled from other parts`}
children={PartIcon('assembly')}
/>
)}
{component && (
<Tooltip
label={t`Part can be used in assemblies`}
children={PartIcon('component')}
/>
)}
{trackable && (
<Tooltip
label={t`Part stock is tracked by serial number`}
children={PartIcon('trackable')}
/>
)}
{purchaseable && (
<Tooltip
label={t`Part can be purchased from external suppliers`}
children={PartIcon('purchaseable')}
/>
)}
{saleable && (
<Tooltip
label={t`Part can be sold to customers`}
children={PartIcon('saleable')}
/>
)}
{virtual && (
<Tooltip label={t`Part is virtual (not a physical part)`}>
<Badge color="yellow" variant="filled">
<div
style={{ display: 'flex', alignItems: 'center', gap: '5px' }}
>
<InvenTreeIcon icon="virtual" iconProps={{ size: 18 }} />{' '}
<Trans>Virtual</Trans>
</div>
</Badge>
</Tooltip>
)}
</div>
</td>
);
}
/**
* Fetches user or group info from backend and formats into a badge.
* Badge shows username, full name, or group name depending on server settings.
* Badge appends icon to describe type of Owner
*/
function NameBadge({ pk, type }: { pk: string | number; type: BadgeType }) {
const { data } = useSuspenseQuery({
queryKey: ['badge', type, pk],
queryFn: async () => {
let path: string = '';
switch (type) {
case 'owner':
path = ApiEndpoints.owner_list;
break;
case 'user':
path = ApiEndpoints.user_list;
break;
case 'group':
path = ApiEndpoints.group_list;
break;
}
const url = apiUrl(path, pk);
return api
.get(url)
.then((response) => {
switch (response.status) {
case 200:
return response.data;
default:
return null;
}
})
.catch(() => {
return null;
});
}
});
const settings = useGlobalSettingsState();
// Rendering a user's rame for the badge
function _render_name() {
if (type === 'user' && settings.isSet('DISPLAY_FULL_NAMES')) {
if (data.first_name || data.last_name) {
return `${data.first_name} ${data.last_name}`;
} else {
return data.username;
}
} else if (type === 'user') {
return data.username;
} else {
return data.name;
}
}
return (
<Suspense fallback={<Skeleton width={200} height={20} radius="xl" />}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<Badge
color="dark"
variant="filled"
style={{ display: 'flex', alignItems: 'center' }}
>
{data.name ?? _render_name()}
</Badge>
<InvenTreeIcon icon={type === 'user' ? type : data.label} />
</div>
</Suspense>
);
}
/**
* Renders the value of a 'string' or 'text' field.
* If owner is defined, only renders a badge
* If user is defined, a badge is rendered in addition to main value
*/
function TableStringValue(props: FieldProps) {
let value = props.field_value;
if (props.field_data.value_formatter) {
value = props.field_data.value_formatter();
}
if (props.field_data.badge) {
return <NameBadge pk={value} type={props.field_data.badge} />;
}
return (
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Suspense fallback={<Skeleton width={200} height={20} radius="xl" />}>
<span>
{value ? value : props.field_data.unit && '0'}{' '}
{props.field_data.unit == true && props.unit}
</span>
</Suspense>
{props.field_data.user && (
<NameBadge pk={props.field_data.user} type="user" />
)}
</div>
);
}
function TableAnchorValue(props: FieldProps) {
if (props.field_data.external) {
return (
<Anchor
href={`${props.field_value}`}
target={'_blank'}
rel={'noreferrer noopener'}
>
<span style={{ display: 'flex', alignItems: 'center', gap: '3px' }}>
<Text>{props.field_value}</Text>
<InvenTreeIcon icon="external" iconProps={{ size: 15 }} />
</span>
</Anchor>
);
}
const { data } = useSuspenseQuery({
queryKey: ['detail', props.field_data.path],
queryFn: async () => {
const url = apiUrl(props.field_data.path, props.field_value);
return api
.get(url)
.then((response) => {
switch (response.status) {
case 200:
return response.data;
default:
return null;
}
})
.catch(() => {
return null;
});
}
});
return (
<Suspense fallback={<Skeleton width={200} height={20} radius="xl" />}>
<Anchor
href={
'/platform' + data.url ?? props.field_data.dest + props.field_value
}
target={data.external ? '_blank' : undefined}
rel={data.external ? 'noreferrer noopener' : undefined}
>
<Text>{data.name ?? 'No name defined'}</Text>
</Anchor>
</Suspense>
);
}
function ProgressBarValue(props: FieldProps) {
return (
<ProgressBar
value={props.field_data.progress}
maximum={props.field_data.total}
progressLabel
/>
);
}
function CopyField({ value }: { value: string }) {
return (
<CopyButton value={value}>
{({ copied, copy }) => (
<Tooltip label={copied ? t`Copied` : t`Copy`} withArrow>
<ActionIcon color={copied ? 'teal' : 'gray'} onClick={copy}>
{copied ? (
<InvenTreeIcon icon="check" />
) : (
<InvenTreeIcon icon="copy" />
)}
</ActionIcon>
</Tooltip>
)}
</CopyButton>
);
}
function TableField({
field_data,
field_value,
unit = null
}: {
field_data: DetailsField[];
field_value: FieldValueType[];
unit?: string | null;
}) {
function getFieldType(type: string) {
switch (type) {
case 'text':
case 'string':
return TableStringValue;
case 'link':
return TableAnchorValue;
case 'progressbar':
return ProgressBarValue;
}
}
return (
<tr>
<td
style={{
display: 'flex',
alignItems: 'center',
gap: '20px',
justifyContent: 'flex-start'
}}
>
<InvenTreeIcon icon={field_data[0].name} />
<Text>{field_data[0].label}</Text>
</td>
<td style={{ minWidth: '40%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
flexGrow: '1'
}}
>
{field_data.map((data: DetailsField, index: number) => {
let FieldType: any = getFieldType(data.type);
return (
<FieldType
field_data={data}
field_value={field_value[index]}
unit={unit}
key={index}
/>
);
})}
</div>
{field_data[0].copy && <CopyField value={`${field_value[0]}`} />}
</div>
</td>
</tr>
);
}
export function DetailsTable({
item,
fields,
partIcons = false
}: {
item: any;
fields: DetailsField[][];
partIcons?: boolean;
}) {
return (
<Group>
<Table striped>
<tbody>
{partIcons && (
<tr>
<PartIcons
assembly={item.assembly}
template={item.is_template}
component={item.component}
trackable={item.trackable}
purchaseable={item.purchaseable}
saleable={item.salable}
virtual={item.virtual}
active={item.active}
/>
</tr>
)}
{fields.map((data: DetailsField[], index: number) => {
let value: FieldValueType[] = [];
for (const val of data) {
if (val.value_formatter) {
value.push(undefined);
} else {
value.push(item[val.name]);
}
}
return (
<TableField
field_data={data}
field_value={value}
key={index}
unit={item.units}
/>
);
})}
</tbody>
</Table>
</Group>
);
}

View File

@ -0,0 +1,94 @@
import { Paper } from '@mantine/core';
import { UserRoles } from '../../enums/Roles';
import { DetailImageButtonProps, DetailsImage } from '../images/DetailsImage';
import { DetailsField, DetailsTable } from './Details';
/**
* Type for defining field arrays
*/
export type ItemDetailFields = {
left: DetailsField[][];
right?: DetailsField[][];
bottom_left?: DetailsField[][];
bottom_right?: DetailsField[][];
image?: DetailsImageType;
};
/**
* Type for defining details image
*/
export type DetailsImageType = {
name: string;
imageActions: DetailImageButtonProps;
};
/**
* Render a Details panel of the given model
* @param params Object with the data of the model to render
* @param apiPath Path to use for image updating
* @param refresh useInstance refresh method to refresh when making updates
* @param fields Object with all field sections
* @param partModel set to true only if source model is Part
*/
export function ItemDetails({
appRole,
params = {},
apiPath,
refresh,
fields,
partModel = false
}: {
appRole: UserRoles;
params?: any;
apiPath: string;
refresh: () => void;
fields: ItemDetailFields;
partModel: boolean;
}) {
return (
<Paper style={{ display: 'flex', gap: '20px', flexWrap: 'wrap' }}>
<Paper
withBorder
style={{ flexBasis: '49%', display: 'flex', gap: '10px' }}
>
{fields.image && (
<div style={{ flexGrow: '0' }}>
<DetailsImage
appRole={appRole}
imageActions={fields.image.imageActions}
src={params.image}
apiPath={apiPath}
refresh={refresh}
pk={params.pk}
/>
</div>
)}
{fields.left && (
<div style={{ flexGrow: '1' }}>
<DetailsTable
item={params}
fields={fields.left}
partIcons={partModel}
/>
</div>
)}
</Paper>
{fields.right && (
<Paper style={{ flexBasis: '49%' }} withBorder>
<DetailsTable item={params} fields={fields.right} />
</Paper>
)}
{fields.bottom_left && (
<Paper style={{ flexBasis: '49%' }} withBorder>
<DetailsTable item={params} fields={fields.bottom_left} />
</Paper>
)}
{fields.bottom_right && (
<Paper style={{ flexBasis: '49%' }} withBorder>
<DetailsTable item={params} fields={fields.bottom_right} />
</Paper>
)}
</Paper>
);
}

View File

@ -0,0 +1,216 @@
import { t } from '@lingui/macro';
import { Button, Paper, Skeleton, Text, TextInput } from '@mantine/core';
import { useHover } from '@mantine/hooks';
import { useQuery } from '@tanstack/react-query';
import React, { Suspense, useEffect, useState } from 'react';
import { api } from '../../../App';
import { ApiEndpoints } from '../../../enums/ApiEndpoints';
import { apiUrl } from '../../../states/ApiState';
import { Thumbnail } from '../../images/Thumbnail';
/**
* Input props to table
*/
export type ThumbTableProps = {
pk: string;
limit?: number;
offset?: number;
search?: string;
close: () => void;
setImage: (image: string) => void;
};
/**
* Data per image returned from API
*/
type ImageElement = {
image: string;
count: number;
};
/**
* Input props for each thumbnail in the table
*/
type ThumbProps = {
selected: string | null;
element: ImageElement;
selectImage: React.Dispatch<React.SetStateAction<string | null>>;
};
/**
* Renders a single image thumbnail
*/
function PartThumbComponent({ selected, element, selectImage }: ThumbProps) {
const { hovered, ref } = useHover();
const hoverColor = 'rgba(127,127,127,0.2)';
const selectedColor = 'rgba(127,127,127,0.29)';
let color = '';
if (selected === element?.image) {
color = selectedColor;
} else if (hovered) {
color = hoverColor;
}
const src: string | undefined = element?.image
? `/media/${element?.image}`
: undefined;
return (
<Paper
withBorder
style={{
backgroundColor: color,
padding: '5px',
display: 'flex',
flex: '0 1 150px',
flexFlow: 'column wrap',
placeContent: 'center space-between'
}}
ref={ref}
onClick={() => selectImage(element.image)}
>
<div
style={{
display: 'flex',
alignItems: 'center',
flexGrow: 1
}}
>
<Thumbnail size={120} src={src} align="center"></Thumbnail>
</div>
<Text style={{ alignSelf: 'center', overflowWrap: 'anywhere' }}>
{element.image.split('/')[1]} ({element.count})
</Text>
</Paper>
);
}
/**
* Changes a part's image to the supplied URL and updates the DOM accordingly
*/
async function setNewImage(
image: string | null,
pk: string,
close: () => void,
setImage: (image: string) => void
) {
// No need to do anything if no image is selected
if (image === null) {
return;
}
const response = await api.patch(apiUrl(ApiEndpoints.part_list, pk), {
existing_image: image
});
// Update image component and close modal if update was successful
if (response.data.image.includes(image)) {
setImage(response.data.image);
close();
}
}
/**
* Renders a "table" of thumbnails
*/
export function PartThumbTable({
limit = 25,
offset = 0,
search = '',
pk,
close,
setImage
}: ThumbTableProps) {
const [img, selectImage] = useState<string | null>(null);
const [filterInput, setFilterInput] = useState<string>('');
const [filterQuery, setFilter] = useState<string>(search);
// Keep search filters from updating while user is typing
useEffect(() => {
const timeoutId = setTimeout(() => setFilter(filterInput), 500);
return () => clearTimeout(timeoutId);
}, [filterInput]);
// Fetch thumbnails from API
const thumbQuery = useQuery({
queryKey: [
ApiEndpoints.part_thumbs_list,
{ limit: limit, offset: offset, search: filterQuery }
],
queryFn: async () => {
return api.get(ApiEndpoints.part_thumbs_list, {
params: {
offset: offset,
limit: limit,
search: filterQuery
}
});
}
});
return (
<>
<Suspense>
<Paper
style={{
display: 'flex',
alignItems: 'stretch',
placeContent: 'stretch center',
flexWrap: 'wrap',
gap: '10px'
}}
>
{!thumbQuery.isFetching
? thumbQuery.data?.data.map((data: ImageElement, index: number) => (
<PartThumbComponent
element={data}
key={index}
selected={img}
selectImage={selectImage}
/>
))
: [...Array(limit)].map((elem, idx) => (
<Skeleton
height={150}
width={150}
radius="sm"
key={idx}
style={{ padding: '5px' }}
/>
))}
</Paper>
</Suspense>
<Paper
style={{
position: 'sticky',
bottom: 0,
left: 0,
right: 0,
height: '60px',
zIndex: 1,
display: 'flex',
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'space-between'
}}
>
<TextInput
placeholder={t`Search...`}
onChange={(event) => {
setFilterInput(event.currentTarget.value);
}}
/>
<Button
disabled={!img}
onClick={() => setNewImage(img, pk, close, setImage)}
>
Submit
</Button>
</Paper>
</>
);
}

View File

@ -52,6 +52,9 @@ export enum ApiEndpoints {
part_list = 'part/', part_list = 'part/',
part_parameter_list = 'part/parameter/', part_parameter_list = 'part/parameter/',
part_parameter_template_list = 'part/parameter/template/', part_parameter_template_list = 'part/parameter/template/',
part_thumbs_list = 'part/thumbs/',
part_pricing_get = 'part/:id/pricing/',
part_stocktake_list = 'part/stocktake/',
category_list = 'part/category/', category_list = 'part/category/',
category_tree = 'part/category/tree/', category_tree = 'part/category/tree/',
related_part_list = 'part/related/', related_part_list = 'part/related/',

View File

@ -0,0 +1,140 @@
import {
Icon123,
IconBinaryTree2,
IconBookmarks,
IconBuilding,
IconBuildingFactory2,
IconCalendarStats,
IconCheck,
IconClipboardList,
IconCopy,
IconCornerUpRightDouble,
IconCurrencyDollar,
IconExternalLink,
IconFileUpload,
IconGitBranch,
IconGridDots,
IconLayersLinked,
IconLink,
IconList,
IconListTree,
IconMapPinHeart,
IconNotes,
IconPackage,
IconPackages,
IconPaperclip,
IconPhoto,
IconQuestionMark,
IconRulerMeasure,
IconShoppingCart,
IconShoppingCartHeart,
IconStack2,
IconStatusChange,
IconTag,
IconTestPipe,
IconTool,
IconTools,
IconTrash,
IconTruck,
IconTruckDelivery,
IconUser,
IconUserStar,
IconUsersGroup,
IconVersions,
IconWorldCode,
IconX
} from '@tabler/icons-react';
import { IconFlag } from '@tabler/icons-react';
import { IconInfoCircle } from '@tabler/icons-react';
import { IconCalendarTime } from '@tabler/icons-react';
import { TablerIconsProps } from '@tabler/icons-react';
import React from 'react';
const icons: { [key: string]: (props: TablerIconsProps) => React.JSX.Element } =
{
description: IconInfoCircle,
variant_of: IconStatusChange,
unallocated_stock: IconPackage,
total_in_stock: IconPackages,
minimum_stock: IconFlag,
allocated_to_build_orders: IconTool,
allocated_to_sales_orders: IconTruck,
can_build: IconTools,
ordering: IconShoppingCart,
building: IconTool,
category: IconBinaryTree2,
IPN: Icon123,
revision: IconGitBranch,
units: IconRulerMeasure,
keywords: IconTag,
details: IconInfoCircle,
parameters: IconList,
stock: IconPackages,
variants: IconVersions,
allocations: IconBookmarks,
bom: IconListTree,
builds: IconTools,
used_in: IconStack2,
manufacturers: IconBuildingFactory2,
suppliers: IconBuilding,
purchase_orders: IconShoppingCart,
sales_orders: IconTruckDelivery,
scheduling: IconCalendarStats,
test_templates: IconTestPipe,
related_parts: IconLayersLinked,
attachments: IconPaperclip,
notes: IconNotes,
photo: IconPhoto,
upload: IconFileUpload,
reject: IconX,
select_image: IconGridDots,
delete: IconTrash,
// Part Icons
template: IconCopy,
assembly: IconTool,
component: IconGridDots,
trackable: IconCornerUpRightDouble,
purchaseable: IconShoppingCart,
saleable: IconCurrencyDollar,
virtual: IconWorldCode,
inactive: IconX,
external: IconExternalLink,
creation_date: IconCalendarTime,
default_location: IconMapPinHeart,
default_supplier: IconShoppingCartHeart,
link: IconLink,
responsible: IconUserStar,
pricing: IconCurrencyDollar,
stocktake: IconClipboardList,
user: IconUser,
group: IconUsersGroup,
check: IconCheck,
copy: IconCopy
};
/**
* Returns a Tabler Icon for the model field name supplied
* @param field string defining field name
*/
export function GetIcon(field: keyof typeof icons) {
return icons[field];
}
type IconProps = {
icon: string;
iconProps?: TablerIconsProps;
};
export function InvenTreeIcon(props: IconProps) {
let Icon: (props: TablerIconsProps) => React.JSX.Element;
if (props.icon in icons) {
Icon = GetIcon(props.icon);
} else {
Icon = IconQuestionMark;
}
return <Icon {...props.iconProps} />;
}

View File

@ -23,9 +23,11 @@ import {
IconTruckDelivery, IconTruckDelivery,
IconVersions IconVersions
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useSuspenseQuery } from '@tanstack/react-query';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { api } from '../../App';
import { import {
ActionDropdown, ActionDropdown,
BarcodeActionDropdown, BarcodeActionDropdown,
@ -39,6 +41,12 @@ import {
import { PageDetail } from '../../components/nav/PageDetail'; import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup'; import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { PartCategoryTree } from '../../components/nav/PartCategoryTree'; import { PartCategoryTree } from '../../components/nav/PartCategoryTree';
import { DetailsField } from '../../components/tables/Details';
import {
DetailsImageType,
ItemDetailFields,
ItemDetails
} from '../../components/tables/ItemDetails';
import { BomTable } from '../../components/tables/bom/BomTable'; import { BomTable } from '../../components/tables/bom/BomTable';
import { UsedInTable } from '../../components/tables/bom/UsedInTable'; import { UsedInTable } from '../../components/tables/bom/UsedInTable';
import { BuildOrderTable } from '../../components/tables/build/BuildOrderTable'; import { BuildOrderTable } from '../../components/tables/build/BuildOrderTable';
@ -52,7 +60,9 @@ import { SupplierPartTable } from '../../components/tables/purchasing/SupplierPa
import { SalesOrderTable } from '../../components/tables/sales/SalesOrderTable'; import { SalesOrderTable } from '../../components/tables/sales/SalesOrderTable';
import { StockItemTable } from '../../components/tables/stock/StockItemTable'; import { StockItemTable } from '../../components/tables/stock/StockItemTable';
import { NotesEditor } from '../../components/widgets/MarkdownEditor'; import { NotesEditor } from '../../components/widgets/MarkdownEditor';
import { formatPriceRange } from '../../defaults/formatters';
import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { UserRoles } from '../../enums/Roles';
import { editPart } from '../../forms/PartForms'; import { editPart } from '../../forms/PartForms';
import { useInstance } from '../../hooks/UseInstance'; import { useInstance } from '../../hooks/UseInstance';
import { apiUrl } from '../../states/ApiState'; import { apiUrl } from '../../states/ApiState';
@ -81,13 +91,375 @@ export default function PartDetail() {
refetchOnMount: true refetchOnMount: true
}); });
const detailFields = (part: any): ItemDetailFields => {
let left: DetailsField[][] = [];
let right: DetailsField[][] = [];
let bottom_right: DetailsField[][] = [];
let bottom_left: DetailsField[][] = [];
let image: DetailsImageType = {
name: 'image',
imageActions: {
selectExisting: true,
uploadFile: true,
deleteFile: true
}
};
left.push([
{
type: 'text',
name: 'description',
label: t`Description`,
copy: true
}
]);
if (part.variant_of) {
left.push([
{
type: 'link',
name: 'variant_of',
label: t`Variant of`,
path: ApiEndpoints.part_list,
dest: '/part/'
}
]);
}
right.push([
{
type: 'string',
name: 'unallocated_stock',
unit: true,
label: t`Available Stock`
}
]);
right.push([
{
type: 'string',
name: 'total_in_stock',
unit: true,
label: t`In Stock`
}
]);
if (part.minimum_stock) {
right.push([
{
type: 'string',
name: 'minimum_stock',
unit: true,
label: t`Minimum Stock`
}
]);
}
if (part.ordering <= 0) {
right.push([
{
type: 'string',
name: 'ordering',
label: t`On order`,
unit: true
}
]);
}
if (
part.assembly &&
(part.allocated_to_build_orders > 0 || part.required_for_build_orders > 0)
) {
right.push([
{
type: 'progressbar',
name: 'allocated_to_build_orders',
total: part.required_for_build_orders,
progress: part.allocated_to_build_orders,
label: t`Allocated to Build Orders`
}
]);
}
if (
part.salable &&
(part.allocated_to_sales_orders > 0 || part.required_for_sales_orders > 0)
) {
right.push([
{
type: 'progressbar',
name: 'allocated_to_sales_orders',
total: part.required_for_sales_orders,
progress: part.allocated_to_sales_orders,
label: t`Allocated to Sales Orders`
}
]);
}
if (part.assembly) {
right.push([
{
type: 'string',
name: 'can_build',
unit: true,
label: t`Can Build`
}
]);
}
if (part.assembly) {
right.push([
{
type: 'string',
name: 'building',
unit: true,
label: t`Building`
}
]);
}
if (part.category) {
bottom_left.push([
{
type: 'link',
name: 'category',
label: t`Category`,
path: ApiEndpoints.category_list,
dest: '/part/category/'
}
]);
}
if (part.IPN) {
bottom_left.push([
{
type: 'string',
name: 'IPN',
label: t`IPN`,
copy: true
}
]);
}
if (part.revision) {
bottom_left.push([
{
type: 'string',
name: 'revision',
label: t`Revision`,
copy: true
}
]);
}
if (part.units) {
bottom_left.push([
{
type: 'string',
name: 'units',
label: t`Units`
}
]);
}
if (part.keywords) {
bottom_left.push([
{
type: 'string',
name: 'keywords',
label: t`Keywords`,
copy: true
}
]);
}
bottom_right.push([
{
type: 'string',
name: 'creation_date',
label: t`Creation Date`
},
{
type: 'string',
name: 'creation_user',
badge: 'user'
}
]);
id &&
bottom_right.push([
{
type: 'string',
name: 'pricing',
label: t`Price Range`,
value_formatter: () => {
const { data } = useSuspenseQuery({
queryKey: ['pricing', id],
queryFn: async () => {
const url = apiUrl(ApiEndpoints.part_pricing_get, null, {
id: id
});
return api
.get(url)
.then((response) => {
switch (response.status) {
case 200:
return response.data;
default:
return null;
}
})
.catch(() => {
return null;
});
}
});
return `${formatPriceRange(data.overall_min, data.overall_max)}${
part.units && ' / ' + part.units
}`;
}
}
]);
id &&
part.last_stocktake &&
bottom_right.push([
{
type: 'string',
name: 'stocktake',
label: t`Last Stocktake`,
unit: true,
value_formatter: () => {
const { data } = useSuspenseQuery({
queryKey: ['stocktake', id],
queryFn: async () => {
const url = ApiEndpoints.part_stocktake_list;
return api
.get(url, { params: { part: id, ordering: 'date' } })
.then((response) => {
switch (response.status) {
case 200:
return response.data[response.data.length - 1];
default:
return null;
}
})
.catch(() => {
return null;
});
}
});
return data.quantity;
}
},
{
type: 'string',
name: 'stocktake_user',
badge: 'user',
value_formatter: () => {
const { data } = useSuspenseQuery({
queryKey: ['stocktake', id],
queryFn: async () => {
const url = ApiEndpoints.part_stocktake_list;
return api
.get(url, { params: { part: id, ordering: 'date' } })
.then((response) => {
switch (response.status) {
case 200:
return response.data[response.data.length - 1];
default:
return null;
}
})
.catch(() => {
return null;
});
}
});
return data.user;
}
}
]);
if (part.default_location) {
bottom_right.push([
{
type: 'link',
name: 'default_location',
label: t`Default Location`,
path: ApiEndpoints.stock_location_list,
dest: '/stock/location/'
}
]);
}
if (part.default_supplier) {
bottom_right.push([
{
type: 'link',
name: 'default_supplier',
label: t`Default Supplier`,
path: ApiEndpoints.supplier_part_list,
dest: '/part/'
}
]);
}
if (part.link) {
bottom_right.push([
{
type: 'link',
name: 'link',
label: t`Link`,
external: true,
copy: true
}
]);
}
if (part.responsible) {
bottom_right.push([
{
type: 'string',
name: 'responsible',
label: t`Responsible`,
badge: 'owner'
}
]);
}
let fields: ItemDetailFields = {
left: left,
right: right,
bottom_left: bottom_left,
bottom_right: bottom_right,
image: image
};
return fields;
};
// Part data panels (recalculate when part data changes) // Part data panels (recalculate when part data changes)
const partPanels: PanelType[] = useMemo(() => { const partPanels: PanelType[] = useMemo(() => {
return [ return [
{ {
name: 'details', name: 'details',
label: t`Details`, label: t`Details`,
icon: <IconInfoCircle /> icon: <IconInfoCircle />,
content: !instanceQuery.isFetching && (
<ItemDetails
appRole={UserRoles.part}
params={part}
apiPath={apiUrl(ApiEndpoints.part_list, part.pk)}
refresh={refreshInstance}
fields={detailFields(part)}
partModel
/>
)
}, },
{ {
name: 'parameters', name: 'parameters',