2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-05-05 23:08:48 +00:00

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 information."""
# InvenTree API version # 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.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ 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 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 Part API endpoint
- Adds "external_stock" field to the BomItem API endpoint - Adds "external_stock" field to the BomItem API endpoint

View File

@ -42,11 +42,13 @@ class CompanyBriefSerializer(InvenTreeModelSerializer):
"""Metaclass options.""" """Metaclass options."""
model = Company 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) 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): class AddressSerializer(InvenTreeModelSerializer):

View File

@ -74,6 +74,7 @@ class CategorySerializer(InvenTree.serializers.InvenTreeModelSerializer):
'level', 'level',
'parent', 'parent',
'part_count', 'part_count',
'subcategories',
'pathstring', 'pathstring',
'path', 'path',
'starred', 'starred',
@ -99,13 +100,18 @@ class CategorySerializer(InvenTree.serializers.InvenTreeModelSerializer):
def annotate_queryset(queryset): def annotate_queryset(queryset):
"""Annotate extra information to the queryset.""" """Annotate extra information to the queryset."""
# Annotate the number of 'parts' which exist in each category (including subcategories!) # 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 return queryset
url = serializers.CharField(source='get_absolute_url', read_only=True) 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) level = serializers.IntegerField(read_only=True)
@ -282,6 +288,7 @@ class PartBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer):
'revision', 'revision',
'full_name', 'full_name',
'description', 'description',
'image',
'thumbnail', 'thumbnail',
'active', 'active',
'assembly', 'assembly',
@ -307,6 +314,7 @@ class PartBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer):
self.fields.pop('pricing_min') self.fields.pop('pricing_min')
self.fields.pop('pricing_max') self.fields.pop('pricing_max')
image = InvenTree.serializers.InvenTreeImageSerializerField(read_only=True)
thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True) thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True)
# Pricing fields # Pricing fields

View File

@ -886,6 +886,7 @@ class LocationSerializer(InvenTree.serializers.InvenTreeTagModelSerializer):
'pathstring', 'pathstring',
'path', 'path',
'items', 'items',
'sublocations',
'owner', 'owner',
'icon', 'icon',
'custom_icon', 'custom_icon',
@ -911,13 +912,18 @@ class LocationSerializer(InvenTree.serializers.InvenTreeTagModelSerializer):
def annotate_queryset(queryset): def annotate_queryset(queryset):
"""Annotate extra information to the queryset.""" """Annotate extra information to the queryset."""
# Annotate the number of stock items which exist in this category (including subcategories) # 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 return queryset
url = serializers.CharField(source='get_absolute_url', read_only=True) 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) level = serializers.IntegerField(read_only=True)

View File

@ -14,15 +14,17 @@ import {
import { useSuspenseQuery } from '@tanstack/react-query'; import { useSuspenseQuery } from '@tanstack/react-query';
import { Suspense, useMemo } from 'react'; import { Suspense, useMemo } from 'react';
import { api } from '../App'; import { api } from '../../App';
import { ProgressBar } from '../components/items/ProgressBar'; import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { getModelInfo } from '../components/render/ModelType'; import { ModelType } from '../../enums/ModelType';
import { ApiEndpoints } from '../enums/ApiEndpoints'; import { InvenTreeIcon } from '../../functions/icons';
import { ModelType } from '../enums/ModelType'; import { getDetailUrl } from '../../functions/urls';
import { InvenTreeIcon } from '../functions/icons'; import { apiUrl } from '../../states/ApiState';
import { getDetailUrl } from '../functions/urls'; import { useGlobalSettingsState } from '../../states/SettingsState';
import { apiUrl } from '../states/ApiState'; import { ProgressBar } from '../items/ProgressBar';
import { useGlobalSettingsState } from '../states/SettingsState'; import { YesNoButton } from '../items/YesNoButton';
import { getModelInfo } from '../render/ModelType';
import { StatusRenderer } from '../render/StatusRenderer';
export type PartIconsType = { export type PartIconsType = {
assembly: boolean; assembly: boolean;
@ -37,12 +39,20 @@ export type PartIconsType = {
export type DetailsField = export type DetailsField =
| { | {
hidden?: boolean;
icon?: string;
name: string; name: string;
label?: string; label?: string;
badge?: BadgeType; badge?: BadgeType;
copy?: boolean; copy?: boolean;
value_formatter?: () => ValueFormatterReturn; value_formatter?: () => ValueFormatterReturn;
} & (StringDetailField | LinkDetailField | ProgressBarfield); } & (
| StringDetailField
| BooleanField
| LinkDetailField
| ProgressBarfield
| StatusField
);
type BadgeType = 'owner' | 'user' | 'group'; type BadgeType = 'owner' | 'user' | 'group';
type ValueFormatterReturn = string | number | null; type ValueFormatterReturn = string | number | null;
@ -52,12 +62,20 @@ type StringDetailField = {
unit?: boolean; unit?: boolean;
}; };
type BooleanField = {
type: 'boolean';
};
type LinkDetailField = { type LinkDetailField = {
type: 'link'; type: 'link';
link?: boolean;
} & (InternalLinkField | ExternalLinkField); } & (InternalLinkField | ExternalLinkField);
type InternalLinkField = { type InternalLinkField = {
model: ModelType; model: ModelType;
model_field?: string;
model_formatter?: (value: any) => string;
backup_value?: string;
}; };
type ExternalLinkField = { type ExternalLinkField = {
@ -70,6 +88,11 @@ type ProgressBarfield = {
total: number; total: number;
}; };
type StatusField = {
type: 'status';
model: ModelType;
};
type FieldValueType = string | number | undefined; type FieldValueType = string | number | undefined;
type FieldProps = { type FieldProps = {
@ -78,101 +101,6 @@ type FieldProps = {
unit?: string | null; 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. * Fetches user or group info from backend and formats into a badge.
* Badge shows username, full name, or group name depending on server settings. * Badge 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 * If user is defined, a badge is rendered in addition to main value
*/ */
function TableStringValue(props: FieldProps) { 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(); 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} />; return <NameBadge pk={value} type={props.field_data.badge} />;
} }
@ -267,17 +199,21 @@ function TableStringValue(props: FieldProps) {
<div style={{ display: 'flex', justifyContent: 'space-between' }}> <div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Suspense fallback={<Skeleton width={200} height={20} radius="xl" />}> <Suspense fallback={<Skeleton width={200} height={20} radius="xl" />}>
<span> <span>
{value ? value : props.field_data.unit && '0'}{' '} {value ? value : props.field_data?.unit && '0'}{' '}
{props.field_data.unit == true && props.unit} {props.field_data.unit == true && props.unit}
</span> </span>
</Suspense> </Suspense>
{props.field_data.user && ( {props.field_data.user && (
<NameBadge pk={props.field_data.user} type="user" /> <NameBadge pk={props.field_data?.user} type="user" />
)} )}
</div> </div>
); );
} }
function BooleanValue(props: FieldProps) {
return <YesNoButton value={props.field_value} />;
}
function TableAnchorValue(props: FieldProps) { function TableAnchorValue(props: FieldProps) {
if (props.field_data.external) { if (props.field_data.external) {
return ( return (
@ -299,7 +235,7 @@ function TableAnchorValue(props: FieldProps) {
queryFn: async () => { queryFn: async () => {
const modelDef = getModelInfo(props.field_data.model); const modelDef = getModelInfo(props.field_data.model);
if (!modelDef.api_endpoint) { if (!modelDef?.api_endpoint) {
return {}; return {};
} }
@ -325,15 +261,37 @@ function TableAnchorValue(props: FieldProps) {
return getDetailUrl(props.field_data.model, props.field_value); return getDetailUrl(props.field_data.model, props.field_value);
}, [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 ( return (
<Suspense fallback={<Skeleton width={200} height={20} radius="xl" />}> <Suspense fallback={<Skeleton width={200} height={20} radius="xl" />}>
<Anchor {make_link ? (
href={`/platform${detailUrl}`} <Anchor
target={data?.external ? '_blank' : undefined} href={`/platform${detailUrl}`}
rel={data?.external ? 'noreferrer noopener' : undefined} target={data?.external ? '_blank' : undefined}
> rel={data?.external ? 'noreferrer noopener' : undefined}
<Text>{data.name ?? 'No name defined'}</Text> >
</Anchor> <Text>{value}</Text>
</Anchor>
) : (
<Text>{value}</Text>
)}
</Suspense> </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 }) { function CopyField({ value }: { value: string }) {
return ( return (
<CopyButton value={value}> <CopyButton value={value}>
@ -366,27 +330,33 @@ function CopyField({ value }: { value: string }) {
); );
} }
function TableField({ export function DetailsTableField({
field_data, item,
field_value, field
unit = null
}: { }: {
field_data: DetailsField[]; item: any;
field_value: FieldValueType[]; field: DetailsField;
unit?: string | null;
}) { }) {
function getFieldType(type: string) { function getFieldType(type: string) {
switch (type) { switch (type) {
case 'text': case 'text':
case 'string': case 'string':
return TableStringValue; return TableStringValue;
case 'boolean':
return BooleanValue;
case 'link': case 'link':
return TableAnchorValue; return TableAnchorValue;
case 'progressbar': case 'progressbar':
return ProgressBarValue; return ProgressBarValue;
case 'status':
return StatusValue;
default:
return TableStringValue;
} }
} }
const FieldType: any = getFieldType(field.type);
return ( return (
<tr> <tr>
<td <td
@ -394,35 +364,20 @@ function TableField({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '20px', gap: '20px',
width: '50',
justifyContent: 'flex-start' justifyContent: 'flex-start'
}} }}
> >
<InvenTreeIcon icon={field_data[0].name} /> <InvenTreeIcon icon={field.icon ?? field.name} />
<Text>{field_data[0].label}</Text> </td>
<td>
<Text>{field.label}</Text>
</td> </td>
<td style={{ minWidth: '40%' }}> <td style={{ minWidth: '40%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}> <FieldType field_data={field} field_value={item[field.name]} />
<div </td>
style={{ <td style={{ width: '50' }}>
display: 'flex', {field.copy && <CopyField value={item[field.name]} />}
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>
</td> </td>
</tr> </tr>
); );
@ -430,50 +385,20 @@ function TableField({
export function DetailsTable({ export function DetailsTable({
item, item,
fields, fields
partIcons = false
}: { }: {
item: any; item: any;
fields: DetailsField[][]; fields: DetailsField[];
partIcons?: boolean;
}) { }) {
return ( return (
<Paper p="xs" withBorder radius="xs"> <Paper p="xs" withBorder radius="xs">
<Table striped> <Table striped>
<tbody> <tbody>
{partIcons && ( {fields
<tr> .filter((field: DetailsField) => !field.hidden)
<PartIcons .map((field: DetailsField, index: number) => (
assembly={item.assembly} <DetailsTableField field={field} item={item} key={index} />
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}
/>
);
})}
</tbody> </tbody>
</Table> </Table>
</Paper> </Paper>

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Group, LoadingOverlay, Skeleton, Stack, Table } from '@mantine/core'; import { Grid, LoadingOverlay, Skeleton, Stack } from '@mantine/core';
import { import {
IconClipboardCheck, IconClipboardCheck,
IconClipboardList, IconClipboardList,
@ -17,6 +17,9 @@ import {
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useParams } from 'react-router-dom'; 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 { import {
ActionDropdown, ActionDropdown,
DuplicateItemAction, DuplicateItemAction,
@ -33,6 +36,7 @@ import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType'; import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles'; import { UserRoles } from '../../enums/Roles';
import { buildOrderFields } from '../../forms/BuildForms'; import { buildOrderFields } from '../../forms/BuildForms';
import { partCategoryFields } from '../../forms/PartForms';
import { useEditApiFormModal } from '../../hooks/UseForm'; import { useEditApiFormModal } from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance'; import { useInstance } from '../../hooks/UseInstance';
import { apiUrl } from '../../states/ApiState'; import { apiUrl } from '../../states/ApiState';
@ -63,36 +67,127 @@ export default function BuildDetail() {
refetchOnMount: true 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 ( return (
<Group position="apart" grow> <ItemDetailsGrid>
<Table striped> <Grid>
<tbody> <Grid.Col span={4}>
<tr> <DetailsImage
<td>{t`Base Part`}</td> appRole={UserRoles.part}
<td>{build.part_detail?.name}</td> apiPath={ApiEndpoints.part_list}
</tr> src={build.part_detail?.image ?? build.part_detail?.thumbnail}
<tr> pk={build.part}
<td>{t`Quantity`}</td> />
<td>{build.quantity}</td> </Grid.Col>
</tr> <Grid.Col span={8}>
<tr> <DetailsTable fields={tl} item={build} />
<td>{t`Build Status`}</td> </Grid.Col>
<td> </Grid>
{build?.status && ( <DetailsTable fields={tr} item={build} />
<StatusRenderer <DetailsTable fields={bl} item={build} />
status={build.status} <DetailsTable fields={br} item={build} />
type={ModelType.build} </ItemDetailsGrid>
/>
)}
</td>
</tr>
</tbody>
</Table>
<Table></Table>
</Group>
); );
}, [build]); }, [build, instanceQuery]);
const buildPanels: PanelType[] = useMemo(() => { const buildPanels: PanelType[] = useMemo(() => {
return [ return [
@ -100,7 +195,7 @@ export default function BuildDetail() {
name: 'details', name: 'details',
label: t`Build Details`, label: t`Build Details`,
icon: <IconInfoCircle />, icon: <IconInfoCircle />,
content: buildDetailsPanel content: detailsPanel
}, },
{ {
name: 'allocate-stock', name: 'allocate-stock',
@ -259,7 +354,7 @@ export default function BuildDetail() {
title={build.reference} title={build.reference}
subtitle={build.title} subtitle={build.title}
detail={buildDetail} detail={buildDetail}
imageUrl={build.part_detail?.thumbnail} imageUrl={build.part_detail?.image ?? build.part_detail?.thumbnail}
breadcrumbs={[ breadcrumbs={[
{ name: t`Build Orders`, url: '/build' }, { name: t`Build Orders`, url: '/build' },
{ name: build.reference, url: `/build/${build.pk}` } { name: build.reference, url: `/build/${build.pk}` }

View File

@ -1,5 +1,5 @@
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { LoadingOverlay, Skeleton, Stack } from '@mantine/core'; import { Grid, LoadingOverlay, Skeleton, Stack } from '@mantine/core';
import { import {
IconBuildingFactory2, IconBuildingFactory2,
IconBuildingWarehouse, IconBuildingWarehouse,
@ -18,6 +18,9 @@ import {
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useParams } from 'react-router-dom'; 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 { import {
ActionDropdown, ActionDropdown,
DeleteItemAction, DeleteItemAction,
@ -69,12 +72,99 @@ export default function CompanyDetail(props: CompanyDetailProps) {
refetchOnMount: true 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(() => { const companyPanels: PanelType[] = useMemo(() => {
return [ return [
{ {
name: 'details', name: 'details',
label: t`Details`, label: t`Details`,
icon: <IconInfoCircle /> icon: <IconInfoCircle />,
content: detailsPanel
}, },
{ {
name: 'manufactured-parts', name: 'manufactured-parts',

View File

@ -1,21 +1,24 @@
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { LoadingOverlay, Stack, Text } from '@mantine/core'; import { LoadingOverlay, Skeleton, Stack, Text } from '@mantine/core';
import { import {
IconCategory, IconCategory,
IconInfoCircle,
IconListDetails, IconListDetails,
IconSitemap IconSitemap
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useParams } from 'react-router-dom'; 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 { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup'; import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { PartCategoryTree } from '../../components/nav/PartCategoryTree'; import { PartCategoryTree } from '../../components/nav/PartCategoryTree';
import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { useInstance } from '../../hooks/UseInstance'; import { useInstance } from '../../hooks/UseInstance';
import ParametricPartTable from '../../tables/part/ParametricPartTable'; import ParametricPartTable from '../../tables/part/ParametricPartTable';
import { PartCategoryTable } from '../../tables/part/PartCategoryTable'; import { PartCategoryTable } from '../../tables/part/PartCategoryTable';
import { PartParameterTable } from '../../tables/part/PartParameterTable';
import { PartListTable } from '../../tables/part/PartTable'; 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( const categoryPanels: PanelType[] = useMemo(
() => [ () => [
{
name: 'details',
label: t`Category Details`,
icon: <IconInfoCircle />,
content: detailsPanel
// hidden: !category?.pk,
},
{ {
name: 'parts', name: 'parts',
label: t`Parts`, label: t`Parts`,

View File

@ -1,5 +1,12 @@
import { t } from '@lingui/macro'; 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 { import {
IconBookmarks, IconBookmarks,
IconBuilding, IconBuilding,
@ -28,6 +35,10 @@ import { useMemo, useState } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { api } from '../../App'; 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 { import {
ActionDropdown, ActionDropdown,
BarcodeActionDropdown, BarcodeActionDropdown,
@ -51,12 +62,6 @@ import { useEditApiFormModal } from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance'; import { useInstance } from '../../hooks/UseInstance';
import { apiUrl } from '../../states/ApiState'; import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState'; import { useUserState } from '../../states/UserState';
import { DetailsField } from '../../tables/Details';
import {
DetailsImageType,
ItemDetailFields,
ItemDetails
} from '../../tables/ItemDetails';
import { BomTable } from '../../tables/bom/BomTable'; import { BomTable } from '../../tables/bom/BomTable';
import { UsedInTable } from '../../tables/bom/UsedInTable'; import { UsedInTable } from '../../tables/bom/UsedInTable';
import { BuildOrderTable } from '../../tables/build/BuildOrderTable'; import { BuildOrderTable } from '../../tables/build/BuildOrderTable';
@ -93,188 +98,186 @@ export default function PartDetail() {
refetchOnMount: true refetchOnMount: true
}); });
const detailFields = (part: any): ItemDetailFields => { const detailsPanel = useMemo(() => {
let left: DetailsField[][] = []; if (instanceQuery.isFetching) {
let right: DetailsField[][] = []; return <Skeleton />;
let bottom_right: DetailsField[][] = []; }
let bottom_left: DetailsField[][] = [];
let image: DetailsImageType = { // Construct the details tables
name: 'image', let tl: DetailsField[] = [
imageActions: {
selectExisting: true,
uploadFile: true,
deleteFile: true
}
};
left.push([
{ {
type: 'text', type: 'text',
name: 'description', name: 'description',
label: t`Description`, label: t`Description`,
copy: true 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) { let tr: DetailsField[] = [
left.push([
{
type: 'link',
name: 'variant_of',
label: t`Variant of`,
model: ModelType.part
}
]);
}
right.push([
{ {
type: 'string', type: 'string',
name: 'unallocated_stock', name: 'unallocated_stock',
unit: true, unit: true,
label: t`Available Stock` label: t`Available Stock`
} },
]);
right.push([
{ {
type: 'string', type: 'string',
name: 'total_in_stock', name: 'total_in_stock',
unit: true, unit: true,
label: t`In Stock` 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) { let bl: DetailsField[] = [
right.push([ {
{ type: 'boolean',
type: 'string', name: 'active',
name: 'minimum_stock', label: t`Active`
unit: true, },
label: t`Minimum Stock` {
} 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) { let br: DetailsField[] = [
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([
{ {
type: 'string', type: 'string',
name: 'creation_date', name: 'creation_date',
@ -283,181 +286,169 @@ export default function PartDetail() {
{ {
type: 'string', type: 'string',
name: 'creation_user', 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 && id &&
bottom_right.push([ br.push({
{ type: 'string',
type: 'string', name: 'pricing',
name: 'pricing', label: t`Price Range`,
label: t`Price Range`, value_formatter: () => {
value_formatter: () => { const { data } = useSuspenseQuery({
const { data } = useSuspenseQuery({ queryKey: ['pricing', id],
queryKey: ['pricing', id], queryFn: async () => {
queryFn: async () => { const url = apiUrl(ApiEndpoints.part_pricing_get, null, {
const url = apiUrl(ApiEndpoints.part_pricing_get, null, { id: id
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 // Add in stocktake information
.get(url) if (id && part.last_stocktake) {
.then((response) => { br.push({
switch (response.status) { type: 'string',
case 200: name: 'stocktake',
return response.data; label: t`Last Stocktake`,
default: unit: true,
return null; value_formatter: () => {
} const { data } = useSuspenseQuery({
}) queryKey: ['stocktake', id],
.catch(() => { queryFn: async () => {
return null; const url = apiUrl(ApiEndpoints.part_stocktake_list);
});
} return api
}); .get(url, { params: { part: id, ordering: 'date' } })
return `${formatPriceRange(data.overall_min, data.overall_max)}${ .then((response) => {
part.units && ' / ' + part.units 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 && br.push({
part.last_stocktake && type: 'string',
bottom_right.push([ name: 'stocktake_user',
{ label: t`Stocktake By`,
type: 'string', badge: 'user',
name: 'stocktake', icon: 'user',
label: t`Last Stocktake`, value_formatter: () => {
unit: true, const { data } = useSuspenseQuery({
value_formatter: () => { queryKey: ['stocktake', id],
const { data } = useSuspenseQuery({ queryFn: async () => {
queryKey: ['stocktake', id], const url = apiUrl(ApiEndpoints.part_stocktake_list);
queryFn: async () => {
const url = apiUrl(ApiEndpoints.part_stocktake_list);
return api return api
.get(url, { params: { part: id, ordering: 'date' } }) .get(url, { params: { part: id, ordering: 'date' } })
.then((response) => { .then((response) => {
switch (response.status) { switch (response.status) {
case 200: case 200:
return response.data[response.data.length - 1]; return response.data[response.data.length - 1];
default: default:
return null; return null;
} }
}) })
.catch(() => { .catch(() => {
return null; return null;
}); });
} }
}); });
return data?.quantity; return data?.user;
}
},
{
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;
}
} }
]); });
if (part.default_location) {
bottom_right.push([
{
type: 'link',
name: 'default_location',
label: t`Default Location`,
model: ModelType.stocklocation
}
]);
} }
if (part.default_supplier) { return (
bottom_right.push([ <ItemDetailsGrid>
{ <Grid>
type: 'link', <Grid.Col span={4}>
name: 'default_supplier', <DetailsImage
label: t`Default Supplier`, appRole={UserRoles.part}
model: ModelType.supplierpart imageActions={{
} selectExisting: true,
]); uploadFile: true,
} deleteFile: true
}}
if (part.link) { src={part.image}
bottom_right.push([ apiPath={apiUrl(ApiEndpoints.part_list, part.pk)}
{ refresh={refreshInstance}
type: 'link', pk={part.pk}
name: 'link', />
label: t`Link`, </Grid.Col>
external: true, <Grid.Col span={8}>
copy: true <Stack spacing="xs">
} <PartIcons part={part} />
]); <DetailsTable fields={tl} item={part} />
} </Stack>
</Grid.Col>
if (part.responsible) { </Grid>
bottom_right.push([ <DetailsTable fields={tr} item={part} />
{ <DetailsTable fields={bl} item={part} />
type: 'string', <DetailsTable fields={br} item={part} />
name: 'responsible', </ItemDetailsGrid>
label: t`Responsible`, );
badge: 'owner' }, [part, instanceQuery]);
}
]);
}
let fields: ItemDetailFields = {
left: left,
right: right,
bottom_left: bottom_left,
bottom_right: bottom_right,
image: image
};
return fields;
};
// Part data panels (recalculate when part data changes) // Part data panels (recalculate when part data changes)
const partPanels: PanelType[] = useMemo(() => { const partPanels: PanelType[] = useMemo(() => {
return [ return [
{ {
name: 'details', name: 'details',
label: t`Details`, label: t`Part Details`,
icon: <IconInfoCircle />, icon: <IconInfoCircle />,
content: !instanceQuery.isFetching && ( content: detailsPanel
<ItemDetails
appRole={UserRoles.part}
params={part}
apiPath={apiUrl(ApiEndpoints.part_list, part.pk)}
refresh={refreshInstance}
fields={detailFields(part)}
partModel
/>
)
}, },
{ {
name: 'parameters', name: 'parameters',

View File

@ -1,5 +1,5 @@
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { LoadingOverlay, Stack } from '@mantine/core'; import { Grid, LoadingOverlay, Skeleton, Stack } from '@mantine/core';
import { import {
IconDots, IconDots,
IconInfoCircle, IconInfoCircle,
@ -11,6 +11,9 @@ import {
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useParams } from 'react-router-dom'; 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 { import {
ActionDropdown, ActionDropdown,
BarcodeActionDropdown, BarcodeActionDropdown,
@ -24,6 +27,10 @@ import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup'; import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { NotesEditor } from '../../components/widgets/MarkdownEditor'; import { NotesEditor } from '../../components/widgets/MarkdownEditor';
import { ApiEndpoints } from '../../enums/ApiEndpoints'; 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 { useInstance } from '../../hooks/UseInstance';
import { apiUrl } from '../../states/ApiState'; import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState'; import { useUserState } from '../../states/UserState';
@ -39,7 +46,11 @@ export default function PurchaseOrderDetail() {
const user = useUserState(); const user = useUserState();
const { instance: order, instanceQuery } = useInstance({ const {
instance: order,
instanceQuery,
refreshInstance
} = useInstance({
endpoint: ApiEndpoints.purchase_order_list, endpoint: ApiEndpoints.purchase_order_list,
pk: id, pk: id,
params: { params: {
@ -48,12 +59,167 @@ export default function PurchaseOrderDetail() {
refetchOnMount: true 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(() => { const orderPanels: PanelType[] = useMemo(() => {
return [ return [
{ {
name: 'detail', name: 'detail',
label: t`Order Details`, label: t`Order Details`,
icon: <IconInfoCircle /> icon: <IconInfoCircle />,
content: detailsPanel
}, },
{ {
name: 'line-items', name: 'line-items',
@ -118,13 +284,21 @@ export default function PurchaseOrderDetail() {
key="order-actions" key="order-actions"
tooltip={t`Order Actions`} tooltip={t`Order Actions`}
icon={<IconDots />} icon={<IconDots />}
actions={[EditItemAction({}), DeleteItemAction({})]} actions={[
EditItemAction({
onClick: () => {
editPurchaseOrder.open();
}
}),
DeleteItemAction({})
]}
/> />
]; ];
}, [id, order, user]); }, [id, order, user]);
return ( return (
<> <>
{editPurchaseOrder.modal}
<Stack spacing="xs"> <Stack spacing="xs">
<LoadingOverlay visible={instanceQuery.isFetching} /> <LoadingOverlay visible={instanceQuery.isFetching} />
<PageDetail <PageDetail

View File

@ -1,13 +1,23 @@
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { LoadingOverlay, Stack } from '@mantine/core'; import { Grid, LoadingOverlay, Skeleton, Stack } from '@mantine/core';
import { IconInfoCircle, IconNotes, IconPaperclip } from '@tabler/icons-react'; import {
IconInfoCircle,
IconList,
IconNotes,
IconPaperclip
} from '@tabler/icons-react';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useParams } from 'react-router-dom'; 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 { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup'; import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { NotesEditor } from '../../components/widgets/MarkdownEditor'; import { NotesEditor } from '../../components/widgets/MarkdownEditor';
import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import { useInstance } from '../../hooks/UseInstance'; import { useInstance } from '../../hooks/UseInstance';
import { apiUrl } from '../../states/ApiState'; import { apiUrl } from '../../states/ApiState';
import { AttachmentTable } from '../../tables/general/AttachmentTable'; 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(() => { const orderPanels: PanelType[] = useMemo(() => {
return [ return [
{ {
name: 'detail', name: 'detail',
label: t`Order Details`, label: t`Order Details`,
icon: <IconInfoCircle /> icon: <IconInfoCircle />,
content: detailsPanel
},
{
name: 'line-items',
label: t`Line Items`,
icon: <IconList />
}, },
{ {
name: 'attachments', name: 'attachments',

View File

@ -1,5 +1,5 @@
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { LoadingOverlay, Skeleton, Stack } from '@mantine/core'; import { Grid, LoadingOverlay, Skeleton, Stack } from '@mantine/core';
import { import {
IconInfoCircle, IconInfoCircle,
IconList, IconList,
@ -12,10 +12,15 @@ import {
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useParams } from 'react-router-dom'; 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 { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup'; import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { NotesEditor } from '../../components/widgets/MarkdownEditor'; import { NotesEditor } from '../../components/widgets/MarkdownEditor';
import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import { useInstance } from '../../hooks/UseInstance'; import { useInstance } from '../../hooks/UseInstance';
import { apiUrl } from '../../states/ApiState'; import { apiUrl } from '../../states/ApiState';
import { BuildOrderTable } from '../../tables/build/BuildOrderTable'; 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(() => { const orderPanels: PanelType[] = useMemo(() => {
return [ return [
{ {
name: 'detail', name: 'detail',
label: t`Order Details`, label: t`Order Details`,
icon: <IconInfoCircle /> icon: <IconInfoCircle />,
content: detailsPanel
}, },
{ {
name: 'line-items', name: 'line-items',

View File

@ -1,13 +1,16 @@
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { LoadingOverlay, Stack, Text } from '@mantine/core'; import { LoadingOverlay, Skeleton, Stack, Text } from '@mantine/core';
import { IconPackages, IconSitemap } from '@tabler/icons-react'; import { IconInfoCircle, IconPackages, IconSitemap } from '@tabler/icons-react';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useParams } from 'react-router-dom'; 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 { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup'; import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { StockLocationTree } from '../../components/nav/StockLocationTree'; import { StockLocationTree } from '../../components/nav/StockLocationTree';
import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { useInstance } from '../../hooks/UseInstance'; import { useInstance } from '../../hooks/UseInstance';
import { StockItemTable } from '../../tables/stock/StockItemTable'; import { StockItemTable } from '../../tables/stock/StockItemTable';
import { StockLocationTable } from '../../tables/stock/StockLocationTable'; 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(() => { const locationPanels: PanelType[] = useMemo(() => {
return [ return [
{
name: 'details',
label: t`Location Details`,
icon: <IconInfoCircle />,
content: detailsPanel
},
{ {
name: 'stock-items', name: 'stock-items',
label: t`Stock Items`, label: t`Stock Items`,

View File

@ -1,5 +1,12 @@
import { t } from '@lingui/macro'; 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 { import {
IconBookmark, IconBookmark,
IconBoxPadding, IconBoxPadding,
@ -20,6 +27,9 @@ import {
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useParams } from 'react-router-dom'; 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 { import {
ActionDropdown, ActionDropdown,
BarcodeActionDropdown, BarcodeActionDropdown,
@ -34,6 +44,8 @@ import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { StockLocationTree } from '../../components/nav/StockLocationTree'; import { StockLocationTree } from '../../components/nav/StockLocationTree';
import { NotesEditor } from '../../components/widgets/MarkdownEditor'; import { NotesEditor } from '../../components/widgets/MarkdownEditor';
import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import { useEditStockItem } from '../../forms/StockForms'; import { useEditStockItem } from '../../forms/StockForms';
import { useInstance } from '../../hooks/UseInstance'; import { useInstance } from '../../hooks/UseInstance';
import { apiUrl } from '../../states/ApiState'; 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(() => { const stockPanels: PanelType[] = useMemo(() => {
return [ return [
{ {
name: 'details', name: 'details',
label: t`Details`, label: t`Stock Details`,
icon: <IconInfoCircle /> icon: <IconInfoCircle />,
content: detailsPanel
}, },
{ {
name: 'tracking', 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>
);
}