2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-28 11:36:44 +00:00

[UI] Bulk edit actions (#9320)

* Allow bulk selection of sales order shipment

* Tweaks

* Support bulk-edit for location parent and category parent

* Allow more login attempts for playwright
This commit is contained in:
Oliver 2025-03-17 23:27:32 +11:00 committed by GitHub
parent 9db5205f79
commit ddc3cd32f5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 136 additions and 22 deletions

View File

@ -1,13 +1,18 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 323 INVENTREE_API_VERSION = 324
"""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 = """
v324 - 2025-03-17 : https://github.com/inventree/InvenTree/pull/9320
- Adds BulkUpdate support for the SalesOrderAllocation model
- Adds BulkUpdate support for the PartCategory model
- Adds BulkUpdate support for the StockLocation model
v323 - 2025-03-17 : https://github.com/inventree/InvenTree/pull/9313 v323 - 2025-03-17 : https://github.com/inventree/InvenTree/pull/9313
- Adds BulkUpdate support to the Part API endpoint - Adds BulkUpdate support to the Part API endpoint
- Remove legacy API endpoint to set part category for multiple parts - Remove legacy API endpoint to set part category for multiple parts

View File

@ -21,7 +21,7 @@ import common.settings
import company.models import company.models
from generic.states.api import StatusView from generic.states.api import StatusView
from importer.mixins import DataExportViewMixin from importer.mixins import DataExportViewMixin
from InvenTree.api import ListCreateDestroyAPIView, MetadataView from InvenTree.api import BulkUpdateMixin, ListCreateDestroyAPIView, MetadataView
from InvenTree.filters import ( from InvenTree.filters import (
SEARCH_ORDER_FILTER, SEARCH_ORDER_FILTER,
SEARCH_ORDER_FILTER_ALIAS, SEARCH_ORDER_FILTER_ALIAS,
@ -1178,7 +1178,7 @@ class SalesOrderAllocationMixin:
return queryset return queryset
class SalesOrderAllocationList(SalesOrderAllocationMixin, ListAPI): class SalesOrderAllocationList(SalesOrderAllocationMixin, BulkUpdateMixin, ListAPI):
"""API endpoint for listing SalesOrderAllocation objects.""" """API endpoint for listing SalesOrderAllocation objects."""
filterset_class = SalesOrderAllocationFilter filterset_class = SalesOrderAllocationFilter

View File

@ -226,7 +226,7 @@ class CategoryFilter(rest_filters.FilterSet):
return queryset return queryset
class CategoryList(CategoryMixin, DataExportViewMixin, ListCreateAPI): class CategoryList(CategoryMixin, BulkUpdateMixin, DataExportViewMixin, ListCreateAPI):
"""API endpoint for accessing a list of PartCategory objects. """API endpoint for accessing a list of PartCategory objects.
- GET: Return a list of PartCategory objects - GET: Return a list of PartCategory objects

View File

@ -27,7 +27,7 @@ from company.models import Company, SupplierPart
from company.serializers import CompanySerializer from company.serializers import CompanySerializer
from generic.states.api import StatusView from generic.states.api import StatusView
from importer.mixins import DataExportViewMixin from importer.mixins import DataExportViewMixin
from InvenTree.api import ListCreateDestroyAPIView, MetadataView from InvenTree.api import BulkUpdateMixin, ListCreateDestroyAPIView, MetadataView
from InvenTree.filters import ( from InvenTree.filters import (
ORDER_FILTER_ALIAS, ORDER_FILTER_ALIAS,
SEARCH_ORDER_FILTER, SEARCH_ORDER_FILTER,
@ -365,7 +365,9 @@ class StockLocationMixin:
return queryset return queryset
class StockLocationList(DataExportViewMixin, StockLocationMixin, ListCreateAPI): class StockLocationList(
DataExportViewMixin, BulkUpdateMixin, StockLocationMixin, ListCreateAPI
):
"""API endpoint for list view of StockLocation objects. """API endpoint for list view of StockLocation objects.
- GET: Return list of StockLocation objects - GET: Return list of StockLocation objects

View File

@ -43,7 +43,8 @@ export default defineConfig({
INVENTREE_ADMIN_URL: 'test-admin', INVENTREE_ADMIN_URL: 'test-admin',
INVENTREE_SITE_URL: 'http://localhost:8000', INVENTREE_SITE_URL: 'http://localhost:8000',
INVENTREE_CORS_ORIGIN_ALLOW_ALL: 'True', INVENTREE_CORS_ORIGIN_ALLOW_ALL: 'True',
INVENTREE_COOKIE_SAMESITE: 'Lax' INVENTREE_COOKIE_SAMESITE: 'Lax',
INVENTREE_LOGIN_ATTEMPTS: '100'
}, },
url: 'http://127.0.0.1:8000/api/', url: 'http://127.0.0.1:8000/api/',
reuseExistingServer: !process.env.CI, reuseExistingServer: !process.env.CI,

View File

@ -213,6 +213,7 @@ export default function SalesOrderShipmentDetail() {
icon: <IconBookmark />, icon: <IconBookmark />,
content: ( content: (
<SalesOrderAllocationTable <SalesOrderAllocationTable
orderId={shipment.order}
shipmentId={shipment.pk} shipmentId={shipment.pk}
showPartInfo showPartInfo
allowEdit={isPending} allowEdit={isPending}

View File

@ -172,6 +172,12 @@ export default function Stock() {
icon: <IconInfoCircle />, icon: <IconInfoCircle />,
content: detailsPanel content: detailsPanel
}, },
{
name: 'sublocations',
label: t`Stock Locations`,
icon: <IconSitemap />,
content: <StockLocationTable parentId={id} />
},
{ {
name: 'stock-items', name: 'stock-items',
label: t`Stock Items`, label: t`Stock Items`,
@ -186,12 +192,6 @@ export default function Stock() {
/> />
) )
}, },
{
name: 'sublocations',
label: t`Stock Locations`,
icon: <IconSitemap />,
content: <StockLocationTable parentId={id} />
},
{ {
name: 'default_parts', name: 'default_parts',
label: t`Default Parts`, label: t`Default Parts`,

View File

@ -5,12 +5,15 @@ import { useCallback, useMemo, useState } from 'react';
import { AddItemButton } from '../../components/buttons/AddItemButton'; import { AddItemButton } from '../../components/buttons/AddItemButton';
import { YesNoButton } from '../../components/buttons/YesNoButton'; import { YesNoButton } from '../../components/buttons/YesNoButton';
import { ActionDropdown } from '../../components/items/ActionDropdown';
import { ApiIcon } from '../../components/items/ApiIcon'; import { ApiIcon } from '../../components/items/ApiIcon';
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 { UserRoles } from '../../enums/Roles';
import { partCategoryFields } from '../../forms/PartForms'; import { partCategoryFields } from '../../forms/PartForms';
import { InvenTreeIcon } from '../../functions/icons';
import { import {
useBulkEditApiFormModal,
useCreateApiFormModal, useCreateApiFormModal,
useEditApiFormModal useEditApiFormModal
} from '../../hooks/UseForm'; } from '../../hooks/UseForm';
@ -120,10 +123,38 @@ export function PartCategoryTable({ parentId }: Readonly<{ parentId?: any }>) {
onFormSuccess: (record: any) => table.updateRecord(record) onFormSuccess: (record: any) => table.updateRecord(record)
}); });
const setParent = useBulkEditApiFormModal({
url: ApiEndpoints.category_list,
items: table.selectedIds,
title: t`Set Parent Category`,
fields: {
parent: {}
},
onFormSuccess: table.refreshTable
});
const tableActions = useMemo(() => { const tableActions = useMemo(() => {
const can_add = user.hasAddRole(UserRoles.part_category); const can_add = user.hasAddRole(UserRoles.part_category);
const can_edit = user.hasChangeRole(UserRoles.part_category);
return [ return [
<ActionDropdown
tooltip={t`Category Actions`}
icon={<InvenTreeIcon icon='category' />}
disabled={!table.hasSelectedRecords}
actions={[
{
name: t`Set Parent`,
icon: <InvenTreeIcon icon='category' />,
tooltip: t`Set parent category for the selected items`,
hidden: !can_edit,
disabled: !table.hasSelectedRecords,
onClick: () => {
setParent.open();
}
}
]}
/>,
<AddItemButton <AddItemButton
key='add-part-category' key='add-part-category'
tooltip={t`Add Part Category`} tooltip={t`Add Part Category`}
@ -131,7 +162,7 @@ export function PartCategoryTable({ parentId }: Readonly<{ parentId?: any }>) {
hidden={!can_add} hidden={!can_add}
/> />
]; ];
}, [user]); }, [user, table.hasSelectedRecords]);
const rowActions = useCallback( const rowActions = useCallback(
(record: any): RowAction[] => { (record: any): RowAction[] => {
@ -154,12 +185,14 @@ export function PartCategoryTable({ parentId }: Readonly<{ parentId?: any }>) {
<> <>
{newCategory.modal} {newCategory.modal}
{editCategory.modal} {editCategory.modal}
{setParent.modal}
<InvenTreeTable <InvenTreeTable
url={apiUrl(ApiEndpoints.category_list)} url={apiUrl(ApiEndpoints.category_list)}
tableState={table} tableState={table}
columns={tableColumns} columns={tableColumns}
props={{ props={{
enableDownload: true, enableDownload: true,
enableSelection: true,
params: { params: {
parent: parentId, parent: parentId,
top_level: parentId === undefined ? true : undefined top_level: parentId === undefined ? true : undefined

View File

@ -1,11 +1,15 @@
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { IconTruckDelivery } from '@tabler/icons-react';
import { ActionButton } from '../../components/buttons/ActionButton';
import { formatDate } from '../../defaults/formatters'; import { formatDate } from '../../defaults/formatters';
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 { useSalesOrderAllocationFields } from '../../forms/SalesOrderForms'; import { useSalesOrderAllocationFields } from '../../forms/SalesOrderForms';
import { import {
useBulkEditApiFormModal,
useDeleteApiFormModal, useDeleteApiFormModal,
useEditApiFormModal useEditApiFormModal
} from '../../hooks/UseForm'; } from '../../hooks/UseForm';
@ -244,16 +248,51 @@ export default function SalesOrderAllocationTable({
[allowEdit, user] [allowEdit, user]
); );
const tableActions = useMemo(() => { // A subset of the selected allocations, which can be assigned to a shipment
if (!allowEdit) { const nonShippedAllocationIds: number[] = useMemo(() => {
return []; // Only allow allocations which have not been shipped
} return (
table.selectedRecords?.filter((record) => {
return !record.shipment_detail?.shipment_date;
}) ?? []
).map((record: any) => record.pk);
}, [table.selectedRecords]);
return []; const setShipment = useBulkEditApiFormModal({
}, [allowEdit, user]); url: ApiEndpoints.sales_order_allocation_list,
items: nonShippedAllocationIds,
title: t`Assign to Shipment`,
fields: {
shipment: {
filters: {
order: orderId,
shipped: false
}
}
},
onFormSuccess: table.refreshTable
});
const tableActions = useMemo(() => {
return [
<ActionButton
tooltip={t`Assign to shipment`}
icon={<IconTruckDelivery />}
onClick={() => {
setShipment.open();
}}
disabled={nonShippedAllocationIds.length == 0}
hidden={
!orderId || !allowEdit || !user.hasChangeRole(UserRoles.sales_order)
}
// TODO: Hide if order is already shipped
/>
];
}, [allowEdit, nonShippedAllocationIds, orderId, user]);
return ( return (
<> <>
{setShipment.modal}
{editAllocation.modal} {editAllocation.modal}
{deleteAllocation.modal} {deleteAllocation.modal}
<InvenTreeTable <InvenTreeTable
@ -277,9 +316,10 @@ export default function SalesOrderAllocationTable({
enableColumnSwitching: !isSubTable, enableColumnSwitching: !isSubTable,
enableFilters: !isSubTable, enableFilters: !isSubTable,
enableDownload: !isSubTable, enableDownload: !isSubTable,
enableSelection: !isSubTable,
minHeight: isSubTable ? 100 : undefined, minHeight: isSubTable ? 100 : undefined,
rowActions: rowActions, rowActions: rowActions,
tableActions: tableActions, tableActions: isSubTable ? undefined : tableActions,
tableFilters: tableFilters, tableFilters: tableFilters,
modelField: modelField ?? 'order', modelField: modelField ?? 'order',
modelType: modelTarget ?? ModelType.salesorder modelType: modelTarget ?? ModelType.salesorder

View File

@ -3,12 +3,15 @@ import { Group } from '@mantine/core';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { AddItemButton } from '../../components/buttons/AddItemButton'; import { AddItemButton } from '../../components/buttons/AddItemButton';
import { ActionDropdown } from '../../components/items/ActionDropdown';
import { ApiIcon } from '../../components/items/ApiIcon'; import { ApiIcon } from '../../components/items/ApiIcon';
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 { UserRoles } from '../../enums/Roles';
import { stockLocationFields } from '../../forms/StockForms'; import { stockLocationFields } from '../../forms/StockForms';
import { InvenTreeIcon } from '../../functions/icons';
import { import {
useBulkEditApiFormModal,
useCreateApiFormModal, useCreateApiFormModal,
useEditApiFormModal useEditApiFormModal
} from '../../hooks/UseForm'; } from '../../hooks/UseForm';
@ -118,10 +121,38 @@ export function StockLocationTable({ parentId }: Readonly<{ parentId?: any }>) {
onFormSuccess: (record: any) => table.updateRecord(record) onFormSuccess: (record: any) => table.updateRecord(record)
}); });
const setParent = useBulkEditApiFormModal({
url: ApiEndpoints.stock_location_list,
items: table.selectedIds,
title: t`Set Parent Location`,
fields: {
parent: {}
},
onFormSuccess: table.refreshTable
});
const tableActions = useMemo(() => { const tableActions = useMemo(() => {
const can_add = user.hasAddRole(UserRoles.stock_location); const can_add = user.hasAddRole(UserRoles.stock_location);
const can_edit = user.hasChangeRole(UserRoles.stock_location);
return [ return [
<ActionDropdown
tooltip={t`Location Actions`}
icon={<InvenTreeIcon icon='location' />}
disabled={!table.hasSelectedRecords}
actions={[
{
name: t`Set Parent`,
icon: <InvenTreeIcon icon='location' />,
tooltip: t`Set parent location for the selected items`,
hidden: !can_edit,
disabled: !table.hasSelectedRecords,
onClick: () => {
setParent.open();
}
}
]}
/>,
<AddItemButton <AddItemButton
key='add-stock-location' key='add-stock-location'
tooltip={t`Add Stock Location`} tooltip={t`Add Stock Location`}
@ -129,7 +160,7 @@ export function StockLocationTable({ parentId }: Readonly<{ parentId?: any }>) {
hidden={!can_add} hidden={!can_add}
/> />
]; ];
}, [user]); }, [user, table.hasSelectedRecords]);
const rowActions = useCallback( const rowActions = useCallback(
(record: any): RowAction[] => { (record: any): RowAction[] => {
@ -152,6 +183,7 @@ export function StockLocationTable({ parentId }: Readonly<{ parentId?: any }>) {
<> <>
{newLocation.modal} {newLocation.modal}
{editLocation.modal} {editLocation.modal}
{setParent.modal}
<InvenTreeTable <InvenTreeTable
url={apiUrl(ApiEndpoints.stock_location_list)} url={apiUrl(ApiEndpoints.stock_location_list)}
tableState={table} tableState={table}