mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +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: mixed-line-ending
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.2.2
|
||||
rev: v0.3.0
|
||||
hooks:
|
||||
- id: ruff-format
|
||||
args: [--preview]
|
||||
@ -26,7 +26,7 @@ repos:
|
||||
--preview
|
||||
]
|
||||
- repo: https://github.com/matmair/ruff-pre-commit
|
||||
rev: 830893bf46db844d9c99b6c468e285199adf2de6 # uv-018
|
||||
rev: 8bed1087452bdf816b840ea7b6848b21d32b7419 # uv-018
|
||||
hooks:
|
||||
- id: pip-compile
|
||||
name: pip-compile requirements-dev.in
|
||||
@ -60,7 +60,7 @@ repos:
|
||||
- "prettier@^2.4.1"
|
||||
- "@trivago/prettier-plugin-sort-imports"
|
||||
- repo: https://github.com/pre-commit/mirrors-eslint
|
||||
rev: "v9.0.0-beta.0"
|
||||
rev: "v9.0.0-beta.1"
|
||||
hooks:
|
||||
- id: eslint
|
||||
additional_dependencies:
|
||||
|
@ -1,12 +1,18 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# 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."""
|
||||
|
||||
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 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.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 rest_framework import serializers
|
||||
@ -14,6 +23,8 @@ from sql_util.utils import SubqueryCount
|
||||
|
||||
import order.models
|
||||
import part.filters
|
||||
import part.filters as part_filters
|
||||
import part.models as part_models
|
||||
import stock.models
|
||||
import stock.serializers
|
||||
from common.serializers import ProjectCodeSerializer
|
||||
@ -375,6 +386,17 @@ class PurchaseOrderLineItemSerializer(InvenTreeModelSerializer):
|
||||
- "total_price" = purchase_price * quantity
|
||||
- "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(
|
||||
total_price=ExpressionWrapper(
|
||||
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():
|
||||
"""Construct a queryset annotation which returns the number of subcategories for each provided category."""
|
||||
subquery = part.models.PartCategory.objects.filter(
|
||||
|
@ -81,6 +81,7 @@ class CategorySerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
'url',
|
||||
'structural',
|
||||
'icon',
|
||||
'parent_default_location',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@ -105,6 +106,10 @@ class CategorySerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
subcategories=part.filters.annotate_sub_categories(),
|
||||
)
|
||||
|
||||
queryset = queryset.annotate(
|
||||
parent_default_location=part.filters.annotate_default_location('parent__')
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
parent_default_location = serializers.IntegerField(read_only=True)
|
||||
|
||||
|
||||
class CategoryTree(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
"""Serializer for PartCategory tree."""
|
||||
@ -283,6 +290,7 @@ class PartBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
'pk',
|
||||
'IPN',
|
||||
'barcode_hash',
|
||||
'category_default_location',
|
||||
'default_location',
|
||||
'name',
|
||||
'revision',
|
||||
@ -314,6 +322,8 @@ class PartBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
self.fields.pop('pricing_min')
|
||||
self.fields.pop('pricing_max')
|
||||
|
||||
category_default_location = serializers.IntegerField(read_only=True)
|
||||
|
||||
image = InvenTree.serializers.InvenTreeImageSerializerField(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_sales_orders',
|
||||
'building',
|
||||
'category_default_location',
|
||||
'in_stock',
|
||||
'ordering',
|
||||
'required_for_build_orders',
|
||||
@ -766,6 +777,12 @@ class PartSerializer(
|
||||
required_for_sales_orders=part.filters.annotate_sales_order_requirements(),
|
||||
)
|
||||
|
||||
queryset = queryset.annotate(
|
||||
category_default_location=part.filters.annotate_default_location(
|
||||
'category__'
|
||||
)
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
def get_starred(self, part) -> bool:
|
||||
@ -805,6 +822,7 @@ class PartSerializer(
|
||||
unallocated_stock = serializers.FloatField(
|
||||
read_only=True, label=_('Unallocated Stock')
|
||||
)
|
||||
category_default_location = serializers.IntegerField(read_only=True)
|
||||
variant_stock = serializers.FloatField(read_only=True, label=_('Variant Stock'))
|
||||
|
||||
minimum_stock = serializers.FloatField()
|
||||
|
@ -6,7 +6,7 @@ from decimal import Decimal
|
||||
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
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.utils.translation import gettext_lazy as _
|
||||
|
||||
@ -20,6 +20,7 @@ import company.models
|
||||
import InvenTree.helpers
|
||||
import InvenTree.serializers
|
||||
import InvenTree.status_codes
|
||||
import part.filters as part_filters
|
||||
import part.models as part_models
|
||||
import stock.filters
|
||||
from company.serializers import SupplierPartSerializer
|
||||
@ -289,7 +290,14 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeTagModelSerializer):
|
||||
'location',
|
||||
'sales_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__pricing_data',
|
||||
'supplier_part',
|
||||
|
@ -443,7 +443,7 @@ export function ApiForm({ id, props }: { id: string; props: ApiFormProps }) {
|
||||
))}
|
||||
<Button
|
||||
onClick={form.handleSubmit(submitForm, onFormError)}
|
||||
variant="outline"
|
||||
variant="filled"
|
||||
radius="sm"
|
||||
color={props.submitColor ?? 'green'}
|
||||
disabled={isLoading || (props.fetchInitialData && !isDirty)}
|
||||
|
@ -19,6 +19,7 @@ import { ChoiceField } from './ChoiceField';
|
||||
import DateField from './DateField';
|
||||
import { NestedObjectField } from './NestedObjectField';
|
||||
import { RelatedModelField } from './RelatedModelField';
|
||||
import { TableField } from './TableField';
|
||||
|
||||
export type ApiFormData = UseFormReturnType<Record<string, unknown>>;
|
||||
|
||||
@ -69,7 +70,8 @@ export type ApiFormFieldType = {
|
||||
| 'number'
|
||||
| 'choice'
|
||||
| 'file upload'
|
||||
| 'nested object';
|
||||
| 'nested object'
|
||||
| 'table';
|
||||
api_url?: string;
|
||||
model?: ModelType;
|
||||
modelRenderer?: (instance: any) => ReactNode;
|
||||
@ -86,6 +88,7 @@ export type ApiFormFieldType = {
|
||||
postFieldContent?: JSX.Element;
|
||||
onValueChange?: (value: any) => void;
|
||||
adjustFilters?: (value: ApiFormAdjustFilterType) => any;
|
||||
headers?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
@ -266,6 +269,14 @@ export function ApiFormField({
|
||||
control={control}
|
||||
/>
|
||||
);
|
||||
case 'table':
|
||||
return (
|
||||
<TableField
|
||||
definition={definition}
|
||||
fieldName={fieldName}
|
||||
control={controller}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Alert color="red" title={t`Error`}>
|
||||
|
@ -30,7 +30,6 @@ export function RelatedModelField({
|
||||
limit?: number;
|
||||
}) {
|
||||
const fieldId = useId();
|
||||
|
||||
const {
|
||||
field,
|
||||
fieldState: { error }
|
||||
@ -60,7 +59,6 @@ export function RelatedModelField({
|
||||
field.value !== ''
|
||||
) {
|
||||
const url = `${definition.api_url}${field.value}/`;
|
||||
|
||||
api.get(url).then((response) => {
|
||||
if (response.data && response.data.pk) {
|
||||
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({
|
||||
icon,
|
||||
tooltip,
|
||||
actions
|
||||
actions,
|
||||
disabled = false
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
tooltip?: string;
|
||||
actions: ActionDropdownItem[];
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const hasActions = useMemo(() => {
|
||||
return actions.some((action) => !action.hidden);
|
||||
@ -54,7 +56,12 @@ export function ActionDropdown({
|
||||
<Indicator disabled={!indicatorProps} {...indicatorProps?.indicator}>
|
||||
<Menu.Target>
|
||||
<Tooltip label={tooltip} hidden={!tooltip}>
|
||||
<ActionIcon size="lg" radius="sm" variant="outline">
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
radius="sm"
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
>
|
||||
{icon}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
@ -84,11 +84,20 @@ export enum ApiEndpoints {
|
||||
stock_location_tree = 'stock/location/tree/',
|
||||
stock_attachment_list = 'stock/attachment/',
|
||||
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
|
||||
purchase_order_list = 'order/po/',
|
||||
purchase_order_line_list = 'order/po-line/',
|
||||
purchase_order_attachment_list = 'order/po/attachment/',
|
||||
purchase_order_receive = 'order/po/:id/receive/',
|
||||
sales_order_list = 'order/so/',
|
||||
sales_order_attachment_list = 'order/so/attachment/',
|
||||
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 {
|
||||
IconAddressBook,
|
||||
IconCalendar,
|
||||
@ -11,12 +14,24 @@ import {
|
||||
IconUser,
|
||||
IconUsers
|
||||
} from '@tabler/icons-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { api } from '../App';
|
||||
import { ActionButton } from '../components/buttons/ActionButton';
|
||||
import { StandaloneField } from '../components/forms/StandaloneField';
|
||||
import {
|
||||
ApiFormAdjustFilterType,
|
||||
ApiFormFieldSet
|
||||
} 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
|
||||
@ -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 { 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 {
|
||||
ApiFormAdjustFilterType,
|
||||
ApiFormFieldSet
|
||||
} 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 { 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
|
||||
@ -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 {
|
||||
let fields: ApiFormFieldSet = {
|
||||
parent: {
|
||||
|
@ -1,5 +1,6 @@
|
||||
import {
|
||||
Icon123,
|
||||
IconArrowMerge,
|
||||
IconBinaryTree2,
|
||||
IconBookmarks,
|
||||
IconBox,
|
||||
@ -10,13 +11,19 @@ import {
|
||||
IconCalendarStats,
|
||||
IconCategory,
|
||||
IconCheck,
|
||||
IconCircleMinus,
|
||||
IconCirclePlus,
|
||||
IconClipboardList,
|
||||
IconClipboardText,
|
||||
IconCopy,
|
||||
IconCornerDownLeft,
|
||||
IconCornerUpRightDouble,
|
||||
IconCurrencyDollar,
|
||||
IconDots,
|
||||
IconDotsCircleHorizontal,
|
||||
IconExternalLink,
|
||||
IconFileUpload,
|
||||
IconFlagShare,
|
||||
IconGitBranch,
|
||||
IconGridDots,
|
||||
IconHash,
|
||||
@ -27,6 +34,7 @@ import {
|
||||
IconMail,
|
||||
IconMapPin,
|
||||
IconMapPinHeart,
|
||||
IconMinusVertical,
|
||||
IconNotes,
|
||||
IconNumbers,
|
||||
IconPackage,
|
||||
@ -35,7 +43,9 @@ import {
|
||||
IconPaperclip,
|
||||
IconPhone,
|
||||
IconPhoto,
|
||||
IconPrinter,
|
||||
IconProgressCheck,
|
||||
IconQrcode,
|
||||
IconQuestionMark,
|
||||
IconRulerMeasure,
|
||||
IconShoppingCart,
|
||||
@ -47,9 +57,11 @@ import {
|
||||
IconTestPipe,
|
||||
IconTool,
|
||||
IconTools,
|
||||
IconTransfer,
|
||||
IconTrash,
|
||||
IconTruck,
|
||||
IconTruckDelivery,
|
||||
IconUnlink,
|
||||
IconUser,
|
||||
IconUserStar,
|
||||
IconUsersGroup,
|
||||
@ -59,6 +71,9 @@ import {
|
||||
IconX
|
||||
} 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 { IconInfoCircle } from '@tabler/icons-react';
|
||||
import { IconCalendarTime } from '@tabler/icons-react';
|
||||
@ -127,6 +142,8 @@ const icons = {
|
||||
creation_date: IconCalendarTime,
|
||||
location: IconMapPin,
|
||||
default_location: IconMapPinHeart,
|
||||
category_default_location: IconMapPinHeart,
|
||||
parent_default_location: IconMapPinHeart,
|
||||
default_supplier: IconShoppingCartHeart,
|
||||
link: IconLink,
|
||||
responsible: IconUserStar,
|
||||
@ -137,13 +154,30 @@ const icons = {
|
||||
group: IconUsersGroup,
|
||||
check: IconCheck,
|
||||
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,
|
||||
progress: IconProgressCheck,
|
||||
reference: IconHash,
|
||||
website: IconWorld,
|
||||
email: IconMail,
|
||||
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;
|
||||
@ -167,6 +201,9 @@ export function InvenTreeIcon(props: IconProps) {
|
||||
if (props.icon in icons) {
|
||||
Icon = GetIcon(props.icon);
|
||||
} else {
|
||||
console.warn(
|
||||
`Icon name '${props.icon}' is not registered with the Icon manager`
|
||||
);
|
||||
Icon = IconQuestionMark;
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
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 { useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
@ -20,6 +20,7 @@ export interface ApiFormModalProps extends ApiFormProps {
|
||||
onClose?: () => void;
|
||||
onOpen?: () => void;
|
||||
closeOnClickOutside?: boolean;
|
||||
size?: MantineNumberSize;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -59,7 +60,7 @@ export function useApiFormModal(props: ApiFormModalProps) {
|
||||
onOpen: formProps.onOpen,
|
||||
onClose: formProps.onClose,
|
||||
closeOnClickOutside: formProps.closeOnClickOutside,
|
||||
size: 'xl',
|
||||
size: props.size ?? 'xl',
|
||||
children: (
|
||||
<Stack spacing={'xs'}>
|
||||
<Divider />
|
||||
@ -125,7 +126,7 @@ export function useDeleteApiFormModal(props: ApiFormModalProps) {
|
||||
color={'red'}
|
||||
>{t`Are you sure you want to delete this item?`}</Alert>
|
||||
),
|
||||
fields: {}
|
||||
fields: props.fields ?? {}
|
||||
}),
|
||||
[props]
|
||||
);
|
||||
|
@ -115,6 +115,20 @@ export default function CategoryDetail({}: {}) {
|
||||
name: 'structural',
|
||||
label: t`Structural`,
|
||||
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,
|
||||
IconTestPipe,
|
||||
IconTools,
|
||||
IconTransfer,
|
||||
IconTruckDelivery,
|
||||
IconVersions
|
||||
} from '@tabler/icons-react';
|
||||
@ -58,6 +57,12 @@ import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
import { usePartFields } from '../../forms/PartForms';
|
||||
import {
|
||||
StockOperationProps,
|
||||
useCountStockItem,
|
||||
useTransferStockItem
|
||||
} from '../../forms/StockForms';
|
||||
import { InvenTreeIcon } from '../../functions/icons';
|
||||
import { useEditApiFormModal } from '../../hooks/UseForm';
|
||||
import { useInstance } from '../../hooks/UseInstance';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
@ -131,6 +136,13 @@ export default function PartDetail() {
|
||||
model: ModelType.stocklocation,
|
||||
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',
|
||||
name: 'IPN',
|
||||
@ -460,10 +472,10 @@ export default function PartDetail() {
|
||||
name: 'stock',
|
||||
label: t`Stock`,
|
||||
icon: <IconPackages />,
|
||||
content: (
|
||||
content: part.pk && (
|
||||
<StockItemTable
|
||||
params={{
|
||||
part: part.pk ?? -1
|
||||
part: part.pk
|
||||
}}
|
||||
/>
|
||||
)
|
||||
@ -631,6 +643,17 @@ export default function PartDetail() {
|
||||
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(() => {
|
||||
// TODO: Disable actions based on user permissions
|
||||
return [
|
||||
@ -651,14 +674,24 @@ export default function PartDetail() {
|
||||
icon={<IconPackages />}
|
||||
actions={[
|
||||
{
|
||||
icon: <IconClipboardList color="blue" />,
|
||||
icon: (
|
||||
<InvenTreeIcon icon="stocktake" iconProps={{ color: 'blue' }} />
|
||||
),
|
||||
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`,
|
||||
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}
|
||||
/>
|
||||
<PanelGroup pageKey="part" panels={partPanels} />
|
||||
{transferStockItems.modal}
|
||||
{countStockItems.modal}
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
|
@ -9,11 +9,17 @@ import {
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { ActionButton } from '../../components/buttons/ActionButton';
|
||||
import { DetailsField, DetailsTable } from '../../components/details/Details';
|
||||
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
|
||||
import {
|
||||
ActionDropdown,
|
||||
EditItemAction
|
||||
BarcodeActionDropdown,
|
||||
DeleteItemAction,
|
||||
EditItemAction,
|
||||
LinkBarcodeAction,
|
||||
UnlinkBarcodeAction,
|
||||
ViewBarcodeAction
|
||||
} from '../../components/items/ActionDropdown';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
|
||||
@ -21,10 +27,17 @@ import { StockLocationTree } from '../../components/nav/StockLocationTree';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
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 { useInstance } from '../../hooks/UseInstance';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import { PartListTable } from '../../tables/part/PartTable';
|
||||
import { StockItemTable } from '../../tables/stock/StockItemTable';
|
||||
import { StockLocationTable } from '../../tables/stock/StockLocationTable';
|
||||
|
||||
@ -154,6 +167,21 @@ export default function Stock() {
|
||||
label: t`Stock Locations`,
|
||||
icon: <IconSitemap />,
|
||||
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]);
|
||||
@ -166,8 +194,79 @@ export default function Stock() {
|
||||
onFormSuccess: refreshInstance
|
||||
});
|
||||
|
||||
const locationActions = useMemo(() => {
|
||||
return [
|
||||
const stockItemActionProps: StockOperationProps = useMemo(() => {
|
||||
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
|
||||
key="location"
|
||||
tooltip={t`Location Actions`}
|
||||
@ -180,8 +279,9 @@ export default function Stock() {
|
||||
})
|
||||
]}
|
||||
/>
|
||||
];
|
||||
}, [id, user]);
|
||||
],
|
||||
[location, id, user]
|
||||
);
|
||||
|
||||
const breadcrumbs = useMemo(
|
||||
() => [
|
||||
@ -214,6 +314,8 @@ export default function Stock() {
|
||||
}}
|
||||
/>
|
||||
<PanelGroup pageKey="stocklocation" panels={locationPanels} />
|
||||
{transferStockItems.modal}
|
||||
{countStockItems.modal}
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
|
@ -11,9 +11,6 @@ import {
|
||||
IconBookmark,
|
||||
IconBoxPadding,
|
||||
IconChecklist,
|
||||
IconCircleCheck,
|
||||
IconCircleMinus,
|
||||
IconCirclePlus,
|
||||
IconCopy,
|
||||
IconDots,
|
||||
IconHistory,
|
||||
@ -21,8 +18,7 @@ import {
|
||||
IconNotes,
|
||||
IconPackages,
|
||||
IconPaperclip,
|
||||
IconSitemap,
|
||||
IconTransfer
|
||||
IconSitemap
|
||||
} from '@tabler/icons-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
@ -46,7 +42,15 @@ import { NotesEditor } from '../../components/widgets/MarkdownEditor';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
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 { apiUrl } from '../../states/ApiState';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
@ -300,7 +304,7 @@ export default function StockDetail() {
|
||||
{ name: t`Stock`, url: '/stock' },
|
||||
...(stockitem.location_path ?? []).map((l: any) => ({
|
||||
name: l.name,
|
||||
url: `/stock/location/${l.pk}`
|
||||
url: apiUrl(ApiEndpoints.stock_location_list, l.pk)
|
||||
}))
|
||||
],
|
||||
[stockitem]
|
||||
@ -311,6 +315,19 @@ export default function StockDetail() {
|
||||
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(
|
||||
() => /* TODO: Disable actions based on user permissions*/ [
|
||||
<BarcodeActionDropdown
|
||||
@ -332,22 +349,38 @@ export default function StockDetail() {
|
||||
{
|
||||
name: t`Count`,
|
||||
tooltip: t`Count stock`,
|
||||
icon: <IconCircleCheck color="green" />
|
||||
icon: (
|
||||
<InvenTreeIcon icon="stocktake" iconProps={{ color: 'blue' }} />
|
||||
),
|
||||
onClick: () => {
|
||||
stockitem.pk && countStockItem.open();
|
||||
}
|
||||
},
|
||||
{
|
||||
name: t`Add`,
|
||||
tooltip: t`Add stock`,
|
||||
icon: <IconCirclePlus color="green" />
|
||||
icon: <InvenTreeIcon icon="add" iconProps={{ color: 'green' }} />,
|
||||
onClick: () => {
|
||||
stockitem.pk && addStockItem.open();
|
||||
}
|
||||
},
|
||||
{
|
||||
name: t`Remove`,
|
||||
tooltip: t`Remove stock`,
|
||||
icon: <IconCircleMinus color="red" />
|
||||
icon: <InvenTreeIcon icon="remove" iconProps={{ color: 'red' }} />,
|
||||
onClick: () => {
|
||||
stockitem.pk && removeStockItem.open();
|
||||
}
|
||||
},
|
||||
{
|
||||
name: t`Transfer`,
|
||||
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`,
|
||||
icon: <IconCopy />
|
||||
},
|
||||
EditItemAction({
|
||||
onClick: () => {
|
||||
stockitem.pk && editStockItem.open();
|
||||
}
|
||||
}),
|
||||
EditItemAction({}),
|
||||
DeleteItemAction({})
|
||||
]}
|
||||
/>
|
||||
@ -398,6 +427,10 @@ export default function StockDetail() {
|
||||
/>
|
||||
<PanelGroup pageKey="stockitem" panels={stockPanels} />
|
||||
{editStockItem.modal}
|
||||
{countStockItem.modal}
|
||||
{addStockItem.modal}
|
||||
{removeStockItem.modal}
|
||||
{transferStockItem.modal}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
@ -12,7 +12,10 @@ import { RenderStockLocation } from '../../components/render/Stock';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
import { usePurchaseOrderLineItemFields } from '../../forms/PurchaseOrderForms';
|
||||
import {
|
||||
usePurchaseOrderLineItemFields,
|
||||
useReceiveLineItems
|
||||
} from '../../forms/PurchaseOrderForms';
|
||||
import { getDetailUrl } from '../../functions/urls';
|
||||
import {
|
||||
useCreateApiFormModal,
|
||||
@ -52,6 +55,16 @@ export function PurchaseOrderLineItemTable({
|
||||
const navigate = useNavigate();
|
||||
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(() => {
|
||||
return [
|
||||
{
|
||||
@ -213,7 +226,11 @@ export function PurchaseOrderLineItemTable({
|
||||
hidden: received,
|
||||
title: t`Receive line item`,
|
||||
icon: <IconSquareArrowRight />,
|
||||
color: 'green'
|
||||
color: 'green',
|
||||
onClick: () => {
|
||||
setSingeRecord(record);
|
||||
receiveLineItems.open();
|
||||
}
|
||||
},
|
||||
RowEditAction({
|
||||
hidden: !user.hasChangeRole(UserRoles.purchase_order),
|
||||
@ -241,21 +258,22 @@ export function PurchaseOrderLineItemTable({
|
||||
const tableActions = useMemo(() => {
|
||||
return [
|
||||
<AddItemButton
|
||||
key="add-line-item"
|
||||
tooltip={t`Add line item`}
|
||||
onClick={() => newLine.open()}
|
||||
hidden={!user?.hasAddRole(UserRoles.purchase_order)}
|
||||
/>,
|
||||
<ActionButton
|
||||
key="receive-items"
|
||||
text={t`Receive items`}
|
||||
icon={<IconSquareArrowRight />}
|
||||
onClick={() => receiveLineItems.open()}
|
||||
disabled={table.selectedRecords.length === 0}
|
||||
/>
|
||||
];
|
||||
}, [orderId, user]);
|
||||
}, [orderId, user, table]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{receiveLineItems.modal}
|
||||
{newLine.modal}
|
||||
{editLine.modal}
|
||||
{deleteLine.modal}
|
||||
|
@ -4,11 +4,24 @@ import { ReactNode, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { AddItemButton } from '../../components/buttons/AddItemButton';
|
||||
import { ActionDropdown } from '../../components/items/ActionDropdown';
|
||||
import { formatCurrency, renderDate } from '../../defaults/formatters';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
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 { useCreateApiFormModal } from '../../hooks/UseForm';
|
||||
import { useTable } from '../../hooks/UseTable';
|
||||
@ -335,8 +348,17 @@ export function StockItemTable({ params = {} }: { params?: any }) {
|
||||
|
||||
const table = useTable('stockitems');
|
||||
const user = useUserState();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const tableActionParams: StockOperationProps = useMemo(() => {
|
||||
return {
|
||||
items: table.selectedRecords,
|
||||
model: ModelType.stockitem,
|
||||
refresh: table.refreshTable
|
||||
};
|
||||
}, [table]);
|
||||
|
||||
const stockItemFields = useStockFields({ create: true });
|
||||
|
||||
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(() => {
|
||||
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 [
|
||||
<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
|
||||
hidden={!user.hasAddRole(UserRoles.stock)}
|
||||
tooltip={t`Add Stock Item`}
|
||||
onClick={() => newStockItem.open()}
|
||||
/>
|
||||
];
|
||||
}, [user]);
|
||||
}, [user, table]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{newStockItem.modal}
|
||||
{transferStock.modal}
|
||||
{removeStock.modal}
|
||||
{addStock.modal}
|
||||
{countStock.modal}
|
||||
{changeStockStatus.modal}
|
||||
{mergeStock.modal}
|
||||
{assignStock.modal}
|
||||
{deleteStock.modal}
|
||||
<InvenTreeTable
|
||||
url={apiUrl(ApiEndpoints.stock_item_list)}
|
||||
tableState={table}
|
||||
columns={tableColumns}
|
||||
props={{
|
||||
enableDownload: true,
|
||||
enableSelection: false,
|
||||
enableSelection: true,
|
||||
tableFilters: tableFilters,
|
||||
tableActions: tableActions,
|
||||
onRowClick: (record) =>
|
||||
|
Loading…
Reference in New Issue
Block a user