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
|
||||
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."""
|
||||
|
||||
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
|
||||
- Adds "building" quantity to BuildLine API serializer
|
||||
|
||||
|
@ -895,6 +895,11 @@ class SupplierPart(MetadataMixin, InvenTreeBarcodeMixin, common.models.MetaMixin
|
||||
self.availability_updated = datetime.now()
|
||||
self.save()
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return string representation of own name."""
|
||||
return str(self)
|
||||
|
||||
@property
|
||||
def manufacturer_string(self):
|
||||
"""Format a MPN string for this SupplierPart.
|
||||
|
@ -309,6 +309,7 @@ class SupplierPartSerializer(InvenTreeTagModelSerializer):
|
||||
'manufacturer_part',
|
||||
'manufacturer_part_detail',
|
||||
'MPN',
|
||||
'name',
|
||||
'note',
|
||||
'pk',
|
||||
'barcode_hash',
|
||||
@ -395,6 +396,8 @@ class SupplierPartSerializer(InvenTreeTagModelSerializer):
|
||||
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)
|
||||
|
||||
# Date fields
|
||||
|
@ -442,6 +442,15 @@ class PartThumbs(ListAPI):
|
||||
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)
|
||||
|
||||
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 = ''):
|
||||
"""Annotate the total quantity of each part allocated to sales orders.
|
||||
|
||||
|
@ -538,6 +538,7 @@ class PartSerializer(
|
||||
'category_path',
|
||||
'component',
|
||||
'creation_date',
|
||||
'creation_user',
|
||||
'default_expiry',
|
||||
'default_location',
|
||||
'default_supplier',
|
||||
@ -575,6 +576,7 @@ class PartSerializer(
|
||||
'in_stock',
|
||||
'ordering',
|
||||
'required_for_build_orders',
|
||||
'required_for_sales_orders',
|
||||
'stock_item_count',
|
||||
'suppliers',
|
||||
'total_in_stock',
|
||||
@ -715,7 +717,8 @@ class PartSerializer(
|
||||
|
||||
# Annotate with the total 'required for builds' quantity
|
||||
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
|
||||
@ -738,6 +741,10 @@ class PartSerializer(
|
||||
source='responsible_owner',
|
||||
)
|
||||
|
||||
creation_user = serializers.PrimaryKeyRelatedField(
|
||||
queryset=users.models.User.objects.all(), required=False, allow_null=True
|
||||
)
|
||||
|
||||
# Annotated fields
|
||||
allocated_to_build_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)
|
||||
ordering = serializers.FloatField(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)
|
||||
suppliers = serializers.IntegerField(read_only=True)
|
||||
total_in_stock = serializers.FloatField(read_only=True)
|
||||
|
@ -812,7 +812,7 @@ class Owner(models.Model):
|
||||
self.owner_type.name == 'user'
|
||||
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)
|
||||
|
||||
def label(self):
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { ActionIcon, Group, Tooltip } from '@mantine/core';
|
||||
import { FloatingPosition } from '@mantine/core/lib/Floating';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { notYetImplemented } from '../../functions/notifications';
|
||||
@ -10,10 +11,11 @@ export type ActionButtonProps = {
|
||||
color?: string;
|
||||
tooltip?: string;
|
||||
variant?: string;
|
||||
size?: number;
|
||||
size?: number | string;
|
||||
disabled?: boolean;
|
||||
onClick?: any;
|
||||
hidden?: boolean;
|
||||
tooltipAlignment?: FloatingPosition;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -28,7 +30,7 @@ export function ActionButton(props: ActionButtonProps) {
|
||||
key={`tooltip-${props.key}`}
|
||||
disabled={!props.tooltip && !props.text}
|
||||
label={props.tooltip ?? props.text}
|
||||
position="left"
|
||||
position={props.tooltipAlignment ?? 'left'}
|
||||
>
|
||||
<ActionIcon
|
||||
key={`action-icon-${props.key}`}
|
||||
@ -37,6 +39,7 @@ export function ActionButton(props: ActionButtonProps) {
|
||||
color={props.color}
|
||||
size={props.size}
|
||||
onClick={props.onClick ?? notYetImplemented}
|
||||
variant={props.variant}
|
||||
>
|
||||
<Group spacing="xs" noWrap={true}>
|
||||
{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,
|
||||
alt = t`Thumbnail`,
|
||||
size = 20,
|
||||
text
|
||||
text,
|
||||
align
|
||||
}: {
|
||||
src?: string | undefined;
|
||||
alt?: string;
|
||||
size?: number;
|
||||
text?: ReactNode;
|
||||
align?: string;
|
||||
}) {
|
||||
const backup_image = '/static/img/blank_image.png';
|
||||
|
||||
return (
|
||||
<Group align="left" spacing="xs" noWrap={true}>
|
||||
<Group align={align ?? 'left'} spacing="xs" noWrap={true}>
|
||||
<ApiImage
|
||||
src={src || backup_image}
|
||||
alt={alt}
|
||||
|
@ -22,7 +22,7 @@ export function ProgressBar(props: ProgressBarProps) {
|
||||
}, [props]);
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Stack spacing={2} style={{ flexGrow: 1, minWidth: '100px' }}>
|
||||
{props.progressLabel && (
|
||||
<Text align="center" size="xs">
|
||||
{props.value} / {props.maximum}
|
||||
|
@ -122,6 +122,7 @@ function BasePanelGroup({
|
||||
<Tabs.Tab
|
||||
p="xs"
|
||||
value={panel.name}
|
||||
// icon={(<InvenTreeIcon icon={panel.name}/>)} // Enable when implementing Icon manager everywhere
|
||||
icon={panel.icon}
|
||||
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_parameter_list = 'part/parameter/',
|
||||
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_tree = 'part/category/tree/',
|
||||
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,
|
||||
IconVersions
|
||||
} from '@tabler/icons-react';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { api } from '../../App';
|
||||
import {
|
||||
ActionDropdown,
|
||||
BarcodeActionDropdown,
|
||||
@ -39,6 +41,12 @@ import {
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
|
||||
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 { UsedInTable } from '../../components/tables/bom/UsedInTable';
|
||||
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 { StockItemTable } from '../../components/tables/stock/StockItemTable';
|
||||
import { NotesEditor } from '../../components/widgets/MarkdownEditor';
|
||||
import { formatPriceRange } from '../../defaults/formatters';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
import { editPart } from '../../forms/PartForms';
|
||||
import { useInstance } from '../../hooks/UseInstance';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
@ -81,13 +91,375 @@ export default function PartDetail() {
|
||||
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)
|
||||
const partPanels: PanelType[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
name: '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',
|
||||
|
Loading…
Reference in New Issue
Block a user