Merge commit 'bc03f349085582e99606b94ff9c4cded905aeb1b' into block-notes

This commit is contained in:
Oliver Walters
2026-05-26 15:06:32 +00:00
33 changed files with 1986 additions and 51 deletions
+7 -3
View File
@@ -1,5 +1,5 @@
import { t } from '@lingui/core/macro';
import { Alert, Stack } from '@mantine/core';
import { Alert, Stack, Text } from '@mantine/core';
import { ErrorBoundary, type FallbackRender } from '@sentry/react';
import { IconExclamationCircle } from '@tabler/icons-react';
import { type ReactNode, useCallback } from 'react';
@@ -14,8 +14,12 @@ export function DefaultFallback({
title={`INVE-E17: ${t`Error rendering component`}: ${title}`}
>
<Stack gap='xs'>
{t`An error occurred while rendering this component. Refer to the console for more information.`}
{t`Try reloading the page, or contact your administrator if the problem persists.`}
<Text size='sm'>
{t`An error occurred while rendering this component. Refer to the console for more information.`}
</Text>
<Text size='sm'>
{t`Try reloading the page, or contact your administrator if the problem persists.`}
</Text>
</Stack>
</Alert>
);
+1
View File
@@ -182,6 +182,7 @@ export enum ApiEndpoints {
sales_order_complete = 'order/so/:id/complete/',
sales_order_allocate = 'order/so/:id/allocate/',
sales_order_allocate_serials = 'order/so/:id/allocate-serials/',
sales_order_auto_allocate = 'order/so/:id/auto-allocate/',
sales_order_line_list = 'order/so-line/',
sales_order_extra_line_list = 'order/so-extra-line/',
+29 -5
View File
@@ -5,14 +5,16 @@ import {
IconLink,
IconPlug,
IconPointer,
IconReport,
IconSettings,
IconTags,
IconUserBolt,
IconUserCog
} from '@tabler/icons-react';
import type { NavigateFunction } from 'react-router-dom';
import { ModelInformationDict } from '@lib/enums/ModelInformation';
import { UserRoles } from '@lib/index';
import { ModelType, UserRoles } from '@lib/index';
import { openContextModal } from '@mantine/modals';
import { useMemo } from 'react';
import { useShallow } from 'zustand/react/shallow';
@@ -36,6 +38,8 @@ export function getActions(navigate: NavigateFunction) {
const user = useUserState();
const actions: SpotlightActionData[] = useMemo(() => {
const staff = user?.isStaff() ?? false;
const _actions: SpotlightActionData[] = [
{
id: 'dashboard',
@@ -90,7 +94,7 @@ export function getActions(navigate: NavigateFunction) {
}
];
user?.isStaff() &&
staff &&
_actions.push({
id: 'data-import',
label: t`Import Data`,
@@ -149,7 +153,7 @@ export function getActions(navigate: NavigateFunction) {
leftSection: <IconLink size='1.2rem' />
});
user?.isStaff() &&
staff &&
_actions.push({
id: 'system-settings',
label: t`System Settings`,
@@ -158,7 +162,7 @@ export function getActions(navigate: NavigateFunction) {
leftSection: <IconSettings size='1.2rem' />
});
user?.isStaff() &&
staff &&
_actions.push({
id: 'admin-center',
label: t`Admin Center`,
@@ -167,7 +171,7 @@ export function getActions(navigate: NavigateFunction) {
leftSection: <IconUserBolt size='1.2rem' />
});
user?.isStaff() &&
staff &&
_actions.push({
id: 'plugin-settings',
label: t`Plugins`,
@@ -176,6 +180,26 @@ export function getActions(navigate: NavigateFunction) {
leftSection: <IconPlug size='1.2rem' />
});
staff &&
user?.hasViewPermission(ModelType.reporttemplate) &&
_actions.push({
id: 'report-templates',
label: t`Report Templates`,
description: t`Manage report templates`,
onClick: () => navigate('/settings/admin/reports'),
leftSection: <IconReport size='1.2rem' />
});
staff &&
user?.hasViewPermission(ModelType.labeltemplate) &&
_actions.push({
id: 'label-templates',
label: t`Label Templates`,
description: t`Manage label templates`,
onClick: () => navigate('/settings/admin/labels'),
leftSection: <IconTags size='1.2rem' />
});
return _actions;
}, [navigate, setNavigationOpen, globalSettings, user]);
+4
View File
@@ -239,6 +239,10 @@ export function useBuildAutoAllocateFields({
optional_items: {
hidden: item_type === 'tracked',
value: item_type === 'tracked' ? false : undefined
},
stock_sort_by: {},
build_lines: {
hidden: true
}
};
}, [item_type]);
@@ -584,3 +584,28 @@ export function useSalesOrderAllocationFields({
};
}, [orderId, shipment]);
}
export function useSalesOrderAutoAllocateFields({
orderId
}: {
orderId: number;
}): ApiFormFieldSet {
return useMemo(() => {
return {
location: {},
exclude_location: {},
interchangeable: {},
stock_sort_by: {},
serialized_stock: {},
shipment: {
filters: {
order: orderId,
shipped: false
}
},
line_items: {
hidden: true
}
};
}, [orderId]);
}
+2 -2
View File
@@ -308,7 +308,7 @@ export function UpdatedAfterFilter(): TableFilter {
return {
name: 'updated_after',
label: t`Updated After`,
description: t`Show orders updated after this date`,
description: t`Show items updated after this date`,
type: 'date'
};
}
@@ -317,7 +317,7 @@ export function UpdatedBeforeFilter(): TableFilter {
return {
name: 'updated_before',
label: t`Updated Before`,
description: t`Show orders updated before this date`,
description: t`Show items updated before this date`,
type: 'date'
};
}
@@ -574,6 +574,9 @@ export default function BuildLineTable({
});
const [allocateTaskId, setAllocateTaskId] = useState<string>('');
const [autoAllocateInitialData, setAutoAllocateInitialData] = useState<
Record<string, any>
>({});
useBackgroundTask({
taskId: allocateTaskId,
@@ -584,6 +587,24 @@ export default function BuildLineTable({
}
});
const autoAllocatePreFormContent = useMemo(() => {
const n = table.selectedRecords.length;
if (n > 0) {
return (
<Alert color='blue' title={t`Auto Allocate Stock`}>
<Text>
{t`Auto-allocating stock for`} {n} {t`selected line item(s)`}
</Text>
</Alert>
);
}
return (
<Alert color='green' title={t`Auto Allocate Stock`}>
<Text>{t`Automatically allocate untracked BOM items to this build according to the selected options`}</Text>
</Alert>
);
}, [table.selectedRecords]);
const autoAllocateStock = useCreateApiFormModal({
url: ApiEndpoints.build_order_auto_allocate,
pk: build.pk,
@@ -595,17 +616,18 @@ export default function BuildLineTable({
location: build.take_from,
interchangeable: true,
substitutes: true,
optional_items: false
optional_items: false,
...autoAllocateInitialData
},
successMessage: null,
onFormSuccess: (response: any) => {
setAllocateTaskId(response.task_id);
if (response.task_id) {
setAllocateTaskId(response.task_id);
} else {
table.refreshTable();
}
},
preFormContent: (
<Alert color='green' title={t`Auto Allocate Stock`}>
<Text>{t`Automatically allocate untracked BOM items to this build according to the selected options`}</Text>
</Alert>
)
preFormContent: autoAllocatePreFormContent
});
const allocateStock = useAllocateStockToBuildForm({
@@ -835,6 +857,9 @@ export default function BuildLineTable({
hidden={!visible || hasOutput}
color='blue'
onClick={() => {
setAutoAllocateInitialData({
build_lines: table.selectedRecords.map((r) => r.pk)
});
autoAllocateStock.open();
}}
/>,
@@ -377,6 +377,10 @@ export default function SalesOrderAllocationTable({
enableFilters: !isSubTable,
enableDownload: !isSubTable,
enableSelection: !isSubTable,
enableBulkDelete:
!isSubTable &&
allowEdit &&
user.hasDeleteRole(UserRoles.sales_order),
minHeight: isSubTable ? 100 : undefined,
rowActions: rowActions,
tableActions: isSubTable ? undefined : tableActions,
@@ -1,11 +1,12 @@
import { t } from '@lingui/core/macro';
import { Group, Paper, Text } from '@mantine/core';
import { Alert, Group, Paper, Text } from '@mantine/core';
import {
IconArrowRight,
IconHash,
IconShoppingCart,
IconSquareArrowRight,
IconTools
IconTools,
IconWand
} from '@tabler/icons-react';
import type { DataTableRowExpansionProps } from 'mantine-datatable';
import { type ReactNode, useCallback, useMemo, useState } from 'react';
@@ -35,8 +36,10 @@ import { useBuildOrderFields } from '../../forms/BuildForms';
import {
useAllocateToSalesOrderForm,
useSalesOrderAllocateSerialsFields,
useSalesOrderAutoAllocateFields,
useSalesOrderLineItemFields
} from '../../forms/SalesOrderForms';
import useBackgroundTask from '../../hooks/UseBackgroundTask';
import {
useCreateApiFormModal,
useDeleteApiFormModal,
@@ -327,6 +330,52 @@ export default function SalesOrderLineItemTable({
}
});
const [allocateTaskId, setAllocateTaskId] = useState<string>('');
useBackgroundTask({
taskId: allocateTaskId,
message: t`Allocating stock to sales order`,
successMessage: t`Stock allocation complete`,
onSuccess: () => {
table.refreshTable();
}
});
const [autoAllocateInitialData, setAutoAllocateInitialData] = useState<any>(
{}
);
const autoAllocatePreFormContent = useMemo(() => {
const count = table.selectedRecords.length;
if (count > 0) {
return (
<Alert color='blue'>
<Text size='sm'>
{t`${count} line item(s) selected — only these lines will be allocated`}
</Text>
</Alert>
);
}
return (
<Alert color='green'>
<Text size='sm'>{t`All unallocated line items will be allocated`}</Text>
</Alert>
);
}, [table.selectedRecords.length]);
const autoAllocateStock = useCreateApiFormModal({
url: ApiEndpoints.sales_order_auto_allocate,
pk: orderId,
title: t`Auto Allocate Stock`,
fields: useSalesOrderAutoAllocateFields({ orderId }),
initialData: autoAllocateInitialData,
preFormContent: autoAllocatePreFormContent,
successMessage: null,
onFormSuccess: (response: any) => {
setAllocateTaskId(response.task_id);
}
});
const [partsToOrder, setPartsToOrder] = useState<any[]>([]);
const orderPartsWizard = OrderPartsWizard({
@@ -385,9 +434,28 @@ export default function SalesOrderLineItemTable({
);
allocateStock.open();
}}
/>,
<ActionButton
key='auto-allocate-stock'
tooltip={t`Auto Allocate Stock`}
icon={<IconWand />}
color='blue'
hidden={!editable || !user.hasChangeRole(UserRoles.sales_order)}
onClick={() => {
setAutoAllocateInitialData({
line_items: table.selectedRecords.map((r) => r.pk)
});
autoAllocateStock.open();
}}
/>
];
}, [user, orderId, table.hasSelectedRecords, table.selectedRecords]);
}, [
editable,
user,
orderId,
table.hasSelectedRecords,
table.selectedRecords
]);
const rowActions = useCallback(
(record: any): RowAction[] => {
@@ -529,6 +597,7 @@ export default function SalesOrderLineItemTable({
{newBuildOrder.modal}
{allocateBySerials.modal}
{allocateStock.modal}
{autoAllocateStock.modal}
{orderPartsWizard.wizard}
<InvenTreeTable
url={apiUrl(ApiEndpoints.sales_order_line_list)}
@@ -32,6 +32,8 @@ import {
} from '../ColumnRenderers';
import {
BatchFilter,
CreatedAfterFilter,
CreatedBeforeFilter,
HasBatchCodeFilter,
InStockFilter,
IncludeVariantsFilter,
@@ -41,7 +43,9 @@ import {
SerialGTEFilter,
SerialLTEFilter,
StatusFilterOptions,
SupplierFilter
SupplierFilter,
UpdatedAfterFilter,
UpdatedBeforeFilter
} from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
@@ -143,17 +147,21 @@ function stockItemTableColumns({
sortable: true,
defaultVisible: false
},
DateColumn({
title: t`Created`,
accessor: 'creation_date',
sortable: true
}),
DateColumn({
title: t`Last Updated`,
accessor: 'updated'
}),
DateColumn({
title: t`Expiry Date`,
accessor: 'expiry_date',
hidden: !useGlobalSettingsState.getState().isSet('STOCK_ENABLE_EXPIRY'),
defaultVisible: false
}),
DateColumn({
title: t`Last Updated`,
accessor: 'updated'
}),
DateColumn({
accessor: 'stocktake_date',
title: t`Stocktake Date`,
@@ -273,18 +281,10 @@ function stockItemTableFilters({
type: 'date',
active: enableExpiry
},
{
name: 'updated_before',
label: t`Updated Before`,
description: t`Show items updated before this date`,
type: 'date'
},
{
name: 'updated_after',
label: t`Updated After`,
description: t`Show items updated after this date`,
type: 'date'
},
UpdatedBeforeFilter(),
UpdatedAfterFilter(),
CreatedBeforeFilter(),
CreatedAfterFilter(),
{
name: 'stocktake_before',
label: t`Stocktake Before`,
@@ -297,6 +297,11 @@ function stockItemTableFilters({
description: t`Show items counted after this date`,
type: 'date'
},
{
name: 'has_stocktake',
label: t`Has Stocktake Date`,
description: t`Show items which have a stocktake date`
},
{
name: 'external',
label: t`External Location`,
@@ -128,6 +128,42 @@ test('Sales Orders - Basic Tests', async ({ browser }) => {
await page.getByRole('button', { name: 'Issue Order' }).waitFor();
});
test('Sales Orders - Auto Allocate', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: 'sales/sales-order/11/' });
// Duplicate the order
await page.getByRole('button', { name: 'action-menu-order-actions' }).click();
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Pending').first().waitFor();
await loadTab(page, 'Line Items');
await page
.getByRole('button', { name: 'action-button-auto-allocate-' })
.click();
await page
.getByRole('combobox', { name: 'choice-field-stock_sort_by' })
.click();
await page.getByRole('option', { name: 'Newest stock' }).click();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Stock allocation complete').first().waitFor();
await page.getByText('10 / 10').first().waitFor();
// Cancel the order (to free up the allocated stock)
await page.getByRole('button', { name: 'action-menu-order-actions' }).click();
await page.getByRole('menuitem', { name: 'Cancel' }).click();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Cancelled').first().waitFor();
// Check that the allocated stock has been released
await page
.getByRole('region', { name: 'Line Items', exact: true })
.getByLabel('table-refresh')
.click();
await page.getByText('0 / 10').first().waitFor();
});
test('Sales Orders - Shipments', async ({ browser }) => {
const page = await doCachedLogin(browser);