diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index 50068a827e..cfba12accd 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -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 diff --git a/InvenTree/company/models.py b/InvenTree/company/models.py index c1db51cf50..20c923f60d 100644 --- a/InvenTree/company/models.py +++ b/InvenTree/company/models.py @@ -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. diff --git a/InvenTree/company/serializers.py b/InvenTree/company/serializers.py index 8370d22510..ad6202e83a 100644 --- a/InvenTree/company/serializers.py +++ b/InvenTree/company/serializers.py @@ -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 diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index b819ffee73..4770d50627 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -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] diff --git a/InvenTree/part/filters.py b/InvenTree/part/filters.py index 9eb22624e9..15fb2afc8a 100644 --- a/InvenTree/part/filters.py +++ b/InvenTree/part/filters.py @@ -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. diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index b42dd89607..c26d60c2df 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -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) diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py index abea06e7d2..7e5e348f77 100644 --- a/InvenTree/users/models.py +++ b/InvenTree/users/models.py @@ -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): diff --git a/src/frontend/src/components/buttons/ActionButton.tsx b/src/frontend/src/components/buttons/ActionButton.tsx index 148c0f2102..2a948f39d6 100644 --- a/src/frontend/src/components/buttons/ActionButton.tsx +++ b/src/frontend/src/components/buttons/ActionButton.tsx @@ -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'} > {props.icon} diff --git a/src/frontend/src/components/images/DetailsImage.tsx b/src/frontend/src/components/images/DetailsImage.tsx new file mode 100644 index 0000000000..b446d0abbf --- /dev/null +++ b/src/frontend/src/components/images/DetailsImage.tsx @@ -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: ( + + Remove the associated image from this item? + + ), + 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(null); + let uploading = false; + + const theme = useMantineTheme(); + + // Components to show in the Dropzone when no file is selected + const noFileIdle = ( + + +
+ + Drag and drop to upload + + + Click to select file(s) + +
+
+ ); + + /** + * Generates components to display selected image in Dropzone + */ + const fileInfo = (file: FileWithPath) => { + const imageUrl = URL.createObjectURL(file); + const size = file.size / 1024 ** 2; + + return ( +
+ URL.revokeObjectURL(imageUrl) }} + radius="sm" + height={75} + fit="contain" + style={{ flexBasis: '40%' }} + /> +
+ + {file.name} + + + {size.toFixed(2)} MB + +
+
+ ); + }; + + /** + * 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 ( + + setFile(files[0])} + maxFiles={1} + accept={IMAGE_MIME_TYPE} + loading={uploading} + > + + + + + + + + {file1 ? fileInfo(file1) : noFileIdle} + + + + + + + + ); +} + +/** + * 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 ( + <> + + + + {visible && ( + + {actions.selectExisting && ( + } + tooltip={t`Select from existing images`} + variant="outline" + size="lg" + tooltipAlignment="top" + onClick={open} + /> + )} + {actions.uploadFile && ( + } + tooltip={t`Upload new image`} + variant="outline" + size="lg" + tooltipAlignment="top" + onClick={() => { + modals.open({ + title: t`Upload Image`, + children: ( + + ) + }); + }} + /> + )} + {actions.deleteFile && hasImage && ( + + } + tooltip={t`Delete image`} + variant="outline" + size="lg" + tooltipAlignment="top" + onClick={() => removeModal(apiPath, setImage)} + /> + )} + + )} + + ); +} + +/** + * 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(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 ( + <> + + { + modals.open({ + children: , + withCloseButton: false + }); + }} + /> + {permissions.hasChangeRole(props.appRole) && ( + + )} + + + ); +} diff --git a/src/frontend/src/components/images/Thumbnail.tsx b/src/frontend/src/components/images/Thumbnail.tsx index 2a53494529..0a7d926af8 100644 --- a/src/frontend/src/components/images/Thumbnail.tsx +++ b/src/frontend/src/components/images/Thumbnail.tsx @@ -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 ( - + + {props.progressLabel && ( {props.value} / {props.maximum} diff --git a/src/frontend/src/components/nav/PanelGroup.tsx b/src/frontend/src/components/nav/PanelGroup.tsx index a56d9bb5ef..dcc38e6c39 100644 --- a/src/frontend/src/components/nav/PanelGroup.tsx +++ b/src/frontend/src/components/nav/PanelGroup.tsx @@ -122,6 +122,7 @@ function BasePanelGroup({ )} // Enable when implementing Icon manager everywhere icon={panel.icon} hidden={panel.hidden} > diff --git a/src/frontend/src/components/tables/Details.tsx b/src/frontend/src/components/tables/Details.tsx new file mode 100644 index 0000000000..374550e0a8 --- /dev/null +++ b/src/frontend/src/components/tables/Details.tsx @@ -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 ( +
+ +
+ ); +} + +/** + * 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 ( + +
+ {!active && ( + + +
+ {' '} + Inactive +
+
+
+ )} + {template && ( + + )} + {assembly && ( + + )} + {component && ( + + )} + {trackable && ( + + )} + {purchaseable && ( + + )} + {saleable && ( + + )} + {virtual && ( + + +
+ {' '} + Virtual +
+
+
+ )} +
+ + ); +} + +/** + * 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 ( + }> +
+ + {data.name ?? _render_name()} + + +
+
+ ); +} + +/** + * 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 ; + } + + return ( +
+ }> + + {value ? value : props.field_data.unit && '0'}{' '} + {props.field_data.unit == true && props.unit} + + + {props.field_data.user && ( + + )} +
+ ); +} + +function TableAnchorValue(props: FieldProps) { + if (props.field_data.external) { + return ( + + + {props.field_value} + + + + ); + } + + 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 ( + }> + + {data.name ?? 'No name defined'} + + + ); +} + +function ProgressBarValue(props: FieldProps) { + return ( + + ); +} + +function CopyField({ value }: { value: string }) { + return ( + + {({ copied, copy }) => ( + + + {copied ? ( + + ) : ( + + )} + + + )} + + ); +} + +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 ( + + + + {field_data[0].label} + + +
+
+ {field_data.map((data: DetailsField, index: number) => { + let FieldType: any = getFieldType(data.type); + return ( + + ); + })} +
+ {field_data[0].copy && } +
+ + + ); +} + +export function DetailsTable({ + item, + fields, + partIcons = false +}: { + item: any; + fields: DetailsField[][]; + partIcons?: boolean; +}) { + return ( + + + + {partIcons && ( + + + + )} + {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 ( + + ); + })} + +
+
+ ); +} diff --git a/src/frontend/src/components/tables/ItemDetails.tsx b/src/frontend/src/components/tables/ItemDetails.tsx new file mode 100644 index 0000000000..eb2f24454b --- /dev/null +++ b/src/frontend/src/components/tables/ItemDetails.tsx @@ -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 ( + + + {fields.image && ( +
+ +
+ )} + {fields.left && ( +
+ +
+ )} +
+ {fields.right && ( + + + + )} + {fields.bottom_left && ( + + + + )} + {fields.bottom_right && ( + + + + )} +
+ ); +} diff --git a/src/frontend/src/components/tables/part/PartThumbTable.tsx b/src/frontend/src/components/tables/part/PartThumbTable.tsx new file mode 100644 index 0000000000..3248684b12 --- /dev/null +++ b/src/frontend/src/components/tables/part/PartThumbTable.tsx @@ -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>; +}; + +/** + * 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 ( + selectImage(element.image)} + > +
+ +
+ + {element.image.split('/')[1]} ({element.count}) + +
+ ); +} + +/** + * 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(null); + const [filterInput, setFilterInput] = useState(''); + const [filterQuery, setFilter] = useState(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 ( + <> + + + {!thumbQuery.isFetching + ? thumbQuery.data?.data.map((data: ImageElement, index: number) => ( + + )) + : [...Array(limit)].map((elem, idx) => ( + + ))} + + + + { + setFilterInput(event.currentTarget.value); + }} + /> + + + + ); +} diff --git a/src/frontend/src/enums/ApiEndpoints.tsx b/src/frontend/src/enums/ApiEndpoints.tsx index fd466a6a90..764f20f506 100644 --- a/src/frontend/src/enums/ApiEndpoints.tsx +++ b/src/frontend/src/enums/ApiEndpoints.tsx @@ -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/', diff --git a/src/frontend/src/functions/icons.tsx b/src/frontend/src/functions/icons.tsx new file mode 100644 index 0000000000..e0774beed9 --- /dev/null +++ b/src/frontend/src/functions/icons.tsx @@ -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 ; +} diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index ba7c9d4ddc..50fbff4dbc 100644 --- a/src/frontend/src/pages/part/PartDetail.tsx +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -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: + icon: , + content: !instanceQuery.isFetching && ( + + ) }, { name: 'parameters',