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

View File

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

View File

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

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():
"""Construct a queryset annotation which returns the number of subcategories for each provided category."""
subquery = part.models.PartCategory.objects.filter(

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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