mirror of
https://github.com/inventree/InvenTree.git
synced 2026-03-23 20:48:35 +00:00
Merge branch 'master' into generic-parameters
This commit is contained in:
@@ -7,6 +7,28 @@ import { cancelEvent } from './Events';
|
||||
export const getBaseUrl = (): string =>
|
||||
(window as any).INVENTREE_SETTINGS?.base_url || 'web';
|
||||
|
||||
/**
|
||||
* Returns the overview URL for a given model type.
|
||||
* This is the UI URL, not the API URL.
|
||||
*/
|
||||
export function getOverviewUrl(model: ModelType, absolute?: boolean): string {
|
||||
const modelInfo = ModelInformationDict[model];
|
||||
|
||||
if (modelInfo?.url_overview) {
|
||||
const url = modelInfo.url_overview;
|
||||
const base = getBaseUrl();
|
||||
|
||||
if (absolute && base) {
|
||||
return `/${base}${url}`;
|
||||
} else {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`No overview URL found for model ${model}`);
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the detail view URL for a given model type.
|
||||
* This is the UI URL, not the API URL.
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
[build.environment]
|
||||
VITE_DEMO = "true"
|
||||
PYTHON_VERSION = "3.11"
|
||||
|
||||
# Send requests to subpath
|
||||
|
||||
|
||||
@@ -24,11 +24,7 @@ import { type NavigateFunction, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { isTrue } from '@lib/functions/Conversion';
|
||||
import { getDetailUrl } from '@lib/functions/Navigation';
|
||||
import type {
|
||||
ApiFormFieldSet,
|
||||
ApiFormFieldType,
|
||||
ApiFormProps
|
||||
} from '@lib/types/Forms';
|
||||
import type { ApiFormFieldSet, ApiFormProps } from '@lib/types/Forms';
|
||||
import { useApi } from '../../contexts/ApiContext';
|
||||
import {
|
||||
type NestedDict,
|
||||
@@ -46,9 +42,11 @@ import { ApiFormField } from './fields/ApiFormField';
|
||||
|
||||
export function OptionsApiForm({
|
||||
props: _props,
|
||||
opened,
|
||||
id: pId
|
||||
}: Readonly<{
|
||||
props: ApiFormProps;
|
||||
opened?: boolean;
|
||||
id?: string;
|
||||
}>) {
|
||||
const api = useApi();
|
||||
@@ -75,24 +73,26 @@ export function OptionsApiForm({
|
||||
);
|
||||
|
||||
const optionsQuery = useQuery({
|
||||
enabled: true,
|
||||
enabled: opened !== false && props.ignorePermissionCheck !== true,
|
||||
refetchOnMount: false,
|
||||
queryKey: [
|
||||
'form-options-data',
|
||||
id,
|
||||
opened,
|
||||
props.ignorePermissionCheck,
|
||||
props.method,
|
||||
props.url,
|
||||
props.pk,
|
||||
props.pathParams
|
||||
],
|
||||
queryFn: async () => {
|
||||
const response = await api.options(url);
|
||||
let fields: Record<string, ApiFormFieldType> | null = {};
|
||||
if (!props.ignorePermissionCheck) {
|
||||
fields = extractAvailableFields(response, props.method);
|
||||
if (props.ignorePermissionCheck === true || opened === false) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return fields;
|
||||
return api.options(url).then((response: any) => {
|
||||
return extractAvailableFields(response, props.method);
|
||||
});
|
||||
},
|
||||
throwOnError: (error: any) => {
|
||||
if (error.response) {
|
||||
@@ -110,6 +110,13 @@ export function OptionsApiForm({
|
||||
}
|
||||
});
|
||||
|
||||
// Refetch form options whenever the modal is opened
|
||||
useEffect(() => {
|
||||
if (opened !== false) {
|
||||
optionsQuery.refetch();
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
const formProps: ApiFormProps = useMemo(() => {
|
||||
const _props = { ...props };
|
||||
|
||||
|
||||
@@ -62,6 +62,13 @@ export function RelatedModelField({
|
||||
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
|
||||
const [autoFilled, setAutoFilled] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Reset auto-fill status when the form is reconstructed
|
||||
setAutoFilled(false);
|
||||
}, []);
|
||||
|
||||
// Auto-fill the field with data from the API
|
||||
useEffect(() => {
|
||||
// If there is *no value defined*, and autoFill is enabled, then fetch data from the API
|
||||
@@ -69,10 +76,17 @@ export function RelatedModelField({
|
||||
return;
|
||||
}
|
||||
|
||||
// Return if the autofill has already been performed
|
||||
if (autoFilled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (field.value != undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAutoFilled(true);
|
||||
|
||||
// Construct parameters for auto-filling the field
|
||||
const params = {
|
||||
...(definition?.filters ?? {}),
|
||||
@@ -114,6 +128,7 @@ export function RelatedModelField({
|
||||
}
|
||||
});
|
||||
}, [
|
||||
autoFilled,
|
||||
definition.autoFill,
|
||||
definition.api_url,
|
||||
definition.filters,
|
||||
@@ -395,7 +410,14 @@ export function RelatedModelField({
|
||||
menuPortalTarget={document.body}
|
||||
noOptionsMessage={() => t`No results found`}
|
||||
menuPosition='fixed'
|
||||
styles={{ menuPortal: (base: any) => ({ ...base, zIndex: 9999 }) }}
|
||||
styles={{
|
||||
menuPortal: (base: any) => ({ ...base, zIndex: 9999 }),
|
||||
clearIndicator: (base: any) => ({
|
||||
...base,
|
||||
color: 'red',
|
||||
':hover': { color: 'red' }
|
||||
})
|
||||
}}
|
||||
formatOptionLabel={(option: any) => formatOption(option)}
|
||||
theme={(theme) => {
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { TextInput, Tooltip } from '@mantine/core';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { IconCopyCheck, IconX } from '@tabler/icons-react';
|
||||
import {
|
||||
type ReactNode,
|
||||
@@ -40,30 +39,26 @@ export default function TextField({
|
||||
|
||||
const { value } = useMemo(() => field, [field]);
|
||||
|
||||
const [rawText, setRawText] = useState<string>(value || '');
|
||||
const [textValue, setTextValue] = useState<string>(value || '');
|
||||
|
||||
const [debouncedText] = useDebouncedValue(rawText, 100);
|
||||
const onTextChange = useCallback(
|
||||
(value: any) => {
|
||||
setTextValue(value);
|
||||
onChange(value);
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setRawText(value || '');
|
||||
setTextValue(value || '');
|
||||
}, [value]);
|
||||
|
||||
const onTextChange = useCallback((value: any) => {
|
||||
setRawText(value);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedText !== value) {
|
||||
onChange(debouncedText);
|
||||
}
|
||||
}, [debouncedText]);
|
||||
|
||||
// Construct a "right section" for the text field
|
||||
const textFieldRightSection: ReactNode = useMemo(() => {
|
||||
if (definition.rightSection) {
|
||||
// Use the specified override value
|
||||
return definition.rightSection;
|
||||
} else if (value) {
|
||||
} else if (textValue) {
|
||||
if (!definition.required && !definition.disabled) {
|
||||
// Render a button to clear the text field
|
||||
return (
|
||||
@@ -78,7 +73,7 @@ export default function TextField({
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
!value &&
|
||||
!textValue &&
|
||||
definition.placeholder &&
|
||||
placeholderAutofill &&
|
||||
!definition.disabled
|
||||
@@ -94,7 +89,7 @@ export default function TextField({
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
}, [placeholderAutofill, definition, value]);
|
||||
}, [placeholderAutofill, definition, textValue]);
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
@@ -103,19 +98,19 @@ export default function TextField({
|
||||
id={fieldId}
|
||||
aria-label={`text-field-${field.name}`}
|
||||
type={definition.field_type}
|
||||
value={rawText || ''}
|
||||
value={textValue || ''}
|
||||
error={definition.error ?? error?.message}
|
||||
radius='sm'
|
||||
onChange={(event) => onTextChange(event.currentTarget.value)}
|
||||
onBlur={(event) => {
|
||||
if (event.currentTarget.value != value) {
|
||||
onChange(event.currentTarget.value);
|
||||
if (event.currentTarget.value != textValue) {
|
||||
onTextChange(event.currentTarget.value);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.code === 'Enter') {
|
||||
// Bypass debounce on enter key
|
||||
onChange(event.currentTarget.value);
|
||||
onTextChange(event.currentTarget.value);
|
||||
}
|
||||
onKeyDown(event.code);
|
||||
}}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import type { ApiFormFieldType } from '@lib/types/Forms';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { useApi } from '../../contexts/ApiContext';
|
||||
import type { ImportSessionState } from '../../hooks/UseImportSession';
|
||||
import { StandaloneField } from '../forms/StandaloneField';
|
||||
@@ -83,6 +84,14 @@ function ImporterDefaultField({
|
||||
}) {
|
||||
const api = useApi();
|
||||
|
||||
const [rawValue, setRawValue] = useState<any>('');
|
||||
|
||||
const fieldType: string = useMemo(() => {
|
||||
return session.availableFields[fieldName]?.type;
|
||||
}, [fieldName, session.availableFields]);
|
||||
|
||||
const [value] = useDebouncedValue(rawValue, fieldType == 'string' ? 500 : 10);
|
||||
|
||||
const onChange = useCallback(
|
||||
(value: any) => {
|
||||
// Update the default value for the field
|
||||
@@ -105,6 +114,11 @@ function ImporterDefaultField({
|
||||
[fieldName, session, session.fieldDefaults]
|
||||
);
|
||||
|
||||
// Update the default value after the debounced value changes
|
||||
useEffect(() => {
|
||||
onChange(value);
|
||||
}, [value]);
|
||||
|
||||
const fieldDef: ApiFormFieldType = useMemo(() => {
|
||||
let def: any = session.availableFields[fieldName];
|
||||
|
||||
@@ -114,7 +128,10 @@ function ImporterDefaultField({
|
||||
value: session.fieldDefaults[fieldName],
|
||||
field_type: def.type,
|
||||
description: def.help_text,
|
||||
onValueChange: onChange
|
||||
required: false,
|
||||
onValueChange: (value: string) => {
|
||||
setRawValue(value);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -215,7 +215,7 @@ export function RenderInlineModel({
|
||||
|
||||
return (
|
||||
<Group gap='xs' justify='space-between' title={tooltip}>
|
||||
<Group gap='xs' justify='left' wrap='nowrap'>
|
||||
<Group gap='xs' justify='left'>
|
||||
{prefix}
|
||||
{image && <Thumbnail src={image} size={18} />}
|
||||
{url ? (
|
||||
|
||||
@@ -517,7 +517,7 @@ function BuildAllocateLineRow({
|
||||
field_type: 'related field',
|
||||
api_url: apiUrl(ApiEndpoints.stock_item_list),
|
||||
model: ModelType.stockitem,
|
||||
autoFill: !!output?.serial,
|
||||
autoFill: !output || !!output?.serial,
|
||||
autoFillFilters: {
|
||||
serial: output?.serial
|
||||
},
|
||||
@@ -814,7 +814,7 @@ export function useConsumeBuildItemsForm({
|
||||
url: ApiEndpoints.build_order_consume,
|
||||
pk: buildId,
|
||||
title: t`Consume Stock`,
|
||||
successMessage: t`Stock items consumed`,
|
||||
successMessage: t`Stock items scheduled to be consumed`,
|
||||
onFormSuccess: onFormSuccess,
|
||||
size: '80%',
|
||||
fields: consumeFields,
|
||||
@@ -915,7 +915,7 @@ export function useConsumeBuildLinesForm({
|
||||
url: ApiEndpoints.build_order_consume,
|
||||
pk: buildId,
|
||||
title: t`Consume Stock`,
|
||||
successMessage: t`Stock items consumed`,
|
||||
successMessage: t`Stock items scheduled to be consumed`,
|
||||
onFormSuccess: onFormSuccess,
|
||||
fields: consumeFields,
|
||||
initialData: {
|
||||
|
||||
@@ -20,6 +20,9 @@ export function usePartFields({
|
||||
}): ApiFormFieldSet {
|
||||
const settings = useGlobalSettingsState();
|
||||
|
||||
const [virtual, setVirtual] = useState<boolean>(false);
|
||||
const [purchaseable, setPurchaseable] = useState<boolean>(false);
|
||||
|
||||
return useMemo(() => {
|
||||
const fields: ApiFormFieldSet = {
|
||||
category: {
|
||||
@@ -62,9 +65,19 @@ export function usePartFields({
|
||||
is_template: {},
|
||||
testable: {},
|
||||
trackable: {},
|
||||
purchaseable: {},
|
||||
purchaseable: {
|
||||
value: purchaseable,
|
||||
onValueChange: (value: boolean) => {
|
||||
setPurchaseable(value);
|
||||
}
|
||||
},
|
||||
salable: {},
|
||||
virtual: {},
|
||||
virtual: {
|
||||
value: virtual,
|
||||
onValueChange: (value: boolean) => {
|
||||
setVirtual(value);
|
||||
}
|
||||
},
|
||||
locked: {},
|
||||
active: {},
|
||||
starred: {
|
||||
@@ -80,33 +93,37 @@ export function usePartFields({
|
||||
if (create) {
|
||||
fields.copy_category_parameters = {};
|
||||
|
||||
fields.initial_stock = {
|
||||
icon: <IconPackages />,
|
||||
children: {
|
||||
quantity: {
|
||||
value: 0
|
||||
},
|
||||
location: {}
|
||||
}
|
||||
};
|
||||
if (!virtual) {
|
||||
fields.initial_stock = {
|
||||
icon: <IconPackages />,
|
||||
children: {
|
||||
quantity: {
|
||||
value: 0
|
||||
},
|
||||
location: {}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fields.initial_supplier = {
|
||||
icon: <IconBuildingStore />,
|
||||
children: {
|
||||
supplier: {
|
||||
filters: {
|
||||
is_supplier: true
|
||||
}
|
||||
},
|
||||
sku: {},
|
||||
manufacturer: {
|
||||
filters: {
|
||||
is_manufacturer: true
|
||||
}
|
||||
},
|
||||
mpn: {}
|
||||
}
|
||||
};
|
||||
if (purchaseable) {
|
||||
fields.initial_supplier = {
|
||||
icon: <IconBuildingStore />,
|
||||
children: {
|
||||
supplier: {
|
||||
filters: {
|
||||
is_supplier: true
|
||||
}
|
||||
},
|
||||
sku: {},
|
||||
manufacturer: {
|
||||
filters: {
|
||||
is_manufacturer: true
|
||||
}
|
||||
},
|
||||
mpn: {}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Additional fields for part duplication
|
||||
@@ -159,7 +176,7 @@ export function usePartFields({
|
||||
}
|
||||
|
||||
return fields;
|
||||
}, [create, duplicatePartInstance, settings]);
|
||||
}, [virtual, purchaseable, create, duplicatePartInstance, settings]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -125,8 +125,11 @@ export function useSalesOrderLineItemFields({
|
||||
)
|
||||
.sort((a: any, b: any) => a.quantity - b.quantity);
|
||||
|
||||
if (applicablePriceBreaks.length)
|
||||
if (applicablePriceBreaks.length) {
|
||||
setSalePrice(applicablePriceBreaks[0].price);
|
||||
} else {
|
||||
setSalePrice('');
|
||||
}
|
||||
}, [part, quantity, partCurrency, create]);
|
||||
|
||||
return useMemo(() => {
|
||||
@@ -184,6 +187,7 @@ function SalesOrderAllocateLineRow({
|
||||
field_type: 'related field',
|
||||
api_url: apiUrl(ApiEndpoints.stock_item_list),
|
||||
model: ModelType.stockitem,
|
||||
autoFill: true,
|
||||
filters: {
|
||||
available: true,
|
||||
part_detail: true,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Alert, Divider, Stack } from '@mantine/core';
|
||||
import { useId } from '@mantine/hooks';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type {
|
||||
ApiFormModalProps,
|
||||
@@ -50,14 +50,18 @@ export function useApiFormModal(props: ApiFormModalProps) {
|
||||
[props]
|
||||
);
|
||||
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
|
||||
const modal = useModal({
|
||||
id: modalId,
|
||||
title: formProps.title,
|
||||
onOpen: () => {
|
||||
setIsOpen(true);
|
||||
modalState.setModalOpen(modalId, true);
|
||||
formProps.onOpen?.();
|
||||
},
|
||||
onClose: () => {
|
||||
setIsOpen(false);
|
||||
modalState.setModalOpen(modalId, false);
|
||||
formProps.onClose?.();
|
||||
},
|
||||
@@ -66,7 +70,7 @@ export function useApiFormModal(props: ApiFormModalProps) {
|
||||
children: (
|
||||
<Stack gap={'xs'}>
|
||||
<Divider />
|
||||
<OptionsApiForm props={formProps} id={modalId} />
|
||||
<OptionsApiForm props={formProps} id={modalId} opened={isOpen} />
|
||||
</Stack>
|
||||
)
|
||||
});
|
||||
|
||||
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
@@ -32,7 +32,7 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { ModelType } from '@lib/enums/ModelType';
|
||||
import { UserRoles } from '@lib/enums/Roles';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import { getDetailUrl } from '@lib/functions/Navigation';
|
||||
import { getDetailUrl, getOverviewUrl } from '@lib/functions/Navigation';
|
||||
import type { StockOperationProps } from '@lib/types/Forms';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useBarcodeScanDialog } from '../../components/barcodes/BarcodeScanDialog';
|
||||
@@ -730,7 +730,20 @@ export default function StockDetail() {
|
||||
return {
|
||||
items: [stockitem],
|
||||
model: ModelType.stockitem,
|
||||
refresh: refreshInstance,
|
||||
refresh: () => {
|
||||
const location = stockitem?.location;
|
||||
refreshInstancePromise().then((response) => {
|
||||
if (response.status == 'error') {
|
||||
// If an error occurs refreshing the instance,
|
||||
// the stock likely has likely been depleted
|
||||
if (location) {
|
||||
navigate(getDetailUrl(ModelType.stocklocation, location));
|
||||
} else {
|
||||
navigate(getOverviewUrl(ModelType.stockitem));
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
filters: {
|
||||
in_stock: true
|
||||
}
|
||||
|
||||
@@ -23,11 +23,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { ActionButton } from '@lib/components/ActionButton';
|
||||
import { AddItemButton } from '@lib/components/AddItemButton';
|
||||
import { ProgressBar } from '@lib/components/ProgressBar';
|
||||
import {
|
||||
type RowAction,
|
||||
RowEditAction,
|
||||
RowViewAction
|
||||
} from '@lib/components/RowActions';
|
||||
import { type RowAction, RowEditAction } from '@lib/components/RowActions';
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { ModelType } from '@lib/enums/ModelType';
|
||||
import { UserRoles } from '@lib/enums/Roles';
|
||||
@@ -569,13 +565,7 @@ export default function BuildOutputTable({
|
||||
setSelectedOutputs([record]);
|
||||
cancelBuildOutputsForm.open();
|
||||
}
|
||||
},
|
||||
RowViewAction({
|
||||
title: t`View Build Output`,
|
||||
modelId: record.pk,
|
||||
modelType: ModelType.stockitem,
|
||||
navigate: navigate
|
||||
})
|
||||
}
|
||||
];
|
||||
},
|
||||
[buildStatus, user, partId, hasTrackedItems]
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AddItemButton } from '@lib/components/AddItemButton';
|
||||
import {
|
||||
type RowAction,
|
||||
RowDeleteAction,
|
||||
RowDuplicateAction,
|
||||
RowEditAction
|
||||
} from '@lib/components/RowActions';
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
@@ -88,9 +89,7 @@ export function ManufacturerPartTable({
|
||||
|
||||
const manufacturerPartFields = useManufacturerPartFields();
|
||||
|
||||
const [selectedPart, setSelectedPart] = useState<number | undefined>(
|
||||
undefined
|
||||
);
|
||||
const [selectedPart, setSelectedPart] = useState<any>(undefined);
|
||||
|
||||
const createManufacturerPart = useCreateApiFormModal({
|
||||
url: ApiEndpoints.manufacturer_part_list,
|
||||
@@ -105,15 +104,25 @@ export function ManufacturerPartTable({
|
||||
|
||||
const editManufacturerPart = useEditApiFormModal({
|
||||
url: ApiEndpoints.manufacturer_part_list,
|
||||
pk: selectedPart,
|
||||
pk: selectedPart?.pk,
|
||||
title: t`Edit Manufacturer Part`,
|
||||
fields: manufacturerPartFields,
|
||||
fields: useMemo(() => manufacturerPartFields, [manufacturerPartFields]),
|
||||
table: table
|
||||
});
|
||||
|
||||
const duplicateManufacturerPart = useCreateApiFormModal({
|
||||
url: ApiEndpoints.manufacturer_part_list,
|
||||
title: t`Add Manufacturer Part`,
|
||||
fields: useMemo(() => manufacturerPartFields, [manufacturerPartFields]),
|
||||
table: table,
|
||||
initialData: {
|
||||
...selectedPart
|
||||
}
|
||||
});
|
||||
|
||||
const deleteManufacturerPart = useDeleteApiFormModal({
|
||||
url: ApiEndpoints.manufacturer_part_list,
|
||||
pk: selectedPart,
|
||||
pk: selectedPart?.pk,
|
||||
title: t`Delete Manufacturer Part`,
|
||||
table: table
|
||||
});
|
||||
@@ -157,14 +166,21 @@ export function ManufacturerPartTable({
|
||||
RowEditAction({
|
||||
hidden: !user.hasChangeRole(UserRoles.purchase_order),
|
||||
onClick: () => {
|
||||
setSelectedPart(record.pk);
|
||||
setSelectedPart(record);
|
||||
editManufacturerPart.open();
|
||||
}
|
||||
}),
|
||||
RowDuplicateAction({
|
||||
hidden: !user.hasAddRole(UserRoles.purchase_order),
|
||||
onClick: () => {
|
||||
setSelectedPart(record);
|
||||
duplicateManufacturerPart.open();
|
||||
}
|
||||
}),
|
||||
RowDeleteAction({
|
||||
hidden: !user.hasDeleteRole(UserRoles.purchase_order),
|
||||
onClick: () => {
|
||||
setSelectedPart(record.pk);
|
||||
setSelectedPart(record);
|
||||
deleteManufacturerPart.open();
|
||||
}
|
||||
})
|
||||
@@ -176,6 +192,7 @@ export function ManufacturerPartTable({
|
||||
return (
|
||||
<>
|
||||
{createManufacturerPart.modal}
|
||||
{duplicateManufacturerPart.modal}
|
||||
{editManufacturerPart.modal}
|
||||
{deleteManufacturerPart.modal}
|
||||
<InvenTreeTable
|
||||
|
||||
@@ -7,13 +7,13 @@ import { AddItemButton } from '@lib/components/AddItemButton';
|
||||
import {
|
||||
type RowAction,
|
||||
RowDeleteAction,
|
||||
RowDuplicateAction,
|
||||
RowEditAction
|
||||
} from '@lib/components/RowActions';
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { ModelType } from '@lib/enums/ModelType';
|
||||
import { UserRoles } from '@lib/enums/Roles';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import { formatDecimal } from '@lib/functions/Formatting';
|
||||
import type { TableFilter } from '@lib/types/Filters';
|
||||
import type { TableColumn } from '@lib/types/Tables';
|
||||
import { IconPackageImport } from '@tabler/icons-react';
|
||||
@@ -118,7 +118,6 @@ export function SupplierPartTable({
|
||||
{
|
||||
accessor: 'pack_quantity',
|
||||
sortable: true,
|
||||
|
||||
render: (record: any) => {
|
||||
const part = record?.part_detail ?? {};
|
||||
|
||||
@@ -126,7 +125,7 @@ export function SupplierPartTable({
|
||||
|
||||
if (part.units) {
|
||||
extra.push(
|
||||
<Text key='base'>
|
||||
<Text key='base' size='sm'>
|
||||
{t`Base units`} : {part.units}
|
||||
</Text>
|
||||
);
|
||||
@@ -134,7 +133,7 @@ export function SupplierPartTable({
|
||||
|
||||
return (
|
||||
<TableHoverCard
|
||||
value={formatDecimal(record.pack_quantity)}
|
||||
value={record.pack_quantity}
|
||||
extra={extra}
|
||||
title={t`Pack Quantity`}
|
||||
/>
|
||||
@@ -236,19 +235,32 @@ export function SupplierPartTable({
|
||||
|
||||
const editSupplierPartFields = useSupplierPartFields({});
|
||||
|
||||
const [selectedSupplierPart, setSelectedSupplierPart] = useState<number>(0);
|
||||
const [selectedSupplierPart, setSelectedSupplierPart] =
|
||||
useState<any>(undefined);
|
||||
|
||||
const editSupplierPart = useEditApiFormModal({
|
||||
url: ApiEndpoints.supplier_part_list,
|
||||
pk: selectedSupplierPart,
|
||||
pk: selectedSupplierPart?.pk,
|
||||
title: t`Edit Supplier Part`,
|
||||
fields: editSupplierPartFields,
|
||||
fields: useMemo(() => editSupplierPartFields, [editSupplierPartFields]),
|
||||
table: table
|
||||
});
|
||||
|
||||
const duplicateSupplierPart = useCreateApiFormModal({
|
||||
url: ApiEndpoints.supplier_part_list,
|
||||
title: t`Add Supplier Part`,
|
||||
fields: useMemo(() => editSupplierPartFields, [editSupplierPartFields]),
|
||||
initialData: {
|
||||
...selectedSupplierPart,
|
||||
active: true
|
||||
},
|
||||
table: table,
|
||||
successMessage: t`Supplier part created`
|
||||
});
|
||||
|
||||
const deleteSupplierPart = useDeleteApiFormModal({
|
||||
url: ApiEndpoints.supplier_part_list,
|
||||
pk: selectedSupplierPart,
|
||||
pk: selectedSupplierPart?.pk,
|
||||
title: t`Delete Supplier Part`,
|
||||
table: table
|
||||
});
|
||||
@@ -260,14 +272,21 @@ export function SupplierPartTable({
|
||||
RowEditAction({
|
||||
hidden: !user.hasChangeRole(UserRoles.purchase_order),
|
||||
onClick: () => {
|
||||
setSelectedSupplierPart(record.pk);
|
||||
setSelectedSupplierPart(record);
|
||||
editSupplierPart.open();
|
||||
}
|
||||
}),
|
||||
RowDuplicateAction({
|
||||
hidden: !user.hasAddRole(UserRoles.purchase_order),
|
||||
onClick: () => {
|
||||
setSelectedSupplierPart(record);
|
||||
duplicateSupplierPart.open();
|
||||
}
|
||||
}),
|
||||
RowDeleteAction({
|
||||
hidden: !user.hasDeleteRole(UserRoles.purchase_order),
|
||||
onClick: () => {
|
||||
setSelectedSupplierPart(record.pk);
|
||||
setSelectedSupplierPart(record);
|
||||
deleteSupplierPart.open();
|
||||
}
|
||||
})
|
||||
@@ -280,6 +299,7 @@ export function SupplierPartTable({
|
||||
<>
|
||||
{addSupplierPart.modal}
|
||||
{editSupplierPart.modal}
|
||||
{duplicateSupplierPart.modal}
|
||||
{deleteSupplierPart.modal}
|
||||
{importPartWizard.wizard}
|
||||
<InvenTreeTable
|
||||
|
||||
@@ -320,7 +320,9 @@ export default function SalesOrderLineItemTable({
|
||||
|
||||
const allocateStock = useAllocateToSalesOrderForm({
|
||||
orderId: orderId,
|
||||
lineItems: selectedItems,
|
||||
lineItems: selectedItems.filter(
|
||||
(item) => item.part_detail?.virtual !== true
|
||||
),
|
||||
onFormSuccess: () => {
|
||||
table.refreshTable();
|
||||
table.clearSelectedRecords();
|
||||
|
||||
@@ -99,6 +99,50 @@ test('Build Order - Basic Tests', async ({ browser }) => {
|
||||
.waitFor();
|
||||
});
|
||||
|
||||
// Test that the build order reference field increments correctly
|
||||
test('Build Order - Reference', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser, {
|
||||
url: 'manufacturing/index/buildorders'
|
||||
});
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: 'action-button-add-build-order' })
|
||||
.click();
|
||||
await page.getByRole('button', { name: 'Submit' }).waitFor();
|
||||
|
||||
// Grab the next BuildOrder reference
|
||||
const reference: string = await page
|
||||
.getByRole('textbox', { name: 'text-field-reference' })
|
||||
.inputValue();
|
||||
expect(reference).toMatch(/BO\d+/);
|
||||
|
||||
// Select a part
|
||||
await page.getByLabel('related-field-part').fill('MAST');
|
||||
await page.getByText('MAST | Master Assembly').click();
|
||||
|
||||
// Submit the form
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByText('Item Created').waitFor();
|
||||
|
||||
// Back to the "build order" page - to create a new order
|
||||
await navigate(page, 'manufacturing/index/buildorders');
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: 'action-button-add-build-order' })
|
||||
.click();
|
||||
await page.getByRole('button', { name: 'Submit' }).waitFor();
|
||||
|
||||
const nextReference: string = await page
|
||||
.getByRole('textbox', { name: 'text-field-reference' })
|
||||
.inputValue();
|
||||
expect(nextReference).toMatch(/BO\d+/);
|
||||
|
||||
// Ensure that the reference has incremented
|
||||
const refNumber = Number(reference.replace('BO', ''));
|
||||
const nextRefNumber = Number(nextReference.replace('BO', ''));
|
||||
expect(nextRefNumber).toBe(refNumber + 1);
|
||||
});
|
||||
|
||||
test('Build Order - Calendar', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser);
|
||||
|
||||
|
||||
@@ -212,7 +212,7 @@ test('Parts - Details', async ({ browser }) => {
|
||||
|
||||
// Depending on the state of other tests, the "In Production" value may vary
|
||||
// This could be either 4 / 49, or 5 / 49
|
||||
await page.getByText(/[4|5] \/ 49/).waitFor();
|
||||
await page.getByText(/[4|5] \/ \d+/).waitFor();
|
||||
|
||||
// Badges
|
||||
await page.getByText('Required: 10').waitFor();
|
||||
@@ -232,14 +232,14 @@ test('Parts - Requirements', async ({ browser }) => {
|
||||
// Check top-level badges
|
||||
await page.getByText('In Stock: 209').waitFor();
|
||||
await page.getByText('Available: 204').waitFor();
|
||||
await page.getByText('Required: 275').waitFor();
|
||||
await page.getByText(/Required: 2\d+/).waitFor();
|
||||
await page.getByText('In Production: 24').waitFor();
|
||||
|
||||
// Check requirements details
|
||||
await page.getByText('204 / 209').waitFor(); // Available stock
|
||||
await page.getByText('0 / 100').waitFor(); // Allocated to build orders
|
||||
await page.getByText(/0 \/ 1\d+/).waitFor(); // Allocated to build orders
|
||||
await page.getByText('5 / 175').waitFor(); // Allocated to sales orders
|
||||
await page.getByText('24 / 214').waitFor(); // In production
|
||||
await page.getByText(/24 \/ 2\d+/).waitFor(); // In production
|
||||
|
||||
// Let's check out the "variants" for this part, too
|
||||
await navigate(page, 'part/81/details'); // WID-REV-A
|
||||
@@ -408,12 +408,10 @@ test('Parts - Pricing (Variant)', async ({ browser }) => {
|
||||
await loadTab(page, 'Part Pricing');
|
||||
await page.getByLabel('Part Pricing').getByText('Part Pricing').waitFor();
|
||||
await page.getByRole('button', { name: 'Pricing Overview' }).waitFor();
|
||||
await page.getByText('Last Updated').waitFor();
|
||||
await page.getByRole('button', { name: 'Internal Pricing' }).isDisabled();
|
||||
await page.getByText('Last Updated').first().waitFor();
|
||||
await page.getByRole('button', { name: 'Internal Pricing' }).isEnabled();
|
||||
await page.getByRole('button', { name: 'BOM Pricing' }).isEnabled();
|
||||
await page.getByRole('button', { name: 'Variant Pricing' }).isEnabled();
|
||||
await page.getByRole('button', { name: 'Sale Pricing' }).isDisabled();
|
||||
await page.getByRole('button', { name: 'Sale History' }).isDisabled();
|
||||
|
||||
// Variant Pricing
|
||||
await page.getByRole('button', { name: 'Variant Pricing' }).click();
|
||||
@@ -556,7 +554,7 @@ test('Parts - Parameter Filtering', async ({ browser }) => {
|
||||
await clearTableFilters(page);
|
||||
|
||||
// All parts should be available (no filters applied)
|
||||
await page.getByText('/ 425').waitFor();
|
||||
await page.getByText(/\/ 42\d/).waitFor();
|
||||
|
||||
const clickOnParamFilter = async (name: string) => {
|
||||
const button = await page
|
||||
@@ -584,7 +582,7 @@ test('Parts - Parameter Filtering', async ({ browser }) => {
|
||||
// Reset the filter
|
||||
await clearParamFilter('Color');
|
||||
|
||||
await page.getByText('/ 425').waitFor();
|
||||
await page.getByText(/\/ 42\d/).waitFor();
|
||||
});
|
||||
|
||||
test('Parts - Notes', async ({ browser }) => {
|
||||
@@ -624,7 +622,7 @@ test('Parts - Revision', async ({ browser }) => {
|
||||
.getByText('Green Round Table (revision B) | B', { exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('option', { name: 'Thumbnail Green Round Table Virtual' })
|
||||
.getByRole('option', { name: 'Thumbnail Green Round Table No stock' })
|
||||
.click();
|
||||
|
||||
await page.waitForURL('**/web/part/101/**');
|
||||
|
||||
@@ -225,7 +225,7 @@ test('Sales Orders - Shipments', async ({ browser }) => {
|
||||
|
||||
test('Sales Orders - Duplicate', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser, {
|
||||
url: 'sales/sales-order/11/detail'
|
||||
url: 'sales/sales-order/14/detail'
|
||||
});
|
||||
|
||||
await page.getByLabel('action-menu-order-actions').click();
|
||||
@@ -243,4 +243,39 @@ test('Sales Orders - Duplicate', async ({ browser }) => {
|
||||
await page.getByRole('tab', { name: 'Order Details' }).click();
|
||||
|
||||
await page.getByText('Pending').first().waitFor();
|
||||
|
||||
// Issue the order
|
||||
await page.getByRole('button', { name: 'Issue Order' }).click();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByText('In Progress').first().waitFor();
|
||||
|
||||
// Cancel the outstanding shipment
|
||||
await loadTab(page, 'Shipments');
|
||||
await clearTableFilters(page);
|
||||
const cell = await page.getByRole('cell', { name: '1', exact: true });
|
||||
await clickOnRowMenu(cell);
|
||||
await page.getByRole('menuitem', { name: 'Cancel' }).click();
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
// Check for expected line items
|
||||
await loadTab(page, 'Line Items');
|
||||
await page.getByRole('cell', { name: 'SW-001' }).waitFor();
|
||||
await page.getByRole('cell', { name: 'SW-002' }).waitFor();
|
||||
await page.getByText('1 - 2 / 2').waitFor();
|
||||
|
||||
// Ship the order
|
||||
await page.getByRole('button', { name: 'Ship Order' }).click();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Complete the order
|
||||
await page.getByRole('button', { name: 'Complete Order' }).click();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Go to the "details" tab
|
||||
await loadTab(page, 'Order Details');
|
||||
|
||||
// Check for expected results
|
||||
// 2 line items completed, as they are both virtual (no stock)
|
||||
await page.getByText('Complete').first().waitFor();
|
||||
await page.getByText('2 / 2').waitFor();
|
||||
});
|
||||
|
||||
@@ -98,7 +98,7 @@ test('Forms - Supplier Validation', async ({ browser }) => {
|
||||
// Check for validation errors
|
||||
await page.getByText('Form Error').waitFor();
|
||||
await page.getByText('Errors exist for one or more').waitFor();
|
||||
await page.getByText('This field may not be blank.').waitFor();
|
||||
await page.getByText('This field is required').waitFor();
|
||||
await page.getByText('Enter a valid URL.').waitFor();
|
||||
|
||||
// Fill out another field, expect that the errors persist
|
||||
@@ -106,7 +106,7 @@ test('Forms - Supplier Validation', async ({ browser }) => {
|
||||
.getByLabel('text-field-description', { exact: true })
|
||||
.fill('A description');
|
||||
await page.waitForTimeout(250);
|
||||
await page.getByText('This field may not be blank.').waitFor();
|
||||
await page.getByText('This field is required').waitFor();
|
||||
await page.getByText('Enter a valid URL.').waitFor();
|
||||
|
||||
// Generate a unique supplier name
|
||||
|
||||
@@ -116,9 +116,15 @@ test('Importing - BOM', async ({ browser }) => {
|
||||
|
||||
// Delete selected rows
|
||||
await page
|
||||
.getByRole('dialog', { name: 'Importing Data Upload File 2' })
|
||||
.getByRole('dialog', { name: 'Importing Data Upload File' })
|
||||
.getByLabel('action-button-delete-selected')
|
||||
.waitFor();
|
||||
await page.waitForTimeout(200);
|
||||
await page
|
||||
.getByRole('dialog', { name: 'Importing Data Upload File' })
|
||||
.getByLabel('action-button-delete-selected')
|
||||
.click();
|
||||
|
||||
await page.getByRole('button', { name: 'Delete', exact: true }).click();
|
||||
|
||||
await page.getByText('Success', { exact: true }).waitFor();
|
||||
|
||||
Reference in New Issue
Block a user