2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-05-02 13:28:49 +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)
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

View File

@ -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'

View File

@ -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;

View File

@ -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/',

View File

@ -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<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({
partId,
trackable

View File

@ -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: <IconBoxPadding />,
hidden: !stockitem?.part_detail?.assembly,
content: <InstalledItemsTable parentId={stockitem.pk} />
hidden: !showInstalledItems,
content: <InstalledItemsTable stockItem={stockitem} />
},
{
name: 'child_items',
@ -421,7 +471,14 @@ export default function StockDetail() {
)
}
];
}, [stockitem, id, user]);
}, [
showSalesAlloctions,
showBuildAllocations,
showInstalledItems,
stockitem,
id,
user
]);
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 { 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<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(() => {
return [
{
@ -46,22 +85,61 @@ export default function InstalledItemsTable({
}, []);
const tableActions = useMemo(() => {
return [];
}, [user]);
return [
<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 (
<InvenTreeTable
url={apiUrl(ApiEndpoints.stock_item_list)}
tableState={table}
columns={tableColumns}
props={{
tableActions: tableActions,
modelType: ModelType.stockitem,
params: {
belongs_to: parentId,
part_detail: true
}
}}
/>
<>
{installItem.modal}
{uninstallItem.modal}
{stockItem.pk ? (
<InvenTreeTable
url={apiUrl(ApiEndpoints.stock_item_list)}
tableState={table}
columns={tableColumns}
props={{
tableActions: tableActions,
rowActions: rowActions,
modelType: ModelType.stockitem,
params: {
belongs_to: stockItem.pk,
part_detail: true
}
}}
/>
) : (
<Skeleton />
)}
</>
);
}