mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
[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:
parent
3bfde82394
commit
fb71e847bb
@ -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
|
||||||
|
|
||||||
|
@ -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.
|
||||||
|
@ -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
|
||||||
|
@ -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]
|
||||||
|
@ -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.
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
@ -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):
|
||||||
|
@ -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}
|
||||||
|
358
src/frontend/src/components/images/DetailsImage.tsx
Normal file
358
src/frontend/src/components/images/DetailsImage.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -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}
|
||||||
|
@ -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}
|
||||||
|
@ -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}
|
||||||
>
|
>
|
||||||
|
470
src/frontend/src/components/tables/Details.tsx
Normal file
470
src/frontend/src/components/tables/Details.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
94
src/frontend/src/components/tables/ItemDetails.tsx
Normal file
94
src/frontend/src/components/tables/ItemDetails.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
216
src/frontend/src/components/tables/part/PartThumbTable.tsx
Normal file
216
src/frontend/src/components/tables/part/PartThumbTable.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -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/',
|
||||||
|
140
src/frontend/src/functions/icons.tsx
Normal file
140
src/frontend/src/functions/icons.tsx
Normal 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} />;
|
||||||
|
}
|
@ -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',
|
||||||
|
Loading…
Reference in New Issue
Block a user