From 392624cb8442508d7d9aabaeabcc912f50e904dc Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 27 Sep 2024 16:00:06 +1000 Subject: [PATCH] [PUI] Installed items (#8202) * Improve checks if "installed items" panel should be displayed * Adds ability to exclude a given field from form data * Implement form for installing stock item * Add placeholder for uninstall action * Add "note" field * Add PUI form to uninstall stock item --- src/backend/InvenTree/stock/serializers.py | 6 +- src/frontend/src/components/forms/ApiForm.tsx | 13 +- .../components/forms/fields/ApiFormField.tsx | 2 + src/frontend/src/enums/ApiEndpoints.tsx | 1 + src/frontend/src/forms/StockForms.tsx | 74 +++++++++++- src/frontend/src/pages/stock/StockDetail.tsx | 67 +++++++++- .../src/tables/stock/InstalledItemsTable.tsx | 114 +++++++++++++++--- 7 files changed, 246 insertions(+), 31 deletions(-) diff --git a/src/backend/InvenTree/stock/serializers.py b/src/backend/InvenTree/stock/serializers.py index 3fc8bd477a..5a6b5d9454 100644 --- a/src/backend/InvenTree/stock/serializers.py +++ b/src/backend/InvenTree/stock/serializers.py @@ -820,9 +820,9 @@ class InstallStockItemSerializer(serializers.Serializer): quantity = data.get('quantity', stock_item.quantity) if quantity > stock_item.quantity: - raise ValidationError( - _('Quantity to install must not exceed available quantity') - ) + raise ValidationError({ + 'quantity': _('Quantity to install must not exceed available quantity') + }) return data diff --git a/src/frontend/src/components/forms/ApiForm.tsx b/src/frontend/src/components/forms/ApiForm.tsx index 07ac3edfe2..bf600a5fae 100644 --- a/src/frontend/src/components/forms/ApiForm.tsx +++ b/src/frontend/src/components/forms/ApiForm.tsx @@ -397,11 +397,13 @@ export function ApiForm({ data = props.processFormData(data); } - let dataForm = new FormData(); + let jsonData = { ...data }; + let formData = new FormData(); Object.keys(data).forEach((key: string) => { let value: any = data[key]; let field_type = fields[key]?.field_type; + let exclude = fields[key]?.exclude; if (field_type == 'file upload' && !!value) { hasFiles = true; @@ -418,15 +420,18 @@ export function ApiForm({ } } - if (value != undefined) { - dataForm.append(key, value); + if (exclude) { + // Remove the field from the data + delete jsonData[key]; + } else if (value != undefined) { + formData.append(key, value); } }); return api({ method: method, url: url, - data: hasFiles ? dataForm : data, + data: hasFiles ? formData : jsonData, timeout: props.timeout, headers: { 'Content-Type': hasFiles ? 'multipart/form-data' : 'application/json' diff --git a/src/frontend/src/components/forms/fields/ApiFormField.tsx b/src/frontend/src/components/forms/fields/ApiFormField.tsx index 7cae0ac09f..fb9ded1eb8 100644 --- a/src/frontend/src/components/forms/fields/ApiFormField.tsx +++ b/src/frontend/src/components/forms/fields/ApiFormField.tsx @@ -41,6 +41,7 @@ export type ApiFormAdjustFilterType = { * @param required : Whether the field is required * @param hidden : Whether the field is hidden * @param disabled : Whether the field is disabled + * @param exclude : Whether to exclude the field from the submitted data * @param placeholder : The placeholder text to display * @param description : The description to display for the field * @param preFieldContent : Content to render before the field @@ -84,6 +85,7 @@ export type ApiFormFieldType = { choices?: any[]; hidden?: boolean; disabled?: boolean; + exclude?: boolean; read_only?: boolean; placeholder?: string; description?: string; diff --git a/src/frontend/src/enums/ApiEndpoints.tsx b/src/frontend/src/enums/ApiEndpoints.tsx index 7c9d059748..20c08f43f0 100644 --- a/src/frontend/src/enums/ApiEndpoints.tsx +++ b/src/frontend/src/enums/ApiEndpoints.tsx @@ -134,6 +134,7 @@ export enum ApiEndpoints { stock_assign = 'stock/assign/', stock_status = 'stock/status/', stock_install = 'stock/:id/install/', + stock_uninstall = 'stock/:id/uninstall/', stock_serialize = 'stock/:id/serialize/', build_test_statistics = 'test-statistics/by-build/:id/', part_test_statistics = 'test-statistics/by-part/:id/', diff --git a/src/frontend/src/forms/StockForms.tsx b/src/frontend/src/forms/StockForms.tsx index 8539e7116c..899e598b51 100644 --- a/src/frontend/src/forms/StockForms.tsx +++ b/src/frontend/src/forms/StockForms.tsx @@ -11,7 +11,7 @@ import { IconUsersGroup } from '@tabler/icons-react'; import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; -import { Suspense, useMemo, useState } from 'react'; +import { Suspense, useEffect, useMemo, useState } from 'react'; import { api } from '../App'; import { ActionButton } from '../components/buttons/ActionButton'; @@ -215,6 +215,78 @@ export function useCreateStockItem() { }); } +/** + * Form set for manually removing (uninstalling) a StockItem from an existing StockItem + */ +export function useStockItemUninstallFields(): ApiFormFieldSet { + return useMemo(() => { + return { + location: { + filters: { + structural: false + } + }, + note: {} + }; + }, []); +} + +/** + * Form set for manually installing a StockItem into an existing StockItem + */ +export function useStockItemInstallFields({ + stockItem +}: { + stockItem: any; +}): ApiFormFieldSet { + const globalSettings = useGlobalSettingsState(); + + const [selectedPart, setSelectedPart] = useState(null); + + useEffect(() => { + setSelectedPart(null); + }, [stockItem]); + + return useMemo(() => { + // Note: The 'part' field is not a part of the API endpoint, so we construct it manually + return { + part: { + field_type: 'related field', + required: true, + exclude: true, + label: t`Part`, + description: t`Select the part to install`, + model: ModelType.part, + api_url: apiUrl(ApiEndpoints.part_list), + onValueChange: (value) => { + setSelectedPart(value); + }, + filters: { + trackable: true, + in_bom_for: globalSettings.isSet('STOCK_ENFORCE_BOM_INSTALLATION') + ? stockItem.part + : undefined + } + }, + stock_item: { + disabled: !selectedPart, + filters: { + part_detail: true, + in_stock: true, + available: true, + tracked: true, + part: selectedPart ? selectedPart : undefined + } + }, + quantity: {}, + note: {} + }; + }, [globalSettings, selectedPart, stockItem]); +} + +/** + * Form set for serializing an existing StockItem + */ export function useStockItemSerializeFields({ partId, trackable diff --git a/src/frontend/src/pages/stock/StockDetail.tsx b/src/frontend/src/pages/stock/StockDetail.tsx index 512b8b28b0..fa8d460e5b 100644 --- a/src/frontend/src/pages/stock/StockDetail.tsx +++ b/src/frontend/src/pages/stock/StockDetail.tsx @@ -11,9 +11,11 @@ import { IconPaperclip, IconSitemap } from '@tabler/icons-react'; +import { useQuery } from '@tanstack/react-query'; import { ReactNode, useMemo, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; +import { api } from '../../App'; import AdminButton from '../../components/buttons/AdminButton'; import { PrintingActions } from '../../components/buttons/PrintingActions'; import { DetailsField, DetailsTable } from '../../components/details/Details'; @@ -57,6 +59,7 @@ import { useEditApiFormModal } from '../../hooks/UseForm'; import { useInstance } from '../../hooks/UseInstance'; +import { apiUrl } from '../../states/ApiState'; import { useUserState } from '../../states/UserState'; import BuildAllocatedStockTable from '../../tables/build/BuildAllocatedStockTable'; import { AttachmentTable } from '../../tables/general/AttachmentTable'; @@ -288,7 +291,7 @@ export default function StockDetail() { ); }, [stockitem, instanceQuery]); - const showBuildAllocations = useMemo(() => { + const showBuildAllocations: boolean = useMemo(() => { // Determine if "build allocations" should be shown for this stock item return ( stockitem?.part_detail?.component && // Must be a "component" @@ -297,10 +300,57 @@ export default function StockDetail() { ); // Must not be installed into another item }, [stockitem]); - const showSalesAlloctions = useMemo(() => { + const showSalesAlloctions: boolean = useMemo(() => { return stockitem?.part_detail?.salable; }, [stockitem]); + // API query to determine if this stock item has trackable BOM items + const trackedBomItemQuery = useQuery({ + queryKey: ['tracked-bom-item', stockitem.pk, stockitem.part], + queryFn: () => { + if ( + !stockitem.pk || + !stockitem.part || + !stockitem.part_detail?.assembly + ) { + return false; + } + + return api + .get(apiUrl(ApiEndpoints.bom_list), { + params: { + part: stockitem.part, + sub_part_trackable: true, + limit: 1 + } + }) + .then((response) => { + if (response.status == 200) { + return response.data.count > 0; + } else { + return null; + } + }) + .catch(() => { + return null; + }); + } + }); + + const showInstalledItems: boolean = useMemo(() => { + if (stockitem?.installed_items) { + // There are installed items in this stock item + return true; + } + + if (trackedBomItemQuery.data != null) { + return trackedBomItemQuery.data; + } + + // Fall back to whether this is an assembly or not + return stockitem?.part_detail?.assembly; + }, [trackedBomItemQuery, stockitem]); + const stockPanels: PanelType[] = useMemo(() => { return [ { @@ -380,8 +430,8 @@ export default function StockDetail() { name: 'installed_items', label: t`Installed Items`, icon: , - hidden: !stockitem?.part_detail?.assembly, - content: + hidden: !showInstalledItems, + content: }, { name: 'child_items', @@ -421,7 +471,14 @@ export default function StockDetail() { ) } ]; - }, [stockitem, id, user]); + }, [ + showSalesAlloctions, + showBuildAllocations, + showInstalledItems, + stockitem, + id, + user + ]); const breadcrumbs = useMemo( () => [ diff --git a/src/frontend/src/tables/stock/InstalledItemsTable.tsx b/src/frontend/src/tables/stock/InstalledItemsTable.tsx index ce20f72c60..694d16acfb 100644 --- a/src/frontend/src/tables/stock/InstalledItemsTable.tsx +++ b/src/frontend/src/tables/stock/InstalledItemsTable.tsx @@ -1,7 +1,17 @@ -import { useMemo } from 'react'; +import { t } from '@lingui/macro'; +import { Skeleton } from '@mantine/core'; +import { IconUnlink } from '@tabler/icons-react'; +import { useCallback, useMemo, useState } from 'react'; +import { AddItemButton } from '../../components/buttons/AddItemButton'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ModelType } from '../../enums/ModelType'; +import { UserRoles } from '../../enums/Roles'; +import { + useStockItemInstallFields, + useStockItemUninstallFields +} from '../../forms/StockForms'; +import { useCreateApiFormModal } from '../../hooks/UseForm'; import { useTable } from '../../hooks/UseTable'; import { apiUrl } from '../../states/ApiState'; import { useUserState } from '../../states/UserState'; @@ -10,13 +20,42 @@ import { PartColumn, StatusColumn } from '../ColumnRenderers'; import { InvenTreeTable } from '../InvenTreeTable'; export default function InstalledItemsTable({ - parentId + stockItem }: Readonly<{ - parentId?: number | string; + stockItem: any; }>) { const table = useTable('stock_item_install'); const user = useUserState(); + const installItemFields = useStockItemInstallFields({ + stockItem: stockItem + }); + + const installItem = useCreateApiFormModal({ + url: apiUrl(ApiEndpoints.stock_install), + pk: stockItem.pk, + title: t`Install Item`, + table: table, + successMessage: t`Item installed`, + fields: installItemFields + }); + + const [selectedRecord, setSelectedRecord] = useState({}); + + const uninstallItemFields = useStockItemUninstallFields(); + + const uninstallItem = useCreateApiFormModal({ + url: apiUrl(ApiEndpoints.stock_uninstall), + pk: selectedRecord.pk, + title: t`Uninstall Item`, + table: table, + successMessage: t`Item uninstalled`, + fields: uninstallItemFields, + initialData: { + location: stockItem.location ?? stockItem.part_detail?.default_location + } + }); + const tableColumns: TableColumn[] = useMemo(() => { return [ { @@ -46,22 +85,61 @@ export default function InstalledItemsTable({ }, []); const tableActions = useMemo(() => { - return []; - }, [user]); + return [ + { + installItem.open(); + }} + hidden={ + !user.hasChangeRole(UserRoles.stock) || + stockItem.is_building || + stockItem.part_detail?.assembly != true + } + /> + ]; + }, [stockItem, user]); + + const rowActions = useCallback( + (record: any) => { + return [ + { + title: t`Uninstall`, + tooltip: t`Uninstall stock item`, + onClick: () => { + setSelectedRecord(record); + uninstallItem.open(); + }, + icon: , + hidden: !user.hasChangeRole(UserRoles.stock) + } + ]; + }, + [user] + ); return ( - + <> + {installItem.modal} + {uninstallItem.modal} + {stockItem.pk ? ( + + ) : ( + + )} + ); }