mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
[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:
parent
98d7c49ea5
commit
3f7d05339b
@ -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 />
|
||||
|
@ -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`}>
|
||||
|
@ -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>
|
||||
</>
|
||||
|
@ -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}
|
||||
>
|
||||
|
43
src/frontend/src/components/tables/TableHoverCard.tsx
Normal file
43
src/frontend/src/components/tables/TableHoverCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
239
src/frontend/src/components/tables/bom/BomTable.tsx
Normal file
239
src/frontend/src/components/tables/bom/BomTable.tsx
Normal 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
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
103
src/frontend/src/components/tables/bom/UsedInTable.tsx
Normal file
103
src/frontend/src/components/tables/bom/UsedInTable.tsx
Normal 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}`)
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
@ -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',
|
||||
|
@ -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:
|
||||
|
Loading…
Reference in New Issue
Block a user