Refactor to use part detail view

This commit is contained in:
Oliver Walters 2026-06-30 11:28:52 +00:00
parent ff7e4d4f3b
commit 9f4913d429
3 changed files with 388 additions and 622 deletions

View File

@ -1,217 +1,6 @@
import {
ApiEndpoints,
ModelType,
TagsList,
apiUrl,
formatDecimal
} from '@lib/index';
import { t } from '@lingui/core/macro';
import { Group, Stack, Text } from '@mantine/core';
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useApi } from '../../../contexts/ApiContext';
import { formatDate, formatPriceRange } from '../../../defaults/formatters';
import { useInstance } from '../../../hooks/UseInstance';
import { ApiImage } from '../../images/ApiImage';
import { RenderRemoteInstance } from '../../render/Instance';
import { RenderUser } from '../../render/User';
import { AttributeGrid, type AttributeRow } from '../AttributeGrid';
import { PartDetailsPanel } from '../../../pages/part/PartDetailsPanel';
import type { PreviewType } from '../PreviewType';
function PartPreviewContent({ instance }: Readonly<{ instance: any }>) {
const api = useApi();
const { instance: requirements } = useInstance({
endpoint: ApiEndpoints.part_requirements,
pk: instance?.pk,
hasPrimaryKey: true,
disabled: !instance?.pk,
defaultValue: {}
});
const { data: parameters = [] } = useQuery({
queryKey: ['part-preview-parameters', instance?.pk],
enabled: !!instance?.pk,
queryFn: () =>
api
.get(apiUrl(ApiEndpoints.parameter_list), {
params: {
model_type: ModelType.part,
model_id: instance.pk,
limit: 100
}
})
.then((res) => res.data?.results ?? [])
});
const units = instance?.units ? ` ${instance.units}` : '';
const stockItems: AttributeRow[] = useMemo(() => {
const totalStock =
requirements?.total_stock ?? instance?.total_in_stock ?? 0;
const availableStock =
requirements?.unallocated_stock ?? instance?.unallocated_stock ?? 0;
const minStock = instance?.minimum_stock ?? 0;
const ordering = requirements?.ordering ?? 0;
const canBuild = requirements?.can_build ?? 0;
const building = requirements?.building ?? 0;
return !instance?.virtual
? [
{
label: t`In Stock`,
value: formatDecimal(totalStock) + units
},
availableStock !== totalStock
? {
label: t`Available`,
value: formatDecimal(availableStock) + units
}
: null,
minStock > 0
? { label: t`Minimum`, value: formatDecimal(minStock) + units }
: null,
instance?.purchaseable && ordering > 0
? { label: t`On Order`, value: formatDecimal(ordering) + units }
: null,
instance?.assembly && canBuild >= 0
? { label: t`Can Build`, value: formatDecimal(canBuild) }
: null,
building > 0
? { label: t`In Production`, value: formatDecimal(building) }
: null
].filter((x): x is NonNullable<typeof x> => x !== null)
: [];
}, [requirements, instance, units]);
const parameterItems: AttributeRow[] = parameters.map((param: any) => ({
label: param.template_detail?.name,
value:
param.data +
(param.template_detail?.units ? ` ${param.template_detail.units}` : '')
}));
const infoItems: AttributeRow[] = useMemo(() => {
const priceRange = formatPriceRange(
instance?.pricing_min,
instance?.pricing_max
);
return [
{
label: t`Category`,
value: instance?.category ? (
<RenderRemoteInstance
model={ModelType.partcategory}
pk={instance.category}
/>
) : null
},
{
label: t`Revision Of`,
value: instance?.revision_of ? (
<RenderRemoteInstance
model={ModelType.part}
pk={instance.revision_of}
/>
) : null
},
{
label: t`Variant Of`,
value: instance?.variant_of ? (
<RenderRemoteInstance
model={ModelType.part}
pk={instance.variant_of}
/>
) : null
},
{
label: t`Price Range`,
value:
instance?.pricing_min != null || instance?.pricing_max != null
? priceRange
: null
},
{
label: t`Creation Date`,
value: instance?.creation_date
? formatDate(instance.creation_date)
: null
},
{
label: t`Created By`,
value: instance?.creation_user_detail
? RenderUser({ instance: instance.creation_user_detail })
: null
}
];
}, [instance]);
return (
<Stack gap='md'>
<Group align='flex-start' gap='md'>
<ApiImage
src={
instance?.image ||
instance?.thumbnail ||
'/static/img/blank_image.png'
}
w={80}
h={80}
fit='contain'
radius='sm'
/>
<Stack gap={2} style={{ flex: 1 }}>
<Text fw={700} size='lg'>
{instance?.full_name || instance?.name}
</Text>
{instance?.description && (
<Text c='dimmed' size='sm'>
{instance.description}
</Text>
)}
{instance?.IPN && (
<Group gap='xs'>
<Text size='xs' c='dimmed'>
{t`IPN`}:
</Text>
<Text size='xs' fw={500}>
{instance.IPN}
</Text>
</Group>
)}
{instance?.revision && (
<Group gap='xs'>
<Text size='xs' c='dimmed'>
{t`Revision`}:
</Text>
<Text size='xs' fw={500}>
{instance.revision}
</Text>
</Group>
)}
{instance?.category_name && (
<Group gap='xs'>
<Text size='xs' c='dimmed'>
{t`Category`}:
</Text>
<Text size='xs'>{instance?.category_name}</Text>
</Group>
)}
</Stack>
</Group>
{instance?.tags?.length > 0 && <TagsList tags={instance.tags} />}
<AttributeGrid title={t`Part Information`} items={infoItems} />
{!instance?.virtual && (
<AttributeGrid title={t`Stock Information`} items={stockItems} />
)}
<AttributeGrid title={t`Parameters`} items={parameterItems} />
</Stack>
);
}
export function PartPreviewComponent({
instance,
modelId
@ -221,6 +10,6 @@ export function PartPreviewComponent({
}>): PreviewType {
return {
title: instance?.full_name || instance?.name || `Part #${modelId}`,
preview: <PartPreviewContent instance={instance} />
preview: <PartDetailsPanel instance={instance} />
};
}

View File

@ -3,7 +3,6 @@ import {
ActionIcon,
Alert,
Center,
Grid,
Group,
Loader,
Paper,
@ -41,7 +40,6 @@ import { type ReactNode, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import Select from 'react-select';
import TagsList from '@lib/components/TagsList';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
@ -52,13 +50,7 @@ import type { PanelType } from '@lib/types/Panel';
import AdminButton from '../../components/buttons/AdminButton';
import { PrintingActions } from '../../components/buttons/PrintingActions';
import StarredToggleButton from '../../components/buttons/StarredToggleButton';
import {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import DetailsBadge from '../../components/details/DetailsBadge';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import { Thumbnail } from '../../components/images/Thumbnail';
import {
ActionDropdown,
@ -77,7 +69,7 @@ import { PanelGroup } from '../../components/panels/PanelGroup';
import { RenderPart } from '../../components/render/Part';
import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
import { useApi } from '../../contexts/ApiContext';
import { formatDecimal, formatPriceRange } from '../../defaults/formatters';
import { formatDecimal } from '../../defaults/formatters';
import { usePartFields } from '../../forms/PartForms';
import { useFindSerialNumberForm } from '../../forms/StockForms';
import {
@ -106,6 +98,7 @@ import { SalesOrderTable } from '../../tables/sales/SalesOrderTable';
import { StockItemTable } from '../../tables/stock/StockItemTable';
import { TransferOrderTable } from '../../tables/stock/TransferOrderTable';
import PartAllocationPanel from './PartAllocationPanel';
import { PartDetailsPanel } from './PartDetailsPanel';
import PartPricingPanel from './PartPricingPanel';
import PartStockHistoryDetail from './PartStockHistoryDetail';
import PartSupplierDetail from './PartSupplierDetail';
@ -175,14 +168,6 @@ export default function PartDetail() {
refetchOnMount: true
});
const { instance: serials } = useInstance({
endpoint: ApiEndpoints.part_serial_numbers,
pk: id,
hasPrimaryKey: true,
refetchOnMount: false,
defaultValue: {}
});
const {
instance: part,
refreshInstance,
@ -275,398 +260,22 @@ export default function PartDetail() {
return partRevisionOptions.length > 0 && revisionsEnabled;
}, [partRevisionOptions, revisionsEnabled]);
const detailsPanel = useMemo(() => {
if (instanceQuery.isFetching) {
return <Skeleton />;
}
const data = { ...part };
const fetching =
partRequirementsQuery.isFetching || instanceQuery.isFetching;
// Copy part requirements data into the main part data
data.total_in_stock =
partRequirements?.total_stock ?? part?.total_in_stock ?? 0;
data.unallocated =
partRequirements?.unallocated_stock ?? part?.unallocated_stock ?? 0;
data.ordering = partRequirements?.ordering ?? part?.ordering ?? 0;
data.required =
(partRequirements?.required_for_build_orders ??
part?.required_for_build_orders ??
0) +
(partRequirements?.required_for_sales_orders ??
part?.required_for_sales_orders ??
0);
data.allocated =
(partRequirements?.allocated_to_build_orders ??
part?.allocated_to_build_orders ??
0) +
(partRequirements?.allocated_to_sales_orders ??
part?.allocated_to_sales_orders ??
0);
// Extract requirements data
data.can_build = partRequirements?.can_build ?? 0;
// Provide latest serial number info
if (!!serials.latest) {
data.latest_serial_number = serials.latest;
}
// Top left - core part information
const tl: DetailsField[] = [
{
type: 'string',
name: 'name',
label: t`Name`,
icon: 'part',
copy: true
},
{
type: 'string',
name: 'IPN',
label: t`IPN`,
copy: true,
hidden: !part.IPN
},
{
type: 'string',
name: 'description',
label: t`Description`,
copy: true
},
{
type: 'link',
name: 'variant_of',
label: t`Variant of`,
model: ModelType.part,
model_field: 'full_name',
hidden: !part.variant_of
},
{
type: 'link',
name: 'revision_of',
label: t`Revision of`,
model: ModelType.part,
model_field: 'full_name',
hidden: !part.revision_of
},
{
type: 'string',
name: 'revision',
label: t`Revision`,
hidden: !part.revision,
copy: true
},
{
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: 'link',
name: 'category_default_location',
label: t`Category Default Location`,
model: ModelType.stocklocation,
hidden: part.default_location || !part.category_default_location
},
{
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
}
];
// Top right - stock availability information
const tr: DetailsField[] = [
{
type: 'number',
name: 'total_in_stock',
unit: part.units,
label: t`In Stock`,
hidden: part.virtual
},
{
type: 'progressbar',
name: 'unallocated_stock',
total: data.total_in_stock,
progress: data.unallocated,
label: t`Available Stock`,
hidden: part.virtual || data.total_in_stock == data.unallocated
},
{
type: 'number',
name: 'ordering',
label: t`On order`,
unit: part.units,
hidden: !part.purchaseable || part.ordering <= 0
},
{
type: 'number',
name: 'required',
label: t`Required for Orders`,
unit: part.units,
hidden: data.required <= 0,
icon: 'stocktake'
},
{
type: 'progressbar',
name: 'allocated_to_build_orders',
icon: 'manufacturers',
total: partRequirements.required_for_build_orders,
progress: partRequirements.allocated_to_build_orders,
label: t`Allocated to Build Orders`,
hidden:
fetching ||
(partRequirements.required_for_build_orders <= 0 &&
partRequirements.allocated_to_build_orders <= 0)
},
{
type: 'progressbar',
icon: 'sales_orders',
name: 'allocated_to_sales_orders',
total: partRequirements.required_for_sales_orders,
progress: partRequirements.allocated_to_sales_orders,
label: t`Allocated to Sales Orders`,
hidden:
fetching ||
(partRequirements.required_for_sales_orders <= 0 &&
partRequirements.allocated_to_sales_orders <= 0)
},
{
type: 'progressbar',
name: 'building',
label: t`In Production`,
progress: partRequirements.building,
total: partRequirements.scheduled_to_build,
hidden:
fetching ||
(!partRequirements.building && !partRequirements.scheduled_to_build)
},
{
type: 'number',
name: 'can_build',
unit: part.units,
label: t`Can Build`,
hidden: !part.assembly || fetching
},
{
type: 'number',
name: 'minimum_stock',
unit: part.units,
label: t`Minimum Stock`,
hidden: part.minimum_stock <= 0
},
{
type: 'number',
name: 'maximum_stock',
unit: part.units,
label: t`Maximum Stock`,
hidden: part.maximum_stock <= 0
}
];
// Bottom left - part attributes
const bl: DetailsField[] = [
{
type: 'boolean',
name: 'active',
label: t`Active`
},
{
type: 'boolean',
name: 'locked',
label: t`Locked`
},
{
type: 'boolean',
icon: 'template',
name: 'is_template',
label: t`Template Part`
},
{
type: 'boolean',
name: 'assembly',
label: t`Assembled Part`
},
{
type: 'boolean',
name: 'component',
label: t`Component Part`
},
{
type: 'boolean',
name: 'testable',
label: t`Testable Part`,
icon: 'test'
},
{
type: 'boolean',
name: 'trackable',
label: t`Trackable Part`
},
{
type: 'boolean',
name: 'purchaseable',
label: t`Purchaseable Part`
},
{
type: 'boolean',
name: 'salable',
icon: 'saleable',
label: t`Saleable Part`
},
{
type: 'boolean',
name: 'virtual',
label: t`Virtual Part`
},
{
type: 'boolean',
name: 'starred',
label: t`Subscribed`,
icon: 'bell'
}
];
// Bottom right - other part information
const br: DetailsField[] = [
{
type: 'string',
name: 'creation_date',
label: t`Creation Date`
},
{
type: 'string',
name: 'creation_user',
label: t`Created By`,
badge: 'user',
icon: 'user',
hidden: !part.creation_user
},
{
type: 'string',
name: 'responsible',
label: t`Responsible`,
badge: 'owner',
hidden: !part.responsible
},
{
name: 'default_expiry',
label: t`Default Expiry`,
hidden: !part.default_expiry,
icon: 'calendar',
type: 'string',
value_formatter: () => {
return `${part.default_expiry} ${t`days`}`;
}
}
];
// Add in price range data
if (part.pricing_min || part.pricing_max) {
br.push({
type: 'string',
name: 'pricing',
label: t`Price Range`,
value_formatter: () => {
return formatPriceRange(part.pricing_min, part.pricing_max);
}
});
}
br.push({
type: 'string',
name: 'latest_serial_number',
label: t`Latest Serial Number`,
hidden: !part.trackable || !data.latest_serial_number,
icon: 'serial'
});
return part ? (
<ItemDetailsGrid>
const revisionSelector = useMemo(() => {
if (!enableRevisionSelection) return null;
return (
<Paper p='sm' withBorder>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={UserRoles.part}
imageActions={{
selectExisting: true,
downloadImage: true,
uploadFile: true,
deleteFile: true
}}
src={part.image}
thumbnail={part.thumbnail}
apiPath={apiUrl(ApiEndpoints.part_list, part.pk)}
refresh={refreshInstance}
pk={part.pk}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={data} />
</Grid.Col>
</Grid>
<TagsList tags={part.tags} />
{enableRevisionSelection && (
<Paper p='sm' withBorder>
<Stack gap='xs'>
<Group gap='xs'>
<ActionIcon variant='transparent'>
<IconVersions />
</ActionIcon>
<Text>{t`Select Part Revision`}</Text>
</Group>
<RevisionSelector part={part} options={partRevisionOptions} />
</Stack>
</Paper>
)}
<Group gap='xs'>
<ActionIcon variant='transparent'>
<IconVersions />
</ActionIcon>
<Text>{t`Select Part Revision`}</Text>
</Group>
<RevisionSelector part={part} options={partRevisionOptions} />
</Stack>
<DetailsTable fields={tr} item={data} />
<DetailsTable fields={bl} item={data} />
<DetailsTable fields={br} item={data} />
</ItemDetailsGrid>
) : (
<Skeleton />
</Paper>
);
}, [
globalSettings,
part,
id,
serials,
instanceQuery.isFetching,
instanceQuery.data,
enableRevisionSelection,
partRevisionOptions,
partRequirementsQuery.isFetching,
partRequirements
]);
}, [enableRevisionSelection, part, partRevisionOptions]);
// Part data panels (recalculate when part data changes)
const partPanels: PanelType[] = useMemo(() => {
@ -675,7 +284,14 @@ export default function PartDetail() {
name: 'details',
label: t`Part Details`,
icon: <IconInfoCircle />,
content: detailsPanel
content: (
<PartDetailsPanel
instance={part}
allowImageEdit
refreshInstance={refreshInstance}
additionalContent={revisionSelector}
/>
)
},
{
name: 'stock',
@ -903,8 +519,9 @@ export default function PartDetail() {
user,
globalSettings,
userSettings,
detailsPanel,
bomInformation
bomInformation,
revisionSelector,
refreshInstance
]);
const breadcrumbs = useMemo(() => {

View File

@ -0,0 +1,360 @@
import { t } from '@lingui/core/macro';
import { Grid, Skeleton, Stack } from '@mantine/core';
import { type ReactNode, useMemo } from 'react';
import TagsList from '@lib/components/TagsList';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import { formatPriceRange } from '../../defaults/formatters';
import { useInstance } from '../../hooks/UseInstance';
export function PartDetailsPanel({
instance,
allowImageEdit = false,
refreshInstance,
additionalContent
}: Readonly<{
instance: any;
allowImageEdit?: boolean;
refreshInstance?: () => void;
additionalContent?: ReactNode;
}>) {
const { instance: requirements, instanceQuery: requirementsQuery } =
useInstance({
endpoint: ApiEndpoints.part_requirements,
pk: instance?.pk,
hasPrimaryKey: true,
disabled: !instance?.pk,
defaultValue: {}
});
const { instance: serials } = useInstance({
endpoint: ApiEndpoints.part_serial_numbers,
pk: instance?.pk,
hasPrimaryKey: true,
disabled: !instance?.pk,
defaultValue: {}
});
const data = useMemo(() => {
if (!instance) return {};
const d = { ...instance };
d.total_in_stock =
requirements?.total_stock ?? instance?.total_in_stock ?? 0;
d.unallocated =
requirements?.unallocated_stock ?? instance?.unallocated_stock ?? 0;
d.ordering = requirements?.ordering ?? instance?.ordering ?? 0;
d.required =
(requirements?.required_for_build_orders ??
instance?.required_for_build_orders ??
0) +
(requirements?.required_for_sales_orders ??
instance?.required_for_sales_orders ??
0);
d.allocated =
(requirements?.allocated_to_build_orders ??
instance?.allocated_to_build_orders ??
0) +
(requirements?.allocated_to_sales_orders ??
instance?.allocated_to_sales_orders ??
0);
d.can_build = requirements?.can_build ?? 0;
if (serials?.latest) d.latest_serial_number = serials.latest;
return d;
}, [instance, requirements, serials]);
const fetching = requirementsQuery?.isFetching ?? false;
const tl: DetailsField[] = [
{ type: 'string', name: 'name', label: t`Name`, icon: 'part', copy: true },
{
type: 'string',
name: 'IPN',
label: t`IPN`,
copy: true,
hidden: !instance?.IPN
},
{ type: 'string', name: 'description', label: t`Description`, copy: true },
{
type: 'link',
name: 'variant_of',
label: t`Variant of`,
model: ModelType.part,
model_field: 'full_name',
hidden: !instance?.variant_of
},
{
type: 'link',
name: 'revision_of',
label: t`Revision of`,
model: ModelType.part,
model_field: 'full_name',
hidden: !instance?.revision_of
},
{
type: 'string',
name: 'revision',
label: t`Revision`,
hidden: !instance?.revision,
copy: true
},
{
type: 'link',
name: 'category',
label: t`Category`,
model: ModelType.partcategory
},
{
type: 'link',
name: 'default_location',
label: t`Default Location`,
model: ModelType.stocklocation,
hidden: !instance?.default_location
},
{
type: 'link',
name: 'category_default_location',
label: t`Category Default Location`,
model: ModelType.stocklocation,
hidden: instance?.default_location || !instance?.category_default_location
},
{
type: 'string',
name: 'units',
label: t`Units`,
copy: true,
hidden: !instance?.units
},
{
type: 'string',
name: 'keywords',
label: t`Keywords`,
copy: true,
hidden: !instance?.keywords
},
{
type: 'link',
name: 'link',
label: t`Link`,
external: true,
copy: true,
hidden: !instance?.link
}
];
const tr: DetailsField[] = [
{
type: 'number',
name: 'total_in_stock',
unit: instance?.units,
label: t`In Stock`,
hidden: instance?.virtual
},
{
type: 'progressbar',
name: 'unallocated_stock',
total: data.total_in_stock,
progress: data.unallocated,
label: t`Available Stock`,
hidden: instance?.virtual || data.total_in_stock == data.unallocated
},
{
type: 'number',
name: 'ordering',
label: t`On Order`,
unit: instance?.units,
hidden: !instance?.purchaseable || instance?.ordering <= 0
},
{
type: 'number',
name: 'required',
label: t`Required for Orders`,
unit: instance?.units,
hidden: data.required <= 0,
icon: 'stocktake'
},
{
type: 'progressbar',
name: 'allocated_to_build_orders',
icon: 'manufacturers',
total: requirements?.required_for_build_orders,
progress: requirements?.allocated_to_build_orders,
label: t`Allocated to Build Orders`,
hidden:
fetching ||
(requirements?.required_for_build_orders <= 0 &&
requirements?.allocated_to_build_orders <= 0)
},
{
type: 'progressbar',
icon: 'sales_orders',
name: 'allocated_to_sales_orders',
total: requirements?.required_for_sales_orders,
progress: requirements?.allocated_to_sales_orders,
label: t`Allocated to Sales Orders`,
hidden:
fetching ||
(requirements?.required_for_sales_orders <= 0 &&
requirements?.allocated_to_sales_orders <= 0)
},
{
type: 'progressbar',
name: 'building',
label: t`In Production`,
progress: requirements?.building,
total: requirements?.scheduled_to_build,
hidden:
fetching ||
(!requirements?.building && !requirements?.scheduled_to_build)
},
{
type: 'number',
name: 'can_build',
unit: instance?.units,
label: t`Can Build`,
hidden: !instance?.assembly || fetching
},
{
type: 'number',
name: 'minimum_stock',
unit: instance?.units,
label: t`Minimum Stock`,
hidden: instance?.minimum_stock <= 0
},
{
type: 'number',
name: 'maximum_stock',
unit: instance?.units,
label: t`Maximum Stock`,
hidden: instance?.maximum_stock <= 0
}
];
const bl: DetailsField[] = [
{ type: 'boolean', name: 'active', label: t`Active` },
{ type: 'boolean', name: 'locked', label: t`Locked` },
{
type: 'boolean',
icon: 'template',
name: 'is_template',
label: t`Template Part`
},
{ type: 'boolean', name: 'assembly', label: t`Assembled Part` },
{ type: 'boolean', name: 'component', label: t`Component Part` },
{
type: 'boolean',
name: 'testable',
label: t`Testable Part`,
icon: 'test'
},
{ type: 'boolean', name: 'trackable', label: t`Trackable Part` },
{ type: 'boolean', name: 'purchaseable', label: t`Purchaseable Part` },
{
type: 'boolean',
name: 'salable',
icon: 'saleable',
label: t`Saleable Part`
},
{ type: 'boolean', name: 'virtual', label: t`Virtual Part` },
{ type: 'boolean', name: 'starred', label: t`Subscribed`, icon: 'bell' }
];
const br: DetailsField[] = useMemo(() => {
const fields: DetailsField[] = [
{
type: 'string',
name: 'creation_date',
label: t`Creation Date`
},
{
type: 'string',
name: 'creation_user',
label: t`Created By`,
badge: 'user',
icon: 'user',
hidden: !instance?.creation_user
},
{
type: 'string',
name: 'responsible',
label: t`Responsible`,
badge: 'owner',
hidden: !instance?.responsible
},
{
name: 'default_expiry',
label: t`Default Expiry`,
hidden: !instance?.default_expiry,
icon: 'calendar',
type: 'string',
value_formatter: () => `${instance?.default_expiry} ${t`days`}`
}
];
if (instance?.pricing_min || instance?.pricing_max) {
fields.push({
type: 'string',
name: 'pricing',
label: t`Price Range`,
value_formatter: () =>
formatPriceRange(instance?.pricing_min, instance?.pricing_max)
});
}
fields.push({
type: 'string',
name: 'latest_serial_number',
label: t`Latest Serial Number`,
hidden: !instance?.trackable || !data.latest_serial_number,
icon: 'serial'
});
return fields;
}, [instance, data.latest_serial_number]);
if (!instance?.pk) return <Skeleton />;
return (
<ItemDetailsGrid>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={allowImageEdit ? UserRoles.part : undefined}
imageActions={
allowImageEdit
? {
selectExisting: true,
downloadImage: true,
uploadFile: true,
deleteFile: true
}
: {}
}
src={instance.image}
thumbnail={instance.thumbnail}
apiPath={apiUrl(ApiEndpoints.part_list, instance.pk)}
refresh={refreshInstance}
pk={instance.pk}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={data} />
</Grid.Col>
</Grid>
<TagsList tags={instance.tags} />
{additionalContent}
</Stack>
<DetailsTable fields={tr} item={data} />
<DetailsTable fields={bl} item={data} />
<DetailsTable fields={br} item={data} />
</ItemDetailsGrid>
);
}