2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-07-06 21:50:55 +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
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."""
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
- 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.serializers import (
LocationBriefSerializer,
StockItemSerializerBrief,
StockItemSerializer,
StockStatusCustomSerializer,
)
from stock.status_codes import StockStatus
@ -1227,7 +1227,7 @@ class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
pricing=False,
)
stock_item_detail = StockItemSerializerBrief(
stock_item_detail = StockItemSerializer(
source='stock_item', read_only=True, allow_null=True, label=_('Stock Item')
)

View File

@ -1430,7 +1430,7 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
part_detail = PartBriefSerializer(
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
)
location_detail = stock.serializers.LocationBriefSerializer(

View File

@ -307,41 +307,6 @@ class StockItemTestResultSerializer(
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()
class StockItemSerializer(
DataImportExportSerializerMixin,
@ -1336,7 +1301,7 @@ class StockTrackingSerializer(
label = serializers.CharField(read_only=True)
item_detail = StockItemSerializerBrief(
item_detail = StockItemSerializer(
source='item', many=False, read_only=True, allow_null=True
)

View File

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

View File

@ -436,7 +436,10 @@ function LineItemFormRow({
}
// 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`;
}

View File

@ -934,7 +934,7 @@ function stockMergeFields(items: any[]): ApiFormFieldSet {
]
},
location: {
default: items[0]?.part_detail.default_location,
default: items[0]?.part_detail?.default_location,
filters: {
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 {
type StockOperationProps,
useCountStockItem,
useFindSerialNumberForm,
useTransferStockItem
useFindSerialNumberForm
} from '../../forms/StockForms';
import { InvenTreeIcon } from '../../functions/icons';
import {
useCreateApiFormModal,
useDeleteApiFormModal,
useEditApiFormModal
} from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance';
import { useStockAdjustActions } from '../../hooks/UseStockAdjustActions';
import {
useGlobalSettingsState,
useUserSettingsState
@ -899,7 +897,7 @@ export default function PartDetail() {
)
});
const stockActionProps: StockOperationProps = useMemo(() => {
const stockOperationProps: StockOperationProps = useMemo(() => {
return {
pk: part.pk,
model: ModelType.part,
@ -910,8 +908,11 @@ export default function PartDetail() {
};
}, [part]);
const countStockItems = useCountStockItem(stockActionProps);
const transferStockItems = useTransferStockItem(stockActionProps);
const stockAdjustActions = useStockAdjustActions({
formProps: stockOperationProps,
merge: false,
enabled: true
});
const orderPartsWizard = OrderPartsWizard({
parts: [part]
@ -947,28 +948,7 @@ export default function PartDetail() {
tooltip={t`Stock Actions`}
icon={<IconPackages />}
actions={[
{
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();
}
},
...stockAdjustActions.menuActions,
{
name: t`Order`,
tooltip: t`Order Stock`,
@ -1009,7 +989,7 @@ export default function PartDetail() {
]}
/>
];
}, [id, part, user]);
}, [id, part, user, stockAdjustActions.menuActions]);
const enableRevisionSelection: boolean = useMemo(() => {
return (
@ -1023,10 +1003,9 @@ export default function PartDetail() {
{editPart.modal}
{deletePart.modal}
{duplicatePart.modal}
{countStockItems.modal}
{orderPartsWizard.wizard}
{findBySerialNumber.modal}
{transferStockItems.modal}
{stockAdjustActions.modals.map((modal) => modal.modal)}
<InstanceDetail query={instanceQuery} requiredRole={UserRoles.part}>
<Stack gap='xs'>
{user.hasViewRole(UserRoles.part_category) && (

View File

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

View File

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

View File

@ -68,14 +68,9 @@ import { useApi } from '../../contexts/ApiContext';
import { formatCurrency } from '../../defaults/formatters';
import {
type StockOperationProps,
useAddStockItem,
useAssignStockItem,
useCountStockItem,
useFindSerialNumberForm,
useRemoveStockItem,
useStockFields,
useStockItemSerializeFields,
useTransferStockItem
useStockItemSerializeFields
} from '../../forms/StockForms';
import { InvenTreeIcon } from '../../functions/icons';
import {
@ -84,6 +79,7 @@ import {
useEditApiFormModal
} from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance';
import { useStockAdjustActions } from '../../hooks/UseStockAdjustActions';
import { useGlobalSettingsState } from '../../states/SettingsStates';
import { useUserState } from '../../states/UserState';
import BuildAllocatedStockTable from '../../tables/build/BuildAllocatedStockTable';
@ -715,7 +711,7 @@ export default function StockDetail() {
}
});
const stockActionProps: StockOperationProps = useMemo(() => {
const stockOperationProps: StockOperationProps = useMemo(() => {
return {
items: [stockitem],
model: ModelType.stockitem,
@ -726,11 +722,11 @@ export default function StockDetail() {
};
}, [stockitem]);
const countStockItem = useCountStockItem(stockActionProps);
const addStockItem = useAddStockItem(stockActionProps);
const removeStockItem = useRemoveStockItem(stockActionProps);
const transferStockItem = useTransferStockItem(stockActionProps);
const assignToCustomer = useAssignStockItem(stockActionProps);
const stockAdjustActions = useStockAdjustActions({
formProps: stockOperationProps,
delete: false,
merge: false
});
const serializeStockFields = useStockItemSerializeFields({
partId: stockitem.part,
@ -862,50 +858,7 @@ export default function StockDetail() {
tooltip={t`Stock Operations`}
icon={<IconPackages />}
actions={[
{
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();
}
},
...stockAdjustActions.menuActions,
{
name: t`Serialize`,
tooltip: t`Serialize stock`,
@ -944,17 +897,6 @@ export default function StockDetail() {
onClick: () => {
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(() => {
let available = (stockitem?.quantity ?? 0) - (stockitem?.allocated ?? 0);
@ -1099,13 +1041,9 @@ export default function StockDetail() {
{editStockItem.modal}
{duplicateStockItem.modal}
{deleteStockItem.modal}
{countStockItem.modal}
{addStockItem.modal}
{removeStockItem.modal}
{transferStockItem.modal}
{serializeStockItem.modal}
{returnStockItem.modal}
{assignToCustomer.modal}
{stockAdjustActions.modals.map((modal) => modal.modal)}
{orderPartsWizard.wizard}
</Stack>
</InstanceDetail>

View File

@ -6,10 +6,12 @@ import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import type { TableFilter } from '@lib/types/Filters';
import type { StockOperationProps } from '../../forms/StockForms';
import {
useDeleteApiFormModal,
useEditApiFormModal
} from '../../hooks/UseForm';
import { useStockAdjustActions } from '../../hooks/UseStockAdjustActions';
import { useTable } from '../../hooks/UseTable';
import { useUserState } from '../../states/UserState';
import type { TableColumn } from '../Column';
@ -178,6 +180,37 @@ export default function BuildAllocatedStockTable({
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(
(record: any): RowAction[] => {
return [
@ -200,10 +233,15 @@ export default function BuildAllocatedStockTable({
[user]
);
const tableActions = useMemo(() => {
return [stockAdjustActions.dropdown];
}, [stockAdjustActions.dropdown]);
return (
<>
{editItem.modal}
{deleteItem.modal}
{stockAdjustActions.modals.map((modal) => modal.modal)}
<InvenTreeTable
tableState={table}
url={apiUrl(ApiEndpoints.build_item_list)}
@ -223,6 +261,7 @@ export default function BuildAllocatedStockTable({
enableDownload: true,
enableSelection: allowEdit && user.hasDeleteRole(UserRoles.build),
rowActions: rowActions,
tableActions: tableActions,
tableFilters: tableFilters,
modelField: modelField ?? 'stock_item',
modelType: modelTarget ?? ModelType.stockitem

View File

@ -36,12 +36,16 @@ import {
useCompleteBuildOutputsForm,
useScrapBuildOutputsForm
} from '../../forms/BuildForms';
import { useStockFields } from '../../forms/StockForms';
import {
type StockOperationProps,
useStockFields
} from '../../forms/StockForms';
import { InvenTreeIcon } from '../../functions/icons';
import {
useCreateApiFormModal,
useEditApiFormModal
} from '../../hooks/UseForm';
import { useStockAdjustActions } from '../../hooks/UseStockAdjustActions';
import { useTable } from '../../hooks/UseTable';
import { useUserState } from '../../states/UserState';
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(() => {
return [
stockAdjustActions.dropdown,
<ActionButton
key='complete-selected-outputs'
tooltip={t`Complete selected outputs`}
@ -410,7 +434,13 @@ export default function BuildOutputTable({
onClick={addBuildOutput.open}
/>
];
}, [build, user, table.selectedRecords, table.hasSelectedRecords]);
}, [
build,
user,
table.selectedRecords,
table.hasSelectedRecords,
stockAdjustActions.dropdown
]);
const rowActions = useCallback(
(record: any): RowAction[] => {
@ -595,6 +625,7 @@ export default function BuildOutputTable({
{editBuildOutput.modal}
{deallocateBuildOutput.modal}
{cancelBuildOutputsForm.modal}
{stockAdjustActions.modals.map((modal) => modal.modal)}
<OutputAllocationDrawer
build={build}
output={selectedOutputs[0]}

View File

@ -10,11 +10,13 @@ import { IconTruckDelivery } from '@tabler/icons-react';
import { ActionButton } from '../../components/buttons/ActionButton';
import { formatDate } from '../../defaults/formatters';
import { useSalesOrderAllocationFields } from '../../forms/SalesOrderForms';
import type { StockOperationProps } from '../../forms/StockForms';
import {
useBulkEditApiFormModal,
useDeleteApiFormModal,
useEditApiFormModal
} from '../../hooks/UseForm';
import { useStockAdjustActions } from '../../hooks/UseStockAdjustActions';
import { useTable } from '../../hooks/UseTable';
import { useUserState } from '../../states/UserState';
import type { TableColumn } from '../Column';
@ -246,6 +248,37 @@ export default function SalesOrderAllocationTable({
[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
const nonShippedAllocationIds: number[] = useMemo(() => {
// Only allow allocations which have not been shipped
@ -273,6 +306,7 @@ export default function SalesOrderAllocationTable({
const tableActions = useMemo(() => {
return [
stockAdjustActions.dropdown,
<ActionButton
tooltip={t`Assign to shipment`}
icon={<IconTruckDelivery />}
@ -286,13 +320,20 @@ export default function SalesOrderAllocationTable({
// TODO: Hide if order is already shipped
/>
];
}, [allowEdit, nonShippedAllocationIds, orderId, user]);
}, [
allowEdit,
nonShippedAllocationIds,
orderId,
user,
stockAdjustActions.dropdown
]);
return (
<>
{setShipment.modal}
{editAllocation.modal}
{deleteAllocation.modal}
{!isSubTable && stockAdjustActions.modals.map((modal) => modal.modal)}
<InvenTreeTable
url={apiUrl(ApiEndpoints.sales_order_allocation_list)}
tableState={table}

View File

@ -7,24 +7,17 @@ import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import type { TableFilter } from '@lib/types/Filters';
import { ActionButton } from '../../components/buttons/ActionButton';
import { AddItemButton } from '../../components/buttons/AddItemButton';
import { ActionDropdown } from '../../components/items/ActionDropdown';
import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
import { formatCurrency, formatPriceRange } from '../../defaults/formatters';
import {
type StockOperationProps,
useAddStockItem,
useAssignStockItem,
useChangeStockStatus,
useCountStockItem,
useDeleteStockItem,
useMergeStockItem,
useRemoveStockItem,
useStockFields,
useTransferStockItem
useStockFields
} from '../../forms/StockForms';
import { InvenTreeIcon } from '../../functions/icons';
import { useCreateApiFormModal } from '../../hooks/UseForm';
import { useStockAdjustActions } from '../../hooks/UseStockAdjustActions';
import { useTable } from '../../hooks/UseTable';
import { useGlobalSettingsState } from '../../states/SettingsStates';
import { useUserState } from '../../states/UserState';
@ -501,7 +494,7 @@ export function StockItemTable({
[stockExpiryEnabled]
);
const tableActionParams: StockOperationProps = useMemo(() => {
const stockOperationProps: StockOperationProps = useMemo(() => {
return {
items: table.selectedRecords,
model: ModelType.stockitem,
@ -510,7 +503,7 @@ export function StockItemTable({
in_stock: true
}
};
}, [table]);
}, [table.selectedRecords, table.refreshTable]);
const newStockItemFields = useStockFields({
create: true,
@ -538,117 +531,25 @@ export function StockItemTable({
parts: partsToOrder
});
const transferStock = useTransferStockItem(tableActionParams);
const addStock = useAddStockItem(tableActionParams);
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 stockAdjustActions = useStockAdjustActions({
formProps: stockOperationProps
});
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 [
<ActionDropdown
key='stock-actions'
tooltip={t`Stock Actions`}
icon={<InvenTreeIcon icon='stock' />}
disabled={table.selectedRecords.length === 0}
actions={[
{
name: t`Count Stock`,
icon: (
<InvenTreeIcon icon='stocktake' iconProps={{ color: 'blue' }} />
),
tooltip: t`Count Stock`,
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();
}
}
]}
stockAdjustActions.dropdown,
<ActionButton
key='stock-order'
hidden={!user.hasAddRole(UserRoles.purchase_order)}
tooltip={t`Order items`}
icon={<InvenTreeIcon icon='buy' />}
disabled={!table.hasSelectedRecords}
onClick={() => {
setPartsToOrder(
table.selectedRecords.map((record) => record.part_detail)
);
orderPartsWizard.openWizard();
}}
/>,
<AddItemButton
key='add-stock-item'
@ -657,20 +558,19 @@ export function StockItemTable({
onClick={() => newStockItem.open()}
/>
];
}, [user, allowAdd, table.hasSelectedRecords, table.selectedRecords]);
}, [
user,
allowAdd,
table.hasSelectedRecords,
table.selectedRecords,
stockAdjustActions.dropdown
]);
return (
<>
{newStockItem.modal}
{transferStock.modal}
{removeStock.modal}
{addStock.modal}
{countStock.modal}
{changeStockStatus.modal}
{mergeStock.modal}
{assignStock.modal}
{deleteStock.modal}
{orderPartsWizard.wizard}
{stockAdjustActions.modals.map((modal) => modal.modal)}
<InvenTreeTable
url={apiUrl(ApiEndpoints.stock_item_list)}
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('Stock Items')
.getByLabel('action-menu-stock-actions')
.click();
await page.getByLabel('action-menu-stock-actions-order-stock').click();
await page.getByRole('button', { name: 'action-button-order-items' }).click();
await page.getByRole('banner').getByRole('button').click();
// Order from the part detail page