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 && (
-
- )}
-
-
- );
-}