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 ( -
- -
- ); -} - -/** - * 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. @@ -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 ; } @@ -267,17 +199,21 @@ function TableStringValue(props: FieldProps) {
}> - {value ? value : props.field_data.unit && '0'}{' '} + {value ? value : props.field_data?.unit && '0'}{' '} {props.field_data.unit == true && props.unit} {props.field_data.user && ( - + )}
); } +function BooleanValue(props: FieldProps) { + return ; +} + 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 ( }> - - {data.name ?? 'No name defined'} - + {make_link ? ( + + {value} + + ) : ( + {value} + )} ); } @@ -348,6 +306,12 @@ function ProgressBarValue(props: FieldProps) { ); } +function StatusValue(props: FieldProps) { + return ( + + ); +} + function CopyField({ value }: { value: string }) { return ( @@ -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 ( - - {field_data[0].label} + + + + {field.label} -
-
- {field_data.map((data: DetailsField, index: number) => { - let FieldType: any = getFieldType(data.type); - return ( - - ); - })} -
- {field_data[0].copy && } -
+ + + + {field.copy && } ); @@ -430,50 +385,20 @@ function TableField({ export function DetailsTable({ item, - fields, - partIcons = false + fields }: { item: any; - fields: DetailsField[][]; - partIcons?: boolean; + fields: DetailsField[]; }) { 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 ( - - ); - })} + {fields + .filter((field: DetailsField) => !field.hidden) + .map((field: DetailsField, index: number) => ( + + ))}
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: {t`Select Image`}, 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: {t`Upload Image`}, 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); + }} /> )} @@ -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: , + withCloseButton: false + }); + }; + return ( <> @@ -337,25 +366,22 @@ export function DetailsImage(props: DetailImageProps) { src={img} height={IMAGE_DIMENSION} width={IMAGE_DIMENSION} - onClick={() => { - modals.open({ - children: , - withCloseButton: false - }); - }} + onClick={expandImage} /> - {permissions.hasChangeRole(props.appRole) && hovered && ( - - - - )} + {permissions.hasChangeRole(props.appRole) && + hasOverlay && + hovered && ( + + + + )} 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 ( + + + {props.children} + + + ); +} 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 ( +
+ +
+ ); +} + +/** + * Generates a table cell with Part icons. + * Only used for Part Model Details + */ +export function PartIcons({ part }: { part: any }) { + return ( + +
+ {!part.active && ( + + +
+ {' '} + Inactive +
+
+
+ )} + {part.template && ( + + )} + {part.assembly && ( + + )} + {part.component && ( + + )} + {part.trackable && ( + + )} + {part.purchaseable && ( + + )} + {part.saleable && ( + + )} + {part.virtual && ( + + +
+ {' '} + Virtual +
+
+
+ )} +
+ + ); +} 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 ; } +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 ; + } + + 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 ( - - - - - - - - - - - - - - - - -
{t`Base Part`}{build.part_detail?.name}
{t`Quantity`}{build.quantity}
{t`Build Status`} - {build?.status && ( - - )} -
-
-
+ + + + + + + + + + + + + ); - }, [build]); + }, [build, instanceQuery]); const buildPanels: PanelType[] = useMemo(() => { return [ @@ -100,7 +195,7 @@ export default function BuildDetail() { name: 'details', label: t`Build Details`, icon: , - 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 ; + } + + 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 ( + + + + + + + + + + + + ); + }, [company, instanceQuery]); + const companyPanels: PanelType[] = useMemo(() => { return [ { name: 'details', label: t`Details`, - icon: + icon: , + 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 ; + } + + 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 ( + + {id && category?.pk ? ( + + ) : ( + {t`Top level part category`} + )} + {id && category?.pk && } + + ); + }, [category, instanceQuery]); + const categoryPanels: PanelType[] = useMemo( () => [ + { + name: 'details', + label: t`Category Details`, + icon: , + 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 ; + } - 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 ( + + + + + + + + + + + + + + + + + ); + }, [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: , - content: !instanceQuery.isFetching && ( - - ) + 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 ; + } + + 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 ( + + + + + + + + + + + + + + ); + }, [order, instanceQuery]); + const orderPanels: PanelType[] = useMemo(() => { return [ { name: 'detail', label: t`Order Details`, - icon: + icon: , + content: detailsPanel }, { name: 'line-items', @@ -118,13 +284,21 @@ export default function PurchaseOrderDetail() { key="order-actions" tooltip={t`Order Actions`} icon={} - actions={[EditItemAction({}), DeleteItemAction({})]} + actions={[ + EditItemAction({ + onClick: () => { + editPurchaseOrder.open(); + } + }), + DeleteItemAction({}) + ]} /> ]; }, [id, order, user]); return ( <> + {editPurchaseOrder.modal} { + if (instanceQuery.isFetching) { + return ; + } + + 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 ( + + + + + + + + + + + + + + ); + }, [order, instanceQuery]); + const orderPanels: PanelType[] = useMemo(() => { return [ { name: 'detail', label: t`Order Details`, - icon: + icon: , + content: detailsPanel + }, + { + name: 'line-items', + label: t`Line Items`, + icon: }, { 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 ; + } + + 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 ( + + + + + + + + + + + + + + ); + }, [order, instanceQuery]); + const orderPanels: PanelType[] = useMemo(() => { return [ { name: 'detail', label: t`Order Details`, - icon: + icon: , + 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 ; + } + + 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 ( + + {id && location?.pk ? ( + + ) : ( + {t`Top level stock location`} + )} + {id && location?.pk && } + + ); + }, [location, instanceQuery]); + const locationPanels: PanelType[] = useMemo(() => { return [ + { + name: 'details', + label: t`Location Details`, + icon: , + 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 ; + } + + // 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 ( + + + + + + + + + + + + + + ); + }, [stockitem, instanceQuery]); + const stockPanels: PanelType[] = useMemo(() => { return [ { name: 'details', - label: t`Details`, - icon: + label: t`Stock Details`, + icon: , + 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 ( - - - - {fields.image && ( - - - - )} - - {fields.left && ( - - )} - - - {fields.right && } - {fields.bottom_left && ( - - )} - {fields.bottom_right && ( - - )} - - - ); -}