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:
parent
a5f2273e14
commit
392624cb84
@ -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
|
||||||
|
|
||||||
|
@ -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'
|
||||||
|
@ -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;
|
||||||
|
@ -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/',
|
||||||
|
@ -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
|
||||||
|
@ -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(
|
||||||
() => [
|
() => [
|
||||||
|
@ -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 (
|
||||||
|
<>
|
||||||
|
{installItem.modal}
|
||||||
|
{uninstallItem.modal}
|
||||||
|
{stockItem.pk ? (
|
||||||
<InvenTreeTable
|
<InvenTreeTable
|
||||||
url={apiUrl(ApiEndpoints.stock_item_list)}
|
url={apiUrl(ApiEndpoints.stock_item_list)}
|
||||||
tableState={table}
|
tableState={table}
|
||||||
columns={tableColumns}
|
columns={tableColumns}
|
||||||
props={{
|
props={{
|
||||||
tableActions: tableActions,
|
tableActions: tableActions,
|
||||||
|
rowActions: rowActions,
|
||||||
modelType: ModelType.stockitem,
|
modelType: ModelType.stockitem,
|
||||||
params: {
|
params: {
|
||||||
belongs_to: parentId,
|
belongs_to: stockItem.pk,
|
||||||
part_detail: true
|
part_detail: true
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<Skeleton />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user