SupplierPart and ManufacturerPart

This commit is contained in:
Oliver Walters 2026-06-30 14:11:07 +00:00
parent ed074810a4
commit 524cf0288c
8 changed files with 402 additions and 290 deletions

View File

@ -1,10 +1,12 @@
import { ModelType } from '@lib/enums/ModelType';
import type { ReactNode } from 'react';
import { ManufacturerPartPreviewComponent } from './models/ManufacturerPartPreview';
import { PartPreviewComponent } from './models/PartPreview';
import { PurchaseOrderPreviewComponent } from './models/PurchaseOrderPreview';
import { ReturnOrderPreviewComponent } from './models/ReturnOrderPreview';
import { SalesOrderPreviewComponent } from './models/SalesOrderPreview';
import { StockPreviewComponent } from './models/StockPreview';
import { SupplierPartPreviewComponent } from './models/SupplierPartPreview';
export interface PreviewType {
preview: ReactNode;
@ -37,6 +39,10 @@ export function getPreviewComponentForModel({
return SalesOrderPreviewComponent({ instance, modelId });
case ModelType.returnorder:
return ReturnOrderPreviewComponent({ instance, modelId });
case ModelType.supplierpart:
return SupplierPartPreviewComponent({ instance, modelId });
case ModelType.manufacturerpart:
return ManufacturerPartPreviewComponent({ instance, modelId });
default:
return null;
}

View File

@ -0,0 +1,26 @@
import { t } from '@lingui/core/macro';
import { ManufacturerPartDetailsPanel } from '../../../pages/company/ManufacturerPartDetailsPanel';
import type { PreviewType } from '../PreviewType';
export function ManufacturerPartPreviewComponent({
instance,
modelId
}: Readonly<{
instance: any;
modelId: number;
}>): PreviewType {
const manufacturer =
instance?.manufacturer_detail?.name ?? instance?.manufacturer_name;
const mpn = instance?.MPN ?? `#${modelId}`;
let title = `${t`Manufacturer Part`} ${mpn}`;
if (manufacturer) {
title += ` (${manufacturer})`;
}
return {
title,
preview: <ManufacturerPartDetailsPanel instance={instance} />
};
}

View File

@ -0,0 +1,25 @@
import { t } from '@lingui/core/macro';
import { SupplierPartDetailsPanel } from '../../../pages/company/SupplierPartDetailsPanel';
import type { PreviewType } from '../PreviewType';
export function SupplierPartPreviewComponent({
instance,
modelId
}: Readonly<{
instance: any;
modelId: number;
}>): PreviewType {
const supplier = instance?.supplier_detail?.name ?? instance?.supplier_name;
const sku = instance?.SKU ?? `#${modelId}`;
let title = `${t`Supplier Part`} ${sku}`;
if (supplier) {
title += ` (${supplier})`;
}
return {
title,
preview: <SupplierPartDetailsPanel instance={instance} />
};
}

View File

@ -1,5 +1,5 @@
import { t } from '@lingui/core/macro';
import { Grid, Skeleton, Stack } from '@mantine/core';
import { Skeleton, Stack } from '@mantine/core';
import {
IconBuildingWarehouse,
IconInfoCircle,
@ -8,20 +8,12 @@ import {
import { useMemo } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
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 { getDetailUrl } from '@lib/functions/Navigation';
import type { PanelType } from '@lib/types/Panel';
import AdminButton from '../../components/buttons/AdminButton';
import {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import {
DeleteItemAction,
DuplicateItemAction,
@ -44,6 +36,7 @@ import { useInstance } from '../../hooks/UseInstance';
import { useUserState } from '../../states/UserState';
import { SupplierPartTable } from '../../tables/purchasing/SupplierPartTable';
import { StockItemTable } from '../../tables/stock/StockItemTable';
import { ManufacturerPartDetailsPanel } from './ManufacturerPartDetailsPanel';
export default function ManufacturerPartDetail() {
const { id } = useParams();
@ -65,106 +58,19 @@ export default function ManufacturerPartDetail() {
}
});
const detailsPanel = useMemo(() => {
if (instanceQuery.isFetching) {
return <Skeleton />;
}
const data = manufacturerPart ?? {};
const tl: DetailsField[] = [
{
type: 'link',
name: 'part',
label: t`Internal Part`,
model: ModelType.part,
hidden: !manufacturerPart.part
},
{
type: 'string',
name: 'part_detail.IPN',
label: t`IPN`,
copy: true,
icon: 'serial',
hidden: !data.part_detail?.IPN
},
{
type: 'string',
name: 'part_detail.description',
label: t`Description`,
copy: true,
icon: 'info',
hidden: !manufacturerPart.description
}
];
const tr: DetailsField[] = [
{
type: 'link',
name: 'manufacturer',
label: t`Manufacturer`,
icon: 'manufacturers',
model: ModelType.company,
hidden: !manufacturerPart.manufacturer
},
{
type: 'string',
name: 'MPN',
label: t`Manufacturer Part Number`,
copy: true,
hidden: !manufacturerPart.MPN,
icon: 'reference'
},
{
type: 'string',
name: 'description',
label: t`Description`,
copy: true,
hidden: !manufacturerPart.description,
icon: 'info'
},
{
type: 'link',
external: true,
name: 'link',
label: t`External Link`,
copy: true,
hidden: !manufacturerPart.link
}
];
return (
<ItemDetailsGrid
tables={[{ title: t`Manufacturer Details`, fields: tr, item: data }]}
>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={UserRoles.part}
src={manufacturerPart?.part_detail?.image}
apiPath={apiUrl(
ApiEndpoints.part_list,
manufacturerPart?.part_detail?.pk
)}
pk={manufacturerPart?.part_detail?.pk}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable title={t`Part Details`} fields={tl} item={data} />
</Grid.Col>
</Grid>
<TagsList tags={manufacturerPart.tags} />
</Stack>
</ItemDetailsGrid>
);
}, [manufacturerPart, instanceQuery]);
const panels: PanelType[] = useMemo(() => {
return [
{
name: 'details',
label: t`Manufacturer Part Details`,
icon: <IconInfoCircle />,
content: detailsPanel
content: (
<ManufacturerPartDetailsPanel
instance={manufacturerPart}
allowImageEdit
refreshInstance={refreshInstance}
/>
)
},
{
name: 'stock',

View File

@ -0,0 +1,119 @@
import { t } from '@lingui/core/macro';
import { Grid, Skeleton, Stack } from '@mantine/core';
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 { useParameterDetailsGrid } from '../../components/details/ParameterDetailsGrid';
export function ManufacturerPartDetailsPanel({
instance,
allowImageEdit = false,
refreshInstance
}: Readonly<{
instance: any;
allowImageEdit?: boolean;
refreshInstance?: () => void;
}>) {
const tl: DetailsField[] = [
{
type: 'link',
name: 'part',
label: t`Internal Part`,
model: ModelType.part,
hidden: !instance?.part
},
{
type: 'string',
name: 'part_detail.IPN',
label: t`IPN`,
copy: true,
icon: 'serial',
hidden: !instance?.part_detail?.IPN
},
{
type: 'string',
name: 'part_detail.description',
label: t`Description`,
copy: true,
icon: 'info',
hidden: !instance?.description
}
];
const tr: DetailsField[] = [
{
type: 'link',
name: 'manufacturer',
label: t`Manufacturer`,
icon: 'manufacturers',
model: ModelType.company,
hidden: !instance?.manufacturer
},
{
type: 'string',
name: 'MPN',
label: t`Manufacturer Part Number`,
copy: true,
hidden: !instance?.MPN,
icon: 'reference'
},
{
type: 'string',
name: 'description',
label: t`Description`,
copy: true,
hidden: !instance?.description,
icon: 'info'
},
{
type: 'link',
external: true,
name: 'link',
label: t`External Link`,
copy: true,
hidden: !instance?.link
}
];
const parametersTable = useParameterDetailsGrid({
model_type: ModelType.manufacturerpart,
model_id: instance?.pk
});
if (!instance?.pk) return <Skeleton />;
return (
<ItemDetailsGrid
tables={[
{ title: t`Manufacturer Details`, fields: tr, item: instance },
parametersTable
]}
>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={allowImageEdit ? UserRoles.part : undefined}
src={instance?.part_detail?.image}
apiPath={apiUrl(ApiEndpoints.part_list, instance?.part_detail?.pk)}
pk={instance?.part_detail?.pk}
refresh={refreshInstance}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable title={t`Part Details`} fields={tl} item={instance} />
</Grid.Col>
</Grid>
<TagsList tags={instance?.tags} />
</Stack>
</ItemDetailsGrid>
);
}

View File

@ -1,5 +1,5 @@
import { t } from '@lingui/core/macro';
import { Grid, Skeleton, Stack } from '@mantine/core';
import { Skeleton, Stack } from '@mantine/core';
import {
IconCurrencyDollar,
IconInfoCircle,
@ -9,22 +9,14 @@ import {
import { type ReactNode, useMemo } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
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 { formatDecimal } from '@lib/functions/Formatting';
import { getDetailUrl } from '@lib/functions/Navigation';
import type { PanelType } from '@lib/types/Panel';
import AdminButton from '../../components/buttons/AdminButton';
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 {
BarcodeActionDropdown,
DeleteItemAction,
@ -49,6 +41,7 @@ import { useUserState } from '../../states/UserState';
import { PurchaseOrderTable } from '../../tables/purchasing/PurchaseOrderTable';
import SupplierPriceBreakTable from '../../tables/purchasing/SupplierPriceBreakTable';
import { StockItemTable } from '../../tables/stock/StockItemTable';
import { SupplierPartDetailsPanel } from './SupplierPartDetailsPanel';
export default function SupplierPartDetail() {
const { id } = useParams();
@ -73,190 +66,19 @@ export default function SupplierPartDetail() {
}
});
const detailsPanel = useMemo(() => {
if (instanceQuery.isFetching) {
return <Skeleton />;
}
const data = supplierPart ?? {};
// Access nested data
data.manufacturer =
supplierPart.manufacturer || data.manufacturer_detail?.pk;
data.MPN = supplierPart.MPN || data.manufacturer_part_detail?.MPN;
data.manufacturer_part =
supplierPart.manufacturer_part || data.manufacturer_part_detail?.pk;
const tl: DetailsField[] = [
{
type: 'link',
name: 'part',
label: t`Internal Part`,
model: ModelType.part,
hidden: !supplierPart.part
},
{
type: 'string',
name: 'part_detail.IPN',
label: t`IPN`,
copy: true,
hidden: !data.part_detail?.IPN,
icon: 'serial'
},
{
type: 'string',
name: 'part_detail.description',
label: t`Part Description`,
copy: true,
icon: 'info',
hidden: !data.part_detail?.description
},
{
type: 'link',
external: true,
name: 'link',
label: t`External Link`,
copy: true,
hidden: !supplierPart.link
},
{
type: 'string',
name: 'note',
label: t`Note`,
copy: true,
hidden: !supplierPart.note
}
];
const bl: DetailsField[] = [
{
type: 'link',
name: 'supplier',
label: t`Supplier`,
model: ModelType.company,
icon: 'suppliers',
hidden: !supplierPart.supplier
},
{
type: 'string',
name: 'SKU',
label: t`SKU`,
copy: true,
icon: 'reference'
},
{
type: 'string',
name: 'description',
label: t`Description`,
copy: true,
hidden: !data.description
},
{
type: 'link',
name: 'manufacturer',
label: t`Manufacturer`,
model: ModelType.company,
icon: 'manufacturers',
hidden: !data.manufacturer
},
{
type: 'link',
name: 'manufacturer_part',
model_field: 'MPN',
label: t`Manufacturer Part`,
model: ModelType.manufacturerpart,
icon: 'reference',
hidden: !data.manufacturer_part
}
];
const br: DetailsField[] = [
{
type: 'string',
name: 'packaging',
label: t`Packaging`,
copy: true,
hidden: !data.packaging
},
{
type: 'string',
name: 'pack_quantity',
label: t`Pack Quantity`,
copy: true,
hidden: !data.pack_quantity,
icon: 'packages'
}
];
const tr: DetailsField[] = [
{
type: 'number',
name: 'in_stock',
label: t`In Stock`,
copy: true,
icon: 'stock'
},
{
type: 'number',
name: 'on_order',
label: t`On Order`,
copy: true,
icon: 'purchase_orders'
},
{
type: 'number',
name: 'available',
label: t`Supplier Availability`,
hidden: !data.availability_updated,
copy: true,
icon: 'packages'
},
{
type: 'date',
name: 'availability_updated',
label: t`Availability Updated`,
copy: true,
hidden: !data.availability_updated,
icon: 'calendar'
}
];
return (
<ItemDetailsGrid
tables={[
{ title: t`Supplier`, fields: bl, item: data },
{ title: t`Packaging`, fields: br, item: data },
{ title: t`Availability`, fields: tr, item: data }
]}
>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={UserRoles.part}
src={supplierPart?.part_detail?.image}
apiPath={apiUrl(
ApiEndpoints.part_list,
supplierPart?.part_detail?.pk
)}
pk={supplierPart?.part_detail?.pk}
/>
<Grid.Col span={8}>
<DetailsTable title={t`Part Details`} fields={tl} item={data} />
</Grid.Col>
</Grid>
<TagsList tags={supplierPart.tags} />
</Stack>
</ItemDetailsGrid>
);
}, [supplierPart, instanceQuery.isFetching]);
const panels: PanelType[] = useMemo(() => {
return [
{
name: 'details',
label: t`Supplier Part Details`,
icon: <IconInfoCircle />,
content: detailsPanel
content: (
<SupplierPartDetailsPanel
instance={supplierPart}
allowImageEdit
refreshInstance={refreshInstance}
/>
)
},
{
name: 'stock',

View File

@ -0,0 +1,206 @@
import { t } from '@lingui/core/macro';
import { Grid, Skeleton, Stack } from '@mantine/core';
import { 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 { useParameterDetailsGrid } from '../../components/details/ParameterDetailsGrid';
export function SupplierPartDetailsPanel({
instance,
allowImageEdit = false,
refreshInstance
}: Readonly<{
instance: any;
allowImageEdit?: boolean;
refreshInstance?: () => void;
}>) {
const data = useMemo(() => {
if (!instance) return {};
return {
...instance,
manufacturer: instance.manufacturer || instance.manufacturer_detail?.pk,
MPN: instance.MPN || instance.manufacturer_part_detail?.MPN,
manufacturer_part:
instance.manufacturer_part || instance.manufacturer_part_detail?.pk
};
}, [instance]);
const tl: DetailsField[] = [
{
type: 'link',
name: 'part',
label: t`Internal Part`,
model: ModelType.part,
hidden: !instance?.part
},
{
type: 'string',
name: 'part_detail.IPN',
label: t`IPN`,
copy: true,
hidden: !data.part_detail?.IPN,
icon: 'serial'
},
{
type: 'string',
name: 'part_detail.description',
label: t`Part Description`,
copy: true,
icon: 'info',
hidden: !data.part_detail?.description
},
{
type: 'link',
external: true,
name: 'link',
label: t`External Link`,
copy: true,
hidden: !instance?.link
},
{
type: 'string',
name: 'note',
label: t`Note`,
copy: true,
hidden: !instance?.note
}
];
const bl: DetailsField[] = [
{
type: 'link',
name: 'supplier',
label: t`Supplier`,
model: ModelType.company,
icon: 'suppliers',
hidden: !instance?.supplier
},
{
type: 'string',
name: 'SKU',
label: t`SKU`,
copy: true,
icon: 'reference'
},
{
type: 'string',
name: 'description',
label: t`Description`,
copy: true,
hidden: !data.description
},
{
type: 'link',
name: 'manufacturer',
label: t`Manufacturer`,
model: ModelType.company,
icon: 'manufacturers',
hidden: !data.manufacturer
},
{
type: 'link',
name: 'manufacturer_part',
model_field: 'MPN',
label: t`Manufacturer Part`,
model: ModelType.manufacturerpart,
icon: 'reference',
hidden: !data.manufacturer_part
}
];
const br: DetailsField[] = [
{
type: 'string',
name: 'packaging',
label: t`Packaging`,
copy: true,
hidden: !data.packaging
},
{
type: 'string',
name: 'pack_quantity',
label: t`Pack Quantity`,
copy: true,
hidden: !data.pack_quantity,
icon: 'packages'
}
];
const tr: DetailsField[] = [
{
type: 'number',
name: 'in_stock',
label: t`In Stock`,
copy: true,
icon: 'stock'
},
{
type: 'number',
name: 'on_order',
label: t`On Order`,
copy: true,
icon: 'purchase_orders'
},
{
type: 'number',
name: 'available',
label: t`Supplier Availability`,
hidden: !data.availability_updated,
copy: true,
icon: 'packages'
},
{
type: 'date',
name: 'availability_updated',
label: t`Availability Updated`,
copy: true,
hidden: !data.availability_updated,
icon: 'calendar'
}
];
const parametersTable = useParameterDetailsGrid({
model_type: ModelType.supplierpart,
model_id: instance?.pk
});
if (!instance?.pk) return <Skeleton />;
return (
<ItemDetailsGrid
tables={[
{ title: t`Supplier`, fields: bl, item: data },
{ title: t`Packaging`, fields: br, item: data },
{ title: t`Availability`, fields: tr, item: data },
parametersTable
]}
>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={allowImageEdit ? UserRoles.part : undefined}
src={instance?.part_detail?.image}
apiPath={apiUrl(ApiEndpoints.part_list, instance?.part_detail?.pk)}
pk={instance?.part_detail?.pk}
refresh={refreshInstance}
/>
<Grid.Col span={8}>
<DetailsTable title={t`Part Details`} fields={tl} item={data} />
</Grid.Col>
</Grid>
<TagsList tags={instance?.tags} />
</Stack>
</ItemDetailsGrid>
);
}

View File

@ -741,6 +741,8 @@ export function InvenTreeTableInternal<T extends Record<string, any>>({
const accessor = tableProps.modelField ?? 'pk';
const pk = resolveItem(record, accessor);
console.log('row click:', tableProps.modelType, accessor, '->', pk);
if (pk) {
cancelEvent(event);
// If a model type is provided, navigate to the detail view for that model