From 69871699c0d997c7b0ce0892fe10865563b94214 Mon Sep 17 00:00:00 2001 From: Oliver <oliver.henry.walters@gmail.com> Date: Fri, 1 Mar 2024 17:13:08 +1100 Subject: [PATCH] Details updates (#6605) * Fix onClick behaviour for details image * Moving items - These are not "tables" per-se * Refactoring for DetailsTable * Skip hidden fields * Cleanup table column widths * Update part details * Fix icons * Add image back to part details - Also fix onClick events * Update stockitem details page * Implement details page for build order * Implement CompanyDetails page * Implemented salesorder details * Update SalesOrder detalis * ReturnOrder detail * PurchaseOrder detail page * Cleanup build details page * Stock location detail * Part Category detail * Bump API version * Bug fixes * Use image, not thumbnail * Fix field copy * Cleanup imgae hover * Improve PartDetail - Add more data - Add icons * Refactoring - Move Details out of "tables" directory * Remove old file * Revert "Remove old file" This reverts commit 6fd131f2a597963f28434d665f6f24c90d909357. * Fix files * Fix unused import --- InvenTree/InvenTree/api_version.py | 8 +- InvenTree/company/serializers.py | 6 +- InvenTree/part/serializers.py | 12 +- InvenTree/stock/serializers.py | 10 +- .../details}/Details.tsx | 297 +++----- .../{images => details}/DetailsImage.tsx | 80 ++- .../src/components/details/ItemDetails.tsx | 14 + .../src/components/details/PartIcons.tsx | 90 +++ .../src/components/render/StatusRenderer.tsx | 4 +- src/frontend/src/functions/icons.tsx | 35 +- src/frontend/src/functions/urls.tsx | 8 +- src/frontend/src/pages/build/BuildDetail.tsx | 157 ++++- .../src/pages/company/CompanyDetail.tsx | 94 ++- .../src/pages/part/CategoryDetail.tsx | 85 ++- src/frontend/src/pages/part/PartDetail.tsx | 639 +++++++++--------- .../pages/purchasing/PurchaseOrderDetail.tsx | 182 ++++- .../src/pages/sales/ReturnOrderDetail.tsx | 165 ++++- .../src/pages/sales/SalesOrderDetail.tsx | 153 ++++- .../src/pages/stock/LocationDetail.tsx | 89 ++- src/frontend/src/pages/stock/StockDetail.tsx | 161 ++++- src/frontend/src/tables/ItemDetails.tsx | 88 --- 21 files changed, 1691 insertions(+), 686 deletions(-) rename src/frontend/src/{tables => components/details}/Details.tsx (53%) rename src/frontend/src/components/{images => details}/DetailsImage.tsx (82%) create mode 100644 src/frontend/src/components/details/ItemDetails.tsx create mode 100644 src/frontend/src/components/details/PartIcons.tsx delete mode 100644 src/frontend/src/tables/ItemDetails.tsx diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index 48dc8df775..c99f1d2faf 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -1,11 +1,17 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 178 +INVENTREE_API_VERSION = 179 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v179 - 2024-03-01 : https://github.com/inventree/InvenTree/pull/6605 + - Adds "subcategories" count to PartCategory serializer + - Adds "sublocations" count to StockLocation serializer + - Adds "image" field to PartBrief serializer + - Adds "image" field to CompanyBrief serializer + v178 - 2024-02-29 : https://github.com/inventree/InvenTree/pull/6604 - Adds "external_stock" field to the Part API endpoint - Adds "external_stock" field to the BomItem API endpoint diff --git a/InvenTree/company/serializers.py b/InvenTree/company/serializers.py index ad6202e83a..f94333aca8 100644 --- a/InvenTree/company/serializers.py +++ b/InvenTree/company/serializers.py @@ -42,11 +42,13 @@ class CompanyBriefSerializer(InvenTreeModelSerializer): """Metaclass options.""" model = Company - fields = ['pk', 'url', 'name', 'description', 'image'] + fields = ['pk', 'url', 'name', 'description', 'image', 'thumbnail'] url = serializers.CharField(source='get_absolute_url', read_only=True) - image = serializers.CharField(source='get_thumbnail_url', read_only=True) + image = InvenTreeImageSerializerField(read_only=True) + + thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True) class AddressSerializer(InvenTreeModelSerializer): diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index c09504853c..38e2b7157d 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -74,6 +74,7 @@ class CategorySerializer(InvenTree.serializers.InvenTreeModelSerializer): 'level', 'parent', 'part_count', + 'subcategories', 'pathstring', 'path', 'starred', @@ -99,13 +100,18 @@ class CategorySerializer(InvenTree.serializers.InvenTreeModelSerializer): def annotate_queryset(queryset): """Annotate extra information to the queryset.""" # Annotate the number of 'parts' which exist in each category (including subcategories!) - queryset = queryset.annotate(part_count=part.filters.annotate_category_parts()) + queryset = queryset.annotate( + part_count=part.filters.annotate_category_parts(), + subcategories=part.filters.annotate_sub_categories(), + ) return queryset url = serializers.CharField(source='get_absolute_url', read_only=True) - part_count = serializers.IntegerField(read_only=True) + part_count = serializers.IntegerField(read_only=True, label=_('Parts')) + + subcategories = serializers.IntegerField(read_only=True, label=_('Subcategories')) level = serializers.IntegerField(read_only=True) @@ -282,6 +288,7 @@ class PartBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer): 'revision', 'full_name', 'description', + 'image', 'thumbnail', 'active', 'assembly', @@ -307,6 +314,7 @@ class PartBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer): self.fields.pop('pricing_min') self.fields.pop('pricing_max') + image = InvenTree.serializers.InvenTreeImageSerializerField(read_only=True) thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True) # Pricing fields diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index cae8460a41..88ed8e05a2 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -886,6 +886,7 @@ class LocationSerializer(InvenTree.serializers.InvenTreeTagModelSerializer): 'pathstring', 'path', 'items', + 'sublocations', 'owner', 'icon', 'custom_icon', @@ -911,13 +912,18 @@ class LocationSerializer(InvenTree.serializers.InvenTreeTagModelSerializer): def annotate_queryset(queryset): """Annotate extra information to the queryset.""" # Annotate the number of stock items which exist in this category (including subcategories) - queryset = queryset.annotate(items=stock.filters.annotate_location_items()) + queryset = queryset.annotate( + items=stock.filters.annotate_location_items(), + sublocations=stock.filters.annotate_sub_locations(), + ) return queryset url = serializers.CharField(source='get_absolute_url', read_only=True) - items = serializers.IntegerField(read_only=True) + items = serializers.IntegerField(read_only=True, label=_('Stock Items')) + + sublocations = serializers.IntegerField(read_only=True, label=_('Sublocations')) level = serializers.IntegerField(read_only=True) diff --git a/src/frontend/src/tables/Details.tsx b/src/frontend/src/components/details/Details.tsx similarity index 53% rename from src/frontend/src/tables/Details.tsx rename to src/frontend/src/components/details/Details.tsx index b062635f5f..02de921a19 100644 --- a/src/frontend/src/tables/Details.tsx +++ b/src/frontend/src/components/details/Details.tsx @@ -14,15 +14,17 @@ import { import { useSuspenseQuery } from '@tanstack/react-query'; import { Suspense, useMemo } from 'react'; -import { api } from '../App'; -import { ProgressBar } from '../components/items/ProgressBar'; -import { getModelInfo } from '../components/render/ModelType'; -import { ApiEndpoints } from '../enums/ApiEndpoints'; -import { ModelType } from '../enums/ModelType'; -import { InvenTreeIcon } from '../functions/icons'; -import { getDetailUrl } from '../functions/urls'; -import { apiUrl } from '../states/ApiState'; -import { useGlobalSettingsState } from '../states/SettingsState'; +import { api } from '../../App'; +import { ApiEndpoints } from '../../enums/ApiEndpoints'; +import { ModelType } from '../../enums/ModelType'; +import { InvenTreeIcon } from '../../functions/icons'; +import { getDetailUrl } from '../../functions/urls'; +import { apiUrl } from '../../states/ApiState'; +import { useGlobalSettingsState } from '../../states/SettingsState'; +import { ProgressBar } from '../items/ProgressBar'; +import { YesNoButton } from '../items/YesNoButton'; +import { getModelInfo } from '../render/ModelType'; +import { StatusRenderer } from '../render/StatusRenderer'; export type PartIconsType = { assembly: boolean; @@ -37,12 +39,20 @@ export type PartIconsType = { export type DetailsField = | { + hidden?: boolean; + icon?: string; name: string; label?: string; badge?: BadgeType; copy?: boolean; value_formatter?: () => ValueFormatterReturn; - } & (StringDetailField | LinkDetailField | ProgressBarfield); + } & ( + | StringDetailField + | BooleanField + | LinkDetailField + | ProgressBarfield + | StatusField + ); type BadgeType = 'owner' | 'user' | 'group'; type ValueFormatterReturn = string | number | null; @@ -52,12 +62,20 @@ type StringDetailField = { unit?: boolean; }; +type BooleanField = { + type: 'boolean'; +}; + type LinkDetailField = { type: 'link'; + link?: boolean; } & (InternalLinkField | ExternalLinkField); type InternalLinkField = { model: ModelType; + model_field?: string; + model_formatter?: (value: any) => string; + backup_value?: string; }; type ExternalLinkField = { @@ -70,6 +88,11 @@ type ProgressBarfield = { total: number; }; +type StatusField = { + type: 'status'; + model: ModelType; +}; + type FieldValueType = string | number | undefined; type FieldProps = { @@ -78,101 +101,6 @@ type FieldProps = { 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. @@ -253,13 +181,17 @@ function NameBadge({ pk, type }: { pk: string | number; type: BadgeType }) { * If user is defined, a badge is rendered in addition to main value */ function TableStringValue(props: FieldProps) { - let value = props.field_value; + let value = props?.field_value; - if (props.field_data.value_formatter) { + if (value === undefined) { + return '---'; + } + + if (props.field_data?.value_formatter) { value = props.field_data.value_formatter(); } - if (props.field_data.badge) { + if (props.field_data?.badge) { return <NameBadge pk={value} type={props.field_data.badge} />; } @@ -267,17 +199,21 @@ function TableStringValue(props: FieldProps) { <div style={{ display: 'flex', justifyContent: 'space-between' }}> <Suspense fallback={<Skeleton width={200} height={20} radius="xl" />}> <span> - {value ? value : props.field_data.unit && '0'}{' '} + {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" /> + <NameBadge pk={props.field_data?.user} type="user" /> )} </div> ); } +function BooleanValue(props: FieldProps) { + return <YesNoButton value={props.field_value} />; +} + function TableAnchorValue(props: FieldProps) { if (props.field_data.external) { return ( @@ -299,7 +235,7 @@ function TableAnchorValue(props: FieldProps) { queryFn: async () => { const modelDef = getModelInfo(props.field_data.model); - if (!modelDef.api_endpoint) { + if (!modelDef?.api_endpoint) { return {}; } @@ -325,15 +261,37 @@ function TableAnchorValue(props: FieldProps) { return getDetailUrl(props.field_data.model, props.field_value); }, [props.field_data.model, props.field_value]); + let make_link = props.field_data?.link ?? true; + + // Construct the "return value" for the fetched data + let value = undefined; + + if (props.field_data.model_formatter) { + value = props.field_data.model_formatter(data) ?? value; + } else if (props.field_data.model_field) { + value = data?.[props.field_data.model_field] ?? value; + } else { + value = data?.name; + } + + if (value === undefined) { + value = data?.name ?? props.field_data?.backup_value ?? 'No name defined'; + make_link = false; + } + return ( <Suspense fallback={<Skeleton width={200} height={20} radius="xl" />}> - <Anchor - href={`/platform${detailUrl}`} - target={data?.external ? '_blank' : undefined} - rel={data?.external ? 'noreferrer noopener' : undefined} - > - <Text>{data.name ?? 'No name defined'}</Text> - </Anchor> + {make_link ? ( + <Anchor + href={`/platform${detailUrl}`} + target={data?.external ? '_blank' : undefined} + rel={data?.external ? 'noreferrer noopener' : undefined} + > + <Text>{value}</Text> + </Anchor> + ) : ( + <Text>{value}</Text> + )} </Suspense> ); } @@ -348,6 +306,12 @@ function ProgressBarValue(props: FieldProps) { ); } +function StatusValue(props: FieldProps) { + return ( + <StatusRenderer type={props.field_data.model} status={props.field_value} /> + ); +} + function CopyField({ value }: { value: string }) { return ( <CopyButton value={value}> @@ -366,27 +330,33 @@ function CopyField({ value }: { value: string }) { ); } -function TableField({ - field_data, - field_value, - unit = null +export function DetailsTableField({ + item, + field }: { - field_data: DetailsField[]; - field_value: FieldValueType[]; - unit?: string | null; + item: any; + field: DetailsField; }) { function getFieldType(type: string) { switch (type) { case 'text': case 'string': return TableStringValue; + case 'boolean': + return BooleanValue; case 'link': return TableAnchorValue; case 'progressbar': return ProgressBarValue; + case 'status': + return StatusValue; + default: + return TableStringValue; } } + const FieldType: any = getFieldType(field.type); + return ( <tr> <td @@ -394,35 +364,20 @@ function TableField({ display: 'flex', alignItems: 'center', gap: '20px', + width: '50', justifyContent: 'flex-start' }} > - <InvenTreeIcon icon={field_data[0].name} /> - <Text>{field_data[0].label}</Text> + <InvenTreeIcon icon={field.icon ?? field.name} /> + </td> + <td> + <Text>{field.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> + <FieldType field_data={field} field_value={item[field.name]} /> + </td> + <td style={{ width: '50' }}> + {field.copy && <CopyField value={item[field.name]} />} </td> </tr> ); @@ -430,50 +385,20 @@ function TableField({ export function DetailsTable({ item, - fields, - partIcons = false + fields }: { item: any; - fields: DetailsField[][]; - partIcons?: boolean; + fields: DetailsField[]; }) { return ( <Paper p="xs" withBorder radius="xs"> <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} - /> - ); - })} + {fields + .filter((field: DetailsField) => !field.hidden) + .map((field: DetailsField, index: number) => ( + <DetailsTableField field={field} item={item} key={index} /> + ))} </tbody> </Table> </Paper> diff --git a/src/frontend/src/components/images/DetailsImage.tsx b/src/frontend/src/components/details/DetailsImage.tsx similarity index 82% rename from src/frontend/src/components/images/DetailsImage.tsx rename to src/frontend/src/components/details/DetailsImage.tsx index 4eda7d0809..6aed0729b9 100644 --- a/src/frontend/src/components/images/DetailsImage.tsx +++ b/src/frontend/src/components/details/DetailsImage.tsx @@ -4,7 +4,6 @@ import { Button, Group, Image, - Modal, Overlay, Paper, Text, @@ -12,9 +11,9 @@ import { useMantineTheme } from '@mantine/core'; import { Dropzone, FileWithPath, IMAGE_MIME_TYPE } from '@mantine/dropzone'; -import { useDisclosure, useHover } from '@mantine/hooks'; +import { useHover } from '@mantine/hooks'; import { modals } from '@mantine/modals'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { api } from '../../App'; import { UserRoles } from '../../enums/Roles'; @@ -22,8 +21,8 @@ import { InvenTreeIcon } from '../../functions/icons'; import { useUserState } from '../../states/UserState'; import { PartThumbTable } from '../../tables/part/PartThumbTable'; import { ActionButton } from '../buttons/ActionButton'; +import { ApiImage } from '../images/ApiImage'; import { StylishText } from '../items/StylishText'; -import { ApiImage } from './ApiImage'; /** * Props for detail image @@ -32,7 +31,7 @@ export type DetailImageProps = { appRole: UserRoles; src: string; apiPath: string; - refresh: () => void; + refresh?: () => void; imageActions?: DetailImageButtonProps; pk: string; }; @@ -267,7 +266,10 @@ function ImageActionButtons({ variant="outline" size="lg" tooltipAlignment="top" - onClick={() => { + onClick={(event: any) => { + event?.preventDefault(); + event?.stopPropagation(); + event?.nativeEvent?.stopImmediatePropagation(); modals.open({ title: <StylishText size="xl">{t`Select Image`}</StylishText>, size: 'xxl', @@ -285,7 +287,10 @@ function ImageActionButtons({ variant="outline" size="lg" tooltipAlignment="top" - onClick={() => { + onClick={(event: any) => { + event?.preventDefault(); + event?.stopPropagation(); + event?.nativeEvent?.stopImmediatePropagation(); modals.open({ title: <StylishText size="xl">{t`Upload Image`}</StylishText>, children: ( @@ -304,7 +309,12 @@ function ImageActionButtons({ variant="outline" size="lg" tooltipAlignment="top" - onClick={() => removeModal(apiPath, setImage)} + onClick={(event: any) => { + event?.preventDefault(); + event?.stopPropagation(); + event?.nativeEvent?.stopImmediatePropagation(); + removeModal(apiPath, setImage); + }} /> )} </Group> @@ -324,11 +334,30 @@ export function DetailsImage(props: DetailImageProps) { // Sets a new image, and triggers upstream instance refresh const setAndRefresh = (image: string) => { setImg(image); - props.refresh(); + props.refresh && props.refresh(); }; const permissions = useUserState(); + const hasOverlay: boolean = useMemo(() => { + return ( + props.imageActions?.selectExisting || + props.imageActions?.uploadFile || + props.imageActions?.deleteFile || + false + ); + }, [props.imageActions]); + + const expandImage = (event: any) => { + event?.preventDefault(); + event?.stopPropagation(); + event?.nativeEvent?.stopImmediatePropagation(); + modals.open({ + children: <ApiImage src={img} />, + withCloseButton: false + }); + }; + return ( <> <AspectRatio ref={ref} maw={IMAGE_DIMENSION} ratio={1}> @@ -337,25 +366,22 @@ export function DetailsImage(props: DetailImageProps) { src={img} height={IMAGE_DIMENSION} width={IMAGE_DIMENSION} - onClick={() => { - modals.open({ - children: <ApiImage src={img} />, - withCloseButton: false - }); - }} + onClick={expandImage} /> - {permissions.hasChangeRole(props.appRole) && hovered && ( - <Overlay color="black" opacity={0.8}> - <ImageActionButtons - visible={hovered} - actions={props.imageActions} - apiPath={props.apiPath} - hasImage={props.src ? true : false} - pk={props.pk} - setImage={setAndRefresh} - /> - </Overlay> - )} + {permissions.hasChangeRole(props.appRole) && + hasOverlay && + hovered && ( + <Overlay color="black" opacity={0.8} onClick={expandImage}> + <ImageActionButtons + visible={hovered} + actions={props.imageActions} + apiPath={props.apiPath} + hasImage={props.src ? true : false} + pk={props.pk} + setImage={setAndRefresh} + /> + </Overlay> + )} </> </AspectRatio> </> diff --git a/src/frontend/src/components/details/ItemDetails.tsx b/src/frontend/src/components/details/ItemDetails.tsx new file mode 100644 index 0000000000..d8e1069d2a --- /dev/null +++ b/src/frontend/src/components/details/ItemDetails.tsx @@ -0,0 +1,14 @@ +import { Paper, SimpleGrid } from '@mantine/core'; +import React from 'react'; + +import { DetailImageButtonProps } from './DetailsImage'; + +export function ItemDetailsGrid(props: React.PropsWithChildren<{}>) { + return ( + <Paper p="xs"> + <SimpleGrid cols={2} spacing="xs" verticalSpacing="xs"> + {props.children} + </SimpleGrid> + </Paper> + ); +} diff --git a/src/frontend/src/components/details/PartIcons.tsx b/src/frontend/src/components/details/PartIcons.tsx new file mode 100644 index 0000000000..ccaf7bef73 --- /dev/null +++ b/src/frontend/src/components/details/PartIcons.tsx @@ -0,0 +1,90 @@ +import { Trans, t } from '@lingui/macro'; +import { Badge, Tooltip } from '@mantine/core'; + +import { InvenTreeIcon } from '../../functions/icons'; + +/** + * 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 + */ +export function PartIcons({ part }: { part: any }) { + return ( + <td colSpan={2}> + <div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}> + {!part.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> + )} + {part.template && ( + <Tooltip + label={t`Part is a template part (variants can be made from this part)`} + children={PartIcon('template')} + /> + )} + {part.assembly && ( + <Tooltip + label={t`Part can be assembled from other parts`} + children={PartIcon('assembly')} + /> + )} + {part.component && ( + <Tooltip + label={t`Part can be used in assemblies`} + children={PartIcon('component')} + /> + )} + {part.trackable && ( + <Tooltip + label={t`Part stock is tracked by serial number`} + children={PartIcon('trackable')} + /> + )} + {part.purchaseable && ( + <Tooltip + label={t`Part can be purchased from external suppliers`} + children={PartIcon('purchaseable')} + /> + )} + {part.saleable && ( + <Tooltip + label={t`Part can be sold to customers`} + children={PartIcon('saleable')} + /> + )} + {part.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> + ); +} diff --git a/src/frontend/src/components/render/StatusRenderer.tsx b/src/frontend/src/components/render/StatusRenderer.tsx index 21bcc549ac..c737c7e905 100644 --- a/src/frontend/src/components/render/StatusRenderer.tsx +++ b/src/frontend/src/components/render/StatusRenderer.tsx @@ -22,7 +22,7 @@ interface renderStatusLabelOptionsInterface { * Generic function to render a status label */ function renderStatusLabel( - key: string, + key: string | number, codes: StatusCodeListInterface, options: renderStatusLabelOptionsInterface = {} ) { @@ -68,7 +68,7 @@ export const StatusRenderer = ({ type, options }: { - status: string; + status: string | number; type: ModelType | string; options?: renderStatusLabelOptionsInterface; }) => { diff --git a/src/frontend/src/functions/icons.tsx b/src/frontend/src/functions/icons.tsx index e0774beed9..73fac29841 100644 --- a/src/frontend/src/functions/icons.tsx +++ b/src/frontend/src/functions/icons.tsx @@ -2,32 +2,44 @@ import { Icon123, IconBinaryTree2, IconBookmarks, + IconBox, IconBuilding, IconBuildingFactory2, + IconBuildingStore, + IconCalendar, IconCalendarStats, IconCheck, IconClipboardList, IconCopy, IconCornerUpRightDouble, IconCurrencyDollar, + IconDotsCircleHorizontal, IconExternalLink, IconFileUpload, IconGitBranch, IconGridDots, + IconHash, IconLayersLinked, IconLink, IconList, IconListTree, + IconMail, + IconMapPin, IconMapPinHeart, IconNotes, + IconNumbers, IconPackage, + IconPackageImport, IconPackages, IconPaperclip, + IconPhone, IconPhoto, + IconProgressCheck, IconQuestionMark, IconRulerMeasure, IconShoppingCart, IconShoppingCartHeart, + IconSitemap, IconStack2, IconStatusChange, IconTag, @@ -41,6 +53,7 @@ import { IconUserStar, IconUsersGroup, IconVersions, + IconWorld, IconWorldCode, IconX } from '@tabler/icons-react'; @@ -67,6 +80,8 @@ const icons: { [key: string]: (props: TablerIconsProps) => React.JSX.Element } = revision: IconGitBranch, units: IconRulerMeasure, keywords: IconTag, + status: IconInfoCircle, + info: IconInfoCircle, details: IconInfoCircle, parameters: IconList, stock: IconPackages, @@ -77,8 +92,10 @@ const icons: { [key: string]: (props: TablerIconsProps) => React.JSX.Element } = used_in: IconStack2, manufacturers: IconBuildingFactory2, suppliers: IconBuilding, + customers: IconBuildingStore, purchase_orders: IconShoppingCart, sales_orders: IconTruckDelivery, + shipment: IconTruckDelivery, scheduling: IconCalendarStats, test_templates: IconTestPipe, related_parts: IconLayersLinked, @@ -91,6 +108,7 @@ const icons: { [key: string]: (props: TablerIconsProps) => React.JSX.Element } = delete: IconTrash, // Part Icons + active: IconCheck, template: IconCopy, assembly: IconTool, component: IconGridDots, @@ -99,19 +117,31 @@ const icons: { [key: string]: (props: TablerIconsProps) => React.JSX.Element } = saleable: IconCurrencyDollar, virtual: IconWorldCode, inactive: IconX, + part: IconBox, + supplier_part: IconPackageImport, + calendar: IconCalendar, external: IconExternalLink, creation_date: IconCalendarTime, + location: IconMapPin, default_location: IconMapPinHeart, default_supplier: IconShoppingCartHeart, link: IconLink, responsible: IconUserStar, pricing: IconCurrencyDollar, + currency: IconCurrencyDollar, stocktake: IconClipboardList, user: IconUser, group: IconUsersGroup, check: IconCheck, - copy: IconCopy + copy: IconCopy, + quantity: IconNumbers, + progress: IconProgressCheck, + reference: IconHash, + website: IconWorld, + email: IconMail, + phone: IconPhone, + sitemap: IconSitemap }; /** @@ -138,3 +168,6 @@ export function InvenTreeIcon(props: IconProps) { return <Icon {...props.iconProps} />; } +function IconShapes(props: TablerIconsProps): Element { + throw new Error('Function not implemented.'); +} diff --git a/src/frontend/src/functions/urls.tsx b/src/frontend/src/functions/urls.tsx index 5920439c89..55a3ae687c 100644 --- a/src/frontend/src/functions/urls.tsx +++ b/src/frontend/src/functions/urls.tsx @@ -7,10 +7,14 @@ import { ModelType } from '../enums/ModelType'; export function getDetailUrl(model: ModelType, pk: number | string): string { const modelInfo = ModelInformationDict[model]; - if (modelInfo && modelInfo.url_detail) { + if (pk === undefined || pk === null) { + return ''; + } + + if (!!pk && modelInfo && modelInfo.url_detail) { return modelInfo.url_detail.replace(':pk', pk.toString()); } - console.error(`No detail URL found for model ${model}!`); + console.error(`No detail URL found for model ${model} <${pk}>`); return ''; } diff --git a/src/frontend/src/pages/build/BuildDetail.tsx b/src/frontend/src/pages/build/BuildDetail.tsx index 5f32b55fc5..bb13247656 100644 --- a/src/frontend/src/pages/build/BuildDetail.tsx +++ b/src/frontend/src/pages/build/BuildDetail.tsx @@ -1,5 +1,5 @@ import { t } from '@lingui/macro'; -import { Group, LoadingOverlay, Skeleton, Stack, Table } from '@mantine/core'; +import { Grid, LoadingOverlay, Skeleton, Stack } from '@mantine/core'; import { IconClipboardCheck, IconClipboardList, @@ -17,6 +17,9 @@ import { import { useMemo } from 'react'; import { useParams } from 'react-router-dom'; +import { DetailsField, DetailsTable } from '../../components/details/Details'; +import { DetailsImage } from '../../components/details/DetailsImage'; +import { ItemDetailsGrid } from '../../components/details/ItemDetails'; import { ActionDropdown, DuplicateItemAction, @@ -33,6 +36,7 @@ import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ModelType } from '../../enums/ModelType'; import { UserRoles } from '../../enums/Roles'; import { buildOrderFields } from '../../forms/BuildForms'; +import { partCategoryFields } from '../../forms/PartForms'; import { useEditApiFormModal } from '../../hooks/UseForm'; import { useInstance } from '../../hooks/UseInstance'; import { apiUrl } from '../../states/ApiState'; @@ -63,36 +67,127 @@ export default function BuildDetail() { refetchOnMount: true }); - const buildDetailsPanel = useMemo(() => { + const detailsPanel = useMemo(() => { + if (instanceQuery.isFetching) { + return <Skeleton />; + } + + let tl: DetailsField[] = [ + { + type: 'link', + name: 'part', + label: t`Part`, + model: ModelType.part + }, + { + type: 'status', + name: 'status', + label: t`Status`, + model: ModelType.build + }, + { + type: 'text', + name: 'reference', + label: t`Reference` + }, + { + type: 'text', + name: 'title', + label: t`Description`, + icon: 'description' + }, + { + type: 'link', + name: 'parent', + icon: 'builds', + label: t`Parent Build`, + model_field: 'reference', + model: ModelType.build, + hidden: !build.parent + } + ]; + + let tr: DetailsField[] = [ + { + type: 'text', + name: 'quantity', + label: t`Build Quantity` + }, + { + type: 'progressbar', + name: 'completed', + icon: 'progress', + total: build.quantity, + progress: build.completed, + label: t`Completed Outputs` + }, + { + type: 'link', + name: 'sales_order', + label: t`Sales Order`, + icon: 'sales_orders', + model: ModelType.salesorder, + model_field: 'reference', + hidden: !build.sales_order + } + ]; + + let bl: DetailsField[] = [ + { + type: 'text', + name: 'issued_by', + label: t`Issued By`, + badge: 'user' + }, + { + type: 'text', + name: 'responsible', + label: t`Responsible`, + badge: 'owner', + hidden: !build.responsible + } + ]; + + let br: DetailsField[] = [ + { + type: 'link', + name: 'take_from', + icon: 'location', + model: ModelType.stocklocation, + label: t`Source Location`, + backup_value: t`Any location` + }, + { + type: 'link', + name: 'destination', + icon: 'location', + model: ModelType.stocklocation, + label: t`Destination Location`, + hidden: !build.destination + } + ]; + return ( - <Group position="apart" grow> - <Table striped> - <tbody> - <tr> - <td>{t`Base Part`}</td> - <td>{build.part_detail?.name}</td> - </tr> - <tr> - <td>{t`Quantity`}</td> - <td>{build.quantity}</td> - </tr> - <tr> - <td>{t`Build Status`}</td> - <td> - {build?.status && ( - <StatusRenderer - status={build.status} - type={ModelType.build} - /> - )} - </td> - </tr> - </tbody> - </Table> - <Table></Table> - </Group> + <ItemDetailsGrid> + <Grid> + <Grid.Col span={4}> + <DetailsImage + appRole={UserRoles.part} + apiPath={ApiEndpoints.part_list} + src={build.part_detail?.image ?? build.part_detail?.thumbnail} + pk={build.part} + /> + </Grid.Col> + <Grid.Col span={8}> + <DetailsTable fields={tl} item={build} /> + </Grid.Col> + </Grid> + <DetailsTable fields={tr} item={build} /> + <DetailsTable fields={bl} item={build} /> + <DetailsTable fields={br} item={build} /> + </ItemDetailsGrid> ); - }, [build]); + }, [build, instanceQuery]); const buildPanels: PanelType[] = useMemo(() => { return [ @@ -100,7 +195,7 @@ export default function BuildDetail() { name: 'details', label: t`Build Details`, icon: <IconInfoCircle />, - content: buildDetailsPanel + content: detailsPanel }, { name: 'allocate-stock', @@ -259,7 +354,7 @@ export default function BuildDetail() { title={build.reference} subtitle={build.title} detail={buildDetail} - imageUrl={build.part_detail?.thumbnail} + imageUrl={build.part_detail?.image ?? build.part_detail?.thumbnail} breadcrumbs={[ { name: t`Build Orders`, url: '/build' }, { name: build.reference, url: `/build/${build.pk}` } diff --git a/src/frontend/src/pages/company/CompanyDetail.tsx b/src/frontend/src/pages/company/CompanyDetail.tsx index 6728cb1caf..dbbc2957e6 100644 --- a/src/frontend/src/pages/company/CompanyDetail.tsx +++ b/src/frontend/src/pages/company/CompanyDetail.tsx @@ -1,5 +1,5 @@ import { t } from '@lingui/macro'; -import { LoadingOverlay, Skeleton, Stack } from '@mantine/core'; +import { Grid, LoadingOverlay, Skeleton, Stack } from '@mantine/core'; import { IconBuildingFactory2, IconBuildingWarehouse, @@ -18,6 +18,9 @@ import { import { useMemo } from 'react'; import { useParams } from 'react-router-dom'; +import { DetailsField, DetailsTable } from '../../components/details/Details'; +import { DetailsImage } from '../../components/details/DetailsImage'; +import { ItemDetailsGrid } from '../../components/details/ItemDetails'; import { ActionDropdown, DeleteItemAction, @@ -69,12 +72,99 @@ export default function CompanyDetail(props: CompanyDetailProps) { refetchOnMount: true }); + const detailsPanel = useMemo(() => { + if (instanceQuery.isFetching) { + return <Skeleton />; + } + + let tl: DetailsField[] = [ + { + type: 'text', + name: 'description', + label: t`Description` + }, + { + type: 'link', + name: 'website', + label: t`Website`, + external: true, + copy: true, + hidden: !company.website + }, + { + type: 'text', + name: 'phone', + label: t`Phone Number`, + copy: true, + hidden: !company.phone + }, + { + type: 'text', + name: 'email', + label: t`Email Address`, + copy: true, + hidden: !company.email + } + ]; + + let tr: DetailsField[] = [ + { + type: 'string', + name: 'currency', + label: t`Default Currency` + }, + { + type: 'boolean', + name: 'is_supplier', + label: t`Supplier`, + icon: 'suppliers' + }, + { + type: 'boolean', + name: 'is_manufacturer', + label: t`Manufacturer`, + icon: 'manufacturers' + }, + { + type: 'boolean', + name: 'is_customer', + label: t`Customer`, + icon: 'customers' + } + ]; + + return ( + <ItemDetailsGrid> + <Grid> + <Grid.Col span={4}> + <DetailsImage + appRole={UserRoles.purchase_order} + apiPath={ApiEndpoints.company_list} + src={company.image} + pk={company.pk} + refresh={refreshInstance} + imageActions={{ + uploadFile: true, + deleteFile: true + }} + /> + </Grid.Col> + <Grid.Col span={8}> + <DetailsTable item={company} fields={tl} /> + </Grid.Col> + </Grid> + <DetailsTable item={company} fields={tr} /> + </ItemDetailsGrid> + ); + }, [company, instanceQuery]); + const companyPanels: PanelType[] = useMemo(() => { return [ { name: 'details', label: t`Details`, - icon: <IconInfoCircle /> + icon: <IconInfoCircle />, + content: detailsPanel }, { name: 'manufactured-parts', diff --git a/src/frontend/src/pages/part/CategoryDetail.tsx b/src/frontend/src/pages/part/CategoryDetail.tsx index 938fe0356e..a7921e1011 100644 --- a/src/frontend/src/pages/part/CategoryDetail.tsx +++ b/src/frontend/src/pages/part/CategoryDetail.tsx @@ -1,21 +1,24 @@ import { t } from '@lingui/macro'; -import { LoadingOverlay, Stack, Text } from '@mantine/core'; +import { LoadingOverlay, Skeleton, Stack, Text } from '@mantine/core'; import { IconCategory, + IconInfoCircle, IconListDetails, IconSitemap } from '@tabler/icons-react'; import { useMemo, useState } from 'react'; import { useParams } from 'react-router-dom'; +import { DetailsField, DetailsTable } from '../../components/details/Details'; +import { ItemDetailsGrid } from '../../components/details/ItemDetails'; import { PageDetail } from '../../components/nav/PageDetail'; import { PanelGroup, PanelType } from '../../components/nav/PanelGroup'; import { PartCategoryTree } from '../../components/nav/PartCategoryTree'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; +import { ModelType } from '../../enums/ModelType'; import { useInstance } from '../../hooks/UseInstance'; import ParametricPartTable from '../../tables/part/ParametricPartTable'; import { PartCategoryTable } from '../../tables/part/PartCategoryTable'; -import { PartParameterTable } from '../../tables/part/PartParameterTable'; import { PartListTable } from '../../tables/part/PartTable'; /** @@ -45,8 +48,86 @@ export default function CategoryDetail({}: {}) { } }); + const detailsPanel = useMemo(() => { + if (id && instanceQuery.isFetching) { + return <Skeleton />; + } + + let left: DetailsField[] = [ + { + type: 'text', + name: 'name', + label: t`Name`, + copy: true + }, + { + type: 'text', + name: 'pathstring', + label: t`Path`, + icon: 'sitemap', + copy: true, + hidden: !id + }, + { + type: 'text', + name: 'description', + label: t`Description`, + copy: true + }, + { + type: 'link', + name: 'parent', + model_field: 'name', + icon: 'location', + label: t`Parent Category`, + model: ModelType.partcategory, + hidden: !category?.parent + } + ]; + + let right: DetailsField[] = [ + { + type: 'text', + name: 'part_count', + label: t`Parts`, + icon: 'part' + }, + { + type: 'text', + name: 'subcategories', + label: t`Subcategories`, + icon: 'sitemap', + hidden: !category?.subcategories + }, + { + type: 'boolean', + name: 'structural', + label: t`Structural`, + icon: 'sitemap' + } + ]; + + return ( + <ItemDetailsGrid> + {id && category?.pk ? ( + <DetailsTable item={category} fields={left} /> + ) : ( + <Text>{t`Top level part category`}</Text> + )} + {id && category?.pk && <DetailsTable item={category} fields={right} />} + </ItemDetailsGrid> + ); + }, [category, instanceQuery]); + const categoryPanels: PanelType[] = useMemo( () => [ + { + name: 'details', + label: t`Category Details`, + icon: <IconInfoCircle />, + content: detailsPanel + // hidden: !category?.pk, + }, { name: 'parts', label: t`Parts`, diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index 196425b335..dd36532e3f 100644 --- a/src/frontend/src/pages/part/PartDetail.tsx +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -1,5 +1,12 @@ import { t } from '@lingui/macro'; -import { Group, LoadingOverlay, Skeleton, Stack, Text } from '@mantine/core'; +import { + Grid, + Group, + LoadingOverlay, + Skeleton, + Stack, + Text +} from '@mantine/core'; import { IconBookmarks, IconBuilding, @@ -28,6 +35,10 @@ import { useMemo, useState } from 'react'; import { useParams } from 'react-router-dom'; import { api } from '../../App'; +import { DetailsField, DetailsTable } from '../../components/details/Details'; +import { DetailsImage } from '../../components/details/DetailsImage'; +import { ItemDetailsGrid } from '../../components/details/ItemDetails'; +import { PartIcons } from '../../components/details/PartIcons'; import { ActionDropdown, BarcodeActionDropdown, @@ -51,12 +62,6 @@ import { useEditApiFormModal } from '../../hooks/UseForm'; import { useInstance } from '../../hooks/UseInstance'; import { apiUrl } from '../../states/ApiState'; import { useUserState } from '../../states/UserState'; -import { DetailsField } from '../../tables/Details'; -import { - DetailsImageType, - ItemDetailFields, - ItemDetails -} from '../../tables/ItemDetails'; import { BomTable } from '../../tables/bom/BomTable'; import { UsedInTable } from '../../tables/bom/UsedInTable'; import { BuildOrderTable } from '../../tables/build/BuildOrderTable'; @@ -93,188 +98,186 @@ 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[][] = []; + const detailsPanel = useMemo(() => { + if (instanceQuery.isFetching) { + return <Skeleton />; + } - let image: DetailsImageType = { - name: 'image', - imageActions: { - selectExisting: true, - uploadFile: true, - deleteFile: true - } - }; - - left.push([ + // Construct the details tables + let tl: DetailsField[] = [ { type: 'text', name: 'description', label: t`Description`, copy: true + }, + { + type: 'link', + name: 'variant_of', + label: t`Variant of`, + model: ModelType.part, + hidden: !part.variant_of + }, + { + type: 'link', + name: 'category', + label: t`Category`, + model: ModelType.partcategory + }, + { + type: 'link', + name: 'default_location', + label: t`Default Location`, + model: ModelType.stocklocation, + hidden: !part.default_location + }, + { + type: 'string', + name: 'IPN', + label: t`IPN`, + copy: true, + hidden: !part.IPN + }, + { + type: 'string', + name: 'revision', + label: t`Revision`, + copy: true, + hidden: !part.revision + }, + { + type: 'string', + name: 'units', + label: t`Units`, + copy: true, + hidden: !part.units + }, + { + type: 'string', + name: 'keywords', + label: t`Keywords`, + copy: true, + hidden: !part.keywords + }, + { + type: 'link', + name: 'link', + label: t`Link`, + external: true, + copy: true, + hidden: !part.link } - ]); + ]; - if (part.variant_of) { - left.push([ - { - type: 'link', - name: 'variant_of', - label: t`Variant of`, - model: ModelType.part - } - ]); - } - - right.push([ + let tr: DetailsField[] = [ { 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` + }, + { + type: 'string', + name: 'minimum_stock', + unit: true, + label: t`Minimum Stock`, + hidden: part.minimum_stock <= 0 + }, + { + type: 'string', + name: 'ordering', + label: t`On order`, + unit: true, + hidden: part.ordering <= 0 + }, + { + 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`, + hidden: + !part.assembly || + (part.allocated_to_build_orders <= 0 && + part.required_for_build_orders <= 0) + }, + { + 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`, + hidden: + !part.salable || + (part.allocated_to_sales_orders <= 0 && + part.required_for_sales_orders <= 0) + }, + { + type: 'string', + name: 'can_build', + unit: true, + label: t`Can Build`, + hidden: !part.assembly + }, + { + type: 'string', + name: 'building', + unit: true, + label: t`Building`, + hidden: !part.assembly } - ]); + ]; - if (part.minimum_stock) { - right.push([ - { - type: 'string', - name: 'minimum_stock', - unit: true, - label: t`Minimum Stock` - } - ]); - } + let bl: DetailsField[] = [ + { + type: 'boolean', + name: 'active', + label: t`Active` + }, + { + type: 'boolean', + name: 'template', + label: t`Template Part` + }, + { + type: 'boolean', + name: 'assembly', + label: t`Assembled Part` + }, + { + type: 'boolean', + name: 'component', + label: t`Component Part` + }, + { + type: 'boolean', + name: 'trackable', + label: t`Trackable Part` + }, + { + type: 'boolean', + name: 'purchaseable', + label: t`Purchaseable Part` + }, + { + type: 'boolean', + name: 'saleable', + label: t`Saleable Part` + }, + { + type: 'boolean', + name: 'virtual', + label: t`Virtual Part` + } + ]; - 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`, - model: ModelType.partcategory - } - ]); - } - - 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([ + let br: DetailsField[] = [ { type: 'string', name: 'creation_date', @@ -283,181 +286,169 @@ export default function PartDetail() { { type: 'string', name: 'creation_user', - badge: 'user' + label: t`Created By`, + badge: 'user', + icon: 'user' + }, + { + type: 'string', + name: 'responsible', + label: t`Responsible`, + badge: 'owner', + hidden: !part.responsible + }, + { + type: 'link', + name: 'default_supplier', + label: t`Default Supplier`, + model: ModelType.supplierpart, + hidden: !part.default_supplier } - ]); + ]; + // Add in price range data 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 + br.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 + }`; + } + }); - 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 - }`; + // Add in stocktake information + if (id && part.last_stocktake) { + br.push({ + type: 'string', + name: 'stocktake', + label: t`Last Stocktake`, + unit: true, + value_formatter: () => { + const { data } = useSuspenseQuery({ + queryKey: ['stocktake', id], + queryFn: async () => { + const url = apiUrl(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; + }); + } + }); + + if (data.quantity) { + return `${data.quantity} (${data.date})`; + } else { + return '-'; } } - ]); + }); - 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 = apiUrl(ApiEndpoints.part_stocktake_list); + br.push({ + type: 'string', + name: 'stocktake_user', + label: t`Stocktake By`, + badge: 'user', + icon: 'user', + value_formatter: () => { + const { data } = useSuspenseQuery({ + queryKey: ['stocktake', id], + queryFn: async () => { + const url = apiUrl(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 = apiUrl(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; - } + 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`, - model: ModelType.stocklocation - } - ]); + }); } - if (part.default_supplier) { - bottom_right.push([ - { - type: 'link', - name: 'default_supplier', - label: t`Default Supplier`, - model: ModelType.supplierpart - } - ]); - } - - 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; - }; + return ( + <ItemDetailsGrid> + <Grid> + <Grid.Col span={4}> + <DetailsImage + appRole={UserRoles.part} + imageActions={{ + selectExisting: true, + uploadFile: true, + deleteFile: true + }} + src={part.image} + apiPath={apiUrl(ApiEndpoints.part_list, part.pk)} + refresh={refreshInstance} + pk={part.pk} + /> + </Grid.Col> + <Grid.Col span={8}> + <Stack spacing="xs"> + <PartIcons part={part} /> + <DetailsTable fields={tl} item={part} /> + </Stack> + </Grid.Col> + </Grid> + <DetailsTable fields={tr} item={part} /> + <DetailsTable fields={bl} item={part} /> + <DetailsTable fields={br} item={part} /> + </ItemDetailsGrid> + ); + }, [part, instanceQuery]); // Part data panels (recalculate when part data changes) const partPanels: PanelType[] = useMemo(() => { return [ { name: 'details', - label: t`Details`, + label: t`Part Details`, icon: <IconInfoCircle />, - content: !instanceQuery.isFetching && ( - <ItemDetails - appRole={UserRoles.part} - params={part} - apiPath={apiUrl(ApiEndpoints.part_list, part.pk)} - refresh={refreshInstance} - fields={detailFields(part)} - partModel - /> - ) + content: detailsPanel }, { name: 'parameters', diff --git a/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx b/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx index 0464f7d4b5..1030eb4d8c 100644 --- a/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx +++ b/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx @@ -1,5 +1,5 @@ import { t } from '@lingui/macro'; -import { LoadingOverlay, Stack } from '@mantine/core'; +import { Grid, LoadingOverlay, Skeleton, Stack } from '@mantine/core'; import { IconDots, IconInfoCircle, @@ -11,6 +11,9 @@ import { import { useMemo } from 'react'; import { useParams } from 'react-router-dom'; +import { DetailsField, DetailsTable } from '../../components/details/Details'; +import { DetailsImage } from '../../components/details/DetailsImage'; +import { ItemDetailsGrid } from '../../components/details/ItemDetails'; import { ActionDropdown, BarcodeActionDropdown, @@ -24,6 +27,10 @@ import { PageDetail } from '../../components/nav/PageDetail'; import { PanelGroup, PanelType } from '../../components/nav/PanelGroup'; import { NotesEditor } from '../../components/widgets/MarkdownEditor'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; +import { ModelType } from '../../enums/ModelType'; +import { UserRoles } from '../../enums/Roles'; +import { purchaseOrderFields } from '../../forms/PurchaseOrderForms'; +import { useEditApiFormModal } from '../../hooks/UseForm'; import { useInstance } from '../../hooks/UseInstance'; import { apiUrl } from '../../states/ApiState'; import { useUserState } from '../../states/UserState'; @@ -39,7 +46,11 @@ export default function PurchaseOrderDetail() { const user = useUserState(); - const { instance: order, instanceQuery } = useInstance({ + const { + instance: order, + instanceQuery, + refreshInstance + } = useInstance({ endpoint: ApiEndpoints.purchase_order_list, pk: id, params: { @@ -48,12 +59,167 @@ export default function PurchaseOrderDetail() { refetchOnMount: true }); + const editPurchaseOrder = useEditApiFormModal({ + url: ApiEndpoints.purchase_order_list, + pk: id, + title: t`Edit Purchase Order`, + fields: purchaseOrderFields(), + onFormSuccess: () => { + refreshInstance(); + } + }); + + const detailsPanel = useMemo(() => { + if (instanceQuery.isFetching) { + return <Skeleton />; + } + + let tl: DetailsField[] = [ + { + type: 'text', + name: 'reference', + label: t`Reference`, + copy: true + }, + { + type: 'text', + name: 'supplier_reference', + label: t`Supplier Reference`, + icon: 'reference', + hidden: !order.supplier_reference, + copy: true + }, + { + type: 'link', + name: 'supplier', + icon: 'suppliers', + label: t`Supplier`, + model: ModelType.company + }, + { + type: 'text', + name: 'description', + label: t`Description`, + copy: true + }, + { + type: 'status', + name: 'status', + label: t`Status`, + model: ModelType.purchaseorder + } + ]; + + let tr: DetailsField[] = [ + { + type: 'text', + name: 'line_items', + label: t`Line Items`, + icon: 'list' + }, + { + type: 'progressbar', + name: 'completed', + icon: 'progress', + label: t`Completed Line Items`, + total: order.line_items, + progress: order.completed_lines + }, + { + type: 'progressbar', + name: 'shipments', + icon: 'shipment', + label: t`Completed Shipments`, + total: order.shipments, + progress: order.completed_shipments + // TODO: Fix this progress bar + }, + { + type: 'text', + name: 'currency', + label: t`Order Currency,` + }, + { + type: 'text', + name: 'total_cost', + label: t`Total Cost` + // TODO: Implement this! + } + ]; + + let bl: DetailsField[] = [ + { + type: 'link', + external: true, + name: 'link', + label: t`Link`, + copy: true, + hidden: !order.link + }, + { + type: 'link', + model: ModelType.contact, + link: false, + name: 'contact', + label: t`Contact`, + icon: 'user', + copy: true + } + // TODO: Project code + ]; + + let br: DetailsField[] = [ + { + type: 'text', + name: 'creation_date', + label: t`Created On`, + icon: 'calendar' + }, + { + type: 'text', + name: 'target_date', + label: t`Target Date`, + icon: 'calendar', + hidden: !order.target_date + }, + { + type: 'text', + name: 'responsible', + label: t`Responsible`, + badge: 'owner', + hidden: !order.responsible + } + ]; + + return ( + <ItemDetailsGrid> + <Grid> + <Grid.Col span={4}> + <DetailsImage + appRole={UserRoles.purchase_order} + apiPath={ApiEndpoints.company_list} + src={order.supplier_detail?.image} + pk={order.supplier} + /> + </Grid.Col> + <Grid.Col span={8}> + <DetailsTable fields={tl} item={order} /> + </Grid.Col> + </Grid> + <DetailsTable fields={tr} item={order} /> + <DetailsTable fields={bl} item={order} /> + <DetailsTable fields={br} item={order} /> + </ItemDetailsGrid> + ); + }, [order, instanceQuery]); + const orderPanels: PanelType[] = useMemo(() => { return [ { name: 'detail', label: t`Order Details`, - icon: <IconInfoCircle /> + icon: <IconInfoCircle />, + content: detailsPanel }, { name: 'line-items', @@ -118,13 +284,21 @@ export default function PurchaseOrderDetail() { key="order-actions" tooltip={t`Order Actions`} icon={<IconDots />} - actions={[EditItemAction({}), DeleteItemAction({})]} + actions={[ + EditItemAction({ + onClick: () => { + editPurchaseOrder.open(); + } + }), + DeleteItemAction({}) + ]} /> ]; }, [id, order, user]); return ( <> + {editPurchaseOrder.modal} <Stack spacing="xs"> <LoadingOverlay visible={instanceQuery.isFetching} /> <PageDetail diff --git a/src/frontend/src/pages/sales/ReturnOrderDetail.tsx b/src/frontend/src/pages/sales/ReturnOrderDetail.tsx index d08be33201..c8b405ecfa 100644 --- a/src/frontend/src/pages/sales/ReturnOrderDetail.tsx +++ b/src/frontend/src/pages/sales/ReturnOrderDetail.tsx @@ -1,13 +1,23 @@ import { t } from '@lingui/macro'; -import { LoadingOverlay, Stack } from '@mantine/core'; -import { IconInfoCircle, IconNotes, IconPaperclip } from '@tabler/icons-react'; +import { Grid, LoadingOverlay, Skeleton, Stack } from '@mantine/core'; +import { + IconInfoCircle, + IconList, + IconNotes, + IconPaperclip +} from '@tabler/icons-react'; import { useMemo } from 'react'; import { useParams } from 'react-router-dom'; +import { DetailsField, DetailsTable } from '../../components/details/Details'; +import { DetailsImage } from '../../components/details/DetailsImage'; +import { ItemDetailsGrid } from '../../components/details/ItemDetails'; import { PageDetail } from '../../components/nav/PageDetail'; import { PanelGroup, PanelType } from '../../components/nav/PanelGroup'; import { NotesEditor } from '../../components/widgets/MarkdownEditor'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; +import { ModelType } from '../../enums/ModelType'; +import { UserRoles } from '../../enums/Roles'; import { useInstance } from '../../hooks/UseInstance'; import { apiUrl } from '../../states/ApiState'; import { AttachmentTable } from '../../tables/general/AttachmentTable'; @@ -26,12 +36,161 @@ export default function ReturnOrderDetail() { } }); + const detailsPanel = useMemo(() => { + if (instanceQuery.isFetching) { + return <Skeleton />; + } + + let tl: DetailsField[] = [ + { + type: 'text', + name: 'reference', + label: t`Reference`, + copy: true + }, + { + type: 'text', + name: 'customer_reference', + label: t`Customer Reference`, + copy: true, + hidden: !order.customer_reference + }, + { + type: 'link', + name: 'customer', + icon: 'customers', + label: t`Customer`, + model: ModelType.company + }, + { + type: 'text', + name: 'description', + label: t`Description`, + copy: true + }, + { + type: 'status', + name: 'status', + label: t`Status`, + model: ModelType.salesorder + } + ]; + + let tr: DetailsField[] = [ + { + type: 'text', + name: 'line_items', + label: t`Line Items`, + icon: 'list' + }, + { + type: 'progressbar', + name: 'completed', + icon: 'progress', + label: t`Completed Line Items`, + total: order.line_items, + progress: order.completed_lines + }, + { + type: 'progressbar', + name: 'shipments', + icon: 'shipment', + label: t`Completed Shipments`, + total: order.shipments, + progress: order.completed_shipments + // TODO: Fix this progress bar + }, + { + type: 'text', + name: 'currency', + label: t`Order Currency,` + }, + { + type: 'text', + name: 'total_cost', + label: t`Total Cost` + // TODO: Implement this! + } + ]; + + let bl: DetailsField[] = [ + { + type: 'link', + external: true, + name: 'link', + label: t`Link`, + copy: true, + hidden: !order.link + }, + { + type: 'link', + model: ModelType.contact, + link: false, + name: 'contact', + label: t`Contact`, + icon: 'user', + copy: true + } + // TODO: Project code + ]; + + let br: DetailsField[] = [ + { + type: 'text', + name: 'creation_date', + label: t`Created On`, + icon: 'calendar' + }, + { + type: 'text', + name: 'target_date', + label: t`Target Date`, + icon: 'calendar', + hidden: !order.target_date + }, + { + type: 'text', + name: 'responsible', + label: t`Responsible`, + badge: 'owner', + hidden: !order.responsible + } + ]; + + return ( + <ItemDetailsGrid> + <Grid> + <Grid.Col span={4}> + <DetailsImage + appRole={UserRoles.purchase_order} + apiPath={ApiEndpoints.company_list} + src={order.customer_detail?.image} + pk={order.customer} + /> + </Grid.Col> + <Grid.Col span={8}> + <DetailsTable fields={tl} item={order} /> + </Grid.Col> + </Grid> + <DetailsTable fields={tr} item={order} /> + <DetailsTable fields={bl} item={order} /> + <DetailsTable fields={br} item={order} /> + </ItemDetailsGrid> + ); + }, [order, instanceQuery]); + const orderPanels: PanelType[] = useMemo(() => { return [ { name: 'detail', label: t`Order Details`, - icon: <IconInfoCircle /> + icon: <IconInfoCircle />, + content: detailsPanel + }, + { + name: 'line-items', + label: t`Line Items`, + icon: <IconList /> }, { name: 'attachments', diff --git a/src/frontend/src/pages/sales/SalesOrderDetail.tsx b/src/frontend/src/pages/sales/SalesOrderDetail.tsx index 04908963ca..84db04670e 100644 --- a/src/frontend/src/pages/sales/SalesOrderDetail.tsx +++ b/src/frontend/src/pages/sales/SalesOrderDetail.tsx @@ -1,5 +1,5 @@ import { t } from '@lingui/macro'; -import { LoadingOverlay, Skeleton, Stack } from '@mantine/core'; +import { Grid, LoadingOverlay, Skeleton, Stack } from '@mantine/core'; import { IconInfoCircle, IconList, @@ -12,10 +12,15 @@ import { import { useMemo } from 'react'; import { useParams } from 'react-router-dom'; +import { DetailsField, DetailsTable } from '../../components/details/Details'; +import { DetailsImage } from '../../components/details/DetailsImage'; +import { ItemDetailsGrid } from '../../components/details/ItemDetails'; import { PageDetail } from '../../components/nav/PageDetail'; import { PanelGroup, PanelType } from '../../components/nav/PanelGroup'; import { NotesEditor } from '../../components/widgets/MarkdownEditor'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; +import { ModelType } from '../../enums/ModelType'; +import { UserRoles } from '../../enums/Roles'; import { useInstance } from '../../hooks/UseInstance'; import { apiUrl } from '../../states/ApiState'; import { BuildOrderTable } from '../../tables/build/BuildOrderTable'; @@ -35,12 +40,156 @@ export default function SalesOrderDetail() { } }); + const detailsPanel = useMemo(() => { + if (instanceQuery.isFetching) { + return <Skeleton />; + } + + let tl: DetailsField[] = [ + { + type: 'text', + name: 'reference', + label: t`Reference`, + copy: true + }, + { + type: 'text', + name: 'customer_reference', + label: t`Customer Reference`, + copy: true, + hidden: !order.customer_reference + }, + { + type: 'link', + name: 'customer', + icon: 'customers', + label: t`Customer`, + model: ModelType.company + }, + { + type: 'text', + name: 'description', + label: t`Description`, + copy: true + }, + { + type: 'status', + name: 'status', + label: t`Status`, + model: ModelType.salesorder + } + ]; + + let tr: DetailsField[] = [ + { + type: 'text', + name: 'line_items', + label: t`Line Items`, + icon: 'list' + }, + { + type: 'progressbar', + name: 'completed', + icon: 'progress', + label: t`Completed Line Items`, + total: order.line_items, + progress: order.completed_lines + }, + { + type: 'progressbar', + name: 'shipments', + icon: 'shipment', + label: t`Completed Shipments`, + total: order.shipments, + progress: order.completed_shipments + // TODO: Fix this progress bar + }, + { + type: 'text', + name: 'currency', + label: t`Order Currency,` + }, + { + type: 'text', + name: 'total_cost', + label: t`Total Cost` + // TODO: Implement this! + } + ]; + + let bl: DetailsField[] = [ + { + type: 'link', + external: true, + name: 'link', + label: t`Link`, + copy: true, + hidden: !order.link + }, + { + type: 'link', + model: ModelType.contact, + link: false, + name: 'contact', + label: t`Contact`, + icon: 'user', + copy: true + } + // TODO: Project code + ]; + + let br: DetailsField[] = [ + { + type: 'text', + name: 'creation_date', + label: t`Created On`, + icon: 'calendar' + }, + { + type: 'text', + name: 'target_date', + label: t`Target Date`, + icon: 'calendar', + hidden: !order.target_date + }, + { + type: 'text', + name: 'responsible', + label: t`Responsible`, + badge: 'owner', + hidden: !order.responsible + } + ]; + + return ( + <ItemDetailsGrid> + <Grid> + <Grid.Col span={4}> + <DetailsImage + appRole={UserRoles.purchase_order} + apiPath={ApiEndpoints.company_list} + src={order.customer_detail?.image} + pk={order.customer} + /> + </Grid.Col> + <Grid.Col span={8}> + <DetailsTable fields={tl} item={order} /> + </Grid.Col> + </Grid> + <DetailsTable fields={tr} item={order} /> + <DetailsTable fields={bl} item={order} /> + <DetailsTable fields={br} item={order} /> + </ItemDetailsGrid> + ); + }, [order, instanceQuery]); + const orderPanels: PanelType[] = useMemo(() => { return [ { name: 'detail', label: t`Order Details`, - icon: <IconInfoCircle /> + icon: <IconInfoCircle />, + content: detailsPanel }, { name: 'line-items', diff --git a/src/frontend/src/pages/stock/LocationDetail.tsx b/src/frontend/src/pages/stock/LocationDetail.tsx index a1da38dd23..3214ed8183 100644 --- a/src/frontend/src/pages/stock/LocationDetail.tsx +++ b/src/frontend/src/pages/stock/LocationDetail.tsx @@ -1,13 +1,16 @@ import { t } from '@lingui/macro'; -import { LoadingOverlay, Stack, Text } from '@mantine/core'; -import { IconPackages, IconSitemap } from '@tabler/icons-react'; +import { LoadingOverlay, Skeleton, Stack, Text } from '@mantine/core'; +import { IconInfoCircle, IconPackages, IconSitemap } from '@tabler/icons-react'; import { useMemo, useState } from 'react'; import { useParams } from 'react-router-dom'; +import { DetailsField, DetailsTable } from '../../components/details/Details'; +import { ItemDetailsGrid } from '../../components/details/ItemDetails'; import { PageDetail } from '../../components/nav/PageDetail'; import { PanelGroup, PanelType } from '../../components/nav/PanelGroup'; import { StockLocationTree } from '../../components/nav/StockLocationTree'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; +import { ModelType } from '../../enums/ModelType'; import { useInstance } from '../../hooks/UseInstance'; import { StockItemTable } from '../../tables/stock/StockItemTable'; import { StockLocationTable } from '../../tables/stock/StockLocationTable'; @@ -35,8 +38,90 @@ export default function Stock() { } }); + const detailsPanel = useMemo(() => { + if (id && instanceQuery.isFetching) { + return <Skeleton />; + } + + let left: DetailsField[] = [ + { + type: 'text', + name: 'name', + label: t`Name`, + copy: true + }, + { + type: 'text', + name: 'pathstring', + label: t`Path`, + icon: 'sitemap', + copy: true, + hidden: !id + }, + { + type: 'text', + name: 'description', + label: t`Description`, + copy: true + }, + { + type: 'link', + name: 'parent', + model_field: 'name', + icon: 'location', + label: t`Parent Location`, + model: ModelType.stocklocation, + hidden: !location?.parent + } + ]; + + let right: DetailsField[] = [ + { + type: 'text', + name: 'items', + icon: 'stock', + label: t`Stock Items` + }, + { + type: 'text', + name: 'sublocations', + icon: 'location', + label: t`Sublocations`, + hidden: !location?.sublocations + }, + { + type: 'boolean', + name: 'structural', + label: t`Structural`, + icon: 'sitemap' + }, + { + type: 'boolean', + name: 'external', + label: t`External` + } + ]; + + return ( + <ItemDetailsGrid> + {id && location?.pk ? ( + <DetailsTable item={location} fields={left} /> + ) : ( + <Text>{t`Top level stock location`}</Text> + )} + {id && location?.pk && <DetailsTable item={location} fields={right} />} + </ItemDetailsGrid> + ); + }, [location, instanceQuery]); + const locationPanels: PanelType[] = useMemo(() => { return [ + { + name: 'details', + label: t`Location Details`, + icon: <IconInfoCircle />, + content: detailsPanel + }, { name: 'stock-items', label: t`Stock Items`, diff --git a/src/frontend/src/pages/stock/StockDetail.tsx b/src/frontend/src/pages/stock/StockDetail.tsx index 4901bf20ed..7daeb55285 100644 --- a/src/frontend/src/pages/stock/StockDetail.tsx +++ b/src/frontend/src/pages/stock/StockDetail.tsx @@ -1,5 +1,12 @@ import { t } from '@lingui/macro'; -import { Alert, LoadingOverlay, Skeleton, Stack, Text } from '@mantine/core'; +import { + Alert, + Grid, + LoadingOverlay, + Skeleton, + Stack, + Text +} from '@mantine/core'; import { IconBookmark, IconBoxPadding, @@ -20,6 +27,9 @@ import { import { useMemo, useState } from 'react'; import { useParams } from 'react-router-dom'; +import { DetailsField, DetailsTable } from '../../components/details/Details'; +import { DetailsImage } from '../../components/details/DetailsImage'; +import { ItemDetailsGrid } from '../../components/details/ItemDetails'; import { ActionDropdown, BarcodeActionDropdown, @@ -34,6 +44,8 @@ import { PanelGroup, PanelType } from '../../components/nav/PanelGroup'; import { StockLocationTree } from '../../components/nav/StockLocationTree'; import { NotesEditor } from '../../components/widgets/MarkdownEditor'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; +import { ModelType } from '../../enums/ModelType'; +import { UserRoles } from '../../enums/Roles'; import { useEditStockItem } from '../../forms/StockForms'; import { useInstance } from '../../hooks/UseInstance'; import { apiUrl } from '../../states/ApiState'; @@ -63,12 +75,155 @@ export default function StockDetail() { } }); + const detailsPanel = useMemo(() => { + let data = stockitem; + + data.available_stock = Math.max(0, data.quantity - data.allocated); + + if (instanceQuery.isFetching) { + return <Skeleton />; + } + + // Top left - core part information + let tl: DetailsField[] = [ + { + name: 'part', + label: t`Base Part`, + type: 'link', + model: ModelType.part + }, + { + name: 'status', + type: 'text', + label: t`Stock Status` + }, + { + type: 'text', + name: 'tests', + label: `Completed Tests`, + icon: 'progress' + }, + { + type: 'text', + name: 'updated', + icon: 'calendar', + label: t`Last Updated` + }, + { + type: 'text', + name: 'stocktake', + icon: 'calendar', + label: t`Last Stocktake`, + hidden: !stockitem.stocktake + } + ]; + + // Top right - available stock information + let tr: DetailsField[] = [ + { + type: 'text', + name: 'quantity', + label: t`Quantity` + }, + { + type: 'text', + name: 'serial', + label: t`Serial Number`, + hidden: !stockitem.serial + }, + { + type: 'text', + name: 'available_stock', + label: t`Available` + } + // TODO: allocated_to_sales_orders + // TODO: allocated_to_build_orders + ]; + + // Bottom left: location information + let bl: DetailsField[] = [ + { + name: 'supplier_part', + label: t`Supplier Part`, + type: 'link', + model: ModelType.supplierpart, + hidden: !stockitem.supplier_part + }, + { + type: 'link', + name: 'location', + label: t`Location`, + model: ModelType.stocklocation, + hidden: !stockitem.location + }, + { + type: 'link', + name: 'belongs_to', + label: t`Installed In`, + model: ModelType.stockitem, + hidden: !stockitem.belongs_to + }, + { + type: 'link', + name: 'consumed_by', + label: t`Consumed By`, + model: ModelType.build, + hidden: !stockitem.consumed_by + }, + { + type: 'link', + name: 'sales_order', + label: t`Sales Order`, + model: ModelType.salesorder, + hidden: !stockitem.sales_order + } + ]; + + // Bottom right - any other information + let br: DetailsField[] = [ + // TODO: Expiry date + // TODO: Ownership + { + type: 'text', + name: 'packaging', + icon: 'part', + label: t`Packaging`, + hidden: !stockitem.packaging + } + ]; + + return ( + <ItemDetailsGrid> + <Grid> + <Grid.Col span={4}> + <DetailsImage + appRole={UserRoles.part} + apiPath={ApiEndpoints.part_list} + src={ + stockitem.part_detail?.image ?? + stockitem?.part_detail?.thumbnail + } + pk={stockitem.part} + /> + </Grid.Col> + <Grid.Col span={8}> + <DetailsTable fields={tl} item={stockitem} /> + </Grid.Col> + </Grid> + <DetailsTable fields={tr} item={stockitem} /> + <DetailsTable fields={bl} item={stockitem} /> + <DetailsTable fields={br} item={stockitem} /> + </ItemDetailsGrid> + ); + }, [stockitem, instanceQuery]); + const stockPanels: PanelType[] = useMemo(() => { return [ { name: 'details', - label: t`Details`, - icon: <IconInfoCircle /> + label: t`Stock Details`, + icon: <IconInfoCircle />, + content: detailsPanel }, { name: 'tracking', diff --git a/src/frontend/src/tables/ItemDetails.tsx b/src/frontend/src/tables/ItemDetails.tsx deleted file mode 100644 index 1ffc49473f..0000000000 --- a/src/frontend/src/tables/ItemDetails.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { Grid, Group, Paper, SimpleGrid } from '@mantine/core'; - -import { - DetailImageButtonProps, - DetailsImage -} from '../components/images/DetailsImage'; -import { UserRoles } from '../enums/Roles'; -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 p="xs"> - <SimpleGrid cols={2} spacing="xs" verticalSpacing="xs"> - <Grid> - {fields.image && ( - <Grid.Col span={4}> - <DetailsImage - appRole={appRole} - imageActions={fields.image.imageActions} - src={params.image} - apiPath={apiPath} - refresh={refresh} - pk={params.pk} - /> - </Grid.Col> - )} - <Grid.Col span={8}> - {fields.left && ( - <DetailsTable - item={params} - fields={fields.left} - partIcons={partModel} - /> - )} - </Grid.Col> - </Grid> - {fields.right && <DetailsTable item={params} fields={fields.right} />} - {fields.bottom_left && ( - <DetailsTable item={params} fields={fields.bottom_left} /> - )} - {fields.bottom_right && ( - <DetailsTable item={params} fields={fields.bottom_right} /> - )} - </SimpleGrid> - </Paper> - ); -}