[PUI] StockTrackingTable (#7273)

* Bare bones <StockTrackingTable /> component

* Implement details panel for StockTrackingTable

* Remove unused userState hook

* Expand RenderInstance to include link

* Allow inline renderers to display links
This commit is contained in:
Oliver 2024-05-21 21:06:02 +10:00 committed by GitHub
parent c1def12203
commit 76b298c43e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 379 additions and 73 deletions

View File

@ -1,17 +1,21 @@
import { ReactNode } from 'react';
import { ModelType } from '../../enums/ModelType';
import { getDetailUrl } from '../../functions/urls';
import { InstanceRenderInterface, RenderInlineModel } from './Instance';
import { StatusRenderer } from './StatusRenderer';
/**
* Inline rendering of a single BuildOrder instance
*/
export function RenderBuildOrder({
instance
}: Readonly<InstanceRenderInterface>): ReactNode {
export function RenderBuildOrder(
props: Readonly<InstanceRenderInterface>
): ReactNode {
const { instance } = props;
return (
<RenderInlineModel
{...props}
primary={instance.reference}
secondary={instance.title}
suffix={StatusRenderer({
@ -19,6 +23,7 @@ export function RenderBuildOrder({
type: ModelType.build
})}
image={instance.part_detail?.thumbnail || instance.part_detail?.image}
url={props.link ? getDetailUrl(ModelType.build, instance.pk) : undefined}
/>
);
}

View File

@ -1,5 +1,7 @@
import { ReactNode } from 'react';
import { ModelType } from '../../enums/ModelType';
import { getDetailUrl } from '../../functions/urls';
import { InstanceRenderInterface, RenderInlineModel } from './Instance';
/**
@ -25,16 +27,20 @@ export function RenderAddress({
/**
* Inline rendering of a single Company instance
*/
export function RenderCompany({
instance
}: Readonly<InstanceRenderInterface>): ReactNode {
// TODO: Handle URL
export function RenderCompany(
props: Readonly<InstanceRenderInterface>
): ReactNode {
const { instance } = props;
return (
<RenderInlineModel
{...props}
image={instance.thumnbnail || instance.image}
primary={instance.name}
secondary={instance.description}
url={
props.link ? getDetailUrl(ModelType.company, instance.pk) : undefined
}
/>
);
}
@ -51,20 +57,25 @@ export function RenderContact({
/**
* Inline rendering of a single SupplierPart instance
*/
export function RenderSupplierPart({
instance
}: Readonly<InstanceRenderInterface>): ReactNode {
// TODO: handle URL
let supplier = instance.supplier_detail ?? {};
let part = instance.part_detail ?? {};
export function RenderSupplierPart(
props: Readonly<InstanceRenderInterface>
): ReactNode {
const { instance } = props;
const supplier = instance.supplier_detail ?? {};
const part = instance.part_detail ?? {};
return (
<RenderInlineModel
{...props}
primary={supplier?.name}
secondary={instance.SKU}
image={part?.thumbnail ?? part?.image}
suffix={part.full_name}
url={
props.link
? getDetailUrl(ModelType.supplierpart, instance.pk)
: undefined
}
/>
);
}
@ -72,18 +83,25 @@ export function RenderSupplierPart({
/**
* Inline rendering of a single ManufacturerPart instance
*/
export function RenderManufacturerPart({
instance
}: Readonly<InstanceRenderInterface>): ReactNode {
let part = instance.part_detail ?? {};
let manufacturer = instance.manufacturer_detail ?? {};
export function RenderManufacturerPart(
props: Readonly<InstanceRenderInterface>
): ReactNode {
const { instance } = props;
const part = instance.part_detail ?? {};
const manufacturer = instance.manufacturer_detail ?? {};
return (
<RenderInlineModel
{...props}
primary={manufacturer.name}
secondary={instance.MPN}
suffix={part.full_name}
image={manufacturer?.thumnbnail ?? manufacturer.image}
url={
props.link
? getDetailUrl(ModelType.manufacturerpart, instance.pk)
: undefined
}
/>
);
}

View File

@ -1,8 +1,9 @@
import { t } from '@lingui/macro';
import { Alert, Group, Space, Text } from '@mantine/core';
import { ReactNode } from 'react';
import { Alert, Anchor, Group, Space, Text } from '@mantine/core';
import { ReactNode, useCallback } from 'react';
import { ModelType } from '../../enums/ModelType';
import { navigateToLink } from '../../functions/navigation';
import { Thumbnail } from '../images/Thumbnail';
import { RenderBuildLine, RenderBuildOrder } from './Build';
import {
@ -36,6 +37,12 @@ type EnumDictionary<T extends string | symbol | number, U> = {
[K in T]: U;
};
export interface InstanceRenderInterface {
instance: any;
link?: boolean;
navigate?: any;
}
/**
* Lookup table for rendering a model instance
*/
@ -68,31 +75,27 @@ const RendererLookup: EnumDictionary<
[ModelType.user]: RenderUser
};
// import { ApiFormFieldType } from "../forms/fields/ApiFormField";
export type RenderInstanceProps = {
model: ModelType | undefined;
} & InstanceRenderInterface;
/**
* Render an instance of a database model, depending on the provided data
*/
export function RenderInstance({
model,
instance
}: {
model: ModelType | undefined;
instance: any;
}): ReactNode {
if (model === undefined) {
export function RenderInstance(props: RenderInstanceProps): ReactNode {
if (props.model === undefined) {
console.error('RenderInstance: No model provided');
return <UnknownRenderer model={model} />;
return <UnknownRenderer model={props.model} />;
}
const RenderComponent = RendererLookup[model];
const RenderComponent = RendererLookup[props.model];
if (!RenderComponent) {
console.error(`RenderInstance: No renderer for model ${model}`);
return <UnknownRenderer model={model} />;
console.error(`RenderInstance: No renderer for model ${props.model}`);
return <UnknownRenderer model={props.model} />;
}
return <RenderComponent instance={instance} />;
return <RenderComponent {...props} />;
}
/**
@ -104,7 +107,8 @@ export function RenderInlineModel({
suffix,
image,
labels,
url
url,
navigate
}: {
primary: string;
secondary?: string;
@ -112,15 +116,30 @@ export function RenderInlineModel({
image?: string;
labels?: string[];
url?: string;
navigate?: any;
}): ReactNode {
// TODO: Handle labels
// TODO: Handle URL
const onClick = useCallback(
(event: any) => {
if (url && navigate) {
navigateToLink(url, navigate, event);
}
},
[url, navigate]
);
return (
<Group gap="xs" justify="space-between" wrap="nowrap">
<Group gap="xs" justify="left" wrap="nowrap">
{image && Thumbnail({ src: image, size: 18 })}
<Text size="sm">{primary}</Text>
{url ? (
<Anchor href={url} onClick={(event: any) => onClick(event)}>
<Text size="sm">{primary}</Text>
</Anchor>
) : (
<Text size="sm">{primary}</Text>
)}
{secondary && <Text size="xs">{secondary}</Text>}
</Group>
{suffix && (
@ -144,7 +163,3 @@ export function UnknownRenderer({
</Alert>
);
}
export interface InstanceRenderInterface {
instance: any;
}

View File

@ -2,20 +2,22 @@ import { t } from '@lingui/macro';
import { ReactNode } from 'react';
import { ModelType } from '../../enums/ModelType';
import { getDetailUrl } from '../../functions/urls';
import { InstanceRenderInterface, RenderInlineModel } from './Instance';
import { StatusRenderer } from './StatusRenderer';
/**
* Inline rendering of a single PurchaseOrder instance
*/
export function RenderPurchaseOrder({
instance
}: Readonly<InstanceRenderInterface>): ReactNode {
let supplier = instance.supplier_detail || {};
export function RenderPurchaseOrder(
props: Readonly<InstanceRenderInterface>
): ReactNode {
const { instance } = props;
const supplier = instance?.supplier_detail || {};
// TODO: Handle URL
return (
<RenderInlineModel
{...props}
primary={instance.reference}
secondary={instance.description}
suffix={StatusRenderer({
@ -23,6 +25,11 @@ export function RenderPurchaseOrder({
type: ModelType.purchaseorder
})}
image={supplier.thumnbnail || supplier.image}
url={
props.link
? getDetailUrl(ModelType.purchaseorder, instance.pk)
: undefined
}
/>
);
}
@ -30,13 +37,15 @@ export function RenderPurchaseOrder({
/**
* Inline rendering of a single ReturnOrder instance
*/
export function RenderReturnOrder({
instance
}: Readonly<InstanceRenderInterface>): ReactNode {
let customer = instance.customer_detail || {};
export function RenderReturnOrder(
props: Readonly<InstanceRenderInterface>
): ReactNode {
const { instance } = props;
const customer = instance?.customer_detail || {};
return (
<RenderInlineModel
{...props}
primary={instance.reference}
secondary={instance.description}
suffix={StatusRenderer({
@ -44,6 +53,11 @@ export function RenderReturnOrder({
type: ModelType.returnorder
})}
image={customer.thumnbnail || customer.image}
url={
props.link
? getDetailUrl(ModelType.returnorder, instance.pk)
: undefined
}
/>
);
}
@ -51,15 +65,15 @@ export function RenderReturnOrder({
/**
* Inline rendering of a single SalesOrder instance
*/
export function RenderSalesOrder({
instance
}: Readonly<InstanceRenderInterface>): ReactNode {
let customer = instance.customer_detail || {};
// TODO: Handle URL
export function RenderSalesOrder(
props: Readonly<InstanceRenderInterface>
): ReactNode {
const { instance } = props;
const customer = instance?.customer_detail || {};
return (
<RenderInlineModel
{...props}
primary={instance.reference}
secondary={instance.description}
suffix={StatusRenderer({
@ -67,6 +81,9 @@ export function RenderSalesOrder({
type: ModelType.salesorder
})}
image={customer.thumnbnail || customer.image}
url={
props.link ? getDetailUrl(ModelType.salesorder, instance.pk) : undefined
}
/>
);
}

View File

@ -1,22 +1,27 @@
import { t } from '@lingui/macro';
import { ReactNode } from 'react';
import { ModelType } from '../../enums/ModelType';
import { getDetailUrl } from '../../functions/urls';
import { InstanceRenderInterface, RenderInlineModel } from './Instance';
/**
* Inline rendering of a single Part instance
*/
export function RenderPart({
instance
}: Readonly<InstanceRenderInterface>): ReactNode {
export function RenderPart(
props: Readonly<InstanceRenderInterface>
): ReactNode {
const { instance } = props;
const stock = t`Stock` + `: ${instance.in_stock}`;
return (
<RenderInlineModel
{...props}
primary={instance.name}
secondary={instance.description}
suffix={stock}
image={instance.thumnbnail || instance.image}
url={props.link ? getDetailUrl(ModelType.part, instance.pk) : undefined}
/>
);
}
@ -24,17 +29,22 @@ export function RenderPart({
/**
* Inline rendering of a PartCategory instance
*/
export function RenderPartCategory({
instance
}: Readonly<InstanceRenderInterface>): ReactNode {
// TODO: Handle URL
let lvl = '-'.repeat(instance.level || 0);
export function RenderPartCategory(
props: Readonly<InstanceRenderInterface>
): ReactNode {
const { instance } = props;
const lvl = '-'.repeat(instance.level || 0);
return (
<RenderInlineModel
{...props}
primary={`${lvl} ${instance.name}`}
secondary={instance.description}
url={
props.link
? getDetailUrl(ModelType.partcategory, instance.pk)
: undefined
}
/>
);
}

View File

@ -1,18 +1,28 @@
import { t } from '@lingui/macro';
import { ReactNode } from 'react';
import { ModelType } from '../../enums/ModelType';
import { getDetailUrl } from '../../functions/urls';
import { InstanceRenderInterface, RenderInlineModel } from './Instance';
/**
* Inline rendering of a single StockLocation instance
*/
export function RenderStockLocation({
instance
}: Readonly<InstanceRenderInterface>): ReactNode {
export function RenderStockLocation(
props: Readonly<InstanceRenderInterface>
): ReactNode {
const { instance } = props;
return (
<RenderInlineModel
{...props}
primary={instance.name}
secondary={instance.description}
url={
props.link
? getDetailUrl(ModelType.stocklocation, instance.pk)
: undefined
}
/>
);
}
@ -32,9 +42,10 @@ export function RenderStockLocationType({
);
}
export function RenderStockItem({
instance
}: Readonly<InstanceRenderInterface>): ReactNode {
export function RenderStockItem(
props: Readonly<InstanceRenderInterface>
): ReactNode {
const { instance } = props;
let quantity_string = '';
if (instance?.serial !== null && instance?.serial !== undefined) {
@ -45,9 +56,13 @@ export function RenderStockItem({
return (
<RenderInlineModel
{...props}
primary={instance.part_detail?.full_name}
suffix={quantity_string}
image={instance.part_detail?.thumbnail || instance.part_detail?.image}
url={
props.link ? getDetailUrl(ModelType.stockitem, instance.pk) : undefined
}
/>
);
}

View File

@ -60,6 +60,7 @@ import { AttachmentTable } from '../../tables/general/AttachmentTable';
import InstalledItemsTable from '../../tables/stock/InstalledItemsTable';
import { StockItemTable } from '../../tables/stock/StockItemTable';
import StockItemTestResultTable from '../../tables/stock/StockItemTestResultTable';
import { StockTrackingTable } from '../../tables/stock/StockTrackingTable';
export default function StockDetail() {
const { id } = useParams();
@ -269,7 +270,12 @@ export default function StockDetail() {
{
name: 'tracking',
label: t`Stock Tracking`,
icon: <IconHistory />
icon: <IconHistory />,
content: stockitem.pk ? (
<StockTrackingTable itemId={stockitem.pk} />
) : (
<Skeleton />
)
},
{
name: 'allocations',

View File

@ -0,0 +1,220 @@
import { t } from '@lingui/macro';
import { Table, Text } from '@mantine/core';
import { ReactNode, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { RenderBuildOrder } from '../../components/render/Build';
import { RenderCompany } from '../../components/render/Company';
import {
RenderPurchaseOrder,
RenderReturnOrder,
RenderSalesOrder
} from '../../components/render/Order';
import { RenderPart } from '../../components/render/Part';
import { StatusRenderer } from '../../components/render/StatusRenderer';
import {
RenderStockItem,
RenderStockLocation
} from '../../components/render/Stock';
import { RenderUser } from '../../components/render/User';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
import { TableColumn } from '../Column';
import { DateColumn, DescriptionColumn } from '../ColumnRenderers';
import { InvenTreeTable } from '../InvenTreeTable';
type StockTrackingEntry = {
label: string;
key: string;
details: ReactNode;
};
export function StockTrackingTable({ itemId }: { itemId: number }) {
const navigate = useNavigate();
const table = useTable('stock_tracking');
// Render "details" for a stock tracking record
const renderDetails = useCallback(
(record: any) => {
const deltas: any = record?.deltas ?? {};
let entries: StockTrackingEntry[] = [
{
label: t`Stock Item`,
key: 'stockitem',
details:
deltas.stockitem_detail &&
RenderStockItem({ instance: deltas.stockitem_detail })
},
{
label: t`Status`,
key: 'status',
details:
deltas.status &&
StatusRenderer({ status: deltas.status, type: ModelType.stockitem })
},
{
label: t`Quantity`,
key: 'quantity',
details: deltas.quantity
},
{
label: t`Added`,
key: 'added',
details: deltas.added
},
{
label: t`Removed`,
key: 'removed',
details: deltas.removed
},
{
label: t`Part`,
key: 'part',
details:
deltas.part_detail &&
RenderPart({
instance: deltas.part_detail,
link: true,
navigate: navigate
})
},
{
label: t`Location`,
key: 'location',
details:
deltas.location_detail &&
RenderStockLocation({
instance: deltas.location_detail,
link: true,
navigate: navigate
})
},
{
label: t`Build Order`,
key: 'buildorder',
details:
deltas.buildorder_detail &&
RenderBuildOrder({
instance: deltas.buildorder_detail,
link: true,
navigate: navigate
})
},
{
label: t`Purchase Order`,
key: 'purchaseorder',
details:
deltas.purchaseorder_detail &&
RenderPurchaseOrder({
instance: deltas.purchaseorder_detail,
link: true,
navigate: navigate
})
},
{
label: t`Sales Order`,
key: 'salesorder',
details:
deltas.salesorder_detail &&
RenderSalesOrder({
instance: deltas.salesorder_detail,
link: true,
navigate: navigate
})
},
{
label: t`Return Order`,
key: 'returnorder',
details:
deltas.returnorder_detail &&
RenderReturnOrder({
instance: deltas.returnorder_detail,
link: true,
navigate: navigate
})
},
{
label: t`Customer`,
key: 'customer',
details:
deltas.customer_detail &&
RenderCompany({
instance: deltas.customer_detail,
link: true,
navigate: navigate
})
}
];
return (
<Table striped>
<Table.Tbody>
{entries.map(
(entry) =>
entry.details && (
<Table.Tr key={entry.key}>
<Table.Td>
<Text>{entry.label}</Text>
</Table.Td>
<Table.Td>{entry.details}</Table.Td>
</Table.Tr>
)
)}
</Table.Tbody>
</Table>
);
},
[navigate]
);
const tableColumns: TableColumn[] = useMemo(() => {
return [
DateColumn({
switchable: false
}),
DescriptionColumn({
accessor: 'label'
}),
{
accessor: 'details',
title: t`Details`,
switchable: false,
render: renderDetails
},
{
accessor: 'notes',
title: t`Notes`,
sortable: false,
switchable: true
},
{
accessor: 'user',
title: t`User`,
render: (record: any) => {
if (!record.user_detail) {
return <Text size="sm" fs="italic">{t`No user information`}</Text>;
}
return RenderUser({ instance: record.user_detail });
}
}
];
}, []);
return (
<InvenTreeTable
tableState={table}
url={apiUrl(ApiEndpoints.stock_tracking_list)}
columns={tableColumns}
props={{
params: {
item: itemId,
user_detail: true
}
}}
/>
);
}