Merge commit 'fcbe4302b9fda7682b1a85e7d315b2d1545a16bf' into line-discount

This commit is contained in:
Oliver Walters
2026-07-20 01:02:46 +00:00
108 changed files with 136552 additions and 127035 deletions
+32 -19
View File
@@ -1,27 +1,35 @@
import { t } from '@lingui/core/macro';
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';
import { IconExclamationCircle, IconInfoCircle } from '@tabler/icons-react';
import { type ReactNode, useCallback, useState } from 'react';
export function DefaultFallback({
title
}: Readonly<{ title: string }>): ReactNode {
title,
error
}: Readonly<{ title: string; error: string | null }>): ReactNode {
return (
<Alert
color='red'
icon={<IconExclamationCircle />}
title={`INVE-E17: ${t`Error rendering component`}: ${title}`}
>
<Stack gap='xs'>
<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>
<>
<Alert
color='red'
icon={<IconExclamationCircle />}
title={`INVE-E17: ${t`Error rendering component`}: ${title}`}
>
<Stack gap='xs'>
<Text size='sm'>
{t`An error occurred while rendering this component.`}
</Text>
<Text size='sm'>
{t`Try reloading the page, or contact your administrator if the problem persists.`}
</Text>
</Stack>
</Alert>
{error && (
<Alert color='red' icon={<IconInfoCircle />} title={t`Error Details`}>
<Text size='sm'>{error}</Text>
</Alert>
)}
</>
);
}
@@ -34,17 +42,22 @@ export function Boundary({
label: string;
fallback?: React.ReactElement<any> | FallbackRender;
}>): ReactNode {
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const onError = useCallback(
(error: unknown, componentStack: string | undefined, eventId: string) => {
console.error(`ERR: Error rendering component: ${label}`);
console.error(error);
setErrorMessage(error instanceof Error ? error.message : String(error));
},
[]
);
return (
<ErrorBoundary
fallback={fallback ?? <DefaultFallback title={label} />}
fallback={
fallback ?? <DefaultFallback title={label} error={errorMessage} />
}
onError={onError}
>
{children}
@@ -0,0 +1,132 @@
import { t } from '@lingui/core/macro';
import { Group, Text } from '@mantine/core';
import type { ReactNode } from 'react';
import { formatDecimal } from '../../defaults/formatters';
import { TableHoverCard } from './TableHoverCard';
export function renderPartStockCell(record: any): ReactNode {
if (record.virtual) {
return (
<Text size='sm' c='dimmed' fs='italic'>
{t`Virtual part`}
</Text>
);
}
const extra: ReactNode[] = [];
const stock = record?.total_in_stock ?? 0;
const allocated =
(record?.allocated_to_build_orders ?? 0) +
(record?.allocated_to_sales_orders ?? 0);
const available = Math.max(0, stock - allocated);
const min_stock = record?.minimum_stock ?? 0;
const max_stock = record?.maximum_stock ?? 0;
let text = String(formatDecimal(stock));
let color: string | undefined = undefined;
if (min_stock > stock) {
extra.push(
<Text key='min-stock' c='orange'>
{`${t`Minimum stock`}: ${formatDecimal(min_stock)}`}
</Text>
);
color = 'orange';
}
if (max_stock > 0 && stock > max_stock) {
extra.push(
<Text key='max-stock' c='teal'>
{`${t`Maximum stock`}: ${formatDecimal(max_stock)}`}
</Text>
);
}
if (record.ordering > 0) {
extra.push(
<Text key='on-order'>{`${t`On Order`}: ${formatDecimal(record.ordering)}`}</Text>
);
}
if (record.building) {
extra.push(
<Text key='building'>{`${t`Building`}: ${formatDecimal(record.building)}`}</Text>
);
}
if (record.allocated_to_build_orders > 0) {
extra.push(
<Text key='bo-allocations'>
{`${t`Build Order Allocations`}: ${formatDecimal(record.allocated_to_build_orders)}`}
</Text>
);
}
if (record.allocated_to_sales_orders > 0) {
extra.push(
<Text key='so-allocations'>
{`${t`Sales Order Allocations`}: ${formatDecimal(record.allocated_to_sales_orders)}`}
</Text>
);
}
if (available != stock) {
extra.push(
<Text key='available'>
{t`Available`}: {formatDecimal(available)}
</Text>
);
}
if (record.external_stock > 0) {
extra.push(
<Text key='external'>
{t`External stock`}: {formatDecimal(record.external_stock)}
</Text>
);
}
if ((record.variant_stock ?? 0) > 0) {
extra.push(
<Text key='variant-stock' size='sm'>
{t`Includes variant stock`}: {formatDecimal(record.variant_stock)}
</Text>
);
extra.push(
<Text key='direct-stock' size='sm'>
{t`Direct stock`}: {formatDecimal(record.in_stock ?? 0)}
</Text>
);
}
if (stock <= 0) {
color = 'red';
text = t`No stock`;
} else if (available <= 0) {
color = 'orange';
} else if (available < min_stock) {
color = 'yellow';
}
return (
<TableHoverCard
value={
<Group gap='xs' justify='left' wrap='nowrap'>
<Text c={color} size='sm'>
{text}
</Text>
{record.units && (
<Text size='xs' c={color}>
[{record.units}]
</Text>
)}
</Group>
}
title={t`Stock Information`}
extra={extra}
/>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -45,9 +45,12 @@ export function SecurityContent() {
const user = useUserState();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const onError = useCallback(
(error: unknown, componentStack: string | undefined, eventId: string) => {
console.error(`ERR: Error rendering component: ${error}`);
setErrorMessage(error instanceof Error ? error.message : String(error));
},
[]
);
@@ -95,7 +98,9 @@ export function SecurityContent() {
</Accordion.Control>
<Accordion.Panel>
<ErrorBoundary
fallback={<DefaultFallback title={'API Table'} />}
fallback={
<DefaultFallback title={'API Table'} error={errorMessage} />
}
onError={onError}
>
<ApiTokenTable only_myself />
@@ -390,7 +390,8 @@ export default function SystemSettings() {
keys={[
'TRANSFERORDER_ENABLED',
'TRANSFERORDER_REFERENCE_PATTERN',
'TRANSFERORDER_REQUIRE_RESPONSIBLE'
'TRANSFERORDER_REQUIRE_RESPONSIBLE',
'TRANSFERORDER_EDIT_COMPLETED_ORDERS'
]}
/>
)
@@ -69,13 +69,11 @@ export default function TransferOrderDetail() {
const orderOpen: boolean =
order.status != toStatus.COMPLETE && order.status != toStatus.CANCELLED;
return orderOpen;
// TODO: does this setting make any sense for Transfer Orders???
// if (orderOpen) {
// return true;
// } else {
// return globalSettings.isSet('TRANSFERORDER_EDIT_COMPLETED_ORDERS');
// }
if (orderOpen) {
return true;
} else {
return globalSettings.isSet('TRANSFERORDER_EDIT_COMPLETED_ORDERS');
}
}, [globalSettings, order.status, toStatus]);
// for now, only permit editing allocations when line items can be edited
@@ -184,6 +184,9 @@ export default function ExtraLineItemTable({
params: {
order: orderId
},
enableSelection: true,
enableBulkDelete: editable && user.hasDeleteRole(role),
afterBulkDelete: orderDetailRefresh,
defaultSortColumn: 'line',
rowActions: rowActions,
tableActions: tableActions
@@ -7,6 +7,7 @@ import {
DescriptionColumn,
PartColumn
} from '../../components/tables/ColumnRenderers';
import { renderPartStockCell } from '../../components/tables/PartStockCell';
import ParametricDataTable from '../general/ParametricDataTable';
import { PartTableFilters } from './PartTableFilters';
@@ -33,7 +34,8 @@ export default function ParametricPartTable({
},
{
accessor: 'total_in_stock',
sortable: true
sortable: true,
render: renderPartStockCell
}
];
}, []);
+4 -117
View File
@@ -12,14 +12,13 @@ import type { ApiFormFieldSet } from '@lib/types/Forms';
import type { TableColumn } from '@lib/types/Tables';
import type { InvenTreeTableProps } from '@lib/types/Tables';
import { t } from '@lingui/core/macro';
import { Group, Text } from '@mantine/core';
import {
IconFileUpload,
IconPackageImport,
IconPlus,
IconShoppingCart
} from '@tabler/icons-react';
import { type ReactNode, useCallback, useMemo, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { ActionDropdown } from '../../components/items/ActionDropdown';
import {
BooleanColumn,
@@ -31,10 +30,10 @@ import {
PartColumn
} from '../../components/tables/ColumnRenderers';
import { InvenTreeTable } from '../../components/tables/InvenTreeTable';
import { TableHoverCard } from '../../components/tables/TableHoverCard';
import { renderPartStockCell } from '../../components/tables/PartStockCell';
import ImportPartWizard from '../../components/wizards/ImportPartWizard';
import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
import { formatDecimal, formatPriceRange } from '../../defaults/formatters';
import { formatPriceRange } from '../../defaults/formatters';
import { DuplicateField } from '../../forms/CommonFields';
import { dataImporterSessionFields } from '../../forms/ImporterForms';
import { usePartFields } from '../../forms/PartForms';
@@ -84,119 +83,7 @@ function partTableColumns(): TableColumn[] {
accessor: 'total_in_stock',
sortable: true,
filter: ['has_stock', 'low_stock', 'high_stock'],
render: (record) => {
if (record.virtual) {
return (
<Text size='sm' c='dimmed' fs='italic'>
{t`Virtual part`}
</Text>
);
}
const extra: ReactNode[] = [];
const stock = record?.total_in_stock ?? 0;
const allocated =
(record?.allocated_to_build_orders ?? 0) +
(record?.allocated_to_sales_orders ?? 0);
const available = Math.max(0, stock - allocated);
const min_stock = record?.minimum_stock ?? 0;
const max_stock = record?.maximum_stock ?? 0;
let text = String(formatDecimal(stock));
let color: string | undefined = undefined;
if (min_stock > stock) {
extra.push(
<Text key='min-stock' c='orange'>
{`${t`Minimum stock`}: ${formatDecimal(min_stock)}`}
</Text>
);
color = 'orange';
}
if (max_stock > 0 && stock > max_stock) {
extra.push(
<Text key='max-stock' c='teal'>
{`${t`Maximum stock`}: ${formatDecimal(max_stock)}`}
</Text>
);
}
if (record.ordering > 0) {
extra.push(
<Text key='on-order'>{`${t`On Order`}: ${formatDecimal(record.ordering)}`}</Text>
);
}
if (record.building) {
extra.push(
<Text key='building'>{`${t`Building`}: ${formatDecimal(record.building)}`}</Text>
);
}
if (record.allocated_to_build_orders > 0) {
extra.push(
<Text key='bo-allocations'>
{`${t`Build Order Allocations`}: ${formatDecimal(record.allocated_to_build_orders)}`}
</Text>
);
}
if (record.allocated_to_sales_orders > 0) {
extra.push(
<Text key='so-allocations'>
{`${t`Sales Order Allocations`}: ${formatDecimal(record.allocated_to_sales_orders)}`}
</Text>
);
}
if (available != stock) {
extra.push(
<Text key='available'>
{t`Available`}: {formatDecimal(available)}
</Text>
);
}
if (record.external_stock > 0) {
extra.push(
<Text key='external'>
{t`External stock`}: {formatDecimal(record.external_stock)}
</Text>
);
}
if (stock <= 0) {
color = 'red';
text = t`No stock`;
} else if (available <= 0) {
color = 'orange';
} else if (available < min_stock) {
color = 'yellow';
}
return (
<TableHoverCard
value={
<Group gap='xs' justify='left' wrap='nowrap'>
<Text c={color} size='sm'>
{text}
</Text>
{record.units && (
<Text size='xs' c={color}>
[{record.units}]
</Text>
)}
</Group>
}
title={t`Stock Information`}
extra={extra}
/>
);
}
render: renderPartStockCell
},
{
accessor: 'price_range',
@@ -440,6 +440,9 @@ export function PurchaseOrderLineItemTable({
props={{
enableSelection: true,
enableDownload: true,
enableBulkDelete:
editable && user.hasDeleteRole(UserRoles.purchase_order),
afterBulkDelete: orderDetailRefresh,
defaultSortColumn: 'line',
params: {
...params,
@@ -280,6 +280,9 @@ export default function ReturnOrderLineItemTable({
defaultSortColumn: 'line',
enableSelection:
inProgress && user.hasChangeRole(UserRoles.return_order),
enableBulkDelete:
editable && user.hasDeleteRole(UserRoles.return_order),
afterBulkDelete: orderDetailRefresh,
tableActions: tableActions,
tableFilters: tableFilters,
rowActions: rowActions,
@@ -615,6 +615,9 @@ export default function SalesOrderLineItemTable({
props={{
enableSelection: true,
enableDownload: true,
enableBulkDelete:
editable && user.hasDeleteRole(UserRoles.sales_order),
afterBulkDelete: orderDetailRefresh,
params: {
order: orderId,
part_detail: true
@@ -517,6 +517,9 @@ export default function TransferOrderLineItemTable({
props={{
enableSelection: true,
enableDownload: true,
enableBulkDelete:
editable && user.hasDeleteRole(UserRoles.transfer_order),
afterBulkDelete: orderDetailRefresh,
params: {
order: orderId,
part_detail: true
+1 -1
View File
@@ -283,7 +283,7 @@ test('Build Order - Build Outputs', async ({ browser }) => {
await page.waitForLoadState('networkidle');
// Check the "printing" actions for the selected outputs
await page.getByRole('checkbox', { name: 'Select all records' }).check();
await page.getByRole('checkbox', { name: 'Select all records' }).click();
await page
.getByRole('tabpanel', { name: 'Incomplete Outputs' })
.getByLabel('action-menu-printing-actions')
@@ -507,7 +507,12 @@ test('Purchase Orders - Receive Items', async ({ browser }) => {
await loadTab(page, 'Line Items');
await page.getByRole('cell', { name: '002.02-PCB' }).waitFor();
await page.getByLabel('Select all records').click();
// Select all line items to receive
await page
.getByRole('region', { name: 'Line Items', exact: true })
.getByLabel('Select all records')
.click();
await page.waitForTimeout(100);
await page.getByLabel('action-button-receive-items').click();
@@ -619,7 +624,10 @@ test('Purchase Orders - Receive Virtual Items', async ({ browser }) => {
await loadTab(page, 'Line Items');
await page.getByRole('cell', { name: 'Thumbnail CRM license' }).waitFor();
await page.getByRole('checkbox', { name: 'Select all records' }).click();
await page
.getByRole('region', { name: 'Line Items', exact: true })
.getByLabel('Select all records')
.click();
await page
.getByRole('button', { name: 'action-button-receive-items' })
.click();
+4 -1
View File
@@ -14,7 +14,10 @@ test('Return Orders - Receive Items', async ({ browser }) => {
await page.getByRole('cell', { name: 'WID-REV-A' }).first().waitFor();
await page.getByRole('checkbox', { name: 'Select all records' }).click();
await page
.getByRole('region', { name: 'Line Items', exact: true })
.getByLabel('Select all records')
.click();
await page.getByRole('button', { name: 'action-button-receive-' }).click();
await page.getByRole('banner').getByText('Receive Items').waitFor();
await page.getByRole('button', { name: 'Cancel' }).click();