Details updates (#6605)

* Fix onClick behaviour for details image

* Moving items

- These are not "tables" per-se

* Refactoring for DetailsTable

* Skip hidden fields

* Cleanup table column widths

* Update part details

* Fix icons

* Add image back to part details

- Also fix onClick events

* Update stockitem details page

* Implement details page for build order

* Implement CompanyDetails page

* Implemented salesorder details

* Update SalesOrder detalis

* ReturnOrder detail

* PurchaseOrder detail page

* Cleanup build details page

* Stock location detail

* Part Category detail

* Bump API version

* Bug fixes

* Use image, not thumbnail

* Fix field copy

* Cleanup imgae hover

* Improve PartDetail

- Add more data
- Add icons

* Refactoring

- Move Details out of "tables" directory

* Remove old file

* Revert "Remove old file"

This reverts commit 6fd131f2a597963f28434d665f6f24c90d909357.

* Fix files

* Fix unused import
This commit is contained in:
Oliver 2024-03-01 17:13:08 +11:00 committed by GitHub
parent c8d6f2246b
commit 69871699c0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1691 additions and 686 deletions

View File

@ -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

View File

@ -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):

View File

@ -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

View File

@ -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)

View File

@ -14,15 +14,17 @@ import {
import { useSuspenseQuery } from '@tanstack/react-query';
import { Suspense, useMemo } from 'react';
import { api } from '../App';
import { ProgressBar } from '../components/items/ProgressBar';
import { getModelInfo } from '../components/render/ModelType';
import { ApiEndpoints } from '../enums/ApiEndpoints';
import { ModelType } from '../enums/ModelType';
import { InvenTreeIcon } from '../functions/icons';
import { getDetailUrl } from '../functions/urls';
import { apiUrl } from '../states/ApiState';
import { useGlobalSettingsState } from '../states/SettingsState';
import { api } from '../../App';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { InvenTreeIcon } from '../../functions/icons';
import { getDetailUrl } from '../../functions/urls';
import { apiUrl } from '../../states/ApiState';
import { useGlobalSettingsState } from '../../states/SettingsState';
import { ProgressBar } from '../items/ProgressBar';
import { YesNoButton } from '../items/YesNoButton';
import { getModelInfo } from '../render/ModelType';
import { StatusRenderer } from '../render/StatusRenderer';
export type PartIconsType = {
assembly: boolean;
@ -37,12 +39,20 @@ export type PartIconsType = {
export type DetailsField =
| {
hidden?: boolean;
icon?: string;
name: string;
label?: string;
badge?: BadgeType;
copy?: boolean;
value_formatter?: () => ValueFormatterReturn;
} & (StringDetailField | LinkDetailField | ProgressBarfield);
} & (
| StringDetailField
| BooleanField
| LinkDetailField
| ProgressBarfield
| StatusField
);
type BadgeType = 'owner' | 'user' | 'group';
type ValueFormatterReturn = string | number | null;
@ -52,12 +62,20 @@ type StringDetailField = {
unit?: boolean;
};
type BooleanField = {
type: 'boolean';
};
type LinkDetailField = {
type: 'link';
link?: boolean;
} & (InternalLinkField | ExternalLinkField);
type InternalLinkField = {
model: ModelType;
model_field?: string;
model_formatter?: (value: any) => string;
backup_value?: string;
};
type ExternalLinkField = {
@ -70,6 +88,11 @@ type ProgressBarfield = {
total: number;
};
type StatusField = {
type: 'status';
model: ModelType;
};
type FieldValueType = string | number | undefined;
type FieldProps = {
@ -78,101 +101,6 @@ type FieldProps = {
unit?: string | null;
};
/**
* Fetches and wraps an InvenTreeIcon in a flex div
* @param icon name of icon
*
*/
function PartIcon(icon: string) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
<InvenTreeIcon icon={icon} />
</div>
);
}
/**
* Generates a table cell with Part icons.
* Only used for Part Model Details
*/
function PartIcons({
assembly,
template,
component,
trackable,
purchaseable,
saleable,
virtual,
active
}: PartIconsType) {
return (
<td colSpan={2}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
{!active && (
<Tooltip label={t`Part is not active`}>
<Badge color="red" variant="filled">
<div
style={{ display: 'flex', alignItems: 'center', gap: '5px' }}
>
<InvenTreeIcon icon="inactive" iconProps={{ size: 19 }} />{' '}
<Trans>Inactive</Trans>
</div>
</Badge>
</Tooltip>
)}
{template && (
<Tooltip
label={t`Part is a template part (variants can be made from this part)`}
children={PartIcon('template')}
/>
)}
{assembly && (
<Tooltip
label={t`Part can be assembled from other parts`}
children={PartIcon('assembly')}
/>
)}
{component && (
<Tooltip
label={t`Part can be used in assemblies`}
children={PartIcon('component')}
/>
)}
{trackable && (
<Tooltip
label={t`Part stock is tracked by serial number`}
children={PartIcon('trackable')}
/>
)}
{purchaseable && (
<Tooltip
label={t`Part can be purchased from external suppliers`}
children={PartIcon('purchaseable')}
/>
)}
{saleable && (
<Tooltip
label={t`Part can be sold to customers`}
children={PartIcon('saleable')}
/>
)}
{virtual && (
<Tooltip label={t`Part is virtual (not a physical part)`}>
<Badge color="yellow" variant="filled">
<div
style={{ display: 'flex', alignItems: 'center', gap: '5px' }}
>
<InvenTreeIcon icon="virtual" iconProps={{ size: 18 }} />{' '}
<Trans>Virtual</Trans>
</div>
</Badge>
</Tooltip>
)}
</div>
</td>
);
}
/**
* Fetches user or group info from backend and formats into a badge.
* Badge shows username, full name, or group name depending on server settings.
@ -253,13 +181,17 @@ function NameBadge({ pk, type }: { pk: string | number; type: BadgeType }) {
* If user is defined, a badge is rendered in addition to main value
*/
function TableStringValue(props: FieldProps) {
let value = props.field_value;
let value = props?.field_value;
if (props.field_data.value_formatter) {
if (value === undefined) {
return '---';
}
if (props.field_data?.value_formatter) {
value = props.field_data.value_formatter();
}
if (props.field_data.badge) {
if (props.field_data?.badge) {
return <NameBadge pk={value} type={props.field_data.badge} />;
}
@ -267,17 +199,21 @@ function TableStringValue(props: FieldProps) {
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Suspense fallback={<Skeleton width={200} height={20} radius="xl" />}>
<span>
{value ? value : props.field_data.unit && '0'}{' '}
{value ? value : props.field_data?.unit && '0'}{' '}
{props.field_data.unit == true && props.unit}
</span>
</Suspense>
{props.field_data.user && (
<NameBadge pk={props.field_data.user} type="user" />
<NameBadge pk={props.field_data?.user} type="user" />
)}
</div>
);
}
function BooleanValue(props: FieldProps) {
return <YesNoButton value={props.field_value} />;
}
function TableAnchorValue(props: FieldProps) {
if (props.field_data.external) {
return (
@ -299,7 +235,7 @@ function TableAnchorValue(props: FieldProps) {
queryFn: async () => {
const modelDef = getModelInfo(props.field_data.model);
if (!modelDef.api_endpoint) {
if (!modelDef?.api_endpoint) {
return {};
}
@ -325,15 +261,37 @@ function TableAnchorValue(props: FieldProps) {
return getDetailUrl(props.field_data.model, props.field_value);
}, [props.field_data.model, props.field_value]);
let make_link = props.field_data?.link ?? true;
// Construct the "return value" for the fetched data
let value = undefined;
if (props.field_data.model_formatter) {
value = props.field_data.model_formatter(data) ?? value;
} else if (props.field_data.model_field) {
value = data?.[props.field_data.model_field] ?? value;
} else {
value = data?.name;
}
if (value === undefined) {
value = data?.name ?? props.field_data?.backup_value ?? 'No name defined';
make_link = false;
}
return (
<Suspense fallback={<Skeleton width={200} height={20} radius="xl" />}>
<Anchor
href={`/platform${detailUrl}`}
target={data?.external ? '_blank' : undefined}
rel={data?.external ? 'noreferrer noopener' : undefined}
>
<Text>{data.name ?? 'No name defined'}</Text>
</Anchor>
{make_link ? (
<Anchor
href={`/platform${detailUrl}`}
target={data?.external ? '_blank' : undefined}
rel={data?.external ? 'noreferrer noopener' : undefined}
>
<Text>{value}</Text>
</Anchor>
) : (
<Text>{value}</Text>
)}
</Suspense>
);
}
@ -348,6 +306,12 @@ function ProgressBarValue(props: FieldProps) {
);
}
function StatusValue(props: FieldProps) {
return (
<StatusRenderer type={props.field_data.model} status={props.field_value} />
);
}
function CopyField({ value }: { value: string }) {
return (
<CopyButton value={value}>
@ -366,27 +330,33 @@ function CopyField({ value }: { value: string }) {
);
}
function TableField({
field_data,
field_value,
unit = null
export function DetailsTableField({
item,
field
}: {
field_data: DetailsField[];
field_value: FieldValueType[];
unit?: string | null;
item: any;
field: DetailsField;
}) {
function getFieldType(type: string) {
switch (type) {
case 'text':
case 'string':
return TableStringValue;
case 'boolean':
return BooleanValue;
case 'link':
return TableAnchorValue;
case 'progressbar':
return ProgressBarValue;
case 'status':
return StatusValue;
default:
return TableStringValue;
}
}
const FieldType: any = getFieldType(field.type);
return (
<tr>
<td
@ -394,35 +364,20 @@ function TableField({
display: 'flex',
alignItems: 'center',
gap: '20px',
width: '50',
justifyContent: 'flex-start'
}}
>
<InvenTreeIcon icon={field_data[0].name} />
<Text>{field_data[0].label}</Text>
<InvenTreeIcon icon={field.icon ?? field.name} />
</td>
<td>
<Text>{field.label}</Text>
</td>
<td style={{ minWidth: '40%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
flexGrow: '1'
}}
>
{field_data.map((data: DetailsField, index: number) => {
let FieldType: any = getFieldType(data.type);
return (
<FieldType
field_data={data}
field_value={field_value[index]}
unit={unit}
key={index}
/>
);
})}
</div>
{field_data[0].copy && <CopyField value={`${field_value[0]}`} />}
</div>
<FieldType field_data={field} field_value={item[field.name]} />
</td>
<td style={{ width: '50' }}>
{field.copy && <CopyField value={item[field.name]} />}
</td>
</tr>
);
@ -430,50 +385,20 @@ function TableField({
export function DetailsTable({
item,
fields,
partIcons = false
fields
}: {
item: any;
fields: DetailsField[][];
partIcons?: boolean;
fields: DetailsField[];
}) {
return (
<Paper p="xs" withBorder radius="xs">
<Table striped>
<tbody>
{partIcons && (
<tr>
<PartIcons
assembly={item.assembly}
template={item.is_template}
component={item.component}
trackable={item.trackable}
purchaseable={item.purchaseable}
saleable={item.salable}
virtual={item.virtual}
active={item.active}
/>
</tr>
)}
{fields.map((data: DetailsField[], index: number) => {
let value: FieldValueType[] = [];
for (const val of data) {
if (val.value_formatter) {
value.push(undefined);
} else {
value.push(item[val.name]);
}
}
return (
<TableField
field_data={data}
field_value={value}
key={index}
unit={item.units}
/>
);
})}
{fields
.filter((field: DetailsField) => !field.hidden)
.map((field: DetailsField, index: number) => (
<DetailsTableField field={field} item={item} key={index} />
))}
</tbody>
</Table>
</Paper>

View File

@ -4,7 +4,6 @@ import {
Button,
Group,
Image,
Modal,
Overlay,
Paper,
Text,
@ -12,9 +11,9 @@ import {
useMantineTheme
} from '@mantine/core';
import { Dropzone, FileWithPath, IMAGE_MIME_TYPE } from '@mantine/dropzone';
import { useDisclosure, useHover } from '@mantine/hooks';
import { useHover } from '@mantine/hooks';
import { modals } from '@mantine/modals';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { api } from '../../App';
import { UserRoles } from '../../enums/Roles';
@ -22,8 +21,8 @@ import { InvenTreeIcon } from '../../functions/icons';
import { useUserState } from '../../states/UserState';
import { PartThumbTable } from '../../tables/part/PartThumbTable';
import { ActionButton } from '../buttons/ActionButton';
import { ApiImage } from '../images/ApiImage';
import { StylishText } from '../items/StylishText';
import { ApiImage } from './ApiImage';
/**
* Props for detail image
@ -32,7 +31,7 @@ export type DetailImageProps = {
appRole: UserRoles;
src: string;
apiPath: string;
refresh: () => void;
refresh?: () => void;
imageActions?: DetailImageButtonProps;
pk: string;
};
@ -267,7 +266,10 @@ function ImageActionButtons({
variant="outline"
size="lg"
tooltipAlignment="top"
onClick={() => {
onClick={(event: any) => {
event?.preventDefault();
event?.stopPropagation();
event?.nativeEvent?.stopImmediatePropagation();
modals.open({
title: <StylishText size="xl">{t`Select Image`}</StylishText>,
size: 'xxl',
@ -285,7 +287,10 @@ function ImageActionButtons({
variant="outline"
size="lg"
tooltipAlignment="top"
onClick={() => {
onClick={(event: any) => {
event?.preventDefault();
event?.stopPropagation();
event?.nativeEvent?.stopImmediatePropagation();
modals.open({
title: <StylishText size="xl">{t`Upload Image`}</StylishText>,
children: (
@ -304,7 +309,12 @@ function ImageActionButtons({
variant="outline"
size="lg"
tooltipAlignment="top"
onClick={() => removeModal(apiPath, setImage)}
onClick={(event: any) => {
event?.preventDefault();
event?.stopPropagation();
event?.nativeEvent?.stopImmediatePropagation();
removeModal(apiPath, setImage);
}}
/>
)}
</Group>
@ -324,11 +334,30 @@ export function DetailsImage(props: DetailImageProps) {
// Sets a new image, and triggers upstream instance refresh
const setAndRefresh = (image: string) => {
setImg(image);
props.refresh();
props.refresh && props.refresh();
};
const permissions = useUserState();
const hasOverlay: boolean = useMemo(() => {
return (
props.imageActions?.selectExisting ||
props.imageActions?.uploadFile ||
props.imageActions?.deleteFile ||
false
);
}, [props.imageActions]);
const expandImage = (event: any) => {
event?.preventDefault();
event?.stopPropagation();
event?.nativeEvent?.stopImmediatePropagation();
modals.open({
children: <ApiImage src={img} />,
withCloseButton: false
});
};
return (
<>
<AspectRatio ref={ref} maw={IMAGE_DIMENSION} ratio={1}>
@ -337,25 +366,22 @@ export function DetailsImage(props: DetailImageProps) {
src={img}
height={IMAGE_DIMENSION}
width={IMAGE_DIMENSION}
onClick={() => {
modals.open({
children: <ApiImage src={img} />,
withCloseButton: false
});
}}
onClick={expandImage}
/>
{permissions.hasChangeRole(props.appRole) && hovered && (
<Overlay color="black" opacity={0.8}>
<ImageActionButtons
visible={hovered}
actions={props.imageActions}
apiPath={props.apiPath}
hasImage={props.src ? true : false}
pk={props.pk}
setImage={setAndRefresh}
/>
</Overlay>
)}
{permissions.hasChangeRole(props.appRole) &&
hasOverlay &&
hovered && (
<Overlay color="black" opacity={0.8} onClick={expandImage}>
<ImageActionButtons
visible={hovered}
actions={props.imageActions}
apiPath={props.apiPath}
hasImage={props.src ? true : false}
pk={props.pk}
setImage={setAndRefresh}
/>
</Overlay>
)}
</>
</AspectRatio>
</>

View File

@ -0,0 +1,14 @@
import { Paper, SimpleGrid } from '@mantine/core';
import React from 'react';
import { DetailImageButtonProps } from './DetailsImage';
export function ItemDetailsGrid(props: React.PropsWithChildren<{}>) {
return (
<Paper p="xs">
<SimpleGrid cols={2} spacing="xs" verticalSpacing="xs">
{props.children}
</SimpleGrid>
</Paper>
);
}

View File

@ -0,0 +1,90 @@
import { Trans, t } from '@lingui/macro';
import { Badge, Tooltip } from '@mantine/core';
import { InvenTreeIcon } from '../../functions/icons';
/**
* Fetches and wraps an InvenTreeIcon in a flex div
* @param icon name of icon
*
*/
function PartIcon(icon: string) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
<InvenTreeIcon icon={icon} />
</div>
);
}
/**
* Generates a table cell with Part icons.
* Only used for Part Model Details
*/
export function PartIcons({ part }: { part: any }) {
return (
<td colSpan={2}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
{!part.active && (
<Tooltip label={t`Part is not active`}>
<Badge color="red" variant="filled">
<div
style={{ display: 'flex', alignItems: 'center', gap: '5px' }}
>
<InvenTreeIcon icon="inactive" iconProps={{ size: 19 }} />{' '}
<Trans>Inactive</Trans>
</div>
</Badge>
</Tooltip>
)}
{part.template && (
<Tooltip
label={t`Part is a template part (variants can be made from this part)`}
children={PartIcon('template')}
/>
)}
{part.assembly && (
<Tooltip
label={t`Part can be assembled from other parts`}
children={PartIcon('assembly')}
/>
)}
{part.component && (
<Tooltip
label={t`Part can be used in assemblies`}
children={PartIcon('component')}
/>
)}
{part.trackable && (
<Tooltip
label={t`Part stock is tracked by serial number`}
children={PartIcon('trackable')}
/>
)}
{part.purchaseable && (
<Tooltip
label={t`Part can be purchased from external suppliers`}
children={PartIcon('purchaseable')}
/>
)}
{part.saleable && (
<Tooltip
label={t`Part can be sold to customers`}
children={PartIcon('saleable')}
/>
)}
{part.virtual && (
<Tooltip label={t`Part is virtual (not a physical part)`}>
<Badge color="yellow" variant="filled">
<div
style={{ display: 'flex', alignItems: 'center', gap: '5px' }}
>
<InvenTreeIcon icon="virtual" iconProps={{ size: 18 }} />{' '}
<Trans>Virtual</Trans>
</div>
</Badge>
</Tooltip>
)}
</div>
</td>
);
}

View File

@ -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;
}) => {

View File

@ -2,32 +2,44 @@ import {
Icon123,
IconBinaryTree2,
IconBookmarks,
IconBox,
IconBuilding,
IconBuildingFactory2,
IconBuildingStore,
IconCalendar,
IconCalendarStats,
IconCheck,
IconClipboardList,
IconCopy,
IconCornerUpRightDouble,
IconCurrencyDollar,
IconDotsCircleHorizontal,
IconExternalLink,
IconFileUpload,
IconGitBranch,
IconGridDots,
IconHash,
IconLayersLinked,
IconLink,
IconList,
IconListTree,
IconMail,
IconMapPin,
IconMapPinHeart,
IconNotes,
IconNumbers,
IconPackage,
IconPackageImport,
IconPackages,
IconPaperclip,
IconPhone,
IconPhoto,
IconProgressCheck,
IconQuestionMark,
IconRulerMeasure,
IconShoppingCart,
IconShoppingCartHeart,
IconSitemap,
IconStack2,
IconStatusChange,
IconTag,
@ -41,6 +53,7 @@ import {
IconUserStar,
IconUsersGroup,
IconVersions,
IconWorld,
IconWorldCode,
IconX
} from '@tabler/icons-react';
@ -67,6 +80,8 @@ const icons: { [key: string]: (props: TablerIconsProps) => React.JSX.Element } =
revision: IconGitBranch,
units: IconRulerMeasure,
keywords: IconTag,
status: IconInfoCircle,
info: IconInfoCircle,
details: IconInfoCircle,
parameters: IconList,
stock: IconPackages,
@ -77,8 +92,10 @@ const icons: { [key: string]: (props: TablerIconsProps) => React.JSX.Element } =
used_in: IconStack2,
manufacturers: IconBuildingFactory2,
suppliers: IconBuilding,
customers: IconBuildingStore,
purchase_orders: IconShoppingCart,
sales_orders: IconTruckDelivery,
shipment: IconTruckDelivery,
scheduling: IconCalendarStats,
test_templates: IconTestPipe,
related_parts: IconLayersLinked,
@ -91,6 +108,7 @@ const icons: { [key: string]: (props: TablerIconsProps) => React.JSX.Element } =
delete: IconTrash,
// Part Icons
active: IconCheck,
template: IconCopy,
assembly: IconTool,
component: IconGridDots,
@ -99,19 +117,31 @@ const icons: { [key: string]: (props: TablerIconsProps) => React.JSX.Element } =
saleable: IconCurrencyDollar,
virtual: IconWorldCode,
inactive: IconX,
part: IconBox,
supplier_part: IconPackageImport,
calendar: IconCalendar,
external: IconExternalLink,
creation_date: IconCalendarTime,
location: IconMapPin,
default_location: IconMapPinHeart,
default_supplier: IconShoppingCartHeart,
link: IconLink,
responsible: IconUserStar,
pricing: IconCurrencyDollar,
currency: IconCurrencyDollar,
stocktake: IconClipboardList,
user: IconUser,
group: IconUsersGroup,
check: IconCheck,
copy: IconCopy
copy: IconCopy,
quantity: IconNumbers,
progress: IconProgressCheck,
reference: IconHash,
website: IconWorld,
email: IconMail,
phone: IconPhone,
sitemap: IconSitemap
};
/**
@ -138,3 +168,6 @@ export function InvenTreeIcon(props: IconProps) {
return <Icon {...props.iconProps} />;
}
function IconShapes(props: TablerIconsProps): Element {
throw new Error('Function not implemented.');
}

View File

@ -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 '';
}

View File

@ -1,5 +1,5 @@
import { t } from '@lingui/macro';
import { Group, LoadingOverlay, Skeleton, Stack, Table } from '@mantine/core';
import { Grid, LoadingOverlay, Skeleton, Stack } from '@mantine/core';
import {
IconClipboardCheck,
IconClipboardList,
@ -17,6 +17,9 @@ import {
import { useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { DetailsField, DetailsTable } from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import {
ActionDropdown,
DuplicateItemAction,
@ -33,6 +36,7 @@ import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import { buildOrderFields } from '../../forms/BuildForms';
import { partCategoryFields } from '../../forms/PartForms';
import { useEditApiFormModal } from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance';
import { apiUrl } from '../../states/ApiState';
@ -63,36 +67,127 @@ export default function BuildDetail() {
refetchOnMount: true
});
const buildDetailsPanel = useMemo(() => {
const detailsPanel = useMemo(() => {
if (instanceQuery.isFetching) {
return <Skeleton />;
}
let tl: DetailsField[] = [
{
type: 'link',
name: 'part',
label: t`Part`,
model: ModelType.part
},
{
type: 'status',
name: 'status',
label: t`Status`,
model: ModelType.build
},
{
type: 'text',
name: 'reference',
label: t`Reference`
},
{
type: 'text',
name: 'title',
label: t`Description`,
icon: 'description'
},
{
type: 'link',
name: 'parent',
icon: 'builds',
label: t`Parent Build`,
model_field: 'reference',
model: ModelType.build,
hidden: !build.parent
}
];
let tr: DetailsField[] = [
{
type: 'text',
name: 'quantity',
label: t`Build Quantity`
},
{
type: 'progressbar',
name: 'completed',
icon: 'progress',
total: build.quantity,
progress: build.completed,
label: t`Completed Outputs`
},
{
type: 'link',
name: 'sales_order',
label: t`Sales Order`,
icon: 'sales_orders',
model: ModelType.salesorder,
model_field: 'reference',
hidden: !build.sales_order
}
];
let bl: DetailsField[] = [
{
type: 'text',
name: 'issued_by',
label: t`Issued By`,
badge: 'user'
},
{
type: 'text',
name: 'responsible',
label: t`Responsible`,
badge: 'owner',
hidden: !build.responsible
}
];
let br: DetailsField[] = [
{
type: 'link',
name: 'take_from',
icon: 'location',
model: ModelType.stocklocation,
label: t`Source Location`,
backup_value: t`Any location`
},
{
type: 'link',
name: 'destination',
icon: 'location',
model: ModelType.stocklocation,
label: t`Destination Location`,
hidden: !build.destination
}
];
return (
<Group position="apart" grow>
<Table striped>
<tbody>
<tr>
<td>{t`Base Part`}</td>
<td>{build.part_detail?.name}</td>
</tr>
<tr>
<td>{t`Quantity`}</td>
<td>{build.quantity}</td>
</tr>
<tr>
<td>{t`Build Status`}</td>
<td>
{build?.status && (
<StatusRenderer
status={build.status}
type={ModelType.build}
/>
)}
</td>
</tr>
</tbody>
</Table>
<Table></Table>
</Group>
<ItemDetailsGrid>
<Grid>
<Grid.Col span={4}>
<DetailsImage
appRole={UserRoles.part}
apiPath={ApiEndpoints.part_list}
src={build.part_detail?.image ?? build.part_detail?.thumbnail}
pk={build.part}
/>
</Grid.Col>
<Grid.Col span={8}>
<DetailsTable fields={tl} item={build} />
</Grid.Col>
</Grid>
<DetailsTable fields={tr} item={build} />
<DetailsTable fields={bl} item={build} />
<DetailsTable fields={br} item={build} />
</ItemDetailsGrid>
);
}, [build]);
}, [build, instanceQuery]);
const buildPanels: PanelType[] = useMemo(() => {
return [
@ -100,7 +195,7 @@ export default function BuildDetail() {
name: 'details',
label: t`Build Details`,
icon: <IconInfoCircle />,
content: buildDetailsPanel
content: detailsPanel
},
{
name: 'allocate-stock',
@ -259,7 +354,7 @@ export default function BuildDetail() {
title={build.reference}
subtitle={build.title}
detail={buildDetail}
imageUrl={build.part_detail?.thumbnail}
imageUrl={build.part_detail?.image ?? build.part_detail?.thumbnail}
breadcrumbs={[
{ name: t`Build Orders`, url: '/build' },
{ name: build.reference, url: `/build/${build.pk}` }

View File

@ -1,5 +1,5 @@
import { t } from '@lingui/macro';
import { LoadingOverlay, Skeleton, Stack } from '@mantine/core';
import { Grid, LoadingOverlay, Skeleton, Stack } from '@mantine/core';
import {
IconBuildingFactory2,
IconBuildingWarehouse,
@ -18,6 +18,9 @@ import {
import { useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { DetailsField, DetailsTable } from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import {
ActionDropdown,
DeleteItemAction,
@ -69,12 +72,99 @@ export default function CompanyDetail(props: CompanyDetailProps) {
refetchOnMount: true
});
const detailsPanel = useMemo(() => {
if (instanceQuery.isFetching) {
return <Skeleton />;
}
let tl: DetailsField[] = [
{
type: 'text',
name: 'description',
label: t`Description`
},
{
type: 'link',
name: 'website',
label: t`Website`,
external: true,
copy: true,
hidden: !company.website
},
{
type: 'text',
name: 'phone',
label: t`Phone Number`,
copy: true,
hidden: !company.phone
},
{
type: 'text',
name: 'email',
label: t`Email Address`,
copy: true,
hidden: !company.email
}
];
let tr: DetailsField[] = [
{
type: 'string',
name: 'currency',
label: t`Default Currency`
},
{
type: 'boolean',
name: 'is_supplier',
label: t`Supplier`,
icon: 'suppliers'
},
{
type: 'boolean',
name: 'is_manufacturer',
label: t`Manufacturer`,
icon: 'manufacturers'
},
{
type: 'boolean',
name: 'is_customer',
label: t`Customer`,
icon: 'customers'
}
];
return (
<ItemDetailsGrid>
<Grid>
<Grid.Col span={4}>
<DetailsImage
appRole={UserRoles.purchase_order}
apiPath={ApiEndpoints.company_list}
src={company.image}
pk={company.pk}
refresh={refreshInstance}
imageActions={{
uploadFile: true,
deleteFile: true
}}
/>
</Grid.Col>
<Grid.Col span={8}>
<DetailsTable item={company} fields={tl} />
</Grid.Col>
</Grid>
<DetailsTable item={company} fields={tr} />
</ItemDetailsGrid>
);
}, [company, instanceQuery]);
const companyPanels: PanelType[] = useMemo(() => {
return [
{
name: 'details',
label: t`Details`,
icon: <IconInfoCircle />
icon: <IconInfoCircle />,
content: detailsPanel
},
{
name: 'manufactured-parts',

View File

@ -1,21 +1,24 @@
import { t } from '@lingui/macro';
import { LoadingOverlay, Stack, Text } from '@mantine/core';
import { LoadingOverlay, Skeleton, Stack, Text } from '@mantine/core';
import {
IconCategory,
IconInfoCircle,
IconListDetails,
IconSitemap
} from '@tabler/icons-react';
import { useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import { DetailsField, DetailsTable } from '../../components/details/Details';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { PartCategoryTree } from '../../components/nav/PartCategoryTree';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { useInstance } from '../../hooks/UseInstance';
import ParametricPartTable from '../../tables/part/ParametricPartTable';
import { PartCategoryTable } from '../../tables/part/PartCategoryTable';
import { PartParameterTable } from '../../tables/part/PartParameterTable';
import { PartListTable } from '../../tables/part/PartTable';
/**
@ -45,8 +48,86 @@ export default function CategoryDetail({}: {}) {
}
});
const detailsPanel = useMemo(() => {
if (id && instanceQuery.isFetching) {
return <Skeleton />;
}
let left: DetailsField[] = [
{
type: 'text',
name: 'name',
label: t`Name`,
copy: true
},
{
type: 'text',
name: 'pathstring',
label: t`Path`,
icon: 'sitemap',
copy: true,
hidden: !id
},
{
type: 'text',
name: 'description',
label: t`Description`,
copy: true
},
{
type: 'link',
name: 'parent',
model_field: 'name',
icon: 'location',
label: t`Parent Category`,
model: ModelType.partcategory,
hidden: !category?.parent
}
];
let right: DetailsField[] = [
{
type: 'text',
name: 'part_count',
label: t`Parts`,
icon: 'part'
},
{
type: 'text',
name: 'subcategories',
label: t`Subcategories`,
icon: 'sitemap',
hidden: !category?.subcategories
},
{
type: 'boolean',
name: 'structural',
label: t`Structural`,
icon: 'sitemap'
}
];
return (
<ItemDetailsGrid>
{id && category?.pk ? (
<DetailsTable item={category} fields={left} />
) : (
<Text>{t`Top level part category`}</Text>
)}
{id && category?.pk && <DetailsTable item={category} fields={right} />}
</ItemDetailsGrid>
);
}, [category, instanceQuery]);
const categoryPanels: PanelType[] = useMemo(
() => [
{
name: 'details',
label: t`Category Details`,
icon: <IconInfoCircle />,
content: detailsPanel
// hidden: !category?.pk,
},
{
name: 'parts',
label: t`Parts`,

View File

@ -1,5 +1,12 @@
import { t } from '@lingui/macro';
import { Group, LoadingOverlay, Skeleton, Stack, Text } from '@mantine/core';
import {
Grid,
Group,
LoadingOverlay,
Skeleton,
Stack,
Text
} from '@mantine/core';
import {
IconBookmarks,
IconBuilding,
@ -28,6 +35,10 @@ import { useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import { api } from '../../App';
import { DetailsField, DetailsTable } from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import { PartIcons } from '../../components/details/PartIcons';
import {
ActionDropdown,
BarcodeActionDropdown,
@ -51,12 +62,6 @@ import { useEditApiFormModal } from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
import { DetailsField } from '../../tables/Details';
import {
DetailsImageType,
ItemDetailFields,
ItemDetails
} from '../../tables/ItemDetails';
import { BomTable } from '../../tables/bom/BomTable';
import { UsedInTable } from '../../tables/bom/UsedInTable';
import { BuildOrderTable } from '../../tables/build/BuildOrderTable';
@ -93,188 +98,186 @@ export default function PartDetail() {
refetchOnMount: true
});
const detailFields = (part: any): ItemDetailFields => {
let left: DetailsField[][] = [];
let right: DetailsField[][] = [];
let bottom_right: DetailsField[][] = [];
let bottom_left: DetailsField[][] = [];
const detailsPanel = useMemo(() => {
if (instanceQuery.isFetching) {
return <Skeleton />;
}
let image: DetailsImageType = {
name: 'image',
imageActions: {
selectExisting: true,
uploadFile: true,
deleteFile: true
}
};
left.push([
// Construct the details tables
let tl: DetailsField[] = [
{
type: 'text',
name: 'description',
label: t`Description`,
copy: true
},
{
type: 'link',
name: 'variant_of',
label: t`Variant of`,
model: ModelType.part,
hidden: !part.variant_of
},
{
type: 'link',
name: 'category',
label: t`Category`,
model: ModelType.partcategory
},
{
type: 'link',
name: 'default_location',
label: t`Default Location`,
model: ModelType.stocklocation,
hidden: !part.default_location
},
{
type: 'string',
name: 'IPN',
label: t`IPN`,
copy: true,
hidden: !part.IPN
},
{
type: 'string',
name: 'revision',
label: t`Revision`,
copy: true,
hidden: !part.revision
},
{
type: 'string',
name: 'units',
label: t`Units`,
copy: true,
hidden: !part.units
},
{
type: 'string',
name: 'keywords',
label: t`Keywords`,
copy: true,
hidden: !part.keywords
},
{
type: 'link',
name: 'link',
label: t`Link`,
external: true,
copy: true,
hidden: !part.link
}
]);
];
if (part.variant_of) {
left.push([
{
type: 'link',
name: 'variant_of',
label: t`Variant of`,
model: ModelType.part
}
]);
}
right.push([
let tr: DetailsField[] = [
{
type: 'string',
name: 'unallocated_stock',
unit: true,
label: t`Available Stock`
}
]);
right.push([
},
{
type: 'string',
name: 'total_in_stock',
unit: true,
label: t`In Stock`
},
{
type: 'string',
name: 'minimum_stock',
unit: true,
label: t`Minimum Stock`,
hidden: part.minimum_stock <= 0
},
{
type: 'string',
name: 'ordering',
label: t`On order`,
unit: true,
hidden: part.ordering <= 0
},
{
type: 'progressbar',
name: 'allocated_to_build_orders',
total: part.required_for_build_orders,
progress: part.allocated_to_build_orders,
label: t`Allocated to Build Orders`,
hidden:
!part.assembly ||
(part.allocated_to_build_orders <= 0 &&
part.required_for_build_orders <= 0)
},
{
type: 'progressbar',
name: 'allocated_to_sales_orders',
total: part.required_for_sales_orders,
progress: part.allocated_to_sales_orders,
label: t`Allocated to Sales Orders`,
hidden:
!part.salable ||
(part.allocated_to_sales_orders <= 0 &&
part.required_for_sales_orders <= 0)
},
{
type: 'string',
name: 'can_build',
unit: true,
label: t`Can Build`,
hidden: !part.assembly
},
{
type: 'string',
name: 'building',
unit: true,
label: t`Building`,
hidden: !part.assembly
}
]);
];
if (part.minimum_stock) {
right.push([
{
type: 'string',
name: 'minimum_stock',
unit: true,
label: t`Minimum Stock`
}
]);
}
let bl: DetailsField[] = [
{
type: 'boolean',
name: 'active',
label: t`Active`
},
{
type: 'boolean',
name: 'template',
label: t`Template Part`
},
{
type: 'boolean',
name: 'assembly',
label: t`Assembled Part`
},
{
type: 'boolean',
name: 'component',
label: t`Component Part`
},
{
type: 'boolean',
name: 'trackable',
label: t`Trackable Part`
},
{
type: 'boolean',
name: 'purchaseable',
label: t`Purchaseable Part`
},
{
type: 'boolean',
name: 'saleable',
label: t`Saleable Part`
},
{
type: 'boolean',
name: 'virtual',
label: t`Virtual Part`
}
];
if (part.ordering <= 0) {
right.push([
{
type: 'string',
name: 'ordering',
label: t`On order`,
unit: true
}
]);
}
if (
part.assembly &&
(part.allocated_to_build_orders > 0 || part.required_for_build_orders > 0)
) {
right.push([
{
type: 'progressbar',
name: 'allocated_to_build_orders',
total: part.required_for_build_orders,
progress: part.allocated_to_build_orders,
label: t`Allocated to Build Orders`
}
]);
}
if (
part.salable &&
(part.allocated_to_sales_orders > 0 || part.required_for_sales_orders > 0)
) {
right.push([
{
type: 'progressbar',
name: 'allocated_to_sales_orders',
total: part.required_for_sales_orders,
progress: part.allocated_to_sales_orders,
label: t`Allocated to Sales Orders`
}
]);
}
if (part.assembly) {
right.push([
{
type: 'string',
name: 'can_build',
unit: true,
label: t`Can Build`
}
]);
}
if (part.assembly) {
right.push([
{
type: 'string',
name: 'building',
unit: true,
label: t`Building`
}
]);
}
if (part.category) {
bottom_left.push([
{
type: 'link',
name: 'category',
label: t`Category`,
model: ModelType.partcategory
}
]);
}
if (part.IPN) {
bottom_left.push([
{
type: 'string',
name: 'IPN',
label: t`IPN`,
copy: true
}
]);
}
if (part.revision) {
bottom_left.push([
{
type: 'string',
name: 'revision',
label: t`Revision`,
copy: true
}
]);
}
if (part.units) {
bottom_left.push([
{
type: 'string',
name: 'units',
label: t`Units`
}
]);
}
if (part.keywords) {
bottom_left.push([
{
type: 'string',
name: 'keywords',
label: t`Keywords`,
copy: true
}
]);
}
bottom_right.push([
let br: DetailsField[] = [
{
type: 'string',
name: 'creation_date',
@ -283,181 +286,169 @@ export default function PartDetail() {
{
type: 'string',
name: 'creation_user',
badge: 'user'
label: t`Created By`,
badge: 'user',
icon: 'user'
},
{
type: 'string',
name: 'responsible',
label: t`Responsible`,
badge: 'owner',
hidden: !part.responsible
},
{
type: 'link',
name: 'default_supplier',
label: t`Default Supplier`,
model: ModelType.supplierpart,
hidden: !part.default_supplier
}
]);
];
// Add in price range data
id &&
bottom_right.push([
{
type: 'string',
name: 'pricing',
label: t`Price Range`,
value_formatter: () => {
const { data } = useSuspenseQuery({
queryKey: ['pricing', id],
queryFn: async () => {
const url = apiUrl(ApiEndpoints.part_pricing_get, null, {
id: id
br.push({
type: 'string',
name: 'pricing',
label: t`Price Range`,
value_formatter: () => {
const { data } = useSuspenseQuery({
queryKey: ['pricing', id],
queryFn: async () => {
const url = apiUrl(ApiEndpoints.part_pricing_get, null, {
id: id
});
return api
.get(url)
.then((response) => {
switch (response.status) {
case 200:
return response.data;
default:
return null;
}
})
.catch(() => {
return null;
});
}
});
return `${formatPriceRange(data.overall_min, data.overall_max)}${
part.units && ' / ' + part.units
}`;
}
});
return api
.get(url)
.then((response) => {
switch (response.status) {
case 200:
return response.data;
default:
return null;
}
})
.catch(() => {
return null;
});
}
});
return `${formatPriceRange(data.overall_min, data.overall_max)}${
part.units && ' / ' + part.units
}`;
// Add in stocktake information
if (id && part.last_stocktake) {
br.push({
type: 'string',
name: 'stocktake',
label: t`Last Stocktake`,
unit: true,
value_formatter: () => {
const { data } = useSuspenseQuery({
queryKey: ['stocktake', id],
queryFn: async () => {
const url = apiUrl(ApiEndpoints.part_stocktake_list);
return api
.get(url, { params: { part: id, ordering: 'date' } })
.then((response) => {
switch (response.status) {
case 200:
return response.data[response.data.length - 1];
default:
return null;
}
})
.catch(() => {
return null;
});
}
});
if (data.quantity) {
return `${data.quantity} (${data.date})`;
} else {
return '-';
}
}
]);
});
id &&
part.last_stocktake &&
bottom_right.push([
{
type: 'string',
name: 'stocktake',
label: t`Last Stocktake`,
unit: true,
value_formatter: () => {
const { data } = useSuspenseQuery({
queryKey: ['stocktake', id],
queryFn: async () => {
const url = apiUrl(ApiEndpoints.part_stocktake_list);
br.push({
type: 'string',
name: 'stocktake_user',
label: t`Stocktake By`,
badge: 'user',
icon: 'user',
value_formatter: () => {
const { data } = useSuspenseQuery({
queryKey: ['stocktake', id],
queryFn: async () => {
const url = apiUrl(ApiEndpoints.part_stocktake_list);
return api
.get(url, { params: { part: id, ordering: 'date' } })
.then((response) => {
switch (response.status) {
case 200:
return response.data[response.data.length - 1];
default:
return null;
}
})
.catch(() => {
return null;
});
}
});
return data?.quantity;
}
},
{
type: 'string',
name: 'stocktake_user',
badge: 'user',
value_formatter: () => {
const { data } = useSuspenseQuery({
queryKey: ['stocktake', id],
queryFn: async () => {
const url = apiUrl(ApiEndpoints.part_stocktake_list);
return api
.get(url, { params: { part: id, ordering: 'date' } })
.then((response) => {
switch (response.status) {
case 200:
return response.data[response.data.length - 1];
default:
return null;
}
})
.catch(() => {
return null;
});
}
});
return data?.user;
}
return api
.get(url, { params: { part: id, ordering: 'date' } })
.then((response) => {
switch (response.status) {
case 200:
return response.data[response.data.length - 1];
default:
return null;
}
})
.catch(() => {
return null;
});
}
});
return data?.user;
}
]);
if (part.default_location) {
bottom_right.push([
{
type: 'link',
name: 'default_location',
label: t`Default Location`,
model: ModelType.stocklocation
}
]);
});
}
if (part.default_supplier) {
bottom_right.push([
{
type: 'link',
name: 'default_supplier',
label: t`Default Supplier`,
model: ModelType.supplierpart
}
]);
}
if (part.link) {
bottom_right.push([
{
type: 'link',
name: 'link',
label: t`Link`,
external: true,
copy: true
}
]);
}
if (part.responsible) {
bottom_right.push([
{
type: 'string',
name: 'responsible',
label: t`Responsible`,
badge: 'owner'
}
]);
}
let fields: ItemDetailFields = {
left: left,
right: right,
bottom_left: bottom_left,
bottom_right: bottom_right,
image: image
};
return fields;
};
return (
<ItemDetailsGrid>
<Grid>
<Grid.Col span={4}>
<DetailsImage
appRole={UserRoles.part}
imageActions={{
selectExisting: true,
uploadFile: true,
deleteFile: true
}}
src={part.image}
apiPath={apiUrl(ApiEndpoints.part_list, part.pk)}
refresh={refreshInstance}
pk={part.pk}
/>
</Grid.Col>
<Grid.Col span={8}>
<Stack spacing="xs">
<PartIcons part={part} />
<DetailsTable fields={tl} item={part} />
</Stack>
</Grid.Col>
</Grid>
<DetailsTable fields={tr} item={part} />
<DetailsTable fields={bl} item={part} />
<DetailsTable fields={br} item={part} />
</ItemDetailsGrid>
);
}, [part, instanceQuery]);
// Part data panels (recalculate when part data changes)
const partPanels: PanelType[] = useMemo(() => {
return [
{
name: 'details',
label: t`Details`,
label: t`Part Details`,
icon: <IconInfoCircle />,
content: !instanceQuery.isFetching && (
<ItemDetails
appRole={UserRoles.part}
params={part}
apiPath={apiUrl(ApiEndpoints.part_list, part.pk)}
refresh={refreshInstance}
fields={detailFields(part)}
partModel
/>
)
content: detailsPanel
},
{
name: 'parameters',

View File

@ -1,5 +1,5 @@
import { t } from '@lingui/macro';
import { LoadingOverlay, Stack } from '@mantine/core';
import { Grid, LoadingOverlay, Skeleton, Stack } from '@mantine/core';
import {
IconDots,
IconInfoCircle,
@ -11,6 +11,9 @@ import {
import { useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { DetailsField, DetailsTable } from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import {
ActionDropdown,
BarcodeActionDropdown,
@ -24,6 +27,10 @@ import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { NotesEditor } from '../../components/widgets/MarkdownEditor';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import { purchaseOrderFields } from '../../forms/PurchaseOrderForms';
import { useEditApiFormModal } from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
@ -39,7 +46,11 @@ export default function PurchaseOrderDetail() {
const user = useUserState();
const { instance: order, instanceQuery } = useInstance({
const {
instance: order,
instanceQuery,
refreshInstance
} = useInstance({
endpoint: ApiEndpoints.purchase_order_list,
pk: id,
params: {
@ -48,12 +59,167 @@ export default function PurchaseOrderDetail() {
refetchOnMount: true
});
const editPurchaseOrder = useEditApiFormModal({
url: ApiEndpoints.purchase_order_list,
pk: id,
title: t`Edit Purchase Order`,
fields: purchaseOrderFields(),
onFormSuccess: () => {
refreshInstance();
}
});
const detailsPanel = useMemo(() => {
if (instanceQuery.isFetching) {
return <Skeleton />;
}
let tl: DetailsField[] = [
{
type: 'text',
name: 'reference',
label: t`Reference`,
copy: true
},
{
type: 'text',
name: 'supplier_reference',
label: t`Supplier Reference`,
icon: 'reference',
hidden: !order.supplier_reference,
copy: true
},
{
type: 'link',
name: 'supplier',
icon: 'suppliers',
label: t`Supplier`,
model: ModelType.company
},
{
type: 'text',
name: 'description',
label: t`Description`,
copy: true
},
{
type: 'status',
name: 'status',
label: t`Status`,
model: ModelType.purchaseorder
}
];
let tr: DetailsField[] = [
{
type: 'text',
name: 'line_items',
label: t`Line Items`,
icon: 'list'
},
{
type: 'progressbar',
name: 'completed',
icon: 'progress',
label: t`Completed Line Items`,
total: order.line_items,
progress: order.completed_lines
},
{
type: 'progressbar',
name: 'shipments',
icon: 'shipment',
label: t`Completed Shipments`,
total: order.shipments,
progress: order.completed_shipments
// TODO: Fix this progress bar
},
{
type: 'text',
name: 'currency',
label: t`Order Currency,`
},
{
type: 'text',
name: 'total_cost',
label: t`Total Cost`
// TODO: Implement this!
}
];
let bl: DetailsField[] = [
{
type: 'link',
external: true,
name: 'link',
label: t`Link`,
copy: true,
hidden: !order.link
},
{
type: 'link',
model: ModelType.contact,
link: false,
name: 'contact',
label: t`Contact`,
icon: 'user',
copy: true
}
// TODO: Project code
];
let br: DetailsField[] = [
{
type: 'text',
name: 'creation_date',
label: t`Created On`,
icon: 'calendar'
},
{
type: 'text',
name: 'target_date',
label: t`Target Date`,
icon: 'calendar',
hidden: !order.target_date
},
{
type: 'text',
name: 'responsible',
label: t`Responsible`,
badge: 'owner',
hidden: !order.responsible
}
];
return (
<ItemDetailsGrid>
<Grid>
<Grid.Col span={4}>
<DetailsImage
appRole={UserRoles.purchase_order}
apiPath={ApiEndpoints.company_list}
src={order.supplier_detail?.image}
pk={order.supplier}
/>
</Grid.Col>
<Grid.Col span={8}>
<DetailsTable fields={tl} item={order} />
</Grid.Col>
</Grid>
<DetailsTable fields={tr} item={order} />
<DetailsTable fields={bl} item={order} />
<DetailsTable fields={br} item={order} />
</ItemDetailsGrid>
);
}, [order, instanceQuery]);
const orderPanels: PanelType[] = useMemo(() => {
return [
{
name: 'detail',
label: t`Order Details`,
icon: <IconInfoCircle />
icon: <IconInfoCircle />,
content: detailsPanel
},
{
name: 'line-items',
@ -118,13 +284,21 @@ export default function PurchaseOrderDetail() {
key="order-actions"
tooltip={t`Order Actions`}
icon={<IconDots />}
actions={[EditItemAction({}), DeleteItemAction({})]}
actions={[
EditItemAction({
onClick: () => {
editPurchaseOrder.open();
}
}),
DeleteItemAction({})
]}
/>
];
}, [id, order, user]);
return (
<>
{editPurchaseOrder.modal}
<Stack spacing="xs">
<LoadingOverlay visible={instanceQuery.isFetching} />
<PageDetail

View File

@ -1,13 +1,23 @@
import { t } from '@lingui/macro';
import { LoadingOverlay, Stack } from '@mantine/core';
import { IconInfoCircle, IconNotes, IconPaperclip } from '@tabler/icons-react';
import { Grid, LoadingOverlay, Skeleton, Stack } from '@mantine/core';
import {
IconInfoCircle,
IconList,
IconNotes,
IconPaperclip
} from '@tabler/icons-react';
import { useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { DetailsField, DetailsTable } from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { NotesEditor } from '../../components/widgets/MarkdownEditor';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import { useInstance } from '../../hooks/UseInstance';
import { apiUrl } from '../../states/ApiState';
import { AttachmentTable } from '../../tables/general/AttachmentTable';
@ -26,12 +36,161 @@ export default function ReturnOrderDetail() {
}
});
const detailsPanel = useMemo(() => {
if (instanceQuery.isFetching) {
return <Skeleton />;
}
let tl: DetailsField[] = [
{
type: 'text',
name: 'reference',
label: t`Reference`,
copy: true
},
{
type: 'text',
name: 'customer_reference',
label: t`Customer Reference`,
copy: true,
hidden: !order.customer_reference
},
{
type: 'link',
name: 'customer',
icon: 'customers',
label: t`Customer`,
model: ModelType.company
},
{
type: 'text',
name: 'description',
label: t`Description`,
copy: true
},
{
type: 'status',
name: 'status',
label: t`Status`,
model: ModelType.salesorder
}
];
let tr: DetailsField[] = [
{
type: 'text',
name: 'line_items',
label: t`Line Items`,
icon: 'list'
},
{
type: 'progressbar',
name: 'completed',
icon: 'progress',
label: t`Completed Line Items`,
total: order.line_items,
progress: order.completed_lines
},
{
type: 'progressbar',
name: 'shipments',
icon: 'shipment',
label: t`Completed Shipments`,
total: order.shipments,
progress: order.completed_shipments
// TODO: Fix this progress bar
},
{
type: 'text',
name: 'currency',
label: t`Order Currency,`
},
{
type: 'text',
name: 'total_cost',
label: t`Total Cost`
// TODO: Implement this!
}
];
let bl: DetailsField[] = [
{
type: 'link',
external: true,
name: 'link',
label: t`Link`,
copy: true,
hidden: !order.link
},
{
type: 'link',
model: ModelType.contact,
link: false,
name: 'contact',
label: t`Contact`,
icon: 'user',
copy: true
}
// TODO: Project code
];
let br: DetailsField[] = [
{
type: 'text',
name: 'creation_date',
label: t`Created On`,
icon: 'calendar'
},
{
type: 'text',
name: 'target_date',
label: t`Target Date`,
icon: 'calendar',
hidden: !order.target_date
},
{
type: 'text',
name: 'responsible',
label: t`Responsible`,
badge: 'owner',
hidden: !order.responsible
}
];
return (
<ItemDetailsGrid>
<Grid>
<Grid.Col span={4}>
<DetailsImage
appRole={UserRoles.purchase_order}
apiPath={ApiEndpoints.company_list}
src={order.customer_detail?.image}
pk={order.customer}
/>
</Grid.Col>
<Grid.Col span={8}>
<DetailsTable fields={tl} item={order} />
</Grid.Col>
</Grid>
<DetailsTable fields={tr} item={order} />
<DetailsTable fields={bl} item={order} />
<DetailsTable fields={br} item={order} />
</ItemDetailsGrid>
);
}, [order, instanceQuery]);
const orderPanels: PanelType[] = useMemo(() => {
return [
{
name: 'detail',
label: t`Order Details`,
icon: <IconInfoCircle />
icon: <IconInfoCircle />,
content: detailsPanel
},
{
name: 'line-items',
label: t`Line Items`,
icon: <IconList />
},
{
name: 'attachments',

View File

@ -1,5 +1,5 @@
import { t } from '@lingui/macro';
import { LoadingOverlay, Skeleton, Stack } from '@mantine/core';
import { Grid, LoadingOverlay, Skeleton, Stack } from '@mantine/core';
import {
IconInfoCircle,
IconList,
@ -12,10 +12,15 @@ import {
import { useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { DetailsField, DetailsTable } from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { NotesEditor } from '../../components/widgets/MarkdownEditor';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import { useInstance } from '../../hooks/UseInstance';
import { apiUrl } from '../../states/ApiState';
import { BuildOrderTable } from '../../tables/build/BuildOrderTable';
@ -35,12 +40,156 @@ export default function SalesOrderDetail() {
}
});
const detailsPanel = useMemo(() => {
if (instanceQuery.isFetching) {
return <Skeleton />;
}
let tl: DetailsField[] = [
{
type: 'text',
name: 'reference',
label: t`Reference`,
copy: true
},
{
type: 'text',
name: 'customer_reference',
label: t`Customer Reference`,
copy: true,
hidden: !order.customer_reference
},
{
type: 'link',
name: 'customer',
icon: 'customers',
label: t`Customer`,
model: ModelType.company
},
{
type: 'text',
name: 'description',
label: t`Description`,
copy: true
},
{
type: 'status',
name: 'status',
label: t`Status`,
model: ModelType.salesorder
}
];
let tr: DetailsField[] = [
{
type: 'text',
name: 'line_items',
label: t`Line Items`,
icon: 'list'
},
{
type: 'progressbar',
name: 'completed',
icon: 'progress',
label: t`Completed Line Items`,
total: order.line_items,
progress: order.completed_lines
},
{
type: 'progressbar',
name: 'shipments',
icon: 'shipment',
label: t`Completed Shipments`,
total: order.shipments,
progress: order.completed_shipments
// TODO: Fix this progress bar
},
{
type: 'text',
name: 'currency',
label: t`Order Currency,`
},
{
type: 'text',
name: 'total_cost',
label: t`Total Cost`
// TODO: Implement this!
}
];
let bl: DetailsField[] = [
{
type: 'link',
external: true,
name: 'link',
label: t`Link`,
copy: true,
hidden: !order.link
},
{
type: 'link',
model: ModelType.contact,
link: false,
name: 'contact',
label: t`Contact`,
icon: 'user',
copy: true
}
// TODO: Project code
];
let br: DetailsField[] = [
{
type: 'text',
name: 'creation_date',
label: t`Created On`,
icon: 'calendar'
},
{
type: 'text',
name: 'target_date',
label: t`Target Date`,
icon: 'calendar',
hidden: !order.target_date
},
{
type: 'text',
name: 'responsible',
label: t`Responsible`,
badge: 'owner',
hidden: !order.responsible
}
];
return (
<ItemDetailsGrid>
<Grid>
<Grid.Col span={4}>
<DetailsImage
appRole={UserRoles.purchase_order}
apiPath={ApiEndpoints.company_list}
src={order.customer_detail?.image}
pk={order.customer}
/>
</Grid.Col>
<Grid.Col span={8}>
<DetailsTable fields={tl} item={order} />
</Grid.Col>
</Grid>
<DetailsTable fields={tr} item={order} />
<DetailsTable fields={bl} item={order} />
<DetailsTable fields={br} item={order} />
</ItemDetailsGrid>
);
}, [order, instanceQuery]);
const orderPanels: PanelType[] = useMemo(() => {
return [
{
name: 'detail',
label: t`Order Details`,
icon: <IconInfoCircle />
icon: <IconInfoCircle />,
content: detailsPanel
},
{
name: 'line-items',

View File

@ -1,13 +1,16 @@
import { t } from '@lingui/macro';
import { LoadingOverlay, Stack, Text } from '@mantine/core';
import { IconPackages, IconSitemap } from '@tabler/icons-react';
import { LoadingOverlay, Skeleton, Stack, Text } from '@mantine/core';
import { IconInfoCircle, IconPackages, IconSitemap } from '@tabler/icons-react';
import { useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import { DetailsField, DetailsTable } from '../../components/details/Details';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { StockLocationTree } from '../../components/nav/StockLocationTree';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { useInstance } from '../../hooks/UseInstance';
import { StockItemTable } from '../../tables/stock/StockItemTable';
import { StockLocationTable } from '../../tables/stock/StockLocationTable';
@ -35,8 +38,90 @@ export default function Stock() {
}
});
const detailsPanel = useMemo(() => {
if (id && instanceQuery.isFetching) {
return <Skeleton />;
}
let left: DetailsField[] = [
{
type: 'text',
name: 'name',
label: t`Name`,
copy: true
},
{
type: 'text',
name: 'pathstring',
label: t`Path`,
icon: 'sitemap',
copy: true,
hidden: !id
},
{
type: 'text',
name: 'description',
label: t`Description`,
copy: true
},
{
type: 'link',
name: 'parent',
model_field: 'name',
icon: 'location',
label: t`Parent Location`,
model: ModelType.stocklocation,
hidden: !location?.parent
}
];
let right: DetailsField[] = [
{
type: 'text',
name: 'items',
icon: 'stock',
label: t`Stock Items`
},
{
type: 'text',
name: 'sublocations',
icon: 'location',
label: t`Sublocations`,
hidden: !location?.sublocations
},
{
type: 'boolean',
name: 'structural',
label: t`Structural`,
icon: 'sitemap'
},
{
type: 'boolean',
name: 'external',
label: t`External`
}
];
return (
<ItemDetailsGrid>
{id && location?.pk ? (
<DetailsTable item={location} fields={left} />
) : (
<Text>{t`Top level stock location`}</Text>
)}
{id && location?.pk && <DetailsTable item={location} fields={right} />}
</ItemDetailsGrid>
);
}, [location, instanceQuery]);
const locationPanels: PanelType[] = useMemo(() => {
return [
{
name: 'details',
label: t`Location Details`,
icon: <IconInfoCircle />,
content: detailsPanel
},
{
name: 'stock-items',
label: t`Stock Items`,

View File

@ -1,5 +1,12 @@
import { t } from '@lingui/macro';
import { Alert, LoadingOverlay, Skeleton, Stack, Text } from '@mantine/core';
import {
Alert,
Grid,
LoadingOverlay,
Skeleton,
Stack,
Text
} from '@mantine/core';
import {
IconBookmark,
IconBoxPadding,
@ -20,6 +27,9 @@ import {
import { useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import { DetailsField, DetailsTable } from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import {
ActionDropdown,
BarcodeActionDropdown,
@ -34,6 +44,8 @@ import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { StockLocationTree } from '../../components/nav/StockLocationTree';
import { NotesEditor } from '../../components/widgets/MarkdownEditor';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import { useEditStockItem } from '../../forms/StockForms';
import { useInstance } from '../../hooks/UseInstance';
import { apiUrl } from '../../states/ApiState';
@ -63,12 +75,155 @@ export default function StockDetail() {
}
});
const detailsPanel = useMemo(() => {
let data = stockitem;
data.available_stock = Math.max(0, data.quantity - data.allocated);
if (instanceQuery.isFetching) {
return <Skeleton />;
}
// Top left - core part information
let tl: DetailsField[] = [
{
name: 'part',
label: t`Base Part`,
type: 'link',
model: ModelType.part
},
{
name: 'status',
type: 'text',
label: t`Stock Status`
},
{
type: 'text',
name: 'tests',
label: `Completed Tests`,
icon: 'progress'
},
{
type: 'text',
name: 'updated',
icon: 'calendar',
label: t`Last Updated`
},
{
type: 'text',
name: 'stocktake',
icon: 'calendar',
label: t`Last Stocktake`,
hidden: !stockitem.stocktake
}
];
// Top right - available stock information
let tr: DetailsField[] = [
{
type: 'text',
name: 'quantity',
label: t`Quantity`
},
{
type: 'text',
name: 'serial',
label: t`Serial Number`,
hidden: !stockitem.serial
},
{
type: 'text',
name: 'available_stock',
label: t`Available`
}
// TODO: allocated_to_sales_orders
// TODO: allocated_to_build_orders
];
// Bottom left: location information
let bl: DetailsField[] = [
{
name: 'supplier_part',
label: t`Supplier Part`,
type: 'link',
model: ModelType.supplierpart,
hidden: !stockitem.supplier_part
},
{
type: 'link',
name: 'location',
label: t`Location`,
model: ModelType.stocklocation,
hidden: !stockitem.location
},
{
type: 'link',
name: 'belongs_to',
label: t`Installed In`,
model: ModelType.stockitem,
hidden: !stockitem.belongs_to
},
{
type: 'link',
name: 'consumed_by',
label: t`Consumed By`,
model: ModelType.build,
hidden: !stockitem.consumed_by
},
{
type: 'link',
name: 'sales_order',
label: t`Sales Order`,
model: ModelType.salesorder,
hidden: !stockitem.sales_order
}
];
// Bottom right - any other information
let br: DetailsField[] = [
// TODO: Expiry date
// TODO: Ownership
{
type: 'text',
name: 'packaging',
icon: 'part',
label: t`Packaging`,
hidden: !stockitem.packaging
}
];
return (
<ItemDetailsGrid>
<Grid>
<Grid.Col span={4}>
<DetailsImage
appRole={UserRoles.part}
apiPath={ApiEndpoints.part_list}
src={
stockitem.part_detail?.image ??
stockitem?.part_detail?.thumbnail
}
pk={stockitem.part}
/>
</Grid.Col>
<Grid.Col span={8}>
<DetailsTable fields={tl} item={stockitem} />
</Grid.Col>
</Grid>
<DetailsTable fields={tr} item={stockitem} />
<DetailsTable fields={bl} item={stockitem} />
<DetailsTable fields={br} item={stockitem} />
</ItemDetailsGrid>
);
}, [stockitem, instanceQuery]);
const stockPanels: PanelType[] = useMemo(() => {
return [
{
name: 'details',
label: t`Details`,
icon: <IconInfoCircle />
label: t`Stock Details`,
icon: <IconInfoCircle />,
content: detailsPanel
},
{
name: 'tracking',

View File

@ -1,88 +0,0 @@
import { Grid, Group, Paper, SimpleGrid } from '@mantine/core';
import {
DetailImageButtonProps,
DetailsImage
} from '../components/images/DetailsImage';
import { UserRoles } from '../enums/Roles';
import { DetailsField, DetailsTable } from './Details';
/**
* Type for defining field arrays
*/
export type ItemDetailFields = {
left: DetailsField[][];
right?: DetailsField[][];
bottom_left?: DetailsField[][];
bottom_right?: DetailsField[][];
image?: DetailsImageType;
};
/**
* Type for defining details image
*/
export type DetailsImageType = {
name: string;
imageActions: DetailImageButtonProps;
};
/**
* Render a Details panel of the given model
* @param params Object with the data of the model to render
* @param apiPath Path to use for image updating
* @param refresh useInstance refresh method to refresh when making updates
* @param fields Object with all field sections
* @param partModel set to true only if source model is Part
*/
export function ItemDetails({
appRole,
params = {},
apiPath,
refresh,
fields,
partModel = false
}: {
appRole: UserRoles;
params?: any;
apiPath: string;
refresh: () => void;
fields: ItemDetailFields;
partModel: boolean;
}) {
return (
<Paper p="xs">
<SimpleGrid cols={2} spacing="xs" verticalSpacing="xs">
<Grid>
{fields.image && (
<Grid.Col span={4}>
<DetailsImage
appRole={appRole}
imageActions={fields.image.imageActions}
src={params.image}
apiPath={apiPath}
refresh={refresh}
pk={params.pk}
/>
</Grid.Col>
)}
<Grid.Col span={8}>
{fields.left && (
<DetailsTable
item={params}
fields={fields.left}
partIcons={partModel}
/>
)}
</Grid.Col>
</Grid>
{fields.right && <DetailsTable item={params} fields={fields.right} />}
{fields.bottom_left && (
<DetailsTable item={params} fields={fields.bottom_left} />
)}
{fields.bottom_right && (
<DetailsTable item={params} fields={fields.bottom_right} />
)}
</SimpleGrid>
</Paper>
);
}