From e18b6d38ef09cd513d6a0565c3d0b1cc36259455 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 1 Nov 2023 07:32:40 +1100 Subject: [PATCH] React updates (#5826) * Add more panels to StockItem page * Add some placeholder actions for StockItem page * edit stock item * Add info hover card to stocktable * update extra info for part table * Add extra columns to PurchaseOrder table * Fix unused import --- .../src/components/items/ActionDropdown.tsx | 20 ++- .../src/components/tables/part/PartTable.tsx | 25 +++- .../tables/purchasing/PurchaseOrderTable.tsx | 22 ++- .../tables/stock/StockItemTable.tsx | 110 ++++++++++++++- .../src/functions/forms/PartForms.tsx | 2 +- .../src/functions/forms/StockForms.tsx | 33 +++-- src/frontend/src/pages/part/PartDetail.tsx | 10 +- src/frontend/src/pages/stock/StockDetail.tsx | 125 +++++++++++++++++- 8 files changed, 311 insertions(+), 36 deletions(-) diff --git a/src/frontend/src/components/items/ActionDropdown.tsx b/src/frontend/src/components/items/ActionDropdown.tsx index 530019b47c..e8b937fcac 100644 --- a/src/frontend/src/components/items/ActionDropdown.tsx +++ b/src/frontend/src/components/items/ActionDropdown.tsx @@ -1,4 +1,6 @@ +import { t } from '@lingui/macro'; import { ActionIcon, Menu, Tooltip } from '@mantine/core'; +import { IconQrcode } from '@tabler/icons-react'; import { ReactNode, useMemo } from 'react'; import { notYetImplemented } from '../../functions/notifications'; @@ -32,7 +34,7 @@ export function ActionDropdown({ return hasActions ? ( - + ) : null; } + +// Dropdown menu for barcode actions +export function BarcodeActionDropdown({ + actions +}: { + actions: ActionDropdownItem[]; +}) { + return ( + } + actions={actions} + /> + ); +} diff --git a/src/frontend/src/components/tables/part/PartTable.tsx b/src/frontend/src/components/tables/part/PartTable.tsx index ca76d7b521..d59ded46c6 100644 --- a/src/frontend/src/components/tables/part/PartTable.tsx +++ b/src/frontend/src/components/tables/part/PartTable.tsx @@ -75,16 +75,19 @@ function partTableColumns(): TableColumn[] { let extra: ReactNode[] = []; let stock = record?.total_in_stock ?? 0; + let allocated = + (record?.allocated_to_build_orders ?? 0) + + (record?.allocated_to_sales_orders ?? 0); + let available = Math.max(0, stock - allocated); + let min_stock = record?.minimum_stock ?? 0; let text = String(stock); let color: string | undefined = undefined; - if (record.minimum_stock > stock) { + if (min_stock > stock) { extra.push( - - {t`Minimum stock` + `: ${record.minimum_stock}`} - + {t`Minimum stock` + `: ${min_stock}`} ); color = 'orange'; @@ -116,11 +119,19 @@ function partTableColumns(): TableColumn[] { ); } - // TODO: Add extra information on stock "deman" + if (available != stock) { + extra.push({t`Available` + `: ${available}`}); + } - if (stock == 0) { + // TODO: Add extra information on stock "demand" + + if (stock <= 0) { color = 'red'; text = t`No stock`; + } else if (available <= 0) { + color = 'orange'; + } else if (available < min_stock) { + color = 'yellow'; } return ( @@ -129,7 +140,7 @@ function partTableColumns(): TableColumn[] { {text} {record.units && ( - + [{record.units}] )} diff --git a/src/frontend/src/components/tables/purchasing/PurchaseOrderTable.tsx b/src/frontend/src/components/tables/purchasing/PurchaseOrderTable.tsx index 18e0783903..57111ee6ec 100644 --- a/src/frontend/src/components/tables/purchasing/PurchaseOrderTable.tsx +++ b/src/frontend/src/components/tables/purchasing/PurchaseOrderTable.tsx @@ -61,7 +61,7 @@ export function PurchaseOrderTable({ params }: { params?: any }) { accessor: 'project_code', title: t`Project Code`, switchable: true - // TODO: Custom formatter + // TODO: Custom project code formatter }, { accessor: 'status', @@ -78,22 +78,34 @@ export function PurchaseOrderTable({ params }: { params?: any }) { accessor: 'creation_date', title: t`Created`, switchable: true - // TODO: Custom formatter + // TODO: Custom date formatter }, { accessor: 'target_date', title: t`Target Date`, switchable: true - // TODO: Custom formatter + // TODO: Custom date formatter }, { accessor: 'line_items', title: t`Line Items`, sortable: true, switchable: true + }, + { + accessor: 'total_price', + title: t`Total Price`, + sortable: true, + switchable: true + // TODO: Custom money formatter + }, + { + accessor: 'responsible', + title: t`Responsible`, + sortable: true, + switchable: true + // TODO: custom 'owner' formatter } - // TODO: total_price - // TODO: responsible ]; }, []); diff --git a/src/frontend/src/components/tables/stock/StockItemTable.tsx b/src/frontend/src/components/tables/stock/StockItemTable.tsx index 776ac66f4c..ad342c835c 100644 --- a/src/frontend/src/components/tables/stock/StockItemTable.tsx +++ b/src/frontend/src/components/tables/stock/StockItemTable.tsx @@ -1,6 +1,6 @@ import { t } from '@lingui/macro'; -import { Group, Text } from '@mantine/core'; -import { useMemo } from 'react'; +import { Group, Stack, Text } from '@mantine/core'; +import { ReactNode, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { notYetImplemented } from '../../../functions/notifications'; @@ -12,6 +12,7 @@ import { TableStatusRenderer } from '../../renderers/StatusRenderer'; import { TableColumn } from '../Column'; import { TableFilter } from '../Filter'; import { RowAction } from '../RowActions'; +import { TableHoverCard } from '../TableHoverCard'; import { InvenTreeTable } from './../InvenTreeTable'; /** @@ -46,8 +47,109 @@ function stockItemTableColumns(): TableColumn[] { { accessor: 'quantity', sortable: true, - title: t`Stock` - // TODO: Custom renderer for stock quantity + title: t`Stock`, + render: (record) => { + // TODO: Push this out into a custom renderer + let quantity = record?.quantity ?? 0; + let allocated = record?.allocated ?? 0; + let available = quantity - allocated; + let text = quantity; + let part = record?.part_detail ?? {}; + let extra: ReactNode[] = []; + let color = undefined; + + if (record.serial && quantity == 1) { + text = `# ${record.serial}`; + } + + if (record.is_building) { + color = 'blue'; + extra.push( + {t`This stock item is in production`} + ); + } + + if (record.sales_order) { + extra.push( + {t`This stock item has been assigned to a sales order`} + ); + } + + if (record.customer) { + extra.push( + {t`This stock item has been assigned to a customer`} + ); + } + + if (record.belongs_to) { + extra.push( + {t`This stock item is installed in another stock item`} + ); + } + + if (record.consumed_by) { + extra.push( + {t`This stock item has been consumed by a build order`} + ); + } + + if (record.expired) { + extra.push({t`This stock item has expired`}); + } else if (record.stale) { + extra.push({t`This stock item is stale`}); + } + + if (allocated > 0) { + if (allocated >= quantity) { + color = 'orange'; + extra.push( + {t`This stock item is fully allocated`} + ); + } else { + extra.push( + {t`This stock item is partially allocated`} + ); + } + } + + if (available != quantity) { + if (available > 0) { + extra.push( + + {t`Available` + `: ${available}`} + + ); + } else { + extra.push( + {t`No stock available`} + ); + } + } + + if (quantity <= 0) { + color = 'red'; + extra.push( + {t`This stock item has been depleted`} + ); + } + + return ( + + {text} + {part.units && ( + + [{part.units}] + + )} + + } + title={t`Stock Information`} + extra={extra.length > 0 && {extra}} + /> + ); + } }, { accessor: 'status', diff --git a/src/frontend/src/functions/forms/PartForms.tsx b/src/frontend/src/functions/forms/PartForms.tsx index a20700e7d6..00fc4f3d99 100644 --- a/src/frontend/src/functions/forms/PartForms.tsx +++ b/src/frontend/src/functions/forms/PartForms.tsx @@ -94,8 +94,8 @@ export function editPart({ title: t`Edit Part`, url: ApiPaths.part_list, pk: part_id, - successMessage: t`Part updated`, fields: partFields({ editing: true }), + successMessage: t`Part updated`, onFormSuccess: callback }); } diff --git a/src/frontend/src/functions/forms/StockForms.tsx b/src/frontend/src/functions/forms/StockForms.tsx index aa3d9964ee..83d00da992 100644 --- a/src/frontend/src/functions/forms/StockForms.tsx +++ b/src/frontend/src/functions/forms/StockForms.tsx @@ -11,12 +11,16 @@ import { openCreateApiForm, openEditApiForm } from '../forms'; /** * Construct a set of fields for creating / editing a StockItem instance */ -export function stockFields({}: {}): ApiFormFieldSet { +export function stockFields({ + create = false +}: { + create: boolean; +}): ApiFormFieldSet { let fields: ApiFormFieldSet = { part: { + hidden: !create, onValueChange: (change: ApiFormChangeCallback) => { // TODO: implement remaining functionality from old stock.py - console.log('part changed: ', change.value); // Clear the 'supplier_part' field if the part is changed change.form.setValues({ @@ -41,15 +45,18 @@ export function stockFields({}: {}): ApiFormFieldSet { } }, use_pack_size: { + hidden: !create, description: t`Add given quantity as packs instead of individual items` }, location: { + hidden: !create, filters: { structural: false } // TODO: icon }, quantity: { + hidden: !create, description: t`Enter initial quantity for this stock item` }, serial_numbers: { @@ -57,9 +64,11 @@ export function stockFields({}: {}): ApiFormFieldSet { field_type: 'string', label: t`Serial Numbers`, description: t`Enter serial numbers for new stock (or leave blank)`, - required: false + required: false, + hidden: !create }, serial: { + hidden: create // TODO: icon }, batch: { @@ -100,7 +109,7 @@ export function createStockItem() { openCreateApiForm({ name: 'stockitem-create', url: ApiPaths.stock_item_list, - fields: stockFields({}), + fields: stockFields({ create: true }), title: t`Create Stock Item` }); } @@ -109,12 +118,20 @@ export function createStockItem() { * Launch a form to edit an existing StockItem instance * @param item : primary key of the StockItem to edit */ -export function editStockItem(item: number) { +export function editStockItem({ + item_id, + callback +}: { + item_id: number; + callback?: () => void; +}) { openEditApiForm({ name: 'stockitem-edit', url: ApiPaths.stock_item_list, - pk: item, - fields: stockFields({}), - title: t`Edit Stock Item` + pk: item_id, + fields: stockFields({ create: false }), + title: t`Edit Stock Item`, + successMessage: t`Stock item updated`, + onFormSuccess: callback }); } diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index d78a36b147..2864f0063e 100644 --- a/src/frontend/src/pages/part/PartDetail.tsx +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -30,7 +30,10 @@ import { import { useMemo, useState } from 'react'; import { useParams } from 'react-router-dom'; -import { ActionDropdown } from '../../components/items/ActionDropdown'; +import { + ActionDropdown, + BarcodeActionDropdown +} from '../../components/items/ActionDropdown'; import { PageDetail } from '../../components/nav/PageDetail'; import { PanelGroup, PanelType } from '../../components/nav/PanelGroup'; import { PartCategoryTree } from '../../components/nav/PartCategoryTree'; @@ -236,10 +239,7 @@ export default function PartDetail() { const partActions = useMemo(() => { // TODO: Disable actions based on user permissions return [ - } + , diff --git a/src/frontend/src/pages/stock/StockDetail.tsx b/src/frontend/src/pages/stock/StockDetail.tsx index ea953bd6ac..e02ceb49b5 100644 --- a/src/frontend/src/pages/stock/StockDetail.tsx +++ b/src/frontend/src/pages/stock/StockDetail.tsx @@ -3,27 +3,48 @@ import { Alert, LoadingOverlay, Stack, Text } from '@mantine/core'; import { IconBookmark, IconBoxPadding, + IconChecklist, + IconCircleCheck, + IconCircleMinus, + IconCirclePlus, + IconCopy, + IconDots, + IconEdit, IconHistory, IconInfoCircle, + IconLink, IconNotes, + IconPackages, IconPaperclip, - IconSitemap + IconQrcode, + IconSitemap, + IconTransfer, + IconTrash, + IconUnlink } from '@tabler/icons-react'; import { useMemo, useState } from 'react'; import { useParams } from 'react-router-dom'; +import { + ActionDropdown, + BarcodeActionDropdown +} from '../../components/items/ActionDropdown'; import { PlaceholderPanel } from '../../components/items/Placeholder'; import { PageDetail } from '../../components/nav/PageDetail'; import { PanelGroup, PanelType } from '../../components/nav/PanelGroup'; import { StockLocationTree } from '../../components/nav/StockLocationTree'; import { AttachmentTable } from '../../components/tables/general/AttachmentTable'; import { NotesEditor } from '../../components/widgets/MarkdownEditor'; +import { editStockItem } from '../../functions/forms/StockForms'; import { useInstance } from '../../hooks/UseInstance'; import { ApiPaths, apiUrl } from '../../states/ApiState'; +import { useUserState } from '../../states/UserState'; export default function StockDetail() { const { id } = useParams(); + const user = useUserState(); + const [treeOpen, setTreeOpen] = useState(false); const { @@ -58,13 +79,22 @@ export default function StockDetail() { name: 'allocations', label: t`Allocations`, icon: , - content: + content: , + hidden: + !stockitem?.part_detail?.salable && !stockitem?.part_detail?.component + }, + { + name: 'testdata', + label: t`Test Data`, + icon: , + hidden: !stockitem?.part_detail?.trackable }, { name: 'installed_items', label: t`Installed Items`, icon: , - content: + content: , + hidden: !stockitem?.part_detail?.assembly }, { name: 'child_items', @@ -110,6 +140,89 @@ export default function StockDetail() { [stockitem] ); + const stockActions = useMemo( + () => /* TODO: Disable actions based on user permissions*/ [ + , + name: t`View`, + tooltip: t`View part barcode` + }, + { + icon: , + name: t`Link Barcode`, + tooltip: t`Link custom barcode to stock item`, + disabled: stockitem?.barcode_hash + }, + { + icon: , + name: t`Unlink Barcode`, + tooltip: t`Unlink custom barcode from stock item`, + disabled: !stockitem?.barcode_hash + } + ]} + />, + } + actions={[ + { + name: t`Count`, + tooltip: t`Count stock`, + icon: + }, + { + name: t`Add`, + tooltip: t`Add stock`, + icon: + }, + { + name: t`Remove`, + tooltip: t`Remove stock`, + icon: + }, + { + name: t`Transfer`, + tooltip: t`Transfer stock`, + icon: + } + ]} + />, + } + actions={[ + { + name: t`Duplicate`, + tooltip: t`Duplicate stock item`, + icon: + }, + { + name: t`Edit`, + tooltip: t`Edit stock item`, + icon: , + onClick: () => { + stockitem.pk && + editStockItem({ + item_id: stockitem.pk, + callback: () => refreshInstance + }); + } + }, + { + name: t`Delete`, + tooltip: t`Delete stock item`, + icon: + } + ]} + /> + ], + [id, stockitem, user] + ); + return ( @@ -119,8 +232,9 @@ export default function StockDetail() { selectedLocation={stockitem?.location} /> Quantity: {stockitem.quantity ?? 'idk'} @@ -130,6 +244,7 @@ export default function StockDetail() { breadcrumbAction={() => { setTreeOpen(true); }} + actions={stockActions} />