mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 21:25:42 +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:
		| @@ -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) => | ||||
|   | ||||
		Reference in New Issue
	
	Block a user