[React] BOM table (#5816)

* Add BuildOrderTable to part page

* Add UsedInTable

* Display SalesOrderTable on PartDetail page

* Fix horizontal overflow for panel group

* First pass at BomTable

* Adds <TableHoverCard> component

- Allows us to display more information in a table cell, without clutter

* Fix for row actions

- Prevent opening row actions menu from "clicking" the row
- Prevent selection of row action from "clicking" the row

* Further work on BOM table

- Column rendering
- Placeholder actions

* Prevent navigation to a panel group page and selection of invalid panel

* Fix unused references
This commit is contained in:
Oliver 2023-10-31 00:02:42 +11:00 committed by GitHub
parent 98d7c49ea5
commit 3f7d05339b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 476 additions and 15 deletions

View File

@ -54,11 +54,16 @@ export function PanelGroup({
});
// Update the active panel when the selected panel changes
// If the selected panel is not available, default to the first available panel
useEffect(() => {
if (selectedPanel) {
setActivePanel(selectedPanel);
let activePanelNames = panels
.filter((panel) => !panel.hidden && !panel.disabled)
.map((panel) => panel.name);
if (!activePanelNames.includes(activePanel)) {
setActivePanel(activePanelNames.length > 0 ? activePanelNames[0] : '');
}
}, [selectedPanel]);
}, [panels]);
// Callback when the active panel changes
function handlePanelChange(panel: string) {
@ -116,7 +121,15 @@ export function PanelGroup({
{panels.map(
(panel, idx) =>
!panel.hidden && (
<Tabs.Panel key={idx} value={panel.name} p="sm">
<Tabs.Panel
key={idx}
value={panel.name}
p="sm"
style={{
overflowX: 'scroll',
width: '100%'
}}
>
<Stack spacing="md">
<StylishText size="lg">{panel.label}</StylishText>
<Divider />

View File

@ -11,7 +11,7 @@ export function TableColumnSelect({
onToggleColumn: (columnName: string) => void;
}) {
return (
<Menu shadow="xs">
<Menu shadow="xs" closeOnItemClick={false}>
<Menu.Target>
<ActionIcon>
<Tooltip label={t`Select Columns`}>

View File

@ -421,7 +421,7 @@ export function InvenTreeTable({
onCreateFilter={onFilterAdd}
onClose={() => setFilterSelectOpen(false)}
/>
<Stack>
<Stack spacing="sm">
<Group position="apart">
<Group position="left" key="custom-actions" spacing={5}>
{tableProps.customActionGroups?.map(
@ -522,6 +522,10 @@ export function InvenTreeTable({
records={data}
columns={dataColumns}
onRowClick={tableProps.onRowClick}
defaultColumnProps={{
noWrap: true,
textAlignment: 'left'
}}
/>
</Stack>
</>

View File

@ -2,13 +2,15 @@ import { t } from '@lingui/macro';
import { ActionIcon, Tooltip } from '@mantine/core';
import { Menu, Text } from '@mantine/core';
import { IconDots } from '@tabler/icons-react';
import { ReactNode } from 'react';
import { ReactNode, useState } from 'react';
import { notYetImplemented } from '../../functions/notifications';
// Type definition for a table row action
export type RowAction = {
title: string;
color?: string;
onClick: () => void;
onClick?: () => void;
tooltip?: string;
icon?: ReactNode;
};
@ -26,12 +28,33 @@ export function RowActions({
disabled?: boolean;
actions: RowAction[];
}): ReactNode {
// Prevent default event handling
// Ref: https://icflorescu.github.io/mantine-datatable/examples/links-or-buttons-inside-clickable-rows-or-cells
function openMenu(event: any) {
event?.preventDefault();
event?.stopPropagation();
event?.nativeEvent?.stopImmediatePropagation();
setOpened(true);
}
const [opened, setOpened] = useState(false);
return (
actions.length > 0 && (
<Menu withinPortal={true} disabled={disabled}>
<Menu
withinPortal={true}
disabled={disabled}
opened={opened}
onChange={setOpened}
>
<Menu.Target>
<Tooltip label={title || t`Actions`}>
<ActionIcon disabled={disabled} variant="subtle" color="gray">
<ActionIcon
onClick={openMenu}
disabled={disabled}
variant="subtle"
color="gray"
>
<IconDots />
</ActionIcon>
</Tooltip>
@ -41,7 +64,17 @@ export function RowActions({
{actions.map((action, idx) => (
<Menu.Item
key={idx}
onClick={action.onClick}
onClick={(event) => {
// Prevent clicking on the action from selecting the row itself
event?.preventDefault();
event?.stopPropagation();
event?.nativeEvent?.stopImmediatePropagation();
if (action.onClick) {
action.onClick();
} else {
notYetImplemented();
}
}}
icon={action.icon}
title={action.tooltip || action.title}
>

View File

@ -0,0 +1,43 @@
import { Divider, Group, HoverCard, Stack } from '@mantine/core';
import { IconInfoCircle } from '@tabler/icons-react';
/*
* A custom hovercard element for displaying extra information in a table cell.
* If a table cell has extra information available,
* it can be displayed as a drop-down hovercard when the user hovers over the cell.
*/
export function TableHoverCard({
value, // The value of the cell
extra, // The extra information to display
title // The title of the hovercard
}: {
value: any;
extra?: any;
title?: string;
}) {
// If no extra information presented, just return the raw value
if (!extra) {
return value;
}
return (
<HoverCard>
<HoverCard.Target>
<Group spacing="xs" position="apart">
{value}
<IconInfoCircle size="16" color="blue" />
</Group>
</HoverCard.Target>
<HoverCard.Dropdown>
<Stack spacing="xs">
<Group spacing="xs" position="left">
<IconInfoCircle size="16" color="blue" />
{title}
</Group>
<Divider />
{extra}
</Stack>
</HoverCard.Dropdown>
</HoverCard>
);
}

View File

@ -0,0 +1,239 @@
import { t } from '@lingui/macro';
import { Stack, Text } from '@mantine/core';
import { ReactNode, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTableRefresh } from '../../../hooks/TableRefresh';
import { ApiPaths, apiUrl } from '../../../states/ApiState';
import { useUserState } from '../../../states/UserState';
import { ThumbnailHoverCard } from '../../images/Thumbnail';
import { YesNoButton } from '../../items/YesNoButton';
import { TableColumn } from '../Column';
import { TableFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
import { RowAction } from '../RowActions';
import { TableHoverCard } from '../TableHoverCard';
export function BomTable({
partId,
params = {}
}: {
partId: number;
params?: any;
}) {
const navigate = useNavigate();
const user = useUserState();
const { tableKey } = useTableRefresh('bom');
const tableColumns: TableColumn[] = useMemo(() => {
return [
// TODO: Improve column rendering
{
accessor: 'part',
title: t`Part`,
render: (row) => {
let part = row.sub_part_detail;
return (
part && (
<ThumbnailHoverCard
src={part.thumbnail || part.image}
text={part.full_name}
alt={part.description}
link=""
/>
)
);
}
},
{
accessor: 'description',
title: t`Description`,
switchable: true,
render: (row) => row?.sub_part_detail?.description
},
{
accessor: 'reference',
switchable: true,
title: t`Reference`
},
{
accessor: 'quantity',
title: t`Quantity`
},
{
accessor: 'substitutes',
title: t`Substitutes`,
switchable: true,
render: (row) => {
let substitutes = row.substitutes ?? [];
return substitutes.length > 0 ? (
row.length
) : (
<YesNoButton value={false} />
);
}
},
{
accessor: 'optional',
title: t`Optional`,
switchable: true,
sortable: true,
render: (row) => {
return <YesNoButton value={row.optional} />;
}
},
{
accessor: 'consumable',
title: t`Consumable`,
switchable: true,
sortable: true,
render: (row) => {
return <YesNoButton value={row.consumable} />;
}
},
{
accessor: 'allow_variants',
title: t`Allow Variants`,
switchable: true,
sortable: true,
render: (row) => {
return <YesNoButton value={row.allow_variants} />;
}
},
{
accessor: 'inherited',
title: t`Gets Inherited`,
switchable: true,
sortable: true,
render: (row) => {
// TODO: Update complexity here
return <YesNoButton value={row.inherited} />;
}
},
{
accessor: 'price_range',
title: t`Price Range`,
switchable: true,
sortable: false,
render: (row) => {
let min_price = row.pricing_min || row.pricing_max;
let max_price = row.pricing_max || row.pricing_min;
// TODO: Custom price range rendering component
return `${min_price} - ${max_price}`;
}
},
{
accessor: 'available_stock',
title: t`Available`,
switchable: true,
render: (row) => {
let extra: ReactNode[] = [];
let available_stock: number = row?.available_stock ?? 0;
let substitute_stock: number = row?.substitute_stock ?? 0;
let variant_stock: number = row?.variant_stock ?? 0;
let on_order: number = row?.on_order ?? 0;
if (available_stock <= 0) {
return <Text color="red" italic>{t`No stock`}</Text>;
}
if (substitute_stock > 0) {
extra.push(
<Text key="substitute">{t`Includes substitute stock`}</Text>
);
}
if (variant_stock > 0) {
extra.push(<Text key="variant">{t`Includes variant stock`}</Text>);
}
if (on_order > 0) {
extra.push(
<Text key="on_order">
{t`On order`}: {on_order}
</Text>
);
}
return (
<TableHoverCard
value={available_stock}
extra={
extra.length > 0 ? <Stack spacing="xs">{extra}</Stack> : null
}
title={t`Available Stock`}
/>
);
}
},
{
accessor: 'can_build',
title: t`Can Build`,
switchable: true,
sortable: true // TODO: Custom sorting via API
// TODO: Reference bom.js for canBuildQuantity method
},
{
accessor: 'note',
title: t`Notes`,
switchable: true
}
];
}, [partId, params]);
const tableFilters: TableFilter[] = useMemo(() => {
return [];
}, [partId, params]);
const rowActions = useCallback(
(record: any) => {
// TODO: Check user permissions here,
// TODO: to determine which actions are allowed
let actions: RowAction[] = [];
if (!record.validated) {
actions.push({
title: t`Validate`
});
}
actions.push({
title: t`Edit`
});
actions.push({
title: t`Delete`,
color: 'red'
});
return actions;
},
[partId, user]
);
return (
<InvenTreeTable
url={apiUrl(ApiPaths.bom_list)}
tableKey={tableKey}
columns={tableColumns}
props={{
params: {
...params,
part: partId,
part_detail: true,
sub_part_detail: true
},
customFilters: tableFilters,
onRowClick: (row) => navigate(`/part/${row.sub_part}`),
rowActions: rowActions
}}
/>
);
}

View File

@ -0,0 +1,103 @@
import { t } from '@lingui/macro';
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTableRefresh } from '../../../hooks/TableRefresh';
import { ApiPaths, apiUrl } from '../../../states/ApiState';
import { ThumbnailHoverCard } from '../../images/Thumbnail';
import { TableColumn } from '../Column';
import { TableFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
/*
* For a given part, render a table showing all the assemblies the part is used in
*/
export function UsedInTable({
partId,
params = {}
}: {
partId: number;
params?: any;
}) {
const navigate = useNavigate();
const { tableKey } = useTableRefresh('usedin');
const tableColumns: TableColumn[] = useMemo(() => {
return [
{
accessor: 'part',
title: t`Assembled Part`,
switchable: false,
sortable: true,
render: (record: any) => {
let part = record.part_detail;
return (
part && (
<ThumbnailHoverCard
src={part.thumbnail || part.image}
text={part.full_name}
alt={part.description}
link=""
/>
)
);
}
},
{
accessor: 'sub_part',
title: t`Required Part`,
sortable: true,
render: (record: any) => {
let part = record.sub_part_detail;
return (
part && (
<ThumbnailHoverCard
src={part.thumbnail || part.image}
text={part.full_name}
alt={part.description}
link=""
/>
)
);
}
},
{
accessor: 'quantity',
title: t`Quantity`,
render: (record: any) => {
// TODO: render units if appropriate
return record.quantity;
}
},
{
accessor: 'reference',
title: t`Reference`,
sortable: true,
switchable: true
}
];
}, [partId]);
const tableFilters: TableFilter[] = useMemo(() => {
return [];
}, [partId]);
return (
<InvenTreeTable
url={apiUrl(ApiPaths.bom_list)}
tableKey={tableKey}
columns={tableColumns}
props={{
params: {
...params,
uses: partId,
part_detail: true,
sub_part_detail: true
},
customFilters: tableFilters,
onRowClick: (row) => navigate(`/part/${row.part}`)
}}
/>
);
}

View File

@ -34,10 +34,14 @@ import { ActionDropdown } from '../../components/items/ActionDropdown';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { PartCategoryTree } from '../../components/nav/PartCategoryTree';
import { BomTable } from '../../components/tables/bom/BomTable';
import { UsedInTable } from '../../components/tables/bom/UsedInTable';
import { BuildOrderTable } from '../../components/tables/build/BuildOrderTable';
import { AttachmentTable } from '../../components/tables/general/AttachmentTable';
import { PartParameterTable } from '../../components/tables/part/PartParameterTable';
import { PartVariantTable } from '../../components/tables/part/PartVariantTable';
import { RelatedPartTable } from '../../components/tables/part/RelatedPartTable';
import { SalesOrderTable } from '../../components/tables/sales/SalesOrderTable';
import { StockItemTable } from '../../components/tables/stock/StockItemTable';
import { NotesEditor } from '../../components/widgets/MarkdownEditor';
import { editPart } from '../../functions/forms/PartForms';
@ -105,19 +109,29 @@ export default function PartDetail() {
name: 'bom',
label: t`Bill of Materials`,
icon: <IconListTree />,
hidden: !part.assembly
hidden: !part.assembly,
content: <BomTable partId={part.pk ?? -1} />
},
{
name: 'builds',
label: t`Build Orders`,
icon: <IconTools />,
hidden: !part.assembly && !part.component
hidden: !part.assembly && !part.component,
content: (
<BuildOrderTable
params={{
part_detail: true,
part: part.pk ?? -1
}}
/>
)
},
{
name: 'used_in',
label: t`Used In`,
icon: <IconStack2 />,
hidden: !part.component
hidden: !part.component,
content: <UsedInTable partId={part.pk ?? -1} />
},
{
name: 'pricing',
@ -140,7 +154,14 @@ export default function PartDetail() {
name: 'sales_orders',
label: t`Sales Orders`,
icon: <IconTruckDelivery />,
hidden: !part.salable
hidden: !part.salable,
content: part.pk && (
<SalesOrderTable
params={{
part: part.pk ?? -1
}}
/>
)
},
{
name: 'scheduling',

View File

@ -73,6 +73,9 @@ export enum ApiPaths {
build_order_list = 'api-build-list',
build_order_attachment_list = 'api-build-attachment-list',
// BOM URLs
bom_list = 'api-bom-list',
// Part URLs
part_list = 'api-part-list',
category_list = 'api-category-list',
@ -159,6 +162,8 @@ export function apiEndpoint(path: ApiPaths): string {
return 'build/';
case ApiPaths.build_order_attachment_list:
return 'build/attachment/';
case ApiPaths.bom_list:
return 'bom/';
case ApiPaths.part_list:
return 'part/';
case ApiPaths.part_parameter_list: