diff --git a/src/frontend/src/components/nav/PanelGroup.tsx b/src/frontend/src/components/nav/PanelGroup.tsx index fced385883..058bf87763 100644 --- a/src/frontend/src/components/nav/PanelGroup.tsx +++ b/src/frontend/src/components/nav/PanelGroup.tsx @@ -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 && ( - + {panel.label} diff --git a/src/frontend/src/components/tables/ColumnSelect.tsx b/src/frontend/src/components/tables/ColumnSelect.tsx index 4bd576d408..f3080301ff 100644 --- a/src/frontend/src/components/tables/ColumnSelect.tsx +++ b/src/frontend/src/components/tables/ColumnSelect.tsx @@ -11,7 +11,7 @@ export function TableColumnSelect({ onToggleColumn: (columnName: string) => void; }) { return ( - + diff --git a/src/frontend/src/components/tables/InvenTreeTable.tsx b/src/frontend/src/components/tables/InvenTreeTable.tsx index 1af28739b7..19be7e362f 100644 --- a/src/frontend/src/components/tables/InvenTreeTable.tsx +++ b/src/frontend/src/components/tables/InvenTreeTable.tsx @@ -421,7 +421,7 @@ export function InvenTreeTable({ onCreateFilter={onFilterAdd} onClose={() => setFilterSelectOpen(false)} /> - + {tableProps.customActionGroups?.map( @@ -522,6 +522,10 @@ export function InvenTreeTable({ records={data} columns={dataColumns} onRowClick={tableProps.onRowClick} + defaultColumnProps={{ + noWrap: true, + textAlignment: 'left' + }} /> diff --git a/src/frontend/src/components/tables/RowActions.tsx b/src/frontend/src/components/tables/RowActions.tsx index b3080747f0..8a6df573bb 100644 --- a/src/frontend/src/components/tables/RowActions.tsx +++ b/src/frontend/src/components/tables/RowActions.tsx @@ -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 && ( - + - + @@ -41,7 +64,17 @@ export function RowActions({ {actions.map((action, idx) => ( { + // 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} > diff --git a/src/frontend/src/components/tables/TableHoverCard.tsx b/src/frontend/src/components/tables/TableHoverCard.tsx new file mode 100644 index 0000000000..d3efac13ce --- /dev/null +++ b/src/frontend/src/components/tables/TableHoverCard.tsx @@ -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 ( + + + + {value} + + + + + + + + {title} + + + {extra} + + + + ); +} diff --git a/src/frontend/src/components/tables/bom/BomTable.tsx b/src/frontend/src/components/tables/bom/BomTable.tsx new file mode 100644 index 0000000000..cd7bf93bde --- /dev/null +++ b/src/frontend/src/components/tables/bom/BomTable.tsx @@ -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 && ( + + ) + ); + } + }, + { + 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 + ) : ( + + ); + } + }, + { + accessor: 'optional', + title: t`Optional`, + switchable: true, + sortable: true, + render: (row) => { + return ; + } + }, + { + accessor: 'consumable', + title: t`Consumable`, + switchable: true, + sortable: true, + render: (row) => { + return ; + } + }, + { + accessor: 'allow_variants', + title: t`Allow Variants`, + switchable: true, + sortable: true, + render: (row) => { + return ; + } + }, + { + accessor: 'inherited', + title: t`Gets Inherited`, + switchable: true, + sortable: true, + render: (row) => { + // TODO: Update complexity here + return ; + } + }, + { + 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 {t`No stock`}; + } + + if (substitute_stock > 0) { + extra.push( + {t`Includes substitute stock`} + ); + } + + if (variant_stock > 0) { + extra.push({t`Includes variant stock`}); + } + + if (on_order > 0) { + extra.push( + + {t`On order`}: {on_order} + + ); + } + + return ( + 0 ? {extra} : 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 ( + navigate(`/part/${row.sub_part}`), + rowActions: rowActions + }} + /> + ); +} diff --git a/src/frontend/src/components/tables/bom/UsedInTable.tsx b/src/frontend/src/components/tables/bom/UsedInTable.tsx new file mode 100644 index 0000000000..02886df668 --- /dev/null +++ b/src/frontend/src/components/tables/bom/UsedInTable.tsx @@ -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 && ( + + ) + ); + } + }, + { + accessor: 'sub_part', + title: t`Required Part`, + sortable: true, + render: (record: any) => { + let part = record.sub_part_detail; + return ( + part && ( + + ) + ); + } + }, + { + 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 ( + navigate(`/part/${row.part}`) + }} + /> + ); +} diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index e3d1e692d7..d78a36b147 100644 --- a/src/frontend/src/pages/part/PartDetail.tsx +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -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: , - hidden: !part.assembly + hidden: !part.assembly, + content: }, { name: 'builds', label: t`Build Orders`, icon: , - hidden: !part.assembly && !part.component + hidden: !part.assembly && !part.component, + content: ( + + ) }, { name: 'used_in', label: t`Used In`, icon: , - hidden: !part.component + hidden: !part.component, + content: }, { name: 'pricing', @@ -140,7 +154,14 @@ export default function PartDetail() { name: 'sales_orders', label: t`Sales Orders`, icon: , - hidden: !part.salable + hidden: !part.salable, + content: part.pk && ( + + ) }, { name: 'scheduling', diff --git a/src/frontend/src/states/ApiState.tsx b/src/frontend/src/states/ApiState.tsx index b16034705e..91fb908410 100644 --- a/src/frontend/src/states/ApiState.tsx +++ b/src/frontend/src/states/ApiState.tsx @@ -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: