mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-28 11:36:44 +00:00
[PUI/Feature] Integrate Part "Default Location" into UX (#5972)
* Add default parts to location page * Fix name strings * Add Stock Transfer modal * Add ApiForm Table field * temp * Add stock transfer form to part, stock item and location * All stock operations for Item, Part, and Location added (except order new) * Add default_location category traversal, and initial PO Line Item Receive form * . * Remove debug values * Added PO line receive form * Add functionality to PO receive extra fields * . * Forgot to bump API version * Add Category Default to details panel * Fix stockItem query count * Fix reviewed issues * . * . * . * Prevent root category from checking parent for default location
This commit is contained in:
parent
6abd33f060
commit
0196dd2f60
@ -16,7 +16,7 @@ repos:
|
|||||||
- id: check-yaml
|
- id: check-yaml
|
||||||
- id: mixed-line-ending
|
- id: mixed-line-ending
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: v0.2.2
|
rev: v0.3.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff-format
|
- id: ruff-format
|
||||||
args: [--preview]
|
args: [--preview]
|
||||||
@ -26,7 +26,7 @@ repos:
|
|||||||
--preview
|
--preview
|
||||||
]
|
]
|
||||||
- repo: https://github.com/matmair/ruff-pre-commit
|
- repo: https://github.com/matmair/ruff-pre-commit
|
||||||
rev: 830893bf46db844d9c99b6c468e285199adf2de6 # uv-018
|
rev: 8bed1087452bdf816b840ea7b6848b21d32b7419 # uv-018
|
||||||
hooks:
|
hooks:
|
||||||
- id: pip-compile
|
- id: pip-compile
|
||||||
name: pip-compile requirements-dev.in
|
name: pip-compile requirements-dev.in
|
||||||
@ -60,7 +60,7 @@ repos:
|
|||||||
- "prettier@^2.4.1"
|
- "prettier@^2.4.1"
|
||||||
- "@trivago/prettier-plugin-sort-imports"
|
- "@trivago/prettier-plugin-sort-imports"
|
||||||
- repo: https://github.com/pre-commit/mirrors-eslint
|
- repo: https://github.com/pre-commit/mirrors-eslint
|
||||||
rev: "v9.0.0-beta.0"
|
rev: "v9.0.0-beta.1"
|
||||||
hooks:
|
hooks:
|
||||||
- id: eslint
|
- id: eslint
|
||||||
additional_dependencies:
|
additional_dependencies:
|
||||||
|
@ -1,12 +1,18 @@
|
|||||||
"""InvenTree API version information."""
|
"""InvenTree API version information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# InvenTree API version
|
||||||
INVENTREE_API_VERSION = 182
|
INVENTREE_API_VERSION = 183
|
||||||
"""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 = """
|
||||||
|
|
||||||
v182 - 2024-03-15 : https://github.com/inventree/InvenTree/pull/6714
|
v183 - 2024-03-14 : https://github.com/inventree/InvenTree/pull/5972
|
||||||
|
- Adds "category_default_location" annotated field to part serializer
|
||||||
|
- Adds "part_detail.category_default_location" annotated field to stock item serializer
|
||||||
|
- Adds "part_detail.category_default_location" annotated field to purchase order line serializer
|
||||||
|
- Adds "parent_default_location" annotated field to category serializer
|
||||||
|
|
||||||
|
v182 - 2024-03-13 : https://github.com/inventree/InvenTree/pull/6714
|
||||||
- Expose ReportSnippet model to the /report/snippet/ API endpoint
|
- Expose ReportSnippet model to the /report/snippet/ API endpoint
|
||||||
- Expose ReportAsset model to the /report/asset/ API endpoint
|
- Expose ReportAsset model to the /report/asset/ API endpoint
|
||||||
|
|
||||||
|
@ -5,7 +5,16 @@ from decimal import Decimal
|
|||||||
|
|
||||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||||
from django.db import models, transaction
|
from django.db import models, transaction
|
||||||
from django.db.models import BooleanField, Case, ExpressionWrapper, F, Q, Value, When
|
from django.db.models import (
|
||||||
|
BooleanField,
|
||||||
|
Case,
|
||||||
|
ExpressionWrapper,
|
||||||
|
F,
|
||||||
|
Prefetch,
|
||||||
|
Q,
|
||||||
|
Value,
|
||||||
|
When,
|
||||||
|
)
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
@ -14,6 +23,8 @@ from sql_util.utils import SubqueryCount
|
|||||||
|
|
||||||
import order.models
|
import order.models
|
||||||
import part.filters
|
import part.filters
|
||||||
|
import part.filters as part_filters
|
||||||
|
import part.models as part_models
|
||||||
import stock.models
|
import stock.models
|
||||||
import stock.serializers
|
import stock.serializers
|
||||||
from common.serializers import ProjectCodeSerializer
|
from common.serializers import ProjectCodeSerializer
|
||||||
@ -375,6 +386,17 @@ class PurchaseOrderLineItemSerializer(InvenTreeModelSerializer):
|
|||||||
- "total_price" = purchase_price * quantity
|
- "total_price" = purchase_price * quantity
|
||||||
- "overdue" status (boolean field)
|
- "overdue" status (boolean field)
|
||||||
"""
|
"""
|
||||||
|
queryset = queryset.prefetch_related(
|
||||||
|
Prefetch(
|
||||||
|
'part__part',
|
||||||
|
queryset=part_models.Part.objects.annotate(
|
||||||
|
category_default_location=part_filters.annotate_default_location(
|
||||||
|
'category__'
|
||||||
|
)
|
||||||
|
).prefetch_related(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
queryset = queryset.annotate(
|
queryset = queryset.annotate(
|
||||||
total_price=ExpressionWrapper(
|
total_price=ExpressionWrapper(
|
||||||
F('purchase_price') * F('quantity'), output_field=models.DecimalField()
|
F('purchase_price') * F('quantity'), output_field=models.DecimalField()
|
||||||
|
@ -287,6 +287,32 @@ def annotate_category_parts():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def annotate_default_location(reference=''):
|
||||||
|
"""Construct a queryset that finds the closest default location in the part's category tree.
|
||||||
|
|
||||||
|
If the part's category has its own default_location, this is returned.
|
||||||
|
If not, the category tree is traversed until a value is found.
|
||||||
|
"""
|
||||||
|
subquery = part.models.PartCategory.objects.filter(
|
||||||
|
tree_id=OuterRef(f'{reference}tree_id'),
|
||||||
|
lft__lt=OuterRef(f'{reference}lft'),
|
||||||
|
rght__gt=OuterRef(f'{reference}rght'),
|
||||||
|
level__lte=OuterRef(f'{reference}level'),
|
||||||
|
parent__isnull=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
return Coalesce(
|
||||||
|
F(f'{reference}default_location'),
|
||||||
|
Subquery(
|
||||||
|
subquery.order_by('-level')
|
||||||
|
.filter(default_location__isnull=False)
|
||||||
|
.values('default_location')
|
||||||
|
),
|
||||||
|
Value(None),
|
||||||
|
output_field=IntegerField(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def annotate_sub_categories():
|
def annotate_sub_categories():
|
||||||
"""Construct a queryset annotation which returns the number of subcategories for each provided category."""
|
"""Construct a queryset annotation which returns the number of subcategories for each provided category."""
|
||||||
subquery = part.models.PartCategory.objects.filter(
|
subquery = part.models.PartCategory.objects.filter(
|
||||||
|
@ -81,6 +81,7 @@ class CategorySerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
|||||||
'url',
|
'url',
|
||||||
'structural',
|
'structural',
|
||||||
'icon',
|
'icon',
|
||||||
|
'parent_default_location',
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
@ -105,6 +106,10 @@ class CategorySerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
|||||||
subcategories=part.filters.annotate_sub_categories(),
|
subcategories=part.filters.annotate_sub_categories(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
queryset = queryset.annotate(
|
||||||
|
parent_default_location=part.filters.annotate_default_location('parent__')
|
||||||
|
)
|
||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
url = serializers.CharField(source='get_absolute_url', read_only=True)
|
url = serializers.CharField(source='get_absolute_url', read_only=True)
|
||||||
@ -121,6 +126,8 @@ class CategorySerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
|||||||
child=serializers.DictField(), source='get_path', read_only=True
|
child=serializers.DictField(), source='get_path', read_only=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
parent_default_location = serializers.IntegerField(read_only=True)
|
||||||
|
|
||||||
|
|
||||||
class CategoryTree(InvenTree.serializers.InvenTreeModelSerializer):
|
class CategoryTree(InvenTree.serializers.InvenTreeModelSerializer):
|
||||||
"""Serializer for PartCategory tree."""
|
"""Serializer for PartCategory tree."""
|
||||||
@ -283,6 +290,7 @@ class PartBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
|||||||
'pk',
|
'pk',
|
||||||
'IPN',
|
'IPN',
|
||||||
'barcode_hash',
|
'barcode_hash',
|
||||||
|
'category_default_location',
|
||||||
'default_location',
|
'default_location',
|
||||||
'name',
|
'name',
|
||||||
'revision',
|
'revision',
|
||||||
@ -314,6 +322,8 @@ class PartBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
|||||||
self.fields.pop('pricing_min')
|
self.fields.pop('pricing_min')
|
||||||
self.fields.pop('pricing_max')
|
self.fields.pop('pricing_max')
|
||||||
|
|
||||||
|
category_default_location = serializers.IntegerField(read_only=True)
|
||||||
|
|
||||||
image = InvenTree.serializers.InvenTreeImageSerializerField(read_only=True)
|
image = InvenTree.serializers.InvenTreeImageSerializerField(read_only=True)
|
||||||
thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True)
|
thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True)
|
||||||
|
|
||||||
@ -611,6 +621,7 @@ class PartSerializer(
|
|||||||
'allocated_to_build_orders',
|
'allocated_to_build_orders',
|
||||||
'allocated_to_sales_orders',
|
'allocated_to_sales_orders',
|
||||||
'building',
|
'building',
|
||||||
|
'category_default_location',
|
||||||
'in_stock',
|
'in_stock',
|
||||||
'ordering',
|
'ordering',
|
||||||
'required_for_build_orders',
|
'required_for_build_orders',
|
||||||
@ -766,6 +777,12 @@ class PartSerializer(
|
|||||||
required_for_sales_orders=part.filters.annotate_sales_order_requirements(),
|
required_for_sales_orders=part.filters.annotate_sales_order_requirements(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
queryset = queryset.annotate(
|
||||||
|
category_default_location=part.filters.annotate_default_location(
|
||||||
|
'category__'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
def get_starred(self, part) -> bool:
|
def get_starred(self, part) -> bool:
|
||||||
@ -805,6 +822,7 @@ class PartSerializer(
|
|||||||
unallocated_stock = serializers.FloatField(
|
unallocated_stock = serializers.FloatField(
|
||||||
read_only=True, label=_('Unallocated Stock')
|
read_only=True, label=_('Unallocated Stock')
|
||||||
)
|
)
|
||||||
|
category_default_location = serializers.IntegerField(read_only=True)
|
||||||
variant_stock = serializers.FloatField(read_only=True, label=_('Variant Stock'))
|
variant_stock = serializers.FloatField(read_only=True, label=_('Variant Stock'))
|
||||||
|
|
||||||
minimum_stock = serializers.FloatField()
|
minimum_stock = serializers.FloatField()
|
||||||
|
@ -6,7 +6,7 @@ from decimal import Decimal
|
|||||||
|
|
||||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import BooleanField, Case, Count, Q, Value, When
|
from django.db.models import BooleanField, Case, Count, Prefetch, Q, Value, When
|
||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
@ -20,6 +20,7 @@ import company.models
|
|||||||
import InvenTree.helpers
|
import InvenTree.helpers
|
||||||
import InvenTree.serializers
|
import InvenTree.serializers
|
||||||
import InvenTree.status_codes
|
import InvenTree.status_codes
|
||||||
|
import part.filters as part_filters
|
||||||
import part.models as part_models
|
import part.models as part_models
|
||||||
import stock.filters
|
import stock.filters
|
||||||
from company.serializers import SupplierPartSerializer
|
from company.serializers import SupplierPartSerializer
|
||||||
@ -289,7 +290,14 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeTagModelSerializer):
|
|||||||
'location',
|
'location',
|
||||||
'sales_order',
|
'sales_order',
|
||||||
'purchase_order',
|
'purchase_order',
|
||||||
|
Prefetch(
|
||||||
'part',
|
'part',
|
||||||
|
queryset=part_models.Part.objects.annotate(
|
||||||
|
category_default_location=part_filters.annotate_default_location(
|
||||||
|
'category__'
|
||||||
|
)
|
||||||
|
).prefetch_related(None),
|
||||||
|
),
|
||||||
'part__category',
|
'part__category',
|
||||||
'part__pricing_data',
|
'part__pricing_data',
|
||||||
'supplier_part',
|
'supplier_part',
|
||||||
|
@ -443,7 +443,7 @@ export function ApiForm({ id, props }: { id: string; props: ApiFormProps }) {
|
|||||||
))}
|
))}
|
||||||
<Button
|
<Button
|
||||||
onClick={form.handleSubmit(submitForm, onFormError)}
|
onClick={form.handleSubmit(submitForm, onFormError)}
|
||||||
variant="outline"
|
variant="filled"
|
||||||
radius="sm"
|
radius="sm"
|
||||||
color={props.submitColor ?? 'green'}
|
color={props.submitColor ?? 'green'}
|
||||||
disabled={isLoading || (props.fetchInitialData && !isDirty)}
|
disabled={isLoading || (props.fetchInitialData && !isDirty)}
|
||||||
|
@ -19,6 +19,7 @@ import { ChoiceField } from './ChoiceField';
|
|||||||
import DateField from './DateField';
|
import DateField from './DateField';
|
||||||
import { NestedObjectField } from './NestedObjectField';
|
import { NestedObjectField } from './NestedObjectField';
|
||||||
import { RelatedModelField } from './RelatedModelField';
|
import { RelatedModelField } from './RelatedModelField';
|
||||||
|
import { TableField } from './TableField';
|
||||||
|
|
||||||
export type ApiFormData = UseFormReturnType<Record<string, unknown>>;
|
export type ApiFormData = UseFormReturnType<Record<string, unknown>>;
|
||||||
|
|
||||||
@ -69,7 +70,8 @@ export type ApiFormFieldType = {
|
|||||||
| 'number'
|
| 'number'
|
||||||
| 'choice'
|
| 'choice'
|
||||||
| 'file upload'
|
| 'file upload'
|
||||||
| 'nested object';
|
| 'nested object'
|
||||||
|
| 'table';
|
||||||
api_url?: string;
|
api_url?: string;
|
||||||
model?: ModelType;
|
model?: ModelType;
|
||||||
modelRenderer?: (instance: any) => ReactNode;
|
modelRenderer?: (instance: any) => ReactNode;
|
||||||
@ -86,6 +88,7 @@ export type ApiFormFieldType = {
|
|||||||
postFieldContent?: JSX.Element;
|
postFieldContent?: JSX.Element;
|
||||||
onValueChange?: (value: any) => void;
|
onValueChange?: (value: any) => void;
|
||||||
adjustFilters?: (value: ApiFormAdjustFilterType) => any;
|
adjustFilters?: (value: ApiFormAdjustFilterType) => any;
|
||||||
|
headers?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -266,6 +269,14 @@ export function ApiFormField({
|
|||||||
control={control}
|
control={control}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
case 'table':
|
||||||
|
return (
|
||||||
|
<TableField
|
||||||
|
definition={definition}
|
||||||
|
fieldName={fieldName}
|
||||||
|
control={controller}
|
||||||
|
/>
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<Alert color="red" title={t`Error`}>
|
<Alert color="red" title={t`Error`}>
|
||||||
|
@ -30,7 +30,6 @@ export function RelatedModelField({
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
}) {
|
}) {
|
||||||
const fieldId = useId();
|
const fieldId = useId();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
field,
|
field,
|
||||||
fieldState: { error }
|
fieldState: { error }
|
||||||
@ -60,7 +59,6 @@ export function RelatedModelField({
|
|||||||
field.value !== ''
|
field.value !== ''
|
||||||
) {
|
) {
|
||||||
const url = `${definition.api_url}${field.value}/`;
|
const url = `${definition.api_url}${field.value}/`;
|
||||||
|
|
||||||
api.get(url).then((response) => {
|
api.get(url).then((response) => {
|
||||||
if (response.data && response.data.pk) {
|
if (response.data && response.data.pk) {
|
||||||
const value = {
|
const value = {
|
||||||
|
80
src/frontend/src/components/forms/fields/TableField.tsx
Normal file
80
src/frontend/src/components/forms/fields/TableField.tsx
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import { Trans, t } from '@lingui/macro';
|
||||||
|
import { Table } from '@mantine/core';
|
||||||
|
import { FieldValues, UseControllerReturn } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { InvenTreeIcon } from '../../../functions/icons';
|
||||||
|
import { ApiFormFieldType } from './ApiFormField';
|
||||||
|
|
||||||
|
export function TableField({
|
||||||
|
definition,
|
||||||
|
fieldName,
|
||||||
|
control
|
||||||
|
}: {
|
||||||
|
definition: ApiFormFieldType;
|
||||||
|
fieldName: string;
|
||||||
|
control: UseControllerReturn<FieldValues, any>;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
field,
|
||||||
|
fieldState: { error }
|
||||||
|
} = control;
|
||||||
|
const { value, ref } = field;
|
||||||
|
|
||||||
|
const onRowFieldChange = (idx: number, key: string, value: any) => {
|
||||||
|
const val = field.value;
|
||||||
|
val[idx][key] = value;
|
||||||
|
field.onChange(val);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeRow = (idx: number) => {
|
||||||
|
const val = field.value;
|
||||||
|
val.splice(idx, 1);
|
||||||
|
field.onChange(val);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table highlightOnHover striped>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{definition.headers?.map((header) => {
|
||||||
|
return <th key={header}>{header}</th>;
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{value.length > 0 ? (
|
||||||
|
value.map((item: any, idx: number) => {
|
||||||
|
// Table fields require render function
|
||||||
|
if (!definition.modelRenderer) {
|
||||||
|
return <tr>{t`modelRenderer entry required for tables`}</tr>;
|
||||||
|
}
|
||||||
|
return definition.modelRenderer({
|
||||||
|
item: item,
|
||||||
|
idx: idx,
|
||||||
|
changeFn: onRowFieldChange,
|
||||||
|
removeFn: removeRow
|
||||||
|
});
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style={{ textAlign: 'center' }}
|
||||||
|
colSpan={definition.headers?.length}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '5px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<InvenTreeIcon icon="info" />
|
||||||
|
<Trans>No entries available</Trans>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
}
|
@ -36,11 +36,13 @@ export type ActionDropdownItem = {
|
|||||||
export function ActionDropdown({
|
export function ActionDropdown({
|
||||||
icon,
|
icon,
|
||||||
tooltip,
|
tooltip,
|
||||||
actions
|
actions,
|
||||||
|
disabled = false
|
||||||
}: {
|
}: {
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
tooltip?: string;
|
tooltip?: string;
|
||||||
actions: ActionDropdownItem[];
|
actions: ActionDropdownItem[];
|
||||||
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const hasActions = useMemo(() => {
|
const hasActions = useMemo(() => {
|
||||||
return actions.some((action) => !action.hidden);
|
return actions.some((action) => !action.hidden);
|
||||||
@ -54,7 +56,12 @@ export function ActionDropdown({
|
|||||||
<Indicator disabled={!indicatorProps} {...indicatorProps?.indicator}>
|
<Indicator disabled={!indicatorProps} {...indicatorProps?.indicator}>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<Tooltip label={tooltip} hidden={!tooltip}>
|
<Tooltip label={tooltip} hidden={!tooltip}>
|
||||||
<ActionIcon size="lg" radius="sm" variant="outline">
|
<ActionIcon
|
||||||
|
size="lg"
|
||||||
|
radius="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
{icon}
|
{icon}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
@ -84,11 +84,20 @@ export enum ApiEndpoints {
|
|||||||
stock_location_tree = 'stock/location/tree/',
|
stock_location_tree = 'stock/location/tree/',
|
||||||
stock_attachment_list = 'stock/attachment/',
|
stock_attachment_list = 'stock/attachment/',
|
||||||
stock_test_result_list = 'stock/test/',
|
stock_test_result_list = 'stock/test/',
|
||||||
|
stock_transfer = 'stock/transfer/',
|
||||||
|
stock_remove = 'stock/remove/',
|
||||||
|
stock_add = 'stock/add/',
|
||||||
|
stock_count = 'stock/count/',
|
||||||
|
stock_change_status = 'stock/change_status/',
|
||||||
|
stock_merge = 'stock/merge/',
|
||||||
|
stock_assign = 'stock/assign/',
|
||||||
|
stock_status = 'stock/status/',
|
||||||
|
|
||||||
// Order API endpoints
|
// Order API endpoints
|
||||||
purchase_order_list = 'order/po/',
|
purchase_order_list = 'order/po/',
|
||||||
purchase_order_line_list = 'order/po-line/',
|
purchase_order_line_list = 'order/po-line/',
|
||||||
purchase_order_attachment_list = 'order/po/attachment/',
|
purchase_order_attachment_list = 'order/po/attachment/',
|
||||||
|
purchase_order_receive = 'order/po/:id/receive/',
|
||||||
sales_order_list = 'order/so/',
|
sales_order_list = 'order/so/',
|
||||||
sales_order_attachment_list = 'order/so/attachment/',
|
sales_order_attachment_list = 'order/so/attachment/',
|
||||||
sales_order_shipment_list = 'order/so/shipment/',
|
sales_order_shipment_list = 'order/so/shipment/',
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import { Flex, FocusTrap, Modal, NumberInput, TextInput } from '@mantine/core';
|
||||||
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import {
|
import {
|
||||||
IconAddressBook,
|
IconAddressBook,
|
||||||
IconCalendar,
|
IconCalendar,
|
||||||
@ -11,12 +14,24 @@ import {
|
|||||||
IconUser,
|
IconUser,
|
||||||
IconUsers
|
IconUsers
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { api } from '../App';
|
||||||
|
import { ActionButton } from '../components/buttons/ActionButton';
|
||||||
|
import { StandaloneField } from '../components/forms/StandaloneField';
|
||||||
import {
|
import {
|
||||||
ApiFormAdjustFilterType,
|
ApiFormAdjustFilterType,
|
||||||
ApiFormFieldSet
|
ApiFormFieldSet
|
||||||
} from '../components/forms/fields/ApiFormField';
|
} from '../components/forms/fields/ApiFormField';
|
||||||
|
import { Thumbnail } from '../components/images/Thumbnail';
|
||||||
|
import { ProgressBar } from '../components/items/ProgressBar';
|
||||||
|
import { StylishText } from '../components/items/StylishText';
|
||||||
|
import { ApiEndpoints } from '../enums/ApiEndpoints';
|
||||||
|
import { ModelType } from '../enums/ModelType';
|
||||||
|
import { InvenTreeIcon } from '../functions/icons';
|
||||||
|
import { useCreateApiFormModal } from '../hooks/UseForm';
|
||||||
|
import { apiUrl } from '../states/ApiState';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Construct a set of fields for creating / editing a PurchaseOrderLineItem instance
|
* Construct a set of fields for creating / editing a PurchaseOrderLineItem instance
|
||||||
@ -143,3 +158,497 @@ export function purchaseOrderFields(): ApiFormFieldSet {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a table row for a single TableField entry
|
||||||
|
*/
|
||||||
|
function LineItemFormRow({
|
||||||
|
input,
|
||||||
|
record,
|
||||||
|
statuses
|
||||||
|
}: {
|
||||||
|
input: any;
|
||||||
|
record: any;
|
||||||
|
statuses: any;
|
||||||
|
}) {
|
||||||
|
// Barcode Modal state
|
||||||
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
|
|
||||||
|
// Location value
|
||||||
|
const [location, setLocation] = useState(
|
||||||
|
input.item.location ??
|
||||||
|
record.part_detail.default_location ??
|
||||||
|
record.part_detail.category_default_location
|
||||||
|
);
|
||||||
|
const [locationOpen, locationHandlers] = useDisclosure(
|
||||||
|
location ? true : false,
|
||||||
|
{
|
||||||
|
onClose: () => input.changeFn(input.idx, 'location', null),
|
||||||
|
onOpen: () => input.changeFn(input.idx, 'location', location)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Change form value when state is altered
|
||||||
|
useEffect(() => {
|
||||||
|
input.changeFn(input.idx, 'location', location);
|
||||||
|
}, [location]);
|
||||||
|
|
||||||
|
// State for serializing
|
||||||
|
const [batchCode, setBatchCode] = useState<string>('');
|
||||||
|
const [serials, setSerials] = useState<string>('');
|
||||||
|
const [batchOpen, batchHandlers] = useDisclosure(false, {
|
||||||
|
onClose: () => {
|
||||||
|
input.changeFn(input.idx, 'batch_code', '');
|
||||||
|
input.changeFn(input.idx, 'serial_numbers', '');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Change form value when state is altered
|
||||||
|
useEffect(() => {
|
||||||
|
input.changeFn(input.idx, 'batch_code', batchCode);
|
||||||
|
}, [batchCode]);
|
||||||
|
|
||||||
|
// Change form value when state is altered
|
||||||
|
useEffect(() => {
|
||||||
|
input.changeFn(input.idx, 'serial_numbers', serials);
|
||||||
|
}, [serials]);
|
||||||
|
|
||||||
|
// Status value
|
||||||
|
const [statusOpen, statusHandlers] = useDisclosure(false, {
|
||||||
|
onClose: () => input.changeFn(input.idx, 'status', 10)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Barcode value
|
||||||
|
const [barcodeInput, setBarcodeInput] = useState<any>('');
|
||||||
|
const [barcode, setBarcode] = useState(null);
|
||||||
|
|
||||||
|
// Change form value when state is altered
|
||||||
|
useEffect(() => {
|
||||||
|
input.changeFn(input.idx, 'barcode', barcode);
|
||||||
|
}, [barcode]);
|
||||||
|
|
||||||
|
// Update location field description on state change
|
||||||
|
useEffect(() => {
|
||||||
|
if (!opened) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
setBarcode(barcodeInput.length ? barcodeInput : null);
|
||||||
|
close();
|
||||||
|
setBarcodeInput('');
|
||||||
|
}, 500);
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}, [barcodeInput]);
|
||||||
|
|
||||||
|
// Info string with details about certain selected locations
|
||||||
|
const locationDescription = useMemo(() => {
|
||||||
|
let text = t`Choose Location`;
|
||||||
|
|
||||||
|
if (location === null) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selected location is order line destination
|
||||||
|
if (location === record.destination) {
|
||||||
|
return t`Item Destination selected`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selected location is base part's category default location
|
||||||
|
if (
|
||||||
|
!record.destination &&
|
||||||
|
!record.destination_detail &&
|
||||||
|
location === record.part_detail.category_default_location
|
||||||
|
) {
|
||||||
|
return t`Part category default location selected`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selected location is identical to already received stock for this line
|
||||||
|
if (
|
||||||
|
!record.destination &&
|
||||||
|
record.destination_detail &&
|
||||||
|
location === record.destination_detail.pk &&
|
||||||
|
record.received > 0
|
||||||
|
) {
|
||||||
|
return t`Received stock location selected`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selected location is base part's default location
|
||||||
|
if (location === record.part_detail.default_location) {
|
||||||
|
return t`Default location selected`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return text;
|
||||||
|
}, [location]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={close}
|
||||||
|
title={<StylishText children={t`Scan Barcode`} />}
|
||||||
|
>
|
||||||
|
<FocusTrap>
|
||||||
|
<TextInput
|
||||||
|
label="Barcode data"
|
||||||
|
data-autofocus
|
||||||
|
value={barcodeInput}
|
||||||
|
onChange={(e) => setBarcodeInput(e.target.value)}
|
||||||
|
/>
|
||||||
|
</FocusTrap>
|
||||||
|
</Modal>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<Flex gap="sm" align="center">
|
||||||
|
<Thumbnail
|
||||||
|
size={40}
|
||||||
|
src={record.part_detail.thumbnail}
|
||||||
|
align="center"
|
||||||
|
/>
|
||||||
|
<div>{record.part_detail.name}</div>
|
||||||
|
</Flex>
|
||||||
|
</td>
|
||||||
|
<td>{record.supplier_part_detail.SKU}</td>
|
||||||
|
<td>
|
||||||
|
<ProgressBar
|
||||||
|
value={record.received}
|
||||||
|
maximum={record.quantity}
|
||||||
|
progressLabel
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td style={{ width: '1%', whiteSpace: 'nowrap' }}>
|
||||||
|
<NumberInput
|
||||||
|
value={input.item.quantity}
|
||||||
|
style={{ width: '100px' }}
|
||||||
|
max={input.item.quantity}
|
||||||
|
min={0}
|
||||||
|
onChange={(value) => input.changeFn(input.idx, 'quantity', value)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td style={{ width: '1%', whiteSpace: 'nowrap' }}>
|
||||||
|
<Flex gap="1px">
|
||||||
|
<ActionButton
|
||||||
|
onClick={() => locationHandlers.toggle()}
|
||||||
|
icon={<InvenTreeIcon icon="location" />}
|
||||||
|
tooltip={t`Set Location`}
|
||||||
|
tooltipAlignment="top"
|
||||||
|
variant={locationOpen ? 'filled' : 'outline'}
|
||||||
|
/>
|
||||||
|
<ActionButton
|
||||||
|
onClick={() => batchHandlers.toggle()}
|
||||||
|
icon={<InvenTreeIcon icon="batch_code" />}
|
||||||
|
tooltip={t`Assign Batch Code${
|
||||||
|
record.trackable && ' and Serial Numbers'
|
||||||
|
}`}
|
||||||
|
tooltipAlignment="top"
|
||||||
|
variant={batchOpen ? 'filled' : 'outline'}
|
||||||
|
/>
|
||||||
|
<ActionButton
|
||||||
|
onClick={() => statusHandlers.toggle()}
|
||||||
|
icon={<InvenTreeIcon icon="status" />}
|
||||||
|
tooltip={t`Change Status`}
|
||||||
|
tooltipAlignment="top"
|
||||||
|
variant={statusOpen ? 'filled' : 'outline'}
|
||||||
|
/>
|
||||||
|
{barcode ? (
|
||||||
|
<ActionButton
|
||||||
|
icon={<InvenTreeIcon icon="unlink" />}
|
||||||
|
tooltip={t`Unlink Barcode`}
|
||||||
|
tooltipAlignment="top"
|
||||||
|
variant="filled"
|
||||||
|
color="red"
|
||||||
|
onClick={() => setBarcode(null)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ActionButton
|
||||||
|
icon={<InvenTreeIcon icon="barcode" />}
|
||||||
|
tooltip={t`Scan Barcode`}
|
||||||
|
tooltipAlignment="top"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => open()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ActionButton
|
||||||
|
onClick={() => input.removeFn(input.idx)}
|
||||||
|
icon={<InvenTreeIcon icon="square_x" />}
|
||||||
|
tooltip={t`Remove item from list`}
|
||||||
|
tooltipAlignment="top"
|
||||||
|
color="red"
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{locationOpen && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4}>
|
||||||
|
<Flex align="end" gap={5}>
|
||||||
|
<div style={{ flexGrow: '1' }}>
|
||||||
|
<StandaloneField
|
||||||
|
fieldDefinition={{
|
||||||
|
field_type: 'related field',
|
||||||
|
model: ModelType.stocklocation,
|
||||||
|
api_url: apiUrl(ApiEndpoints.stock_location_list),
|
||||||
|
filters: {
|
||||||
|
structural: false
|
||||||
|
},
|
||||||
|
onValueChange: (value) => {
|
||||||
|
setLocation(value);
|
||||||
|
},
|
||||||
|
description: locationDescription,
|
||||||
|
value: location,
|
||||||
|
label: t`Location`,
|
||||||
|
icon: <InvenTreeIcon icon="location" />
|
||||||
|
}}
|
||||||
|
defaultValue={
|
||||||
|
record.destination ??
|
||||||
|
(record.destination_detail
|
||||||
|
? record.destination_detail.pk
|
||||||
|
: null)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Flex style={{ marginBottom: '7px' }}>
|
||||||
|
{(record.part_detail.default_location ||
|
||||||
|
record.part_detail.category_default_location) && (
|
||||||
|
<ActionButton
|
||||||
|
icon={<InvenTreeIcon icon="default_location" />}
|
||||||
|
tooltip={t`Store at default location`}
|
||||||
|
onClick={() =>
|
||||||
|
setLocation(
|
||||||
|
record.part_detail.default_location ??
|
||||||
|
record.part_detail.category_default_location
|
||||||
|
)
|
||||||
|
}
|
||||||
|
tooltipAlignment="top"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{record.destination && (
|
||||||
|
<ActionButton
|
||||||
|
icon={<InvenTreeIcon icon="destination" />}
|
||||||
|
tooltip={t`Store at line item destination `}
|
||||||
|
onClick={() => setLocation(record.destination)}
|
||||||
|
tooltipAlignment="top"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!record.destination &&
|
||||||
|
record.destination_detail &&
|
||||||
|
record.received > 0 && (
|
||||||
|
<ActionButton
|
||||||
|
icon={<InvenTreeIcon icon="repeat_destination" />}
|
||||||
|
tooltip={t`Store with already received stock`}
|
||||||
|
onClick={() => setLocation(record.destination_detail.pk)}
|
||||||
|
tooltipAlignment="top"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(6, 1fr)',
|
||||||
|
gridTemplateRows: 'auto',
|
||||||
|
alignItems: 'end'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<InvenTreeIcon icon="downleft" />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{batchOpen && (
|
||||||
|
<>
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4}>
|
||||||
|
<Flex align="end" gap={5}>
|
||||||
|
<div style={{ flexGrow: '1' }}>
|
||||||
|
<StandaloneField
|
||||||
|
fieldDefinition={{
|
||||||
|
field_type: 'string',
|
||||||
|
onValueChange: (value) => setBatchCode(value),
|
||||||
|
label: 'Batch Code',
|
||||||
|
value: batchCode
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Flex>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(6, 1fr)',
|
||||||
|
gridTemplateRows: 'auto',
|
||||||
|
alignItems: 'end'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span></span>
|
||||||
|
<InvenTreeIcon icon="downleft" />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{record.trackable && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4}>
|
||||||
|
<Flex align="end" gap={5}>
|
||||||
|
<div style={{ flexGrow: '1' }}>
|
||||||
|
<StandaloneField
|
||||||
|
fieldDefinition={{
|
||||||
|
field_type: 'string',
|
||||||
|
onValueChange: (value) => setSerials(value),
|
||||||
|
label: 'Serial numbers',
|
||||||
|
value: serials
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Flex>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(6, 1fr)',
|
||||||
|
gridTemplateRows: 'auto',
|
||||||
|
alignItems: 'end'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span></span>
|
||||||
|
<InvenTreeIcon icon="downleft" />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{statusOpen && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4}>
|
||||||
|
<StandaloneField
|
||||||
|
fieldDefinition={{
|
||||||
|
field_type: 'choice',
|
||||||
|
api_url: apiUrl(ApiEndpoints.stock_status),
|
||||||
|
choices: statuses,
|
||||||
|
label: 'Status',
|
||||||
|
onValueChange: (value) =>
|
||||||
|
input.changeFn(input.idx, 'status', value)
|
||||||
|
}}
|
||||||
|
defaultValue={10}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(6, 1fr)',
|
||||||
|
gridTemplateRows: 'auto',
|
||||||
|
alignItems: 'end'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
<InvenTreeIcon icon="downleft" />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type LineFormHandlers = {
|
||||||
|
onOpen?: () => void;
|
||||||
|
onClose?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LineItemsForm = {
|
||||||
|
items: any[];
|
||||||
|
orderPk: number;
|
||||||
|
formProps?: LineFormHandlers;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useReceiveLineItems(props: LineItemsForm) {
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['stock', 'status'],
|
||||||
|
queryFn: async () => {
|
||||||
|
return api.get(apiUrl(ApiEndpoints.stock_status)).then((response) => {
|
||||||
|
if (response.status === 200) {
|
||||||
|
const entries = Object.values(response.data.values);
|
||||||
|
const mapped = entries.map((item: any) => {
|
||||||
|
return {
|
||||||
|
value: item.key,
|
||||||
|
display_name: item.label
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const records = Object.fromEntries(
|
||||||
|
props.items.map((item) => [item.pk, item])
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredItems = props.items.filter(
|
||||||
|
(elem) => elem.quantity !== elem.received
|
||||||
|
);
|
||||||
|
|
||||||
|
const fields: ApiFormFieldSet = {
|
||||||
|
id: {
|
||||||
|
value: props.orderPk,
|
||||||
|
hidden: true
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
field_type: 'table',
|
||||||
|
value: filteredItems.map((elem, idx) => {
|
||||||
|
return {
|
||||||
|
line_item: elem.pk,
|
||||||
|
location: elem.destination ?? elem.destination_detail?.pk ?? null,
|
||||||
|
quantity: elem.quantity - elem.received,
|
||||||
|
batch_code: '',
|
||||||
|
serial_numbers: '',
|
||||||
|
status: 10,
|
||||||
|
barcode: null
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
modelRenderer: (instance) => {
|
||||||
|
const record = records[instance.item.line_item];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LineItemFormRow
|
||||||
|
input={instance}
|
||||||
|
record={record}
|
||||||
|
statuses={data}
|
||||||
|
key={record.pk}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
headers: ['Part', 'SKU', 'Received', 'Quantity to receive', 'Actions']
|
||||||
|
},
|
||||||
|
location: {
|
||||||
|
filters: {
|
||||||
|
structural: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = apiUrl(ApiEndpoints.purchase_order_receive, null, {
|
||||||
|
id: props.orderPk
|
||||||
|
});
|
||||||
|
|
||||||
|
return useCreateApiFormModal({
|
||||||
|
...props.formProps,
|
||||||
|
url: url,
|
||||||
|
title: t`Receive line items`,
|
||||||
|
fields: fields,
|
||||||
|
initialData: {
|
||||||
|
location: null
|
||||||
|
},
|
||||||
|
size: 'max(60%,800px)'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
@ -1,12 +1,28 @@
|
|||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { useMemo, useState } from 'react';
|
import { Flex, NumberInput, Skeleton, Text } from '@mantine/core';
|
||||||
|
import { modals } from '@mantine/modals';
|
||||||
|
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
|
||||||
|
import { Suspense, useCallback, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { api } from '../App';
|
||||||
|
import { ActionButton } from '../components/buttons/ActionButton';
|
||||||
import {
|
import {
|
||||||
ApiFormAdjustFilterType,
|
ApiFormAdjustFilterType,
|
||||||
ApiFormFieldSet
|
ApiFormFieldSet
|
||||||
} from '../components/forms/fields/ApiFormField';
|
} from '../components/forms/fields/ApiFormField';
|
||||||
|
import { Thumbnail } from '../components/images/Thumbnail';
|
||||||
|
import { StylishText } from '../components/items/StylishText';
|
||||||
|
import { StatusRenderer } from '../components/render/StatusRenderer';
|
||||||
import { ApiEndpoints } from '../enums/ApiEndpoints';
|
import { ApiEndpoints } from '../enums/ApiEndpoints';
|
||||||
import { useCreateApiFormModal, useEditApiFormModal } from '../hooks/UseForm';
|
import { ModelType } from '../enums/ModelType';
|
||||||
|
import { InvenTreeIcon } from '../functions/icons';
|
||||||
|
import {
|
||||||
|
ApiFormModalProps,
|
||||||
|
useCreateApiFormModal,
|
||||||
|
useDeleteApiFormModal,
|
||||||
|
useEditApiFormModal
|
||||||
|
} from '../hooks/UseForm';
|
||||||
|
import { apiUrl } from '../states/ApiState';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Construct a set of fields for creating / editing a StockItem instance
|
* Construct a set of fields for creating / editing a StockItem instance
|
||||||
@ -144,6 +160,651 @@ export function useEditStockItem({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StockItemDefaultMove({
|
||||||
|
stockItem,
|
||||||
|
value
|
||||||
|
}: {
|
||||||
|
stockItem: any;
|
||||||
|
value: any;
|
||||||
|
}) {
|
||||||
|
console.log('item', stockItem);
|
||||||
|
const { data } = useSuspenseQuery({
|
||||||
|
queryKey: [
|
||||||
|
'location',
|
||||||
|
stockItem.part_detail.default_location ??
|
||||||
|
stockItem.part_detail.category_default_location
|
||||||
|
],
|
||||||
|
queryFn: async () => {
|
||||||
|
const url = apiUrl(
|
||||||
|
ApiEndpoints.stock_location_list,
|
||||||
|
stockItem.part_detail.default_location ??
|
||||||
|
stockItem.part_detail.category_default_location
|
||||||
|
);
|
||||||
|
|
||||||
|
return api
|
||||||
|
.get(url)
|
||||||
|
.then((response) => {
|
||||||
|
switch (response.status) {
|
||||||
|
case 200:
|
||||||
|
return response.data;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex gap="sm" justify="space-evenly" align="center">
|
||||||
|
<Flex gap="sm" direction="column" align="center">
|
||||||
|
<Text>
|
||||||
|
{value} x {stockItem.part_detail.name}
|
||||||
|
</Text>
|
||||||
|
<Thumbnail
|
||||||
|
src={stockItem.part_detail.thumbnail}
|
||||||
|
size={80}
|
||||||
|
align="center"
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
<Flex direction="column" gap="sm" align="center">
|
||||||
|
<Text>{stockItem.location_detail.pathstring}</Text>
|
||||||
|
<InvenTreeIcon icon="arrow_down" />
|
||||||
|
<Suspense fallback={<Skeleton width="150px" />}>
|
||||||
|
<Text>{data?.pathstring}</Text>
|
||||||
|
</Suspense>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveToDefault(
|
||||||
|
stockItem: any,
|
||||||
|
value: StockItemQuantity,
|
||||||
|
refresh: () => void
|
||||||
|
) {
|
||||||
|
modals.openConfirmModal({
|
||||||
|
title: <StylishText>Confirm Stock Transfer</StylishText>,
|
||||||
|
children: <StockItemDefaultMove stockItem={stockItem} value={value} />,
|
||||||
|
onConfirm: () => {
|
||||||
|
if (
|
||||||
|
stockItem.location === stockItem.part_detail.default_location ||
|
||||||
|
stockItem.location === stockItem.part_detail.category_default_location
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
api
|
||||||
|
.post(apiUrl(ApiEndpoints.stock_transfer), {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
pk: stockItem.pk,
|
||||||
|
quantity: value,
|
||||||
|
batch: stockItem.batch,
|
||||||
|
status: stockItem.status
|
||||||
|
}
|
||||||
|
],
|
||||||
|
location:
|
||||||
|
stockItem.part_detail.default_location ??
|
||||||
|
stockItem.part_detail.category_default_location
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
refresh();
|
||||||
|
return response.data;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type StockAdjustmentItemWithRecord = {
|
||||||
|
obj: any;
|
||||||
|
} & StockAdjustmentItem;
|
||||||
|
|
||||||
|
type TableFieldRefreshFn = (idx: number) => void;
|
||||||
|
type TableFieldChangeFn = (idx: number, key: string, value: any) => void;
|
||||||
|
|
||||||
|
type StockRow = {
|
||||||
|
item: StockAdjustmentItemWithRecord;
|
||||||
|
idx: number;
|
||||||
|
changeFn: TableFieldChangeFn;
|
||||||
|
removeFn: TableFieldRefreshFn;
|
||||||
|
};
|
||||||
|
|
||||||
|
function StockOperationsRow({
|
||||||
|
input,
|
||||||
|
transfer = false,
|
||||||
|
add = false,
|
||||||
|
setMax = false,
|
||||||
|
merge = false,
|
||||||
|
record
|
||||||
|
}: {
|
||||||
|
input: StockRow;
|
||||||
|
transfer?: boolean;
|
||||||
|
add?: boolean;
|
||||||
|
setMax?: boolean;
|
||||||
|
merge?: boolean;
|
||||||
|
record?: any;
|
||||||
|
}) {
|
||||||
|
const item = input.item;
|
||||||
|
|
||||||
|
console.log('rec', record);
|
||||||
|
|
||||||
|
const [value, setValue] = useState<StockItemQuantity>(
|
||||||
|
add ? 0 : item.quantity ?? 0
|
||||||
|
);
|
||||||
|
|
||||||
|
const onChange = useCallback(
|
||||||
|
(value: any) => {
|
||||||
|
setValue(value);
|
||||||
|
input.changeFn(input.idx, 'quantity', value);
|
||||||
|
},
|
||||||
|
[item]
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeAndRefresh = () => {
|
||||||
|
input.removeFn(input.idx);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<Flex gap="sm" align="center">
|
||||||
|
<Thumbnail
|
||||||
|
size={40}
|
||||||
|
src={record.part_detail.thumbnail}
|
||||||
|
align="center"
|
||||||
|
/>
|
||||||
|
<div>{record.part_detail.name}</div>
|
||||||
|
</Flex>
|
||||||
|
</td>
|
||||||
|
<td>{record.location ? record.location_detail.pathstring : '-'}</td>
|
||||||
|
<td>
|
||||||
|
<Flex align="center" gap="xs">
|
||||||
|
<Text>{record.quantity}</Text>
|
||||||
|
<StatusRenderer status={record.status} type={ModelType.stockitem} />
|
||||||
|
</Flex>
|
||||||
|
</td>
|
||||||
|
{!merge && (
|
||||||
|
<td>
|
||||||
|
<NumberInput
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
max={setMax ? record.quantity : undefined}
|
||||||
|
min={0}
|
||||||
|
style={{ maxWidth: '100px' }}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
<td>
|
||||||
|
<Flex gap="3px">
|
||||||
|
{transfer && (
|
||||||
|
<ActionButton
|
||||||
|
onClick={() => moveToDefault(record, value, removeAndRefresh)}
|
||||||
|
icon={<InvenTreeIcon icon="default_location" />}
|
||||||
|
tooltip={t`Move to default location`}
|
||||||
|
tooltipAlignment="top"
|
||||||
|
disabled={
|
||||||
|
!record.part_detail.default_location &&
|
||||||
|
!record.part_detail.category_default_location
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ActionButton
|
||||||
|
onClick={() => input.removeFn(input.idx)}
|
||||||
|
icon={<InvenTreeIcon icon="square_x" />}
|
||||||
|
tooltip={t`Remove item from list`}
|
||||||
|
tooltipAlignment="top"
|
||||||
|
color="red"
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type StockItemQuantity = number | '' | undefined;
|
||||||
|
|
||||||
|
type StockAdjustmentItem = {
|
||||||
|
pk: number;
|
||||||
|
quantity: StockItemQuantity;
|
||||||
|
batch?: string;
|
||||||
|
status?: number | '' | null;
|
||||||
|
packaging?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function mapAdjustmentItems(items: any[]) {
|
||||||
|
const mappedItems: StockAdjustmentItemWithRecord[] = items.map((elem) => {
|
||||||
|
return {
|
||||||
|
pk: elem.pk,
|
||||||
|
quantity: elem.quantity,
|
||||||
|
batch: elem.batch,
|
||||||
|
status: elem.status,
|
||||||
|
packaging: elem.packaging,
|
||||||
|
obj: elem
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return mappedItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stockTransferFields(items: any[]): ApiFormFieldSet {
|
||||||
|
if (!items) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const records = Object.fromEntries(items.map((item) => [item.pk, item]));
|
||||||
|
|
||||||
|
const fields: ApiFormFieldSet = {
|
||||||
|
items: {
|
||||||
|
field_type: 'table',
|
||||||
|
value: mapAdjustmentItems(items),
|
||||||
|
modelRenderer: (val) => {
|
||||||
|
return (
|
||||||
|
<StockOperationsRow
|
||||||
|
input={val}
|
||||||
|
transfer
|
||||||
|
setMax
|
||||||
|
key={val.item.pk}
|
||||||
|
record={records[val.item.pk]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
headers: [t`Part`, t`Location`, t`In Stock`, t`Move`, t`Actions`]
|
||||||
|
},
|
||||||
|
location: {
|
||||||
|
filters: {
|
||||||
|
structural: false
|
||||||
|
}
|
||||||
|
// TODO: icon
|
||||||
|
},
|
||||||
|
notes: {}
|
||||||
|
};
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stockRemoveFields(items: any[]): ApiFormFieldSet {
|
||||||
|
if (!items) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const records = Object.fromEntries(items.map((item) => [item.pk, item]));
|
||||||
|
|
||||||
|
const fields: ApiFormFieldSet = {
|
||||||
|
items: {
|
||||||
|
field_type: 'table',
|
||||||
|
value: mapAdjustmentItems(items),
|
||||||
|
modelRenderer: (val) => {
|
||||||
|
return (
|
||||||
|
<StockOperationsRow
|
||||||
|
input={val}
|
||||||
|
setMax
|
||||||
|
key={val.item.pk}
|
||||||
|
record={records[val.item.pk]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
headers: [t`Part`, t`Location`, t`In Stock`, t`Remove`, t`Actions`]
|
||||||
|
},
|
||||||
|
notes: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stockAddFields(items: any[]): ApiFormFieldSet {
|
||||||
|
if (!items) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const records = Object.fromEntries(items.map((item) => [item.pk, item]));
|
||||||
|
|
||||||
|
const fields: ApiFormFieldSet = {
|
||||||
|
items: {
|
||||||
|
field_type: 'table',
|
||||||
|
value: mapAdjustmentItems(items),
|
||||||
|
modelRenderer: (val) => {
|
||||||
|
return (
|
||||||
|
<StockOperationsRow
|
||||||
|
input={val}
|
||||||
|
add
|
||||||
|
key={val.item.pk}
|
||||||
|
record={records[val.item.pk]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
headers: [t`Part`, t`Location`, t`In Stock`, t`Add`, t`Actions`]
|
||||||
|
},
|
||||||
|
notes: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stockCountFields(items: any[]): ApiFormFieldSet {
|
||||||
|
if (!items) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const records = Object.fromEntries(items.map((item) => [item.pk, item]));
|
||||||
|
|
||||||
|
const fields: ApiFormFieldSet = {
|
||||||
|
items: {
|
||||||
|
field_type: 'table',
|
||||||
|
value: mapAdjustmentItems(items),
|
||||||
|
modelRenderer: (val) => {
|
||||||
|
return (
|
||||||
|
<StockOperationsRow
|
||||||
|
input={val}
|
||||||
|
key={val.item.pk}
|
||||||
|
record={records[val.item.pk]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
headers: [t`Part`, t`Location`, t`In Stock`, t`Count`, t`Actions`]
|
||||||
|
},
|
||||||
|
notes: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stockChangeStatusFields(items: any[]): ApiFormFieldSet {
|
||||||
|
if (!items) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const records = Object.fromEntries(items.map((item) => [item.pk, item]));
|
||||||
|
|
||||||
|
const fields: ApiFormFieldSet = {
|
||||||
|
items: {
|
||||||
|
field_type: 'table',
|
||||||
|
value: items.map((elem) => {
|
||||||
|
return elem.pk;
|
||||||
|
}),
|
||||||
|
modelRenderer: (val) => {
|
||||||
|
return (
|
||||||
|
<StockOperationsRow
|
||||||
|
input={val}
|
||||||
|
key={val.item}
|
||||||
|
merge
|
||||||
|
record={records[val.item]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
headers: [t`Part`, t`Location`, t`In Stock`, t`Actions`]
|
||||||
|
},
|
||||||
|
status: {},
|
||||||
|
note: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stockMergeFields(items: any[]): ApiFormFieldSet {
|
||||||
|
if (!items) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const records = Object.fromEntries(items.map((item) => [item.pk, item]));
|
||||||
|
|
||||||
|
const fields: ApiFormFieldSet = {
|
||||||
|
items: {
|
||||||
|
field_type: 'table',
|
||||||
|
value: items.map((elem) => {
|
||||||
|
return {
|
||||||
|
item: elem.pk,
|
||||||
|
obj: elem
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
modelRenderer: (val) => {
|
||||||
|
return (
|
||||||
|
<StockOperationsRow
|
||||||
|
input={val}
|
||||||
|
key={val.item.item}
|
||||||
|
merge
|
||||||
|
record={records[val.item.item]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
headers: [t`Part`, t`Location`, t`In Stock`, t`Actions`]
|
||||||
|
},
|
||||||
|
location: {
|
||||||
|
default: items[0]?.part_detail.default_location,
|
||||||
|
filters: {
|
||||||
|
structural: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
notes: {},
|
||||||
|
allow_mismatched_suppliers: {},
|
||||||
|
allow_mismatched_status: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stockAssignFields(items: any[]): ApiFormFieldSet {
|
||||||
|
if (!items) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const records = Object.fromEntries(items.map((item) => [item.pk, item]));
|
||||||
|
|
||||||
|
const fields: ApiFormFieldSet = {
|
||||||
|
items: {
|
||||||
|
field_type: 'table',
|
||||||
|
value: items.map((elem) => {
|
||||||
|
return {
|
||||||
|
item: elem.pk,
|
||||||
|
obj: elem
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
modelRenderer: (val) => {
|
||||||
|
return (
|
||||||
|
<StockOperationsRow
|
||||||
|
input={val}
|
||||||
|
key={val.item.item}
|
||||||
|
merge
|
||||||
|
record={records[val.item.item]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
headers: [t`Part`, t`Location`, t`In Stock`, t`Actions`]
|
||||||
|
},
|
||||||
|
customer: {
|
||||||
|
filters: {
|
||||||
|
is_customer: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
notes: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stockDeleteFields(items: any[]): ApiFormFieldSet {
|
||||||
|
if (!items) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const records = Object.fromEntries(items.map((item) => [item.pk, item]));
|
||||||
|
|
||||||
|
const fields: ApiFormFieldSet = {
|
||||||
|
items: {
|
||||||
|
field_type: 'table',
|
||||||
|
value: items.map((elem) => {
|
||||||
|
return elem.pk;
|
||||||
|
}),
|
||||||
|
modelRenderer: (val) => {
|
||||||
|
return (
|
||||||
|
<StockOperationsRow
|
||||||
|
input={val}
|
||||||
|
key={val.item}
|
||||||
|
merge
|
||||||
|
record={records[val.item]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
headers: [t`Part`, t`Location`, t`In Stock`, t`Actions`]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiModalFunc = (props: ApiFormModalProps) => {
|
||||||
|
open: () => void;
|
||||||
|
close: () => void;
|
||||||
|
toggle: () => void;
|
||||||
|
modal: JSX.Element;
|
||||||
|
};
|
||||||
|
|
||||||
|
function stockOperationModal({
|
||||||
|
items,
|
||||||
|
pk,
|
||||||
|
model,
|
||||||
|
refresh,
|
||||||
|
fieldGenerator,
|
||||||
|
endpoint,
|
||||||
|
title,
|
||||||
|
modalFunc = useCreateApiFormModal
|
||||||
|
}: {
|
||||||
|
items?: object;
|
||||||
|
pk?: number;
|
||||||
|
model: ModelType | string;
|
||||||
|
refresh: () => void;
|
||||||
|
fieldGenerator: (items: any[]) => ApiFormFieldSet;
|
||||||
|
endpoint: ApiEndpoints;
|
||||||
|
title: string;
|
||||||
|
modalFunc?: apiModalFunc;
|
||||||
|
}) {
|
||||||
|
const params: any = {
|
||||||
|
part_detail: true,
|
||||||
|
location_detail: true,
|
||||||
|
cascade: false
|
||||||
|
};
|
||||||
|
|
||||||
|
// A Stock item can have location=null, but not part=null
|
||||||
|
params[model] = pk === undefined && model === 'location' ? 'null' : pk;
|
||||||
|
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['stockitems', model, pk, items],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (items) {
|
||||||
|
return Array.isArray(items) ? items : [items];
|
||||||
|
}
|
||||||
|
const url = apiUrl(ApiEndpoints.stock_item_list);
|
||||||
|
|
||||||
|
return api
|
||||||
|
.get(url, {
|
||||||
|
params: params
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
if (response.status === 200) {
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const fields = useMemo(() => {
|
||||||
|
return fieldGenerator(data);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
return modalFunc({
|
||||||
|
url: endpoint,
|
||||||
|
fields: fields,
|
||||||
|
title: title,
|
||||||
|
onFormSuccess: () => refresh()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StockOperationProps = {
|
||||||
|
items?: object;
|
||||||
|
pk?: number;
|
||||||
|
model: ModelType.stockitem | 'location' | ModelType.part;
|
||||||
|
refresh: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useAddStockItem(props: StockOperationProps) {
|
||||||
|
return stockOperationModal({
|
||||||
|
...props,
|
||||||
|
fieldGenerator: stockAddFields,
|
||||||
|
endpoint: ApiEndpoints.stock_add,
|
||||||
|
title: t`Add Stock`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRemoveStockItem(props: StockOperationProps) {
|
||||||
|
return stockOperationModal({
|
||||||
|
...props,
|
||||||
|
fieldGenerator: stockRemoveFields,
|
||||||
|
endpoint: ApiEndpoints.stock_remove,
|
||||||
|
title: t`Remove Stock`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTransferStockItem(props: StockOperationProps) {
|
||||||
|
return stockOperationModal({
|
||||||
|
...props,
|
||||||
|
fieldGenerator: stockTransferFields,
|
||||||
|
endpoint: ApiEndpoints.stock_transfer,
|
||||||
|
title: t`Transfer Stock`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCountStockItem(props: StockOperationProps) {
|
||||||
|
return stockOperationModal({
|
||||||
|
...props,
|
||||||
|
fieldGenerator: stockCountFields,
|
||||||
|
endpoint: ApiEndpoints.stock_count,
|
||||||
|
title: t`Count Stock`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChangeStockStatus(props: StockOperationProps) {
|
||||||
|
return stockOperationModal({
|
||||||
|
...props,
|
||||||
|
fieldGenerator: stockChangeStatusFields,
|
||||||
|
endpoint: ApiEndpoints.stock_change_status,
|
||||||
|
title: t`Change Stock Status`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMergeStockItem(props: StockOperationProps) {
|
||||||
|
return stockOperationModal({
|
||||||
|
...props,
|
||||||
|
fieldGenerator: stockMergeFields,
|
||||||
|
endpoint: ApiEndpoints.stock_merge,
|
||||||
|
title: t`Merge Stock`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAssignStockItem(props: StockOperationProps) {
|
||||||
|
return stockOperationModal({
|
||||||
|
...props,
|
||||||
|
fieldGenerator: stockAssignFields,
|
||||||
|
endpoint: ApiEndpoints.stock_assign,
|
||||||
|
title: `Assign Stock to Customer`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteStockItem(props: StockOperationProps) {
|
||||||
|
return stockOperationModal({
|
||||||
|
...props,
|
||||||
|
fieldGenerator: stockDeleteFields,
|
||||||
|
endpoint: ApiEndpoints.stock_item_list,
|
||||||
|
modalFunc: useDeleteApiFormModal,
|
||||||
|
title: t`Delete Stock Items`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function stockLocationFields({}: {}): ApiFormFieldSet {
|
export function stockLocationFields({}: {}): ApiFormFieldSet {
|
||||||
let fields: ApiFormFieldSet = {
|
let fields: ApiFormFieldSet = {
|
||||||
parent: {
|
parent: {
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
Icon123,
|
Icon123,
|
||||||
|
IconArrowMerge,
|
||||||
IconBinaryTree2,
|
IconBinaryTree2,
|
||||||
IconBookmarks,
|
IconBookmarks,
|
||||||
IconBox,
|
IconBox,
|
||||||
@ -10,13 +11,19 @@ import {
|
|||||||
IconCalendarStats,
|
IconCalendarStats,
|
||||||
IconCategory,
|
IconCategory,
|
||||||
IconCheck,
|
IconCheck,
|
||||||
|
IconCircleMinus,
|
||||||
|
IconCirclePlus,
|
||||||
IconClipboardList,
|
IconClipboardList,
|
||||||
|
IconClipboardText,
|
||||||
IconCopy,
|
IconCopy,
|
||||||
|
IconCornerDownLeft,
|
||||||
IconCornerUpRightDouble,
|
IconCornerUpRightDouble,
|
||||||
IconCurrencyDollar,
|
IconCurrencyDollar,
|
||||||
|
IconDots,
|
||||||
IconDotsCircleHorizontal,
|
IconDotsCircleHorizontal,
|
||||||
IconExternalLink,
|
IconExternalLink,
|
||||||
IconFileUpload,
|
IconFileUpload,
|
||||||
|
IconFlagShare,
|
||||||
IconGitBranch,
|
IconGitBranch,
|
||||||
IconGridDots,
|
IconGridDots,
|
||||||
IconHash,
|
IconHash,
|
||||||
@ -27,6 +34,7 @@ import {
|
|||||||
IconMail,
|
IconMail,
|
||||||
IconMapPin,
|
IconMapPin,
|
||||||
IconMapPinHeart,
|
IconMapPinHeart,
|
||||||
|
IconMinusVertical,
|
||||||
IconNotes,
|
IconNotes,
|
||||||
IconNumbers,
|
IconNumbers,
|
||||||
IconPackage,
|
IconPackage,
|
||||||
@ -35,7 +43,9 @@ import {
|
|||||||
IconPaperclip,
|
IconPaperclip,
|
||||||
IconPhone,
|
IconPhone,
|
||||||
IconPhoto,
|
IconPhoto,
|
||||||
|
IconPrinter,
|
||||||
IconProgressCheck,
|
IconProgressCheck,
|
||||||
|
IconQrcode,
|
||||||
IconQuestionMark,
|
IconQuestionMark,
|
||||||
IconRulerMeasure,
|
IconRulerMeasure,
|
||||||
IconShoppingCart,
|
IconShoppingCart,
|
||||||
@ -47,9 +57,11 @@ import {
|
|||||||
IconTestPipe,
|
IconTestPipe,
|
||||||
IconTool,
|
IconTool,
|
||||||
IconTools,
|
IconTools,
|
||||||
|
IconTransfer,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
IconTruck,
|
IconTruck,
|
||||||
IconTruckDelivery,
|
IconTruckDelivery,
|
||||||
|
IconUnlink,
|
||||||
IconUser,
|
IconUser,
|
||||||
IconUserStar,
|
IconUserStar,
|
||||||
IconUsersGroup,
|
IconUsersGroup,
|
||||||
@ -59,6 +71,9 @@ import {
|
|||||||
IconX
|
IconX
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { IconFlag } from '@tabler/icons-react';
|
import { IconFlag } from '@tabler/icons-react';
|
||||||
|
import { IconSquareXFilled } from '@tabler/icons-react';
|
||||||
|
import { IconShoppingCartPlus } from '@tabler/icons-react';
|
||||||
|
import { IconArrowBigDownLineFilled } from '@tabler/icons-react';
|
||||||
import { IconTruckReturn } from '@tabler/icons-react';
|
import { IconTruckReturn } from '@tabler/icons-react';
|
||||||
import { IconInfoCircle } from '@tabler/icons-react';
|
import { IconInfoCircle } from '@tabler/icons-react';
|
||||||
import { IconCalendarTime } from '@tabler/icons-react';
|
import { IconCalendarTime } from '@tabler/icons-react';
|
||||||
@ -127,6 +142,8 @@ const icons = {
|
|||||||
creation_date: IconCalendarTime,
|
creation_date: IconCalendarTime,
|
||||||
location: IconMapPin,
|
location: IconMapPin,
|
||||||
default_location: IconMapPinHeart,
|
default_location: IconMapPinHeart,
|
||||||
|
category_default_location: IconMapPinHeart,
|
||||||
|
parent_default_location: IconMapPinHeart,
|
||||||
default_supplier: IconShoppingCartHeart,
|
default_supplier: IconShoppingCartHeart,
|
||||||
link: IconLink,
|
link: IconLink,
|
||||||
responsible: IconUserStar,
|
responsible: IconUserStar,
|
||||||
@ -137,13 +154,30 @@ const icons = {
|
|||||||
group: IconUsersGroup,
|
group: IconUsersGroup,
|
||||||
check: IconCheck,
|
check: IconCheck,
|
||||||
copy: IconCopy,
|
copy: IconCopy,
|
||||||
|
square_x: IconSquareXFilled,
|
||||||
|
arrow_down: IconArrowBigDownLineFilled,
|
||||||
|
transfer: IconTransfer,
|
||||||
|
actions: IconDots,
|
||||||
|
reports: IconPrinter,
|
||||||
|
buy: IconShoppingCartPlus,
|
||||||
|
add: IconCirclePlus,
|
||||||
|
remove: IconCircleMinus,
|
||||||
|
merge: IconArrowMerge,
|
||||||
|
customer: IconUser,
|
||||||
quantity: IconNumbers,
|
quantity: IconNumbers,
|
||||||
progress: IconProgressCheck,
|
progress: IconProgressCheck,
|
||||||
reference: IconHash,
|
reference: IconHash,
|
||||||
website: IconWorld,
|
website: IconWorld,
|
||||||
email: IconMail,
|
email: IconMail,
|
||||||
phone: IconPhone,
|
phone: IconPhone,
|
||||||
sitemap: IconSitemap
|
sitemap: IconSitemap,
|
||||||
|
downleft: IconCornerDownLeft,
|
||||||
|
barcode: IconQrcode,
|
||||||
|
barLine: IconMinusVertical,
|
||||||
|
batch_code: IconClipboardText,
|
||||||
|
destination: IconFlag,
|
||||||
|
repeat_destination: IconFlagShare,
|
||||||
|
unlink: IconUnlink
|
||||||
};
|
};
|
||||||
|
|
||||||
export type InvenTreeIconType = keyof typeof icons;
|
export type InvenTreeIconType = keyof typeof icons;
|
||||||
@ -167,6 +201,9 @@ export function InvenTreeIcon(props: IconProps) {
|
|||||||
if (props.icon in icons) {
|
if (props.icon in icons) {
|
||||||
Icon = GetIcon(props.icon);
|
Icon = GetIcon(props.icon);
|
||||||
} else {
|
} else {
|
||||||
|
console.warn(
|
||||||
|
`Icon name '${props.icon}' is not registered with the Icon manager`
|
||||||
|
);
|
||||||
Icon = IconQuestionMark;
|
Icon = IconQuestionMark;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { Alert, Divider, Stack } from '@mantine/core';
|
import { Alert, Divider, MantineNumberSize, Stack } from '@mantine/core';
|
||||||
import { useId } from '@mantine/hooks';
|
import { useId } from '@mantine/hooks';
|
||||||
import { useEffect, useMemo, useRef } from 'react';
|
import { useEffect, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
@ -20,6 +20,7 @@ export interface ApiFormModalProps extends ApiFormProps {
|
|||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
onOpen?: () => void;
|
onOpen?: () => void;
|
||||||
closeOnClickOutside?: boolean;
|
closeOnClickOutside?: boolean;
|
||||||
|
size?: MantineNumberSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -59,7 +60,7 @@ export function useApiFormModal(props: ApiFormModalProps) {
|
|||||||
onOpen: formProps.onOpen,
|
onOpen: formProps.onOpen,
|
||||||
onClose: formProps.onClose,
|
onClose: formProps.onClose,
|
||||||
closeOnClickOutside: formProps.closeOnClickOutside,
|
closeOnClickOutside: formProps.closeOnClickOutside,
|
||||||
size: 'xl',
|
size: props.size ?? 'xl',
|
||||||
children: (
|
children: (
|
||||||
<Stack spacing={'xs'}>
|
<Stack spacing={'xs'}>
|
||||||
<Divider />
|
<Divider />
|
||||||
@ -125,7 +126,7 @@ export function useDeleteApiFormModal(props: ApiFormModalProps) {
|
|||||||
color={'red'}
|
color={'red'}
|
||||||
>{t`Are you sure you want to delete this item?`}</Alert>
|
>{t`Are you sure you want to delete this item?`}</Alert>
|
||||||
),
|
),
|
||||||
fields: {}
|
fields: props.fields ?? {}
|
||||||
}),
|
}),
|
||||||
[props]
|
[props]
|
||||||
);
|
);
|
||||||
|
@ -115,6 +115,20 @@ export default function CategoryDetail({}: {}) {
|
|||||||
name: 'structural',
|
name: 'structural',
|
||||||
label: t`Structural`,
|
label: t`Structural`,
|
||||||
icon: 'sitemap'
|
icon: 'sitemap'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'link',
|
||||||
|
name: 'parent_default_location',
|
||||||
|
label: t`Parent default location`,
|
||||||
|
model: ModelType.stocklocation,
|
||||||
|
hidden: !category.parent_default_location || category.default_location
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'link',
|
||||||
|
name: 'default_location',
|
||||||
|
label: t`Default location`,
|
||||||
|
model: ModelType.stocklocation,
|
||||||
|
hidden: !category.default_location
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -26,7 +26,6 @@ import {
|
|||||||
IconStack2,
|
IconStack2,
|
||||||
IconTestPipe,
|
IconTestPipe,
|
||||||
IconTools,
|
IconTools,
|
||||||
IconTransfer,
|
|
||||||
IconTruckDelivery,
|
IconTruckDelivery,
|
||||||
IconVersions
|
IconVersions
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
@ -58,6 +57,12 @@ 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 { usePartFields } from '../../forms/PartForms';
|
import { usePartFields } from '../../forms/PartForms';
|
||||||
|
import {
|
||||||
|
StockOperationProps,
|
||||||
|
useCountStockItem,
|
||||||
|
useTransferStockItem
|
||||||
|
} from '../../forms/StockForms';
|
||||||
|
import { InvenTreeIcon } from '../../functions/icons';
|
||||||
import { useEditApiFormModal } from '../../hooks/UseForm';
|
import { useEditApiFormModal } from '../../hooks/UseForm';
|
||||||
import { useInstance } from '../../hooks/UseInstance';
|
import { useInstance } from '../../hooks/UseInstance';
|
||||||
import { apiUrl } from '../../states/ApiState';
|
import { apiUrl } from '../../states/ApiState';
|
||||||
@ -131,6 +136,13 @@ export default function PartDetail() {
|
|||||||
model: ModelType.stocklocation,
|
model: ModelType.stocklocation,
|
||||||
hidden: !part.default_location
|
hidden: !part.default_location
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: 'link',
|
||||||
|
name: 'category_default_location',
|
||||||
|
label: t`Category Default Location`,
|
||||||
|
model: ModelType.stocklocation,
|
||||||
|
hidden: part.default_location || !part.category_default_location
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: 'string',
|
type: 'string',
|
||||||
name: 'IPN',
|
name: 'IPN',
|
||||||
@ -460,10 +472,10 @@ export default function PartDetail() {
|
|||||||
name: 'stock',
|
name: 'stock',
|
||||||
label: t`Stock`,
|
label: t`Stock`,
|
||||||
icon: <IconPackages />,
|
icon: <IconPackages />,
|
||||||
content: (
|
content: part.pk && (
|
||||||
<StockItemTable
|
<StockItemTable
|
||||||
params={{
|
params={{
|
||||||
part: part.pk ?? -1
|
part: part.pk
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@ -631,6 +643,17 @@ export default function PartDetail() {
|
|||||||
onFormSuccess: refreshInstance
|
onFormSuccess: refreshInstance
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const stockActionProps: StockOperationProps = useMemo(() => {
|
||||||
|
return {
|
||||||
|
pk: part.pk,
|
||||||
|
model: ModelType.part,
|
||||||
|
refresh: refreshInstance
|
||||||
|
};
|
||||||
|
}, [part]);
|
||||||
|
|
||||||
|
const countStockItems = useCountStockItem(stockActionProps);
|
||||||
|
const transferStockItems = useTransferStockItem(stockActionProps);
|
||||||
|
|
||||||
const partActions = useMemo(() => {
|
const partActions = useMemo(() => {
|
||||||
// TODO: Disable actions based on user permissions
|
// TODO: Disable actions based on user permissions
|
||||||
return [
|
return [
|
||||||
@ -651,14 +674,24 @@ export default function PartDetail() {
|
|||||||
icon={<IconPackages />}
|
icon={<IconPackages />}
|
||||||
actions={[
|
actions={[
|
||||||
{
|
{
|
||||||
icon: <IconClipboardList color="blue" />,
|
icon: (
|
||||||
|
<InvenTreeIcon icon="stocktake" iconProps={{ color: 'blue' }} />
|
||||||
|
),
|
||||||
name: t`Count Stock`,
|
name: t`Count Stock`,
|
||||||
tooltip: t`Count part stock`
|
tooltip: t`Count part stock`,
|
||||||
|
onClick: () => {
|
||||||
|
part.pk && countStockItems.open();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <IconTransfer color="blue" />,
|
icon: (
|
||||||
|
<InvenTreeIcon icon="transfer" iconProps={{ color: 'blue' }} />
|
||||||
|
),
|
||||||
name: t`Transfer Stock`,
|
name: t`Transfer Stock`,
|
||||||
tooltip: t`Transfer part stock`
|
tooltip: t`Transfer part stock`,
|
||||||
|
onClick: () => {
|
||||||
|
part.pk && transferStockItems.open();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
/>,
|
/>,
|
||||||
@ -704,6 +737,8 @@ export default function PartDetail() {
|
|||||||
actions={partActions}
|
actions={partActions}
|
||||||
/>
|
/>
|
||||||
<PanelGroup pageKey="part" panels={partPanels} />
|
<PanelGroup pageKey="part" panels={partPanels} />
|
||||||
|
{transferStockItems.modal}
|
||||||
|
{countStockItems.modal}
|
||||||
</Stack>
|
</Stack>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -9,11 +9,17 @@ import {
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { ActionButton } from '../../components/buttons/ActionButton';
|
||||||
import { DetailsField, DetailsTable } from '../../components/details/Details';
|
import { DetailsField, DetailsTable } from '../../components/details/Details';
|
||||||
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
|
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
|
||||||
import {
|
import {
|
||||||
ActionDropdown,
|
ActionDropdown,
|
||||||
EditItemAction
|
BarcodeActionDropdown,
|
||||||
|
DeleteItemAction,
|
||||||
|
EditItemAction,
|
||||||
|
LinkBarcodeAction,
|
||||||
|
UnlinkBarcodeAction,
|
||||||
|
ViewBarcodeAction
|
||||||
} from '../../components/items/ActionDropdown';
|
} from '../../components/items/ActionDropdown';
|
||||||
import { PageDetail } from '../../components/nav/PageDetail';
|
import { PageDetail } from '../../components/nav/PageDetail';
|
||||||
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
|
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
|
||||||
@ -21,10 +27,17 @@ import { StockLocationTree } from '../../components/nav/StockLocationTree';
|
|||||||
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 {
|
||||||
|
StockOperationProps,
|
||||||
|
stockLocationFields,
|
||||||
|
useCountStockItem,
|
||||||
|
useTransferStockItem
|
||||||
|
} from '../../forms/StockForms';
|
||||||
|
import { InvenTreeIcon } from '../../functions/icons';
|
||||||
import { useEditApiFormModal } from '../../hooks/UseForm';
|
import { useEditApiFormModal } from '../../hooks/UseForm';
|
||||||
import { useInstance } from '../../hooks/UseInstance';
|
import { useInstance } from '../../hooks/UseInstance';
|
||||||
import { useUserState } from '../../states/UserState';
|
import { useUserState } from '../../states/UserState';
|
||||||
|
import { PartListTable } from '../../tables/part/PartTable';
|
||||||
import { StockItemTable } from '../../tables/stock/StockItemTable';
|
import { StockItemTable } from '../../tables/stock/StockItemTable';
|
||||||
import { StockLocationTable } from '../../tables/stock/StockLocationTable';
|
import { StockLocationTable } from '../../tables/stock/StockLocationTable';
|
||||||
|
|
||||||
@ -154,6 +167,21 @@ export default function Stock() {
|
|||||||
label: t`Stock Locations`,
|
label: t`Stock Locations`,
|
||||||
icon: <IconSitemap />,
|
icon: <IconSitemap />,
|
||||||
content: <StockLocationTable parentId={id} />
|
content: <StockLocationTable parentId={id} />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'default_parts',
|
||||||
|
label: t`Default Parts`,
|
||||||
|
icon: <IconPackages />,
|
||||||
|
hidden: !location.pk,
|
||||||
|
content: (
|
||||||
|
<PartListTable
|
||||||
|
props={{
|
||||||
|
params: {
|
||||||
|
default_location: location.pk
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
}, [location, id]);
|
}, [location, id]);
|
||||||
@ -166,8 +194,79 @@ export default function Stock() {
|
|||||||
onFormSuccess: refreshInstance
|
onFormSuccess: refreshInstance
|
||||||
});
|
});
|
||||||
|
|
||||||
const locationActions = useMemo(() => {
|
const stockItemActionProps: StockOperationProps = useMemo(() => {
|
||||||
return [
|
return {
|
||||||
|
pk: location.pk,
|
||||||
|
model: 'location',
|
||||||
|
refresh: refreshInstance
|
||||||
|
};
|
||||||
|
}, [location]);
|
||||||
|
|
||||||
|
const transferStockItems = useTransferStockItem(stockItemActionProps);
|
||||||
|
const countStockItems = useCountStockItem(stockItemActionProps);
|
||||||
|
|
||||||
|
const locationActions = useMemo(
|
||||||
|
() => [
|
||||||
|
<ActionButton
|
||||||
|
icon={<InvenTreeIcon icon="stocktake" />}
|
||||||
|
variant="outline"
|
||||||
|
size="lg"
|
||||||
|
/>,
|
||||||
|
<BarcodeActionDropdown
|
||||||
|
actions={[
|
||||||
|
ViewBarcodeAction({}),
|
||||||
|
LinkBarcodeAction({}),
|
||||||
|
UnlinkBarcodeAction({}),
|
||||||
|
{
|
||||||
|
name: 'Scan in stock items',
|
||||||
|
icon: <InvenTreeIcon icon="stock" />,
|
||||||
|
tooltip: 'Scan items'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Scan in container',
|
||||||
|
icon: <InvenTreeIcon icon="unallocated_stock" />,
|
||||||
|
tooltip: 'Scan container'
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
<ActionDropdown
|
||||||
|
key="reports"
|
||||||
|
icon={<InvenTreeIcon icon="reports" />}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
name: 'Print Label',
|
||||||
|
icon: '',
|
||||||
|
tooltip: 'Print label'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Print Location Report',
|
||||||
|
icon: '',
|
||||||
|
tooltip: 'Print Report'
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
<ActionDropdown
|
||||||
|
key="operations"
|
||||||
|
icon={<InvenTreeIcon icon="stock" />}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
name: 'Count Stock',
|
||||||
|
icon: (
|
||||||
|
<InvenTreeIcon icon="stocktake" iconProps={{ color: 'blue' }} />
|
||||||
|
),
|
||||||
|
tooltip: 'Count Stock',
|
||||||
|
onClick: () => countStockItems.open()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Transfer Stock',
|
||||||
|
icon: (
|
||||||
|
<InvenTreeIcon icon="transfer" iconProps={{ color: 'blue' }} />
|
||||||
|
),
|
||||||
|
tooltip: 'Transfer Stock',
|
||||||
|
onClick: () => transferStockItems.open()
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
<ActionDropdown
|
<ActionDropdown
|
||||||
key="location"
|
key="location"
|
||||||
tooltip={t`Location Actions`}
|
tooltip={t`Location Actions`}
|
||||||
@ -180,8 +279,9 @@ export default function Stock() {
|
|||||||
})
|
})
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
];
|
],
|
||||||
}, [id, user]);
|
[location, id, user]
|
||||||
|
);
|
||||||
|
|
||||||
const breadcrumbs = useMemo(
|
const breadcrumbs = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@ -214,6 +314,8 @@ export default function Stock() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<PanelGroup pageKey="stocklocation" panels={locationPanels} />
|
<PanelGroup pageKey="stocklocation" panels={locationPanels} />
|
||||||
|
{transferStockItems.modal}
|
||||||
|
{countStockItems.modal}
|
||||||
</Stack>
|
</Stack>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -11,9 +11,6 @@ import {
|
|||||||
IconBookmark,
|
IconBookmark,
|
||||||
IconBoxPadding,
|
IconBoxPadding,
|
||||||
IconChecklist,
|
IconChecklist,
|
||||||
IconCircleCheck,
|
|
||||||
IconCircleMinus,
|
|
||||||
IconCirclePlus,
|
|
||||||
IconCopy,
|
IconCopy,
|
||||||
IconDots,
|
IconDots,
|
||||||
IconHistory,
|
IconHistory,
|
||||||
@ -21,8 +18,7 @@ import {
|
|||||||
IconNotes,
|
IconNotes,
|
||||||
IconPackages,
|
IconPackages,
|
||||||
IconPaperclip,
|
IconPaperclip,
|
||||||
IconSitemap,
|
IconSitemap
|
||||||
IconTransfer
|
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
@ -46,7 +42,15 @@ import { NotesEditor } from '../../components/widgets/MarkdownEditor';
|
|||||||
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 { useEditStockItem } from '../../forms/StockForms';
|
import {
|
||||||
|
StockOperationProps,
|
||||||
|
useAddStockItem,
|
||||||
|
useCountStockItem,
|
||||||
|
useEditStockItem,
|
||||||
|
useRemoveStockItem,
|
||||||
|
useTransferStockItem
|
||||||
|
} from '../../forms/StockForms';
|
||||||
|
import { InvenTreeIcon } from '../../functions/icons';
|
||||||
import { useInstance } from '../../hooks/UseInstance';
|
import { useInstance } from '../../hooks/UseInstance';
|
||||||
import { apiUrl } from '../../states/ApiState';
|
import { apiUrl } from '../../states/ApiState';
|
||||||
import { useUserState } from '../../states/UserState';
|
import { useUserState } from '../../states/UserState';
|
||||||
@ -300,7 +304,7 @@ export default function StockDetail() {
|
|||||||
{ name: t`Stock`, url: '/stock' },
|
{ name: t`Stock`, url: '/stock' },
|
||||||
...(stockitem.location_path ?? []).map((l: any) => ({
|
...(stockitem.location_path ?? []).map((l: any) => ({
|
||||||
name: l.name,
|
name: l.name,
|
||||||
url: `/stock/location/${l.pk}`
|
url: apiUrl(ApiEndpoints.stock_location_list, l.pk)
|
||||||
}))
|
}))
|
||||||
],
|
],
|
||||||
[stockitem]
|
[stockitem]
|
||||||
@ -311,6 +315,19 @@ export default function StockDetail() {
|
|||||||
callback: () => refreshInstance()
|
callback: () => refreshInstance()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const stockActionProps: StockOperationProps = useMemo(() => {
|
||||||
|
return {
|
||||||
|
items: stockitem,
|
||||||
|
model: ModelType.stockitem,
|
||||||
|
refresh: refreshInstance
|
||||||
|
};
|
||||||
|
}, [stockitem]);
|
||||||
|
|
||||||
|
const countStockItem = useCountStockItem(stockActionProps);
|
||||||
|
const addStockItem = useAddStockItem(stockActionProps);
|
||||||
|
const removeStockItem = useRemoveStockItem(stockActionProps);
|
||||||
|
const transferStockItem = useTransferStockItem(stockActionProps);
|
||||||
|
|
||||||
const stockActions = useMemo(
|
const stockActions = useMemo(
|
||||||
() => /* TODO: Disable actions based on user permissions*/ [
|
() => /* TODO: Disable actions based on user permissions*/ [
|
||||||
<BarcodeActionDropdown
|
<BarcodeActionDropdown
|
||||||
@ -332,22 +349,38 @@ export default function StockDetail() {
|
|||||||
{
|
{
|
||||||
name: t`Count`,
|
name: t`Count`,
|
||||||
tooltip: t`Count stock`,
|
tooltip: t`Count stock`,
|
||||||
icon: <IconCircleCheck color="green" />
|
icon: (
|
||||||
|
<InvenTreeIcon icon="stocktake" iconProps={{ color: 'blue' }} />
|
||||||
|
),
|
||||||
|
onClick: () => {
|
||||||
|
stockitem.pk && countStockItem.open();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: t`Add`,
|
name: t`Add`,
|
||||||
tooltip: t`Add stock`,
|
tooltip: t`Add stock`,
|
||||||
icon: <IconCirclePlus color="green" />
|
icon: <InvenTreeIcon icon="add" iconProps={{ color: 'green' }} />,
|
||||||
|
onClick: () => {
|
||||||
|
stockitem.pk && addStockItem.open();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: t`Remove`,
|
name: t`Remove`,
|
||||||
tooltip: t`Remove stock`,
|
tooltip: t`Remove stock`,
|
||||||
icon: <IconCircleMinus color="red" />
|
icon: <InvenTreeIcon icon="remove" iconProps={{ color: 'red' }} />,
|
||||||
|
onClick: () => {
|
||||||
|
stockitem.pk && removeStockItem.open();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: t`Transfer`,
|
name: t`Transfer`,
|
||||||
tooltip: t`Transfer stock`,
|
tooltip: t`Transfer stock`,
|
||||||
icon: <IconTransfer color="blue" />
|
icon: (
|
||||||
|
<InvenTreeIcon icon="transfer" iconProps={{ color: 'blue' }} />
|
||||||
|
),
|
||||||
|
onClick: () => {
|
||||||
|
stockitem.pk && transferStockItem.open();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
/>,
|
/>,
|
||||||
@ -361,11 +394,7 @@ export default function StockDetail() {
|
|||||||
tooltip: t`Duplicate stock item`,
|
tooltip: t`Duplicate stock item`,
|
||||||
icon: <IconCopy />
|
icon: <IconCopy />
|
||||||
},
|
},
|
||||||
EditItemAction({
|
EditItemAction({}),
|
||||||
onClick: () => {
|
|
||||||
stockitem.pk && editStockItem.open();
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
DeleteItemAction({})
|
DeleteItemAction({})
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@ -398,6 +427,10 @@ export default function StockDetail() {
|
|||||||
/>
|
/>
|
||||||
<PanelGroup pageKey="stockitem" panels={stockPanels} />
|
<PanelGroup pageKey="stockitem" panels={stockPanels} />
|
||||||
{editStockItem.modal}
|
{editStockItem.modal}
|
||||||
|
{countStockItem.modal}
|
||||||
|
{addStockItem.modal}
|
||||||
|
{removeStockItem.modal}
|
||||||
|
{transferStockItem.modal}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -12,7 +12,10 @@ import { RenderStockLocation } from '../../components/render/Stock';
|
|||||||
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 { usePurchaseOrderLineItemFields } from '../../forms/PurchaseOrderForms';
|
import {
|
||||||
|
usePurchaseOrderLineItemFields,
|
||||||
|
useReceiveLineItems
|
||||||
|
} from '../../forms/PurchaseOrderForms';
|
||||||
import { getDetailUrl } from '../../functions/urls';
|
import { getDetailUrl } from '../../functions/urls';
|
||||||
import {
|
import {
|
||||||
useCreateApiFormModal,
|
useCreateApiFormModal,
|
||||||
@ -52,6 +55,16 @@ export function PurchaseOrderLineItemTable({
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const user = useUserState();
|
const user = useUserState();
|
||||||
|
|
||||||
|
const [singleRecord, setSingeRecord] = useState(null);
|
||||||
|
const receiveLineItems = useReceiveLineItems({
|
||||||
|
items: singleRecord ? [singleRecord] : table.selectedRecords,
|
||||||
|
orderPk: orderId,
|
||||||
|
formProps: {
|
||||||
|
// Timeout is a small hack to prevent function being called before re-render
|
||||||
|
onClose: () => setTimeout(() => setSingeRecord(null), 500)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const tableColumns = useMemo(() => {
|
const tableColumns = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@ -213,7 +226,11 @@ export function PurchaseOrderLineItemTable({
|
|||||||
hidden: received,
|
hidden: received,
|
||||||
title: t`Receive line item`,
|
title: t`Receive line item`,
|
||||||
icon: <IconSquareArrowRight />,
|
icon: <IconSquareArrowRight />,
|
||||||
color: 'green'
|
color: 'green',
|
||||||
|
onClick: () => {
|
||||||
|
setSingeRecord(record);
|
||||||
|
receiveLineItems.open();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
RowEditAction({
|
RowEditAction({
|
||||||
hidden: !user.hasChangeRole(UserRoles.purchase_order),
|
hidden: !user.hasChangeRole(UserRoles.purchase_order),
|
||||||
@ -241,21 +258,22 @@ export function PurchaseOrderLineItemTable({
|
|||||||
const tableActions = useMemo(() => {
|
const tableActions = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
<AddItemButton
|
<AddItemButton
|
||||||
key="add-line-item"
|
|
||||||
tooltip={t`Add line item`}
|
tooltip={t`Add line item`}
|
||||||
onClick={() => newLine.open()}
|
onClick={() => newLine.open()}
|
||||||
hidden={!user?.hasAddRole(UserRoles.purchase_order)}
|
hidden={!user?.hasAddRole(UserRoles.purchase_order)}
|
||||||
/>,
|
/>,
|
||||||
<ActionButton
|
<ActionButton
|
||||||
key="receive-items"
|
|
||||||
text={t`Receive items`}
|
text={t`Receive items`}
|
||||||
icon={<IconSquareArrowRight />}
|
icon={<IconSquareArrowRight />}
|
||||||
|
onClick={() => receiveLineItems.open()}
|
||||||
|
disabled={table.selectedRecords.length === 0}
|
||||||
/>
|
/>
|
||||||
];
|
];
|
||||||
}, [orderId, user]);
|
}, [orderId, user, table]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{receiveLineItems.modal}
|
||||||
{newLine.modal}
|
{newLine.modal}
|
||||||
{editLine.modal}
|
{editLine.modal}
|
||||||
{deleteLine.modal}
|
{deleteLine.modal}
|
||||||
|
@ -4,11 +4,24 @@ import { ReactNode, useMemo } from 'react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import { AddItemButton } from '../../components/buttons/AddItemButton';
|
import { AddItemButton } from '../../components/buttons/AddItemButton';
|
||||||
|
import { ActionDropdown } from '../../components/items/ActionDropdown';
|
||||||
import { formatCurrency, renderDate } from '../../defaults/formatters';
|
import { formatCurrency, renderDate } 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 { UserRoles } from '../../enums/Roles';
|
||||||
import { useStockFields } from '../../forms/StockForms';
|
import {
|
||||||
|
StockOperationProps,
|
||||||
|
useAddStockItem,
|
||||||
|
useAssignStockItem,
|
||||||
|
useChangeStockStatus,
|
||||||
|
useCountStockItem,
|
||||||
|
useDeleteStockItem,
|
||||||
|
useMergeStockItem,
|
||||||
|
useRemoveStockItem,
|
||||||
|
useStockFields,
|
||||||
|
useTransferStockItem
|
||||||
|
} from '../../forms/StockForms';
|
||||||
|
import { InvenTreeIcon } from '../../functions/icons';
|
||||||
import { getDetailUrl } from '../../functions/urls';
|
import { getDetailUrl } from '../../functions/urls';
|
||||||
import { useCreateApiFormModal } from '../../hooks/UseForm';
|
import { useCreateApiFormModal } from '../../hooks/UseForm';
|
||||||
import { useTable } from '../../hooks/UseTable';
|
import { useTable } from '../../hooks/UseTable';
|
||||||
@ -335,8 +348,17 @@ export function StockItemTable({ params = {} }: { params?: any }) {
|
|||||||
|
|
||||||
const table = useTable('stockitems');
|
const table = useTable('stockitems');
|
||||||
const user = useUserState();
|
const user = useUserState();
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const tableActionParams: StockOperationProps = useMemo(() => {
|
||||||
|
return {
|
||||||
|
items: table.selectedRecords,
|
||||||
|
model: ModelType.stockitem,
|
||||||
|
refresh: table.refreshTable
|
||||||
|
};
|
||||||
|
}, [table]);
|
||||||
|
|
||||||
const stockItemFields = useStockFields({ create: true });
|
const stockItemFields = useStockFields({ create: true });
|
||||||
|
|
||||||
const newStockItem = useCreateApiFormModal({
|
const newStockItem = useCreateApiFormModal({
|
||||||
@ -354,26 +376,137 @@ export function StockItemTable({ params = {} }: { params?: any }) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 tableActions = useMemo(() => {
|
const tableActions = useMemo(() => {
|
||||||
|
let can_delete_stock = user.hasDeleteRole(UserRoles.stock);
|
||||||
|
let can_add_stock = user.hasAddRole(UserRoles.stock);
|
||||||
|
let can_add_stocktake = user.hasAddRole(UserRoles.stocktake);
|
||||||
|
let can_add_order = user.hasAddRole(UserRoles.purchase_order);
|
||||||
|
let can_change_order = user.hasChangeRole(UserRoles.purchase_order);
|
||||||
return [
|
return [
|
||||||
|
<ActionDropdown
|
||||||
|
key="stockoperations"
|
||||||
|
icon={<InvenTreeIcon icon="stock" />}
|
||||||
|
disabled={table.selectedRecords.length === 0}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
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: 'Count Stock',
|
||||||
|
icon: (
|
||||||
|
<InvenTreeIcon icon="stocktake" iconProps={{ color: 'blue' }} />
|
||||||
|
),
|
||||||
|
tooltip: 'Count Stock',
|
||||||
|
disabled: !can_add_stocktake,
|
||||||
|
onClick: () => {
|
||||||
|
countStock.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`,
|
||||||
|
disabled: !can_add_order || !can_change_order
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: t`Assign to customer`,
|
||||||
|
icon: <InvenTreeIcon icon="customer" />,
|
||||||
|
tooltip: t`Order new stock`,
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
<AddItemButton
|
<AddItemButton
|
||||||
hidden={!user.hasAddRole(UserRoles.stock)}
|
hidden={!user.hasAddRole(UserRoles.stock)}
|
||||||
tooltip={t`Add Stock Item`}
|
tooltip={t`Add Stock Item`}
|
||||||
onClick={() => newStockItem.open()}
|
onClick={() => newStockItem.open()}
|
||||||
/>
|
/>
|
||||||
];
|
];
|
||||||
}, [user]);
|
}, [user, table]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{newStockItem.modal}
|
{newStockItem.modal}
|
||||||
|
{transferStock.modal}
|
||||||
|
{removeStock.modal}
|
||||||
|
{addStock.modal}
|
||||||
|
{countStock.modal}
|
||||||
|
{changeStockStatus.modal}
|
||||||
|
{mergeStock.modal}
|
||||||
|
{assignStock.modal}
|
||||||
|
{deleteStock.modal}
|
||||||
<InvenTreeTable
|
<InvenTreeTable
|
||||||
url={apiUrl(ApiEndpoints.stock_item_list)}
|
url={apiUrl(ApiEndpoints.stock_item_list)}
|
||||||
tableState={table}
|
tableState={table}
|
||||||
columns={tableColumns}
|
columns={tableColumns}
|
||||||
props={{
|
props={{
|
||||||
enableDownload: true,
|
enableDownload: true,
|
||||||
enableSelection: false,
|
enableSelection: true,
|
||||||
tableFilters: tableFilters,
|
tableFilters: tableFilters,
|
||||||
tableActions: tableActions,
|
tableActions: tableActions,
|
||||||
onRowClick: (record) =>
|
onRowClick: (record) =>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user