2
0
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:
Lavissa 2024-03-15 02:06:18 +01:00 committed by GitHub
parent 6abd33f060
commit 0196dd2f60
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1785 additions and 57 deletions

View File

@ -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:

View File

@ -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

View File

@ -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()

View File

@ -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(

View File

@ -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()

View File

@ -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',
'part', Prefetch(
'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',

View File

@ -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)}

View File

@ -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`}>

View File

@ -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 = {

View 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>
);
}

View File

@ -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>

View File

@ -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/',

View File

@ -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)'
});
}

View File

@ -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: {

View File

@ -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;
} }

View File

@ -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]
); );

View File

@ -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
} }
]; ];

View File

@ -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>
</> </>
); );

View File

@ -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>
</> </>
); );

View File

@ -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>
); );
} }

View File

@ -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}

View File

@ -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) =>