2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-05-03 13:58:47 +00:00

[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
This commit is contained in:
Oliver 2024-09-27 16:00:06 +10:00 committed by GitHub
parent a5f2273e14
commit 392624cb84
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 246 additions and 31 deletions

View File

@ -820,9 +820,9 @@ class InstallStockItemSerializer(serializers.Serializer):
quantity = data.get('quantity', stock_item.quantity) quantity = data.get('quantity', stock_item.quantity)
if quantity > stock_item.quantity: if quantity > stock_item.quantity:
raise ValidationError( raise ValidationError({
_('Quantity to install must not exceed available quantity') 'quantity': _('Quantity to install must not exceed available quantity')
) })
return data return data

View File

@ -397,11 +397,13 @@ export function ApiForm({
data = props.processFormData(data); data = props.processFormData(data);
} }
let dataForm = new FormData(); let jsonData = { ...data };
let formData = new FormData();
Object.keys(data).forEach((key: string) => { Object.keys(data).forEach((key: string) => {
let value: any = data[key]; let value: any = data[key];
let field_type = fields[key]?.field_type; let field_type = fields[key]?.field_type;
let exclude = fields[key]?.exclude;
if (field_type == 'file upload' && !!value) { if (field_type == 'file upload' && !!value) {
hasFiles = true; hasFiles = true;
@ -418,15 +420,18 @@ export function ApiForm({
} }
} }
if (value != undefined) { if (exclude) {
dataForm.append(key, value); // Remove the field from the data
delete jsonData[key];
} else if (value != undefined) {
formData.append(key, value);
} }
}); });
return api({ return api({
method: method, method: method,
url: url, url: url,
data: hasFiles ? dataForm : data, data: hasFiles ? formData : jsonData,
timeout: props.timeout, timeout: props.timeout,
headers: { headers: {
'Content-Type': hasFiles ? 'multipart/form-data' : 'application/json' 'Content-Type': hasFiles ? 'multipart/form-data' : 'application/json'

View File

@ -41,6 +41,7 @@ export type ApiFormAdjustFilterType = {
* @param required : Whether the field is required * @param required : Whether the field is required
* @param hidden : Whether the field is hidden * @param hidden : Whether the field is hidden
* @param disabled : Whether the field is disabled * @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 placeholder : The placeholder text to display
* @param description : The description to display for the field * @param description : The description to display for the field
* @param preFieldContent : Content to render before the field * @param preFieldContent : Content to render before the field
@ -84,6 +85,7 @@ export type ApiFormFieldType = {
choices?: any[]; choices?: any[];
hidden?: boolean; hidden?: boolean;
disabled?: boolean; disabled?: boolean;
exclude?: boolean;
read_only?: boolean; read_only?: boolean;
placeholder?: string; placeholder?: string;
description?: string; description?: string;

View File

@ -134,6 +134,7 @@ export enum ApiEndpoints {
stock_assign = 'stock/assign/', stock_assign = 'stock/assign/',
stock_status = 'stock/status/', stock_status = 'stock/status/',
stock_install = 'stock/:id/install/', stock_install = 'stock/:id/install/',
stock_uninstall = 'stock/:id/uninstall/',
stock_serialize = 'stock/:id/serialize/', stock_serialize = 'stock/:id/serialize/',
build_test_statistics = 'test-statistics/by-build/:id/', build_test_statistics = 'test-statistics/by-build/:id/',
part_test_statistics = 'test-statistics/by-part/:id/', part_test_statistics = 'test-statistics/by-part/:id/',

View File

@ -11,7 +11,7 @@ import {
IconUsersGroup IconUsersGroup
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; 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 { api } from '../App';
import { ActionButton } from '../components/buttons/ActionButton'; 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<number | null>(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({ export function useStockItemSerializeFields({
partId, partId,
trackable trackable

View File

@ -11,9 +11,11 @@ import {
IconPaperclip, IconPaperclip,
IconSitemap IconSitemap
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { ReactNode, useMemo, useState } from 'react'; import { ReactNode, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { api } from '../../App';
import AdminButton from '../../components/buttons/AdminButton'; import AdminButton from '../../components/buttons/AdminButton';
import { PrintingActions } from '../../components/buttons/PrintingActions'; import { PrintingActions } from '../../components/buttons/PrintingActions';
import { DetailsField, DetailsTable } from '../../components/details/Details'; import { DetailsField, DetailsTable } from '../../components/details/Details';
@ -57,6 +59,7 @@ import {
useEditApiFormModal useEditApiFormModal
} from '../../hooks/UseForm'; } from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance'; import { useInstance } from '../../hooks/UseInstance';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState'; import { useUserState } from '../../states/UserState';
import BuildAllocatedStockTable from '../../tables/build/BuildAllocatedStockTable'; import BuildAllocatedStockTable from '../../tables/build/BuildAllocatedStockTable';
import { AttachmentTable } from '../../tables/general/AttachmentTable'; import { AttachmentTable } from '../../tables/general/AttachmentTable';
@ -288,7 +291,7 @@ export default function StockDetail() {
); );
}, [stockitem, instanceQuery]); }, [stockitem, instanceQuery]);
const showBuildAllocations = useMemo(() => { const showBuildAllocations: boolean = useMemo(() => {
// Determine if "build allocations" should be shown for this stock item // Determine if "build allocations" should be shown for this stock item
return ( return (
stockitem?.part_detail?.component && // Must be a "component" stockitem?.part_detail?.component && // Must be a "component"
@ -297,10 +300,57 @@ export default function StockDetail() {
); // Must not be installed into another item ); // Must not be installed into another item
}, [stockitem]); }, [stockitem]);
const showSalesAlloctions = useMemo(() => { const showSalesAlloctions: boolean = useMemo(() => {
return stockitem?.part_detail?.salable; return stockitem?.part_detail?.salable;
}, [stockitem]); }, [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(() => { const stockPanels: PanelType[] = useMemo(() => {
return [ return [
{ {
@ -380,8 +430,8 @@ export default function StockDetail() {
name: 'installed_items', name: 'installed_items',
label: t`Installed Items`, label: t`Installed Items`,
icon: <IconBoxPadding />, icon: <IconBoxPadding />,
hidden: !stockitem?.part_detail?.assembly, hidden: !showInstalledItems,
content: <InstalledItemsTable parentId={stockitem.pk} /> content: <InstalledItemsTable stockItem={stockitem} />
}, },
{ {
name: 'child_items', name: 'child_items',
@ -421,7 +471,14 @@ export default function StockDetail() {
) )
} }
]; ];
}, [stockitem, id, user]); }, [
showSalesAlloctions,
showBuildAllocations,
showInstalledItems,
stockitem,
id,
user
]);
const breadcrumbs = useMemo( const breadcrumbs = useMemo(
() => [ () => [

View File

@ -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 { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType'; 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 { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState'; import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState'; import { useUserState } from '../../states/UserState';
@ -10,13 +20,42 @@ import { PartColumn, StatusColumn } from '../ColumnRenderers';
import { InvenTreeTable } from '../InvenTreeTable'; import { InvenTreeTable } from '../InvenTreeTable';
export default function InstalledItemsTable({ export default function InstalledItemsTable({
parentId stockItem
}: Readonly<{ }: Readonly<{
parentId?: number | string; stockItem: any;
}>) { }>) {
const table = useTable('stock_item_install'); const table = useTable('stock_item_install');
const user = useUserState(); 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<any>({});
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(() => { const tableColumns: TableColumn[] = useMemo(() => {
return [ return [
{ {
@ -46,22 +85,61 @@ export default function InstalledItemsTable({
}, []); }, []);
const tableActions = useMemo(() => { const tableActions = useMemo(() => {
return []; return [
}, [user]); <AddItemButton
tooltip={t`Install Item`}
onClick={() => {
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: <IconUnlink />,
hidden: !user.hasChangeRole(UserRoles.stock)
}
];
},
[user]
);
return ( return (
<InvenTreeTable <>
url={apiUrl(ApiEndpoints.stock_item_list)} {installItem.modal}
tableState={table} {uninstallItem.modal}
columns={tableColumns} {stockItem.pk ? (
props={{ <InvenTreeTable
tableActions: tableActions, url={apiUrl(ApiEndpoints.stock_item_list)}
modelType: ModelType.stockitem, tableState={table}
params: { columns={tableColumns}
belongs_to: parentId, props={{
part_detail: true tableActions: tableActions,
} rowActions: rowActions,
}} modelType: ModelType.stockitem,
/> params: {
belongs_to: stockItem.pk,
part_detail: true
}
}}
/>
) : (
<Skeleton />
)}
</>
); );
} }