2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-07-07 06:00:57 +00:00

[UI] Sales order button (#9938)

* Refactor stock actions into a hook

* Cleanup hooks

* Allow transfer of "incomplete" outputs

* Simplify even further

* Enable transfer of stock allocated to build order

* Remove StockItemBriefSerializer entirely

* stock actions for items allocated to sales order

* Refactor <PartDetail> page

* Refactor <StockDetail>

* Refactor <LocationDetail>

* Bump API version

* SO button text

- Observe the "SALESORDER_SHIP_COMPLETE" value

* Fix palywright tests
This commit is contained in:
Oliver
2025-07-03 15:22:23 +10:00
committed by GitHub
parent 7ceee940d3
commit 5e9a8f7cb2
17 changed files with 411 additions and 320 deletions

View File

@ -1,12 +1,16 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 359 INVENTREE_API_VERSION = 360
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ INVENTREE_API_TEXT = """
v360 -> 2025-07-02 : https://github.com/inventree/InvenTree/pull/9937
- Provide more detailed StockItem information in the BuildItem serializer
- Provide more detailed StockItem information in the SalesOrderAllocation serializer
v359 -> 2025-07-01 : https://github.com/inventree/InvenTree/pull/9909 v359 -> 2025-07-01 : https://github.com/inventree/InvenTree/pull/9909
- Fixes annotated types for various part fields - Fixes annotated types for various part fields

View File

@ -42,7 +42,7 @@ from stock.generators import generate_batch_code
from stock.models import StockItem, StockLocation from stock.models import StockItem, StockLocation
from stock.serializers import ( from stock.serializers import (
LocationBriefSerializer, LocationBriefSerializer,
StockItemSerializerBrief, StockItemSerializer,
StockStatusCustomSerializer, StockStatusCustomSerializer,
) )
from stock.status_codes import StockStatus from stock.status_codes import StockStatus
@ -1227,7 +1227,7 @@ class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
pricing=False, pricing=False,
) )
stock_item_detail = StockItemSerializerBrief( stock_item_detail = StockItemSerializer(
source='stock_item', read_only=True, allow_null=True, label=_('Stock Item') source='stock_item', read_only=True, allow_null=True, label=_('Stock Item')
) )

View File

@ -1430,7 +1430,7 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
part_detail = PartBriefSerializer( part_detail = PartBriefSerializer(
source='item.part', many=False, read_only=True, allow_null=True source='item.part', many=False, read_only=True, allow_null=True
) )
item_detail = stock.serializers.StockItemSerializerBrief( item_detail = stock.serializers.StockItemSerializer(
source='item', many=False, read_only=True, allow_null=True source='item', many=False, read_only=True, allow_null=True
) )
location_detail = stock.serializers.LocationBriefSerializer( location_detail = stock.serializers.LocationBriefSerializer(

View File

@ -307,41 +307,6 @@ class StockItemTestResultSerializer(
return data return data
class StockItemSerializerBrief(
InvenTree.serializers.NotesFieldMixin,
InvenTree.serializers.InvenTreeModelSerializer,
):
"""Brief serializers for a StockItem."""
class Meta:
"""Metaclass options."""
model = StockItem
fields = [
'part',
'part_name',
'pk',
'location',
'quantity',
'serial',
'batch',
'supplier_part',
'barcode_hash',
]
read_only_fields = ['barcode_hash']
part_name = serializers.CharField(source='part.full_name', read_only=True)
quantity = InvenTreeDecimalField()
def validate_serial(self, value):
"""Make sure serial is not to big."""
if abs(InvenTree.helpers.extract_int(value)) > 0x7FFFFFFF:
raise serializers.ValidationError(_('Serial number is too large'))
return value
@register_importer() @register_importer()
class StockItemSerializer( class StockItemSerializer(
DataImportExportSerializerMixin, DataImportExportSerializerMixin,
@ -1336,7 +1301,7 @@ class StockTrackingSerializer(
label = serializers.CharField(read_only=True) label = serializers.CharField(read_only=True)
item_detail = StockItemSerializerBrief( item_detail = StockItemSerializer(
source='item', many=False, read_only=True, allow_null=True source='item', many=False, read_only=True, allow_null=True
) )

View File

@ -57,7 +57,7 @@ export function ActionDropdown({
disabled?: boolean; disabled?: boolean;
hidden?: boolean; hidden?: boolean;
noindicator?: boolean; noindicator?: boolean;
}) { }): ReactNode {
const hasActions = useMemo(() => { const hasActions = useMemo(() => {
return actions.some((action) => !action.hidden); return actions.some((action) => !action.hidden);
}, [actions]); }, [actions]);

View File

@ -436,7 +436,10 @@ function LineItemFormRow({
} }
// Selected location is base part's default location // Selected location is base part's default location
if (location === record.part_detail.default_location) { if (
record.part_detail?.default_location &&
location === record.part_detail.default_location
) {
return t`Default location selected`; return t`Default location selected`;
} }

View File

@ -934,7 +934,7 @@ function stockMergeFields(items: any[]): ApiFormFieldSet {
] ]
}, },
location: { location: {
default: items[0]?.part_detail.default_location, default: items[0]?.part_detail?.default_location,
filters: { filters: {
structural: false structural: false
} }

View File

@ -0,0 +1,208 @@
import { UserRoles } from '@lib/index';
import type { UseModalReturn } from '@lib/types/Modals';
import { t } from '@lingui/core/macro';
import { type ReactNode, useMemo } from 'react';
import type { ActionDropdownItem } from '../components/items/ActionDropdown';
import { ActionDropdown } from '../components/items/ActionDropdown';
import {
type StockOperationProps,
useAddStockItem,
useAssignStockItem,
useChangeStockStatus,
useCountStockItem,
useDeleteStockItem,
useMergeStockItem,
useRemoveStockItem,
useTransferStockItem
} from '../forms/StockForms';
import { InvenTreeIcon } from '../functions/icons';
import { useUserState } from '../states/UserState';
interface StockAdjustActionProps {
formProps: StockOperationProps;
enabled?: boolean;
add?: boolean;
assign?: boolean;
count?: boolean;
changeStatus?: boolean;
delete?: boolean;
merge?: boolean;
remove?: boolean;
transfer?: boolean;
}
interface StockAdjustActionReturnProps {
modals: UseModalReturn[];
dropdown: ReactNode;
menuActions: ActionDropdownItem[];
hasActions: boolean;
}
/**
* Hook to provide an interface for stock transfer actions.
* - Provides a set of modals for creating, editing, and deleting stock transfers.
* - Provides a set of menu actions for opening the modals.
*/
export function useStockAdjustActions(
props: StockAdjustActionProps
): StockAdjustActionReturnProps {
const user = useUserState();
// The available modals for stock adjustment actions
const addStock = useAddStockItem(props.formProps);
const assignStock = useAssignStockItem(props.formProps);
const countStock = useCountStockItem(props.formProps);
const changeStatus = useChangeStockStatus(props.formProps);
const deleteStock = useDeleteStockItem(props.formProps);
const mergeStock = useMergeStockItem(props.formProps);
const removeStock = useRemoveStockItem(props.formProps);
const transferStock = useTransferStockItem(props.formProps);
// Construct a list of modals available for stock adjustment actions
const modals: UseModalReturn[] = useMemo(() => {
const modals: UseModalReturn[] = [];
if (!user.hasChangeRole(UserRoles.stock)) {
return [];
}
props.add != false && modals.push(addStock);
props.assign != false && modals.push(assignStock);
props.count != false && modals.push(countStock);
props.changeStatus != false && modals.push(changeStatus);
props.merge != false && modals.push(mergeStock);
props.remove != false && modals.push(removeStock);
props.transfer != false && modals.push(transferStock);
props.delete != false &&
user.hasDeleteRole(UserRoles.stock) &&
modals.push(deleteStock);
return modals;
}, [props, user]);
// Construct a list of actions available in the stock adjustment dropdown menu
const menuActions: ActionDropdownItem[] = useMemo(() => {
const menuActions: ActionDropdownItem[] = [];
if (!user.hasChangeRole(UserRoles.stock)) {
return [];
}
props.count != false &&
menuActions.push({
name: t`Count Stock`,
icon: <InvenTreeIcon icon='stocktake' iconProps={{ color: 'blue' }} />,
tooltip: t`Count selected stock items`,
onClick: () => {
countStock.open();
}
});
props.add != false &&
menuActions.push({
name: t`Add Stock`,
icon: <InvenTreeIcon icon='add' iconProps={{ color: 'green' }} />,
tooltip: t`Add to selected stock items`,
onClick: () => {
addStock.open();
}
});
props.remove != false &&
menuActions.push({
name: t`Remove Stock`,
icon: <InvenTreeIcon icon='remove' iconProps={{ color: 'red' }} />,
tooltip: t`Remove from selected stock items`,
onClick: () => {
removeStock.open();
}
});
props.transfer != false &&
menuActions.push({
name: t`Transfer Stock`,
icon: <InvenTreeIcon icon='transfer' iconProps={{ color: 'blue' }} />,
tooltip: t`Transfer selected stock items`,
onClick: () => {
transferStock.open();
}
});
props.merge != false &&
menuActions.push({
name: t`Merge Stock`,
icon: <InvenTreeIcon icon='merge' />,
tooltip: t`Merge selected stock items`,
onClick: () => {
mergeStock.open();
}
});
props.changeStatus != false &&
menuActions.push({
name: t`Change Status`,
icon: <InvenTreeIcon icon='info' iconProps={{ color: 'blue' }} />,
tooltip: t`Change status of selected stock items`,
onClick: () => {
changeStatus.open();
}
});
props.assign != false &&
menuActions.push({
name: t`Assign Stock`,
icon: <InvenTreeIcon icon='customer' />,
tooltip: t`Assign selected stock items to a customer`,
onClick: () => {
assignStock.open();
}
});
props.delete != false &&
menuActions.push({
name: t`Delete Stock`,
icon: <InvenTreeIcon icon='delete' iconProps={{ color: 'red' }} />,
tooltip: t`Delete selected stock items`,
disabled: !user.hasDeleteRole(UserRoles.stock),
onClick: () => {
deleteStock.open();
}
});
return menuActions;
}, [props, user]);
// Determine if there are any items selected
const hasItems: boolean = useMemo(() => {
return (props.formProps?.items?.length ?? 0) > 0;
}, [props.formProps.items]);
// Determine if there are any actions available
const hasActions: boolean = useMemo(() => {
return menuActions.length > 0;
}, [menuActions]);
// Construct a dropdown menu for stock actions
const dropdown: ReactNode = useMemo(() => {
return (
<ActionDropdown
key='stock-adjust-actions'
tooltip={t`Stock Actions`}
icon={<InvenTreeIcon icon='stock' />}
actions={menuActions}
disabled={
props.enabled != true && (props.enabled == false || !hasItems)
}
hidden={!hasActions}
/>
);
}, [props.enabled, hasActions, hasItems, menuActions]);
return {
modals,
menuActions,
hasActions,
dropdown
};
}

View File

@ -72,17 +72,15 @@ import { formatPriceRange } from '../../defaults/formatters';
import { usePartFields } from '../../forms/PartForms'; import { usePartFields } from '../../forms/PartForms';
import { import {
type StockOperationProps, type StockOperationProps,
useCountStockItem, useFindSerialNumberForm
useFindSerialNumberForm,
useTransferStockItem
} from '../../forms/StockForms'; } from '../../forms/StockForms';
import { InvenTreeIcon } from '../../functions/icons';
import { import {
useCreateApiFormModal, useCreateApiFormModal,
useDeleteApiFormModal, useDeleteApiFormModal,
useEditApiFormModal useEditApiFormModal
} from '../../hooks/UseForm'; } from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance'; import { useInstance } from '../../hooks/UseInstance';
import { useStockAdjustActions } from '../../hooks/UseStockAdjustActions';
import { import {
useGlobalSettingsState, useGlobalSettingsState,
useUserSettingsState useUserSettingsState
@ -899,7 +897,7 @@ export default function PartDetail() {
) )
}); });
const stockActionProps: StockOperationProps = useMemo(() => { const stockOperationProps: StockOperationProps = useMemo(() => {
return { return {
pk: part.pk, pk: part.pk,
model: ModelType.part, model: ModelType.part,
@ -910,8 +908,11 @@ export default function PartDetail() {
}; };
}, [part]); }, [part]);
const countStockItems = useCountStockItem(stockActionProps); const stockAdjustActions = useStockAdjustActions({
const transferStockItems = useTransferStockItem(stockActionProps); formProps: stockOperationProps,
merge: false,
enabled: true
});
const orderPartsWizard = OrderPartsWizard({ const orderPartsWizard = OrderPartsWizard({
parts: [part] parts: [part]
@ -947,28 +948,7 @@ export default function PartDetail() {
tooltip={t`Stock Actions`} tooltip={t`Stock Actions`}
icon={<IconPackages />} icon={<IconPackages />}
actions={[ actions={[
{ ...stockAdjustActions.menuActions,
icon: (
<InvenTreeIcon icon='stocktake' iconProps={{ color: 'blue' }} />
),
name: t`Count Stock`,
tooltip: t`Count part stock`,
hidden: !user.hasChangeRole(UserRoles.stock),
onClick: () => {
part.pk && countStockItems.open();
}
},
{
icon: (
<InvenTreeIcon icon='transfer' iconProps={{ color: 'blue' }} />
),
name: t`Transfer Stock`,
tooltip: t`Transfer part stock`,
hidden: !user.hasChangeRole(UserRoles.stock),
onClick: () => {
part.pk && transferStockItems.open();
}
},
{ {
name: t`Order`, name: t`Order`,
tooltip: t`Order Stock`, tooltip: t`Order Stock`,
@ -1009,7 +989,7 @@ export default function PartDetail() {
]} ]}
/> />
]; ];
}, [id, part, user]); }, [id, part, user, stockAdjustActions.menuActions]);
const enableRevisionSelection: boolean = useMemo(() => { const enableRevisionSelection: boolean = useMemo(() => {
return ( return (
@ -1023,10 +1003,9 @@ export default function PartDetail() {
{editPart.modal} {editPart.modal}
{deletePart.modal} {deletePart.modal}
{duplicatePart.modal} {duplicatePart.modal}
{countStockItems.modal}
{orderPartsWizard.wizard} {orderPartsWizard.wizard}
{findBySerialNumber.modal} {findBySerialNumber.modal}
{transferStockItems.modal} {stockAdjustActions.modals.map((modal) => modal.modal)}
<InstanceDetail query={instanceQuery} requiredRole={UserRoles.part}> <InstanceDetail query={instanceQuery} requiredRole={UserRoles.part}>
<Stack gap='xs'> <Stack gap='xs'>
{user.hasViewRole(UserRoles.part_category) && ( {user.hasViewRole(UserRoles.part_category) && (

View File

@ -475,8 +475,14 @@ export default function SalesOrderDetail() {
(order.status == soStatus.PENDING || (order.status == soStatus.PENDING ||
order.status == soStatus.IN_PROGRESS); order.status == soStatus.IN_PROGRESS);
const canShip: boolean = canEdit && order.status == soStatus.IN_PROGRESS; const autoComplete = globalSettings.isSet('SALESORDER_SHIP_COMPLETE');
const canComplete: boolean = canEdit && order.status == soStatus.SHIPPED;
const canShip: boolean =
!autoComplete && canEdit && order.status == soStatus.IN_PROGRESS;
const canComplete: boolean =
canEdit &&
(order.status == soStatus.SHIPPED ||
(autoComplete && order.status == soStatus.IN_PROGRESS));
return [ return [
<PrimaryActionButton <PrimaryActionButton
@ -537,7 +543,7 @@ export default function SalesOrderDetail() {
]} ]}
/> />
]; ];
}, [user, order, soStatus]); }, [user, order, soStatus, globalSettings]);
const orderBadges: ReactNode[] = useMemo(() => { const orderBadges: ReactNode[] = useMemo(() => {
return instanceQuery.isLoading return instanceQuery.isLoading

View File

@ -18,7 +18,6 @@ import {
} from '../../components/details/Details'; } from '../../components/details/Details';
import { ItemDetailsGrid } from '../../components/details/ItemDetails'; import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import { import {
ActionDropdown,
BarcodeActionDropdown, BarcodeActionDropdown,
DeleteItemAction, DeleteItemAction,
EditItemAction, EditItemAction,
@ -33,9 +32,7 @@ import { PanelGroup } from '../../components/panels/PanelGroup';
import LocateItemButton from '../../components/plugins/LocateItemButton'; import LocateItemButton from '../../components/plugins/LocateItemButton';
import { import {
type StockOperationProps, type StockOperationProps,
stockLocationFields, stockLocationFields
useCountStockItem,
useTransferStockItem
} from '../../forms/StockForms'; } from '../../forms/StockForms';
import { InvenTreeIcon } from '../../functions/icons'; import { InvenTreeIcon } from '../../functions/icons';
import { import {
@ -43,6 +40,7 @@ import {
useEditApiFormModal useEditApiFormModal
} from '../../hooks/UseForm'; } from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance'; import { useInstance } from '../../hooks/UseInstance';
import { useStockAdjustActions } from '../../hooks/UseStockAdjustActions';
import { useUserState } from '../../states/UserState'; import { useUserState } from '../../states/UserState';
import { PartListTable } from '../../tables/part/PartTable'; import { PartListTable } from '../../tables/part/PartTable';
import { StockItemTable } from '../../tables/stock/StockItemTable'; import { StockItemTable } from '../../tables/stock/StockItemTable';
@ -259,7 +257,7 @@ export default function Stock() {
} }
}); });
const stockItemActionProps: StockOperationProps = useMemo(() => { const stockOperationProps: StockOperationProps = useMemo(() => {
return { return {
pk: location.pk, pk: location.pk,
model: 'location', model: 'location',
@ -270,8 +268,13 @@ export default function Stock() {
}; };
}, [location]); }, [location]);
const transferStockItems = useTransferStockItem(stockItemActionProps); const stockAdjustActions = useStockAdjustActions({
const countStockItems = useCountStockItem(stockItemActionProps); formProps: stockOperationProps,
enabled: true,
delete: false,
merge: false,
assign: false
});
const scanInStockItem = useBarcodeScanDialog({ const scanInStockItem = useBarcodeScanDialog({
title: t`Scan Stock Item`, title: t`Scan Stock Item`,
@ -362,28 +365,7 @@ export default function Stock() {
enableLabels enableLabels
enableReports enableReports
/>, />,
<ActionDropdown stockAdjustActions.dropdown,
tooltip={t`Stock Actions`}
icon={<InvenTreeIcon icon='stock' />}
actions={[
{
name: t`Count Stock`,
icon: (
<InvenTreeIcon icon='stocktake' iconProps={{ color: 'blue' }} />
),
tooltip: t`Count Stock`,
onClick: () => countStockItems.open()
},
{
name: 'Transfer Stock',
icon: (
<InvenTreeIcon icon='transfer' iconProps={{ color: 'blue' }} />
),
tooltip: 'Transfer Stock',
onClick: () => transferStockItems.open()
}
]}
/>,
<OptionsActionDropdown <OptionsActionDropdown
tooltip={t`Location Actions`} tooltip={t`Location Actions`}
actions={[ actions={[
@ -400,7 +382,7 @@ export default function Stock() {
]} ]}
/> />
], ],
[location, id, user] [location, id, user, stockAdjustActions.dropdown]
); );
const breadcrumbs = useMemo( const breadcrumbs = useMemo(
@ -463,9 +445,8 @@ export default function Stock() {
id={location?.pk} id={location?.pk}
instance={location} instance={location}
/> />
{transferStockItems.modal}
{countStockItems.modal}
</Stack> </Stack>
{stockAdjustActions.modals.map((modal) => modal.modal)}
</InstanceDetail> </InstanceDetail>
</> </>
); );

View File

@ -68,14 +68,9 @@ import { useApi } from '../../contexts/ApiContext';
import { formatCurrency } from '../../defaults/formatters'; import { formatCurrency } from '../../defaults/formatters';
import { import {
type StockOperationProps, type StockOperationProps,
useAddStockItem,
useAssignStockItem,
useCountStockItem,
useFindSerialNumberForm, useFindSerialNumberForm,
useRemoveStockItem,
useStockFields, useStockFields,
useStockItemSerializeFields, useStockItemSerializeFields
useTransferStockItem
} from '../../forms/StockForms'; } from '../../forms/StockForms';
import { InvenTreeIcon } from '../../functions/icons'; import { InvenTreeIcon } from '../../functions/icons';
import { import {
@ -84,6 +79,7 @@ import {
useEditApiFormModal useEditApiFormModal
} from '../../hooks/UseForm'; } from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance'; import { useInstance } from '../../hooks/UseInstance';
import { useStockAdjustActions } from '../../hooks/UseStockAdjustActions';
import { useGlobalSettingsState } from '../../states/SettingsStates'; import { useGlobalSettingsState } from '../../states/SettingsStates';
import { useUserState } from '../../states/UserState'; import { useUserState } from '../../states/UserState';
import BuildAllocatedStockTable from '../../tables/build/BuildAllocatedStockTable'; import BuildAllocatedStockTable from '../../tables/build/BuildAllocatedStockTable';
@ -715,7 +711,7 @@ export default function StockDetail() {
} }
}); });
const stockActionProps: StockOperationProps = useMemo(() => { const stockOperationProps: StockOperationProps = useMemo(() => {
return { return {
items: [stockitem], items: [stockitem],
model: ModelType.stockitem, model: ModelType.stockitem,
@ -726,11 +722,11 @@ export default function StockDetail() {
}; };
}, [stockitem]); }, [stockitem]);
const countStockItem = useCountStockItem(stockActionProps); const stockAdjustActions = useStockAdjustActions({
const addStockItem = useAddStockItem(stockActionProps); formProps: stockOperationProps,
const removeStockItem = useRemoveStockItem(stockActionProps); delete: false,
const transferStockItem = useTransferStockItem(stockActionProps); merge: false
const assignToCustomer = useAssignStockItem(stockActionProps); });
const serializeStockFields = useStockItemSerializeFields({ const serializeStockFields = useStockItemSerializeFields({
partId: stockitem.part, partId: stockitem.part,
@ -862,50 +858,7 @@ export default function StockDetail() {
tooltip={t`Stock Operations`} tooltip={t`Stock Operations`}
icon={<IconPackages />} icon={<IconPackages />}
actions={[ actions={[
{ ...stockAdjustActions.menuActions,
name: t`Count`,
tooltip: t`Count stock`,
hidden: serialized || !canTransfer || isBuilding,
icon: (
<InvenTreeIcon icon='stocktake' iconProps={{ color: 'blue' }} />
),
onClick: () => {
stockitem.pk && countStockItem.open();
}
},
{
name: t`Add`,
tooltip: t`Add Stock`,
hidden: serialized || !canTransfer || isBuilding,
icon: <InvenTreeIcon icon='add' iconProps={{ color: 'green' }} />,
onClick: () => {
stockitem.pk && addStockItem.open();
}
},
{
name: t`Remove`,
tooltip: t`Remove Stock`,
hidden:
serialized ||
!canTransfer ||
isBuilding ||
stockitem.quantity <= 0,
icon: <InvenTreeIcon icon='remove' iconProps={{ color: 'red' }} />,
onClick: () => {
stockitem.pk && removeStockItem.open();
}
},
{
name: t`Transfer`,
tooltip: t`Transfer Stock`,
hidden: !canTransfer,
icon: (
<InvenTreeIcon icon='transfer' iconProps={{ color: 'blue' }} />
),
onClick: () => {
stockitem.pk && transferStockItem.open();
}
},
{ {
name: t`Serialize`, name: t`Serialize`,
tooltip: t`Serialize stock`, tooltip: t`Serialize stock`,
@ -944,17 +897,6 @@ export default function StockDetail() {
onClick: () => { onClick: () => {
stockitem.pk && returnStockItem.open(); stockitem.pk && returnStockItem.open();
} }
},
{
name: t`Assign to Customer`,
tooltip: t`Assign to a customer`,
hidden: !!stockitem.customer,
icon: (
<InvenTreeIcon icon='customer' iconProps={{ color: 'blue' }} />
),
onClick: () => {
stockitem.pk && assignToCustomer.open();
}
} }
]} ]}
/>, />,
@ -976,7 +918,7 @@ export default function StockDetail() {
]} ]}
/> />
]; ];
}, [id, stockitem, user]); }, [id, stockitem, user, stockAdjustActions.menuActions]);
const stockBadges: ReactNode[] = useMemo(() => { const stockBadges: ReactNode[] = useMemo(() => {
let available = (stockitem?.quantity ?? 0) - (stockitem?.allocated ?? 0); let available = (stockitem?.quantity ?? 0) - (stockitem?.allocated ?? 0);
@ -1099,13 +1041,9 @@ export default function StockDetail() {
{editStockItem.modal} {editStockItem.modal}
{duplicateStockItem.modal} {duplicateStockItem.modal}
{deleteStockItem.modal} {deleteStockItem.modal}
{countStockItem.modal}
{addStockItem.modal}
{removeStockItem.modal}
{transferStockItem.modal}
{serializeStockItem.modal} {serializeStockItem.modal}
{returnStockItem.modal} {returnStockItem.modal}
{assignToCustomer.modal} {stockAdjustActions.modals.map((modal) => modal.modal)}
{orderPartsWizard.wizard} {orderPartsWizard.wizard}
</Stack> </Stack>
</InstanceDetail> </InstanceDetail>

View File

@ -6,10 +6,12 @@ import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles'; import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api'; import { apiUrl } from '@lib/functions/Api';
import type { TableFilter } from '@lib/types/Filters'; import type { TableFilter } from '@lib/types/Filters';
import type { StockOperationProps } from '../../forms/StockForms';
import { import {
useDeleteApiFormModal, useDeleteApiFormModal,
useEditApiFormModal useEditApiFormModal
} from '../../hooks/UseForm'; } from '../../hooks/UseForm';
import { useStockAdjustActions } from '../../hooks/UseStockAdjustActions';
import { useTable } from '../../hooks/UseTable'; import { useTable } from '../../hooks/UseTable';
import { useUserState } from '../../states/UserState'; import { useUserState } from '../../states/UserState';
import type { TableColumn } from '../Column'; import type { TableColumn } from '../Column';
@ -178,6 +180,37 @@ export default function BuildAllocatedStockTable({
table: table table: table
}); });
const stockOperationProps: StockOperationProps = useMemo(() => {
// Extract stock items from the selected records
// Note that the table is actually a list of BuildItem instances,
// so we need to reconstruct the stock item details
const stockItems: any[] = table.selectedRecords
.filter((item: any) => !!item.stock_item_detail)
.map((item: any) => {
return {
...item.stock_item_detail,
part_detail: item.part_detail,
location_detail: item.location_detail
};
});
return {
items: stockItems,
model: ModelType.stockitem,
refresh: table.refreshTable
};
}, [table.selectedRecords, table.refreshTable]);
const stockAdjustActions = useStockAdjustActions({
formProps: stockOperationProps,
merge: false,
assign: false,
delete: false,
add: false,
count: false,
remove: false
});
const rowActions = useCallback( const rowActions = useCallback(
(record: any): RowAction[] => { (record: any): RowAction[] => {
return [ return [
@ -200,10 +233,15 @@ export default function BuildAllocatedStockTable({
[user] [user]
); );
const tableActions = useMemo(() => {
return [stockAdjustActions.dropdown];
}, [stockAdjustActions.dropdown]);
return ( return (
<> <>
{editItem.modal} {editItem.modal}
{deleteItem.modal} {deleteItem.modal}
{stockAdjustActions.modals.map((modal) => modal.modal)}
<InvenTreeTable <InvenTreeTable
tableState={table} tableState={table}
url={apiUrl(ApiEndpoints.build_item_list)} url={apiUrl(ApiEndpoints.build_item_list)}
@ -223,6 +261,7 @@ export default function BuildAllocatedStockTable({
enableDownload: true, enableDownload: true,
enableSelection: allowEdit && user.hasDeleteRole(UserRoles.build), enableSelection: allowEdit && user.hasDeleteRole(UserRoles.build),
rowActions: rowActions, rowActions: rowActions,
tableActions: tableActions,
tableFilters: tableFilters, tableFilters: tableFilters,
modelField: modelField ?? 'stock_item', modelField: modelField ?? 'stock_item',
modelType: modelTarget ?? ModelType.stockitem modelType: modelTarget ?? ModelType.stockitem

View File

@ -36,12 +36,16 @@ import {
useCompleteBuildOutputsForm, useCompleteBuildOutputsForm,
useScrapBuildOutputsForm useScrapBuildOutputsForm
} from '../../forms/BuildForms'; } from '../../forms/BuildForms';
import { useStockFields } from '../../forms/StockForms'; import {
type StockOperationProps,
useStockFields
} from '../../forms/StockForms';
import { InvenTreeIcon } from '../../functions/icons'; import { InvenTreeIcon } from '../../functions/icons';
import { import {
useCreateApiFormModal, useCreateApiFormModal,
useEditApiFormModal useEditApiFormModal
} from '../../hooks/UseForm'; } from '../../hooks/UseForm';
import { useStockAdjustActions } from '../../hooks/UseStockAdjustActions';
import { useTable } from '../../hooks/UseTable'; import { useTable } from '../../hooks/UseTable';
import { useUserState } from '../../states/UserState'; import { useUserState } from '../../states/UserState';
import type { TableColumn } from '../Column'; import type { TableColumn } from '../Column';
@ -368,8 +372,28 @@ export default function BuildOutputTable({
]; ];
}, []); }, []);
const stockOperationProps: StockOperationProps = useMemo(() => {
return {
items: table.selectedRecords,
model: ModelType.stockitem,
refresh: table.refreshTable,
filters: {}
};
}, [table.selectedRecords, table.refreshTable]);
const stockAdjustActions = useStockAdjustActions({
formProps: stockOperationProps,
merge: false,
assign: false,
delete: false,
add: false,
count: false,
remove: false
});
const tableActions = useMemo(() => { const tableActions = useMemo(() => {
return [ return [
stockAdjustActions.dropdown,
<ActionButton <ActionButton
key='complete-selected-outputs' key='complete-selected-outputs'
tooltip={t`Complete selected outputs`} tooltip={t`Complete selected outputs`}
@ -410,7 +434,13 @@ export default function BuildOutputTable({
onClick={addBuildOutput.open} onClick={addBuildOutput.open}
/> />
]; ];
}, [build, user, table.selectedRecords, table.hasSelectedRecords]); }, [
build,
user,
table.selectedRecords,
table.hasSelectedRecords,
stockAdjustActions.dropdown
]);
const rowActions = useCallback( const rowActions = useCallback(
(record: any): RowAction[] => { (record: any): RowAction[] => {
@ -595,6 +625,7 @@ export default function BuildOutputTable({
{editBuildOutput.modal} {editBuildOutput.modal}
{deallocateBuildOutput.modal} {deallocateBuildOutput.modal}
{cancelBuildOutputsForm.modal} {cancelBuildOutputsForm.modal}
{stockAdjustActions.modals.map((modal) => modal.modal)}
<OutputAllocationDrawer <OutputAllocationDrawer
build={build} build={build}
output={selectedOutputs[0]} output={selectedOutputs[0]}

View File

@ -10,11 +10,13 @@ import { IconTruckDelivery } from '@tabler/icons-react';
import { ActionButton } from '../../components/buttons/ActionButton'; import { ActionButton } from '../../components/buttons/ActionButton';
import { formatDate } from '../../defaults/formatters'; import { formatDate } from '../../defaults/formatters';
import { useSalesOrderAllocationFields } from '../../forms/SalesOrderForms'; import { useSalesOrderAllocationFields } from '../../forms/SalesOrderForms';
import type { StockOperationProps } from '../../forms/StockForms';
import { import {
useBulkEditApiFormModal, useBulkEditApiFormModal,
useDeleteApiFormModal, useDeleteApiFormModal,
useEditApiFormModal useEditApiFormModal
} from '../../hooks/UseForm'; } from '../../hooks/UseForm';
import { useStockAdjustActions } from '../../hooks/UseStockAdjustActions';
import { useTable } from '../../hooks/UseTable'; import { useTable } from '../../hooks/UseTable';
import { useUserState } from '../../states/UserState'; import { useUserState } from '../../states/UserState';
import type { TableColumn } from '../Column'; import type { TableColumn } from '../Column';
@ -246,6 +248,37 @@ export default function SalesOrderAllocationTable({
[allowEdit, user] [allowEdit, user]
); );
const stockOperationProps: StockOperationProps = useMemo(() => {
// Extract stock items from the selected records
// Note that the table is actually a list of SalesOrderAllocation instances,
// so we need to reconstruct the stock item details
const stockItems: any[] = table.selectedRecords
.filter((item: any) => !!item.item_detail)
.map((item: any) => {
return {
...item.item_detail,
part_detail: item.part_detail,
location_detail: item.location_detail
};
});
return {
items: stockItems,
model: ModelType.stockitem,
refresh: table.refreshTable
};
}, [table.selectedRecords, table.refreshTable]);
const stockAdjustActions = useStockAdjustActions({
formProps: stockOperationProps,
merge: false,
assign: false,
delete: false,
add: false,
count: false,
remove: false
});
// A subset of the selected allocations, which can be assigned to a shipment // A subset of the selected allocations, which can be assigned to a shipment
const nonShippedAllocationIds: number[] = useMemo(() => { const nonShippedAllocationIds: number[] = useMemo(() => {
// Only allow allocations which have not been shipped // Only allow allocations which have not been shipped
@ -273,6 +306,7 @@ export default function SalesOrderAllocationTable({
const tableActions = useMemo(() => { const tableActions = useMemo(() => {
return [ return [
stockAdjustActions.dropdown,
<ActionButton <ActionButton
tooltip={t`Assign to shipment`} tooltip={t`Assign to shipment`}
icon={<IconTruckDelivery />} icon={<IconTruckDelivery />}
@ -286,13 +320,20 @@ export default function SalesOrderAllocationTable({
// TODO: Hide if order is already shipped // TODO: Hide if order is already shipped
/> />
]; ];
}, [allowEdit, nonShippedAllocationIds, orderId, user]); }, [
allowEdit,
nonShippedAllocationIds,
orderId,
user,
stockAdjustActions.dropdown
]);
return ( return (
<> <>
{setShipment.modal} {setShipment.modal}
{editAllocation.modal} {editAllocation.modal}
{deleteAllocation.modal} {deleteAllocation.modal}
{!isSubTable && stockAdjustActions.modals.map((modal) => modal.modal)}
<InvenTreeTable <InvenTreeTable
url={apiUrl(ApiEndpoints.sales_order_allocation_list)} url={apiUrl(ApiEndpoints.sales_order_allocation_list)}
tableState={table} tableState={table}

View File

@ -7,24 +7,17 @@ import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles'; import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api'; import { apiUrl } from '@lib/functions/Api';
import type { TableFilter } from '@lib/types/Filters'; import type { TableFilter } from '@lib/types/Filters';
import { ActionButton } from '../../components/buttons/ActionButton';
import { AddItemButton } from '../../components/buttons/AddItemButton'; import { AddItemButton } from '../../components/buttons/AddItemButton';
import { ActionDropdown } from '../../components/items/ActionDropdown';
import OrderPartsWizard from '../../components/wizards/OrderPartsWizard'; import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
import { formatCurrency, formatPriceRange } from '../../defaults/formatters'; import { formatCurrency, formatPriceRange } from '../../defaults/formatters';
import { import {
type StockOperationProps, type StockOperationProps,
useAddStockItem, useStockFields
useAssignStockItem,
useChangeStockStatus,
useCountStockItem,
useDeleteStockItem,
useMergeStockItem,
useRemoveStockItem,
useStockFields,
useTransferStockItem
} from '../../forms/StockForms'; } from '../../forms/StockForms';
import { InvenTreeIcon } from '../../functions/icons'; import { InvenTreeIcon } from '../../functions/icons';
import { useCreateApiFormModal } from '../../hooks/UseForm'; import { useCreateApiFormModal } from '../../hooks/UseForm';
import { useStockAdjustActions } from '../../hooks/UseStockAdjustActions';
import { useTable } from '../../hooks/UseTable'; import { useTable } from '../../hooks/UseTable';
import { useGlobalSettingsState } from '../../states/SettingsStates'; import { useGlobalSettingsState } from '../../states/SettingsStates';
import { useUserState } from '../../states/UserState'; import { useUserState } from '../../states/UserState';
@ -501,7 +494,7 @@ export function StockItemTable({
[stockExpiryEnabled] [stockExpiryEnabled]
); );
const tableActionParams: StockOperationProps = useMemo(() => { const stockOperationProps: StockOperationProps = useMemo(() => {
return { return {
items: table.selectedRecords, items: table.selectedRecords,
model: ModelType.stockitem, model: ModelType.stockitem,
@ -510,7 +503,7 @@ export function StockItemTable({
in_stock: true in_stock: true
} }
}; };
}, [table]); }, [table.selectedRecords, table.refreshTable]);
const newStockItemFields = useStockFields({ const newStockItemFields = useStockFields({
create: true, create: true,
@ -538,117 +531,25 @@ export function StockItemTable({
parts: partsToOrder parts: partsToOrder
}); });
const transferStock = useTransferStockItem(tableActionParams); const stockAdjustActions = useStockAdjustActions({
const addStock = useAddStockItem(tableActionParams); formProps: stockOperationProps
const removeStock = useRemoveStockItem(tableActionParams); });
const countStock = useCountStockItem(tableActionParams);
const changeStockStatus = useChangeStockStatus(tableActionParams);
const mergeStock = useMergeStockItem(tableActionParams);
const assignStock = useAssignStockItem(tableActionParams);
const deleteStock = useDeleteStockItem(tableActionParams);
const tableActions = useMemo(() => { const tableActions = useMemo(() => {
const can_delete_stock = user.hasDeleteRole(UserRoles.stock);
const can_add_stock = user.hasAddRole(UserRoles.stock);
const can_add_stocktake = user.hasAddRole(UserRoles.stocktake);
return [ return [
<ActionDropdown stockAdjustActions.dropdown,
key='stock-actions' <ActionButton
tooltip={t`Stock Actions`} key='stock-order'
icon={<InvenTreeIcon icon='stock' />} hidden={!user.hasAddRole(UserRoles.purchase_order)}
disabled={table.selectedRecords.length === 0} tooltip={t`Order items`}
actions={[ icon={<InvenTreeIcon icon='buy' />}
{ disabled={!table.hasSelectedRecords}
name: t`Count Stock`, onClick={() => {
icon: ( setPartsToOrder(
<InvenTreeIcon icon='stocktake' iconProps={{ color: 'blue' }} /> table.selectedRecords.map((record) => record.part_detail)
), );
tooltip: t`Count Stock`, orderPartsWizard.openWizard();
disabled: !can_add_stocktake, }}
onClick: () => {
countStock.open();
}
},
{
name: t`Add Stock`,
icon: <InvenTreeIcon icon='add' iconProps={{ color: 'green' }} />,
tooltip: t`Add a new stock item`,
disabled: !can_add_stock,
onClick: () => {
addStock.open();
}
},
{
name: t`Remove Stock`,
icon: <InvenTreeIcon icon='remove' iconProps={{ color: 'red' }} />,
tooltip: t`Remove some quantity from a stock item`,
disabled: !can_add_stock,
onClick: () => {
removeStock.open();
}
},
{
name: t`Transfer Stock`,
icon: (
<InvenTreeIcon icon='transfer' iconProps={{ color: 'blue' }} />
),
tooltip: t`Move Stock items to new locations`,
disabled: !can_add_stock,
onClick: () => {
transferStock.open();
}
},
{
name: t`Change stock status`,
icon: <InvenTreeIcon icon='info' iconProps={{ color: 'blue' }} />,
tooltip: t`Change the status of stock items`,
disabled: !can_add_stock,
onClick: () => {
changeStockStatus.open();
}
},
{
name: t`Merge stock`,
icon: <InvenTreeIcon icon='merge' />,
tooltip: t`Merge stock items`,
disabled: !can_add_stock,
onClick: () => {
mergeStock.open();
}
},
{
name: t`Order stock`,
icon: <InvenTreeIcon icon='buy' />,
tooltip: t`Order new stock`,
hidden: !user.hasAddRole(UserRoles.purchase_order),
disabled: !table.hasSelectedRecords,
onClick: () => {
setPartsToOrder(
table.selectedRecords.map((record) => record.part_detail)
);
orderPartsWizard.openWizard();
}
},
{
name: t`Assign to customer`,
icon: <InvenTreeIcon icon='customer' />,
tooltip: t`Assign items to a customer`,
disabled: !can_add_stock,
onClick: () => {
assignStock.open();
}
},
{
name: t`Delete stock`,
icon: <InvenTreeIcon icon='delete' iconProps={{ color: 'red' }} />,
tooltip: t`Delete Stock Items`,
disabled: !can_delete_stock,
onClick: () => {
deleteStock.open();
}
}
]}
/>, />,
<AddItemButton <AddItemButton
key='add-stock-item' key='add-stock-item'
@ -657,20 +558,19 @@ export function StockItemTable({
onClick={() => newStockItem.open()} onClick={() => newStockItem.open()}
/> />
]; ];
}, [user, allowAdd, table.hasSelectedRecords, table.selectedRecords]); }, [
user,
allowAdd,
table.hasSelectedRecords,
table.selectedRecords,
stockAdjustActions.dropdown
]);
return ( return (
<> <>
{newStockItem.modal} {newStockItem.modal}
{transferStock.modal}
{removeStock.modal}
{addStock.modal}
{countStock.modal}
{changeStockStatus.modal}
{mergeStock.modal}
{assignStock.modal}
{deleteStock.modal}
{orderPartsWizard.wizard} {orderPartsWizard.wizard}
{stockAdjustActions.modals.map((modal) => modal.modal)}
<InvenTreeTable <InvenTreeTable
url={apiUrl(ApiEndpoints.stock_item_list)} url={apiUrl(ApiEndpoints.stock_item_list)}
tableState={table} tableState={table}

View File

@ -249,11 +249,7 @@ test('Purchase Orders - Order Parts', async ({ browser }) => {
await page.getByLabel(`Select record ${ii}`, { exact: true }).click(); await page.getByLabel(`Select record ${ii}`, { exact: true }).click();
} }
await page await page.getByRole('button', { name: 'action-button-order-items' }).click();
.getByLabel('Stock Items')
.getByLabel('action-menu-stock-actions')
.click();
await page.getByLabel('action-menu-stock-actions-order-stock').click();
await page.getByRole('banner').getByRole('button').click(); await page.getByRole('banner').getByRole('button').click();
// Order from the part detail page // Order from the part detail page