2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-29 12:06:44 +00:00

[PUI] Sales order shipments (#8250)

* Refactor AttachmentPanel into common component

* Remove unused imports

* Add very basic implementation for SalesOrderShipmentDetail page

* Refactor NotesPanel into common component

* Fetch customer data

* Add some placeholder actions

* Updates for shipment detail page

* Adjust SalesOrderShipment API

* Add badges

* Implement API filter for SalesOrderAllocation

* Display allocation table on shipment page

* Add placeholder action to edit allocations

* Improvements for SalesOrderAllocationTable

* Improve API db fetch efficiency

* Edit / delete pending allocations

* Fix for legacy CUI tables

* API tweaks

* Revert custom attachment code for SalesOrderShipment

* Implement "complete shipment" form

* Allocate stock item(s) to sales order

* Fixes for TableField rendering

* Reset sourceLocation when form opens

* Updated playwrigh tests

* Tweak branch (will be reverted)

* Revert github workflow
This commit is contained in:
Oliver 2024-10-10 22:43:22 +11:00 committed by GitHub
parent 35969b11a5
commit 33eba14d3f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 1370 additions and 536 deletions

View File

@ -1,13 +1,17 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 266
INVENTREE_API_VERSION = 267
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
267 - 2024-10-8 : https://github.com/inventree/InvenTree/pull/8250
- Remove "allocations" field from the SalesOrderShipment API endpoint(s)
- Add "allocated_items" field to the SalesOrderShipment API endpoint(s)
266 - 2024-10-07 : https://github.com/inventree/InvenTree/pull/8249
- Tweak SalesOrderShipment API for more efficient data retrieval

View File

@ -749,7 +749,6 @@ class SalesOrderLineItemMixin:
kwargs['part_detail'] = str2bool(params.get('part_detail', False))
kwargs['order_detail'] = str2bool(params.get('order_detail', False))
kwargs['allocations'] = str2bool(params.get('allocations', False))
kwargs['customer_detail'] = str2bool(params.get('customer_detail', False))
except AttributeError:
@ -889,18 +888,83 @@ class SalesOrderAllocate(SalesOrderContextMixin, CreateAPI):
serializer_class = serializers.SalesOrderShipmentAllocationSerializer
class SalesOrderAllocationDetail(RetrieveUpdateDestroyAPI):
"""API endpoint for detali view of a SalesOrderAllocation object."""
class SalesOrderAllocationFilter(rest_filters.FilterSet):
"""Custom filterset for the SalesOrderAllocationList endpoint."""
class Meta:
"""Metaclass options."""
model = models.SalesOrderAllocation
fields = ['shipment', 'item']
order = rest_filters.ModelChoiceFilter(
queryset=models.SalesOrder.objects.all(),
field_name='line__order',
label=_('Order'),
)
part = rest_filters.ModelChoiceFilter(
queryset=Part.objects.all(), field_name='item__part', label=_('Part')
)
outstanding = rest_filters.BooleanFilter(
label=_('Outstanding'), method='filter_outstanding'
)
def filter_outstanding(self, queryset, name, value):
"""Filter by "outstanding" status (boolean)."""
if str2bool(value):
return queryset.filter(
line__order__status__in=SalesOrderStatusGroups.OPEN,
shipment__shipment_date=None,
)
return queryset.exclude(
shipment__shipment_date=None,
line__order__status__in=SalesOrderStatusGroups.OPEN,
)
class SalesOrderAllocationMixin:
"""Mixin class for SalesOrderAllocation endpoints."""
queryset = models.SalesOrderAllocation.objects.all()
serializer_class = serializers.SalesOrderAllocationSerializer
def get_queryset(self, *args, **kwargs):
"""Annotate the queryset for this endpoint."""
queryset = super().get_queryset(*args, **kwargs)
class SalesOrderAllocationList(ListAPI):
queryset = queryset.prefetch_related(
'item',
'item__sales_order',
'item__part',
'item__location',
'line__order',
'line__part',
'shipment',
'shipment__order',
)
return queryset
class SalesOrderAllocationList(SalesOrderAllocationMixin, ListAPI):
"""API endpoint for listing SalesOrderAllocation objects."""
queryset = models.SalesOrderAllocation.objects.all()
serializer_class = serializers.SalesOrderAllocationSerializer
filterset_class = SalesOrderAllocationFilter
filter_backends = SEARCH_ORDER_FILTER_ALIAS
ordering_fields = ['quantity', 'part', 'serial', 'batch', 'location', 'order']
ordering_field_aliases = {
'part': 'item__part__name',
'serial': ['item__serial_int', 'item__serial'],
'batch': 'item__batch',
'location': 'item__location__name',
'order': 'line__order__reference',
}
search_fields = {'item__part__name', 'item__serial', 'item__batch'}
def get_serializer(self, *args, **kwargs):
"""Return the serializer instance for this endpoint.
@ -920,53 +984,9 @@ class SalesOrderAllocationList(ListAPI):
return self.serializer_class(*args, **kwargs)
def filter_queryset(self, queryset):
"""Custom queryset filtering."""
queryset = super().filter_queryset(queryset)
# Filter by order
params = self.request.query_params
# Filter by "part" reference
part = params.get('part', None)
if part is not None:
queryset = queryset.filter(item__part=part)
# Filter by "order" reference
order = params.get('order', None)
if order is not None:
queryset = queryset.filter(line__order=order)
# Filter by "stock item"
item = params.get('item', params.get('stock_item', None))
if item is not None:
queryset = queryset.filter(item=item)
# Filter by "outstanding" order status
outstanding = params.get('outstanding', None)
if outstanding is not None:
outstanding = str2bool(outstanding)
if outstanding:
# Filter only "open" orders
# Filter only allocations which have *not* shipped
queryset = queryset.filter(
line__order__status__in=SalesOrderStatusGroups.OPEN,
shipment__shipment_date=None,
)
else:
queryset = queryset.exclude(
line__order__status__in=SalesOrderStatusGroups.OPEN,
shipment__shipment_date=None,
)
return queryset
filter_backends = [rest_filters.DjangoFilterBackend]
class SalesOrderAllocationDetail(SalesOrderAllocationMixin, RetrieveUpdateDestroyAPI):
"""API endpoint for detali view of a SalesOrderAllocation object."""
class SalesOrderShipmentFilter(rest_filters.FilterSet):
@ -1005,13 +1025,7 @@ class SalesOrderShipmentMixin:
"""Return annotated queryset for this endpoint."""
queryset = super().get_queryset(*args, **kwargs)
queryset = queryset.prefetch_related(
'order',
'order__customer',
'allocations',
'allocations__item',
'allocations__item__part',
)
queryset = serializers.SalesOrderShipmentSerializer.annotate_queryset(queryset)
return queryset
@ -1020,10 +1034,8 @@ class SalesOrderShipmentList(SalesOrderShipmentMixin, ListCreateAPI):
"""API list endpoint for SalesOrderShipment model."""
filterset_class = SalesOrderShipmentFilter
filter_backends = SEARCH_ORDER_FILTER_ALIAS
ordering_fields = ['delivery_date', 'shipment_date']
ordering_fields = ['reference', 'delivery_date', 'shipment_date', 'allocated_items']
class SalesOrderShipmentDetail(SalesOrderShipmentMixin, RetrieveUpdateDestroyAPI):

View File

@ -1923,13 +1923,6 @@ class SalesOrderShipment(
trigger_event('salesordershipment.completed', id=self.pk)
def create_attachment(self, *args, **kwargs):
"""Create an attachment / link on parent order.
This will only be called when a generated report should be attached to this instance.
"""
return self.order.create_attachment(*args, **kwargs)
class SalesOrderExtraLine(OrderExtraLine):
"""Model for a single ExtraLine in a SalesOrder.

View File

@ -1027,88 +1027,6 @@ class SalesOrderIssueSerializer(OrderAdjustSerializer):
self.order.issue_order()
class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
"""Serializer for the SalesOrderAllocation model.
This includes some fields from the related model objects.
"""
class Meta:
"""Metaclass options."""
model = order.models.SalesOrderAllocation
fields = [
'pk',
'line',
'customer_detail',
'serial',
'quantity',
'location',
'location_detail',
'item',
'item_detail',
'order',
'order_detail',
'part',
'part_detail',
'shipment',
'shipment_date',
]
def __init__(self, *args, **kwargs):
"""Initialization routine for the serializer."""
order_detail = kwargs.pop('order_detail', False)
part_detail = kwargs.pop('part_detail', True)
item_detail = kwargs.pop('item_detail', True)
location_detail = kwargs.pop('location_detail', False)
customer_detail = kwargs.pop('customer_detail', False)
super().__init__(*args, **kwargs)
if not order_detail:
self.fields.pop('order_detail', None)
if not part_detail:
self.fields.pop('part_detail', None)
if not item_detail:
self.fields.pop('item_detail', None)
if not location_detail:
self.fields.pop('location_detail', None)
if not customer_detail:
self.fields.pop('customer_detail', None)
part = serializers.PrimaryKeyRelatedField(source='item.part', read_only=True)
order = serializers.PrimaryKeyRelatedField(
source='line.order', many=False, read_only=True
)
serial = serializers.CharField(source='get_serial', read_only=True)
quantity = serializers.FloatField(read_only=False)
location = serializers.PrimaryKeyRelatedField(
source='item.location', many=False, read_only=True
)
# Extra detail fields
order_detail = SalesOrderSerializer(source='line.order', many=False, read_only=True)
part_detail = PartBriefSerializer(source='item.part', many=False, read_only=True)
item_detail = stock.serializers.StockItemSerializerBrief(
source='item', many=False, read_only=True
)
location_detail = stock.serializers.LocationBriefSerializer(
source='item.location', many=False, read_only=True
)
customer_detail = CompanyBriefSerializer(
source='line.order.customer', many=False, read_only=True
)
shipment_date = serializers.DateField(
source='shipment.shipment_date', read_only=True
)
@register_importer()
class SalesOrderLineItemSerializer(
DataImportExportSerializerMixin,
@ -1125,7 +1043,6 @@ class SalesOrderLineItemSerializer(
fields = [
'pk',
'allocated',
'allocations',
'customer_detail',
'quantity',
'reference',
@ -1154,7 +1071,6 @@ class SalesOrderLineItemSerializer(
"""
part_detail = kwargs.pop('part_detail', False)
order_detail = kwargs.pop('order_detail', False)
allocations = kwargs.pop('allocations', False)
customer_detail = kwargs.pop('customer_detail', False)
super().__init__(*args, **kwargs)
@ -1165,9 +1081,6 @@ class SalesOrderLineItemSerializer(
if order_detail is not True:
self.fields.pop('order_detail', None)
if allocations is not True:
self.fields.pop('allocations', None)
if customer_detail is not True:
self.fields.pop('customer_detail', None)
@ -1251,13 +1164,10 @@ class SalesOrderLineItemSerializer(
return queryset
customer_detail = CompanyBriefSerializer(
source='order.customer', many=False, read_only=True
)
order_detail = SalesOrderSerializer(source='order', many=False, read_only=True)
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
allocations = SalesOrderAllocationSerializer(
many=True, read_only=True, location_detail=True
customer_detail = CompanyBriefSerializer(
source='order.customer', many=False, read_only=True
)
# Annotated fields
@ -1293,7 +1203,7 @@ class SalesOrderShipmentSerializer(NotesFieldMixin, InvenTreeModelSerializer):
'pk',
'order',
'order_detail',
'allocations',
'allocated_items',
'shipment_date',
'delivery_date',
'checked_by',
@ -1304,13 +1214,105 @@ class SalesOrderShipmentSerializer(NotesFieldMixin, InvenTreeModelSerializer):
'notes',
]
allocations = SalesOrderAllocationSerializer(
many=True, read_only=True, location_detail=True
@staticmethod
def annotate_queryset(queryset):
"""Annotate the queryset with extra information."""
# Prefetch related objects
queryset = queryset.prefetch_related('order', 'order__customer', 'allocations')
queryset = queryset.annotate(allocated_items=SubqueryCount('allocations'))
return queryset
allocated_items = serializers.IntegerField(
read_only=True, label=_('Allocated Items')
)
order_detail = SalesOrderSerializer(source='order', read_only=True, many=False)
class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
"""Serializer for the SalesOrderAllocation model.
This includes some fields from the related model objects.
"""
class Meta:
"""Metaclass options."""
model = order.models.SalesOrderAllocation
fields = [
'pk',
'line',
'customer_detail',
'serial',
'quantity',
'location',
'location_detail',
'item',
'item_detail',
'order',
'order_detail',
'part',
'part_detail',
'shipment',
'shipment_detail',
]
def __init__(self, *args, **kwargs):
"""Initialization routine for the serializer."""
order_detail = kwargs.pop('order_detail', False)
part_detail = kwargs.pop('part_detail', True)
item_detail = kwargs.pop('item_detail', True)
location_detail = kwargs.pop('location_detail', False)
customer_detail = kwargs.pop('customer_detail', False)
super().__init__(*args, **kwargs)
if not order_detail:
self.fields.pop('order_detail', None)
if not part_detail:
self.fields.pop('part_detail', None)
if not item_detail:
self.fields.pop('item_detail', None)
if not location_detail:
self.fields.pop('location_detail', None)
if not customer_detail:
self.fields.pop('customer_detail', None)
part = serializers.PrimaryKeyRelatedField(source='item.part', read_only=True)
order = serializers.PrimaryKeyRelatedField(
source='line.order', many=False, read_only=True
)
serial = serializers.CharField(source='get_serial', read_only=True)
quantity = serializers.FloatField(read_only=False)
location = serializers.PrimaryKeyRelatedField(
source='item.location', many=False, read_only=True
)
# Extra detail fields
order_detail = SalesOrderSerializer(source='line.order', many=False, read_only=True)
part_detail = PartBriefSerializer(source='item.part', many=False, read_only=True)
item_detail = stock.serializers.StockItemSerializerBrief(
source='item', many=False, read_only=True
)
location_detail = stock.serializers.LocationBriefSerializer(
source='item.location', many=False, read_only=True
)
customer_detail = CompanyBriefSerializer(
source='line.order.customer', many=False, read_only=True
)
shipment_detail = SalesOrderShipmentSerializer(
source='shipment', many=False, read_only=True
)
class SalesOrderShipmentCompleteSerializer(serializers.ModelSerializer):
"""Serializer for completing (shipping) a SalesOrderShipment."""

View File

@ -951,7 +951,7 @@ function loadSalesOrderShipmentTable(table, options={}) {
html += makeIconButton('fa-truck icon-green', 'button-shipment-ship', pk, '{% trans "Complete shipment" %}');
}
var enable_delete = row.allocations && row.allocations.length == 0;
var enable_delete = row.allocated_items == 0;
html += makeDeleteButton('button-shipment-delete', pk, '{% trans "Delete shipment" %}', {disabled: !enable_delete});
@ -1004,10 +1004,19 @@ function loadSalesOrderShipmentTable(table, options={}) {
detailViewByClick: false,
buttons: constructExpandCollapseButtons(table),
detailFilter: function(index, row) {
return row.allocations.length > 0;
return row.allocated_items > 0;
},
detailFormatter: function(index, row, element) {
return showAllocationSubTable(index, row, element, options);
return showAllocationSubTable(
index, row, element,
{
...options,
queryParams: {
shipment: row.pk,
order: row.order,
}
}
);
},
onPostBody: function() {
setupShipmentCallbacks();
@ -1048,17 +1057,10 @@ function loadSalesOrderShipmentTable(table, options={}) {
switchable: false,
},
{
field: 'allocations',
field: 'allocated_items',
title: '{% trans "Items" %}',
switchable: false,
sortable: true,
formatter: function(value, row) {
if (row && row.allocations) {
return row.allocations.length;
} else {
return '-';
}
}
},
{
field: 'shipment_date',
@ -1630,7 +1632,14 @@ function showAllocationSubTable(index, row, element, options) {
}
table.bootstrapTable({
url: '{% url "api-so-allocation-list" %}',
onPostBody: setupCallbacks,
queryParams: {
...options.queryParams,
part_detail: true,
location_detail: true,
order_detail: true,
},
data: row.allocations,
showHeader: true,
columns: [
@ -1641,6 +1650,13 @@ function showAllocationSubTable(index, row, element, options) {
return imageHoverIcon(part.thumbnail) + renderLink(part.full_name, `/part/${part.pk}/`);
}
},
{
field: 'shipment',
title: '{% trans "Shipment" %}',
formatter: function(value, row) {
return row.shipment_detail.reference;
}
},
{
field: 'allocated',
title: '{% trans "Stock Item" %}',
@ -2289,7 +2305,16 @@ function loadSalesOrderLineItemTable(table, options={}) {
},
detailFormatter: function(index, row, element) {
if (options.open) {
return showAllocationSubTable(index, row, element, options);
return showAllocationSubTable(
index, row, element,
{
...options,
queryParams: {
part: row.part,
order: row.order,
}
}
);
} else {
return showFulfilledSubTable(index, row, element, options);
}

View File

@ -159,7 +159,8 @@ export function ApiFormField({
adjustFilters: undefined,
adjustValue: undefined,
read_only: undefined,
children: undefined
children: undefined,
exclude: undefined
};
}, [fieldDefinition]);

View File

@ -220,6 +220,7 @@ export function RelatedModelField({
...definition,
onValueChange: undefined,
adjustFilters: undefined,
exclude: undefined,
read_only: undefined
};
}, [definition]);

View File

@ -1,8 +1,10 @@
import { Trans, t } from '@lingui/macro';
import { Container, Group, Table } from '@mantine/core';
import { Alert, Container, Group, Table } from '@mantine/core';
import { IconExclamationCircle } from '@tabler/icons-react';
import { useCallback, useEffect, useMemo } from 'react';
import { FieldValues, UseControllerReturn } from 'react-hook-form';
import { identifierString } from '../../../functions/conversion';
import { InvenTreeIcon } from '../../../functions/icons';
import { StandaloneField } from '../StandaloneField';
import { ApiFormFieldType } from './ApiFormField';
@ -58,8 +60,14 @@ export function TableField({
<Table highlightOnHover striped aria-label={`table-field-${field.name}`}>
<Table.Thead>
<Table.Tr>
{definition.headers?.map((header) => {
return <Table.Th key={header}>{header}</Table.Th>;
{definition.headers?.map((header, index) => {
return (
<Table.Th
key={`table-header-${identifierString(header)}-${index}`}
>
{header}
</Table.Th>
);
})}
</Table.Tr>
</Table.Thead>
@ -69,7 +77,17 @@ export function TableField({
// Table fields require render function
if (!definition.modelRenderer) {
return (
<Table.Tr key="table-row-no-renderer">{t`modelRenderer entry required for tables`}</Table.Tr>
<Table.Tr key="table-row-no-renderer">
<Table.Td colSpan={definition.headers?.length}>
<Alert
color="red"
title={t`Error`}
icon={<IconExclamationCircle />}
>
{`modelRenderer entry required for tables`}
</Alert>
</Table.Td>
</Table.Tr>
);
}

View File

@ -0,0 +1,27 @@
import { t } from '@lingui/macro';
import { Skeleton } from '@mantine/core';
import { IconPaperclip } from '@tabler/icons-react';
import { ModelType } from '../../enums/ModelType';
import { AttachmentTable } from '../../tables/general/AttachmentTable';
import { PanelType } from './Panel';
export default function AttachmentPanel({
model_type,
model_id
}: {
model_type: ModelType;
model_id: number | undefined;
}): PanelType {
return {
name: 'attachments',
label: t`Attachments`,
icon: <IconPaperclip />,
content:
model_type && model_id ? (
<AttachmentTable model_type={model_type} model_id={model_id} />
) : (
<Skeleton />
)
};
}

View File

@ -0,0 +1,37 @@
import { t } from '@lingui/macro';
import { Skeleton } from '@mantine/core';
import { IconNotes } from '@tabler/icons-react';
import { useMemo } from 'react';
import { ModelType } from '../../enums/ModelType';
import { useUserState } from '../../states/UserState';
import NotesEditor from '../editors/NotesEditor';
import { PanelType } from './Panel';
export default function NotesPanel({
model_type,
model_id,
editable
}: {
model_type: ModelType;
model_id: number | undefined;
editable?: boolean;
}): PanelType {
const user = useUserState.getState();
return {
name: 'notes',
label: t`Notes`,
icon: <IconNotes />,
content:
model_type && model_id ? (
<NotesEditor
modelType={model_type}
modelId={model_id}
editable={editable ?? user.hasChangePermission(model_type)}
/>
) : (
<Skeleton />
)
};
}

View File

@ -28,7 +28,7 @@ import { usePluginPanels } from '../../hooks/UsePluginPanels';
import { useLocalState } from '../../states/LocalState';
import { Boundary } from '../Boundary';
import { StylishText } from '../items/StylishText';
import { PanelType } from './Panel';
import { PanelType } from '../panels/Panel';
/**
* Set of properties which define a panel group:

View File

@ -166,8 +166,8 @@ export const ModelInformationDict: ModelDict = {
salesordershipment: {
label: () => t`Sales Order Shipment`,
label_multiple: () => t`Sales Order Shipments`,
url_overview: '/salesordershipment',
url_detail: '/salesordershipment/:pk/',
url_overview: '/sales/shipment/',
url_detail: '/sales/shipment/:pk/',
api_endpoint: ApiEndpoints.sales_order_shipment_list
},
returnorder: {

View File

@ -113,12 +113,12 @@ export function RenderSalesOrderShipment({
}: Readonly<{
instance: any;
}>): ReactNode {
let order = instance.sales_order_detail || {};
let order = instance.order_detail || {};
return (
<RenderInlineModel
primary={order.reference}
secondary={t`Shipment` + ` ${instance.description}`}
secondary={t`Shipment` + ` ${instance.reference}`}
/>
);
}

View File

@ -163,9 +163,12 @@ export enum ApiEndpoints {
sales_order_line_list = 'order/so-line/',
sales_order_extra_line_list = 'order/so-extra-line/',
sales_order_allocation_list = 'order/so-allocation/',
sales_order_shipment_list = 'order/so/shipment/',
sales_order_allocate = 'order/so/:id/allocate/',
sales_order_allocate_serials = 'order/so/:id/allocate-serials/',
sales_order_shipment_list = 'order/so/shipment/',
sales_order_shipment_complete = 'order/so/shipment/:id/ship/',
return_order_list = 'order/ro/',
return_order_issue = 'order/ro/:id/issue/',
return_order_hold = 'order/ro/:id/hold/',

View File

@ -423,15 +423,17 @@ function BuildAllocateLineRow({
if (instance) {
let available = instance.quantity - instance.allocated;
props.changeFn(
props.idx,
'quantity',
Math.min(props.item.quantity, available)
);
if (available < props.item.quantity) {
props.changeFn(
props.idx,
'quantity',
Math.min(props.item.quantity, available)
);
}
}
}
};
}, [props]);
}, [record, props]);
const quantityField: ApiFormFieldType = useMemo(() => {
return {

View File

@ -1,10 +1,22 @@
import { t } from '@lingui/macro';
import { Table } from '@mantine/core';
import { IconAddressBook, IconUser, IconUsers } from '@tabler/icons-react';
import { useMemo } from 'react';
import { useEffect, useMemo, useState } from 'react';
import RemoveRowButton from '../components/buttons/RemoveRowButton';
import { StandaloneField } from '../components/forms/StandaloneField';
import {
ApiFormAdjustFilterType,
ApiFormFieldSet
ApiFormFieldSet,
ApiFormFieldType
} from '../components/forms/fields/ApiFormField';
import { TableFieldRowProps } from '../components/forms/fields/TableField';
import { ProgressBar } from '../components/items/ProgressBar';
import { ApiEndpoints } from '../enums/ApiEndpoints';
import { ModelType } from '../enums/ModelType';
import { useCreateApiFormModal } from '../hooks/UseForm';
import { apiUrl } from '../states/ApiState';
import { PartColumn } from '../tables/ColumnRenderers';
export function useSalesOrderFields({
duplicateOrderId
@ -105,6 +117,179 @@ export function useSalesOrderLineItemFields({
return fields;
}
function SalesOrderAllocateLineRow({
props,
record,
sourceLocation
}: {
props: TableFieldRowProps;
record: any;
sourceLocation?: number | null;
}) {
// Statically defined field for selecting the stock item
const stockItemField: ApiFormFieldType = useMemo(() => {
return {
field_type: 'related field',
api_url: apiUrl(ApiEndpoints.stock_item_list),
model: ModelType.stockitem,
filters: {
available: true,
part_detail: true,
location_detail: true,
location: sourceLocation,
cascade: sourceLocation ? true : undefined,
part: record.part
},
value: props.item.stock_item,
name: 'stock_item',
onValueChange: (value: any, instance: any) => {
props.changeFn(props.idx, 'stock_item', value);
// Update the allocated quantity based on the selected stock item
if (instance) {
let available = instance.quantity - instance.allocated;
let required = record.quantity - record.allocated;
let quantity = props.item?.quantity ?? 0;
quantity = Math.max(quantity, required);
quantity = Math.min(quantity, available);
if (quantity != props.item.quantity) {
props.changeFn(props.idx, 'quantity', quantity);
}
}
}
};
}, [sourceLocation, record, props]);
// Statically defined field for selecting the allocation quantity
const quantityField: ApiFormFieldType = useMemo(() => {
return {
field_type: 'number',
name: 'quantity',
required: true,
value: props.item.quantity,
onValueChange: (value: any) => {
props.changeFn(props.idx, 'quantity', value);
}
};
}, [props]);
return (
<Table.Tr key={`table-row-${props.idx}-${record.pk}`}>
<Table.Td>
<PartColumn part={record.part_detail} />
</Table.Td>
<Table.Td>
<ProgressBar
value={record.allocated}
maximum={record.quantity}
progressLabel
/>
</Table.Td>
<Table.Td>
<StandaloneField
fieldName="stock_item"
fieldDefinition={stockItemField}
error={props.rowErrors?.stock_item?.message}
/>
</Table.Td>
<Table.Td>
<StandaloneField
fieldName="quantity"
fieldDefinition={quantityField}
error={props.rowErrors?.quantity?.message}
/>
</Table.Td>
<Table.Td>
<RemoveRowButton onClick={() => props.removeFn(props.idx)} />
</Table.Td>
</Table.Tr>
);
}
export function useAllocateToSalesOrderForm({
orderId,
shipmentId,
lineItems,
onFormSuccess
}: {
orderId: number;
shipmentId?: number;
lineItems: any[];
onFormSuccess: (response: any) => void;
}) {
const [sourceLocation, setSourceLocation] = useState<number | null>(null);
// Reset source location to known state
useEffect(() => {
setSourceLocation(null);
}, [orderId, shipmentId, lineItems]);
const fields: ApiFormFieldSet = useMemo(() => {
return {
// Non-submitted field to select the source location
source_location: {
exclude: true,
required: false,
field_type: 'related field',
api_url: apiUrl(ApiEndpoints.stock_location_list),
model: ModelType.stocklocation,
label: t`Source Location`,
description: t`Select the source location for the stock allocation`,
onValueChange: (value: any) => {
setSourceLocation(value);
}
},
items: {
field_type: 'table',
value: [],
headers: [t`Part`, t`Allocated`, t`Stock Item`, t`Quantity`],
modelRenderer: (row: TableFieldRowProps) => {
const record =
lineItems.find((item) => item.pk == row.item.line_item) ?? {};
return (
<SalesOrderAllocateLineRow
key={`table-row-${row.idx}-${record.pk}`}
props={row}
record={record}
sourceLocation={sourceLocation}
/>
);
}
},
shipment: {
filters: {
shipped: false,
order_detail: true,
order: orderId
}
}
};
}, [orderId, shipmentId, lineItems, sourceLocation]);
return useCreateApiFormModal({
title: t`Allocate Stock`,
url: ApiEndpoints.sales_order_allocate,
pk: orderId,
fields: fields,
onFormSuccess: onFormSuccess,
successMessage: t`Stock items allocated`,
size: '80%',
initialData: {
items: lineItems.map((item) => {
return {
line_item: item.pk,
quantity: 0,
stock_item: null
};
})
}
});
}
export function useSalesOrderAllocateSerialsFields({
itemId,
orderId
@ -122,6 +307,7 @@ export function useSalesOrderAllocateSerialsFields({
serial_numbers: {},
shipment: {
filters: {
order_detail: true,
order: orderId,
shipped: false
}
@ -130,19 +316,53 @@ export function useSalesOrderAllocateSerialsFields({
}, [itemId, orderId]);
}
export function useSalesOrderShipmentFields(): ApiFormFieldSet {
export function useSalesOrderShipmentFields({
pending
}: {
pending?: boolean;
}): ApiFormFieldSet {
return useMemo(() => {
return {
order: {
disabled: true
},
reference: {},
shipment_date: {},
delivery_date: {},
shipment_date: {
hidden: pending ?? true
},
delivery_date: {
hidden: pending ?? true
},
tracking_number: {},
invoice_number: {},
link: {},
notes: {}
link: {}
};
}, []);
}, [pending]);
}
export function useSalesOrderShipmentCompleteFields({
shipmentId
}: {
shipmentId?: number;
}): ApiFormFieldSet {
return useMemo(() => {
return {
shipment_date: {},
tracking_number: {},
invoice_number: {},
link: {}
};
}, [shipmentId]);
}
export function useSalesOrderAllocationFields({
shipmentId
}: {
shipmentId?: number;
}): ApiFormFieldSet {
return useMemo(() => {
return {
quantity: {}
};
}, [shipmentId]);
}

View File

@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { api } from '../App';
import { PanelType } from '../components/nav/Panel';
import { PanelType } from '../components/panels/Panel';
import {
InvenTreeContext,
useInvenTreeContext

View File

@ -30,9 +30,9 @@ import { lazy, useMemo } from 'react';
import PermissionDenied from '../../../../components/errors/PermissionDenied';
import { PlaceholderPill } from '../../../../components/items/Placeholder';
import { PanelType } from '../../../../components/nav/Panel';
import { PanelGroup } from '../../../../components/nav/PanelGroup';
import { SettingsHeader } from '../../../../components/nav/SettingsHeader';
import { PanelType } from '../../../../components/panels/Panel';
import { PanelGroup } from '../../../../components/panels/PanelGroup';
import { GlobalSettingList } from '../../../../components/settings/SettingList';
import { Loadable } from '../../../../functions/loading';
import { useUserState } from '../../../../states/UserState';

View File

@ -19,9 +19,9 @@ import { useMemo } from 'react';
import PermissionDenied from '../../../components/errors/PermissionDenied';
import { PlaceholderPanel } from '../../../components/items/Placeholder';
import { PanelType } from '../../../components/nav/Panel';
import { PanelGroup } from '../../../components/nav/PanelGroup';
import { SettingsHeader } from '../../../components/nav/SettingsHeader';
import { PanelType } from '../../../components/panels/Panel';
import { PanelGroup } from '../../../components/panels/PanelGroup';
import { GlobalSettingList } from '../../../components/settings/SettingList';
import { useServerApiState } from '../../../states/ApiState';
import { useUserState } from '../../../states/UserState';

View File

@ -11,9 +11,9 @@ import {
} from '@tabler/icons-react';
import { useMemo } from 'react';
import { PanelType } from '../../../components/nav/Panel';
import { PanelGroup } from '../../../components/nav/PanelGroup';
import { SettingsHeader } from '../../../components/nav/SettingsHeader';
import { PanelType } from '../../../components/panels/Panel';
import { PanelGroup } from '../../../components/panels/PanelGroup';
import { UserSettingList } from '../../../components/settings/SettingList';
import { useUserState } from '../../../states/UserState';
import { SecurityContent } from './AccountSettings/SecurityContent';

View File

@ -14,7 +14,7 @@ import { useCallback, useMemo } from 'react';
import { api } from '../App';
import { ActionButton } from '../components/buttons/ActionButton';
import { PageDetail } from '../components/nav/PageDetail';
import { PanelGroup } from '../components/nav/PanelGroup';
import { PanelGroup } from '../components/panels/PanelGroup';
import { ApiEndpoints } from '../enums/ApiEndpoints';
import { useTable } from '../hooks/UseTable';
import { apiUrl } from '../states/ApiState';

View File

@ -8,8 +8,6 @@ import {
IconList,
IconListCheck,
IconListNumbers,
IconNotes,
IconPaperclip,
IconReportAnalytics,
IconSitemap
} from '@tabler/icons-react';
@ -22,7 +20,6 @@ import { PrintingActions } from '../../components/buttons/PrintingActions';
import { DetailsField, DetailsTable } from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import NotesEditor from '../../components/editors/NotesEditor';
import {
BarcodeActionDropdown,
CancelItemAction,
@ -33,8 +30,10 @@ import {
} from '../../components/items/ActionDropdown';
import InstanceDetail from '../../components/nav/InstanceDetail';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelType } from '../../components/nav/Panel';
import { PanelGroup } from '../../components/nav/PanelGroup';
import AttachmentPanel from '../../components/panels/AttachmentPanel';
import NotesPanel from '../../components/panels/NotesPanel';
import { PanelType } from '../../components/panels/Panel';
import { PanelGroup } from '../../components/panels/PanelGroup';
import { StatusRenderer } from '../../components/render/StatusRenderer';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
@ -53,7 +52,6 @@ import BuildLineTable from '../../tables/build/BuildLineTable';
import { BuildOrderTable } from '../../tables/build/BuildOrderTable';
import BuildOrderTestTable from '../../tables/build/BuildOrderTestTable';
import BuildOutputTable from '../../tables/build/BuildOutputTable';
import { AttachmentTable } from '../../tables/general/AttachmentTable';
import { StockItemTable } from '../../tables/stock/StockItemTable';
import { TestStatisticsTable } from '../../tables/stock/TestStatisticsTable';
@ -343,26 +341,14 @@ export default function BuildDetail() {
),
hidden: !build?.part_detail?.testable
},
{
name: 'attachments',
label: t`Attachments`,
icon: <IconPaperclip />,
content: (
<AttachmentTable model_type={ModelType.build} model_id={Number(id)} />
)
},
{
name: 'notes',
label: t`Notes`,
icon: <IconNotes />,
content: (
<NotesEditor
modelType={ModelType.build}
modelId={build.pk}
editable={user.hasChangeRole(UserRoles.build)}
/>
)
}
AttachmentPanel({
model_type: ModelType.build,
model_id: build.pk
}),
NotesPanel({
model_type: ModelType.build,
model_id: build.pk
})
];
}, [build, id, user]);

View File

@ -5,10 +5,8 @@ import {
IconBuildingWarehouse,
IconInfoCircle,
IconMap2,
IconNotes,
IconPackageExport,
IconPackages,
IconPaperclip,
IconShoppingCart,
IconTruckDelivery,
IconTruckReturn,
@ -22,7 +20,6 @@ import { DetailsField, DetailsTable } from '../../components/details/Details';
import DetailsBadge from '../../components/details/DetailsBadge';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import NotesEditor from '../../components/editors/NotesEditor';
import {
DeleteItemAction,
EditItemAction,
@ -31,8 +28,10 @@ import {
import { Breadcrumb } from '../../components/nav/BreadcrumbList';
import InstanceDetail from '../../components/nav/InstanceDetail';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelType } from '../../components/nav/Panel';
import { PanelGroup } from '../../components/nav/PanelGroup';
import AttachmentPanel from '../../components/panels/AttachmentPanel';
import NotesPanel from '../../components/panels/NotesPanel';
import { PanelType } from '../../components/panels/Panel';
import { PanelGroup } from '../../components/panels/PanelGroup';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
@ -46,7 +45,6 @@ import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
import { AddressTable } from '../../tables/company/AddressTable';
import { ContactTable } from '../../tables/company/ContactTable';
import { AttachmentTable } from '../../tables/general/AttachmentTable';
import { ManufacturerPartTable } from '../../tables/purchasing/ManufacturerPartTable';
import { PurchaseOrderTable } from '../../tables/purchasing/PurchaseOrderTable';
import { SupplierPartTable } from '../../tables/purchasing/SupplierPartTable';
@ -256,33 +254,14 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
icon: <IconMap2 />,
content: company?.pk && <AddressTable companyId={company.pk} />
},
{
name: 'attachments',
label: t`Attachments`,
icon: <IconPaperclip />,
content: (
<AttachmentTable
model_type={ModelType.company}
model_id={company.pk}
/>
)
},
{
name: 'notes',
label: t`Notes`,
icon: <IconNotes />,
content: (
<NotesEditor
modelType={ModelType.company}
modelId={company.pk}
editable={
user.hasChangeRole(UserRoles.purchase_order) ||
user.hasChangeRole(UserRoles.sales_order) ||
user.hasChangeRole(UserRoles.return_order)
}
/>
)
}
AttachmentPanel({
model_type: ModelType.company,
model_id: company.pk
}),
NotesPanel({
model_type: ModelType.company,
model_id: company.pk
})
];
}, [id, company, user]);

View File

@ -3,9 +3,7 @@ import { Grid, Skeleton, Stack } from '@mantine/core';
import {
IconBuildingWarehouse,
IconInfoCircle,
IconList,
IconNotes,
IconPaperclip
IconList
} from '@tabler/icons-react';
import { useMemo } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
@ -14,7 +12,6 @@ import AdminButton from '../../components/buttons/AdminButton';
import { DetailsField, DetailsTable } from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import NotesEditor from '../../components/editors/NotesEditor';
import {
DeleteItemAction,
DuplicateItemAction,
@ -23,8 +20,10 @@ import {
} from '../../components/items/ActionDropdown';
import InstanceDetail from '../../components/nav/InstanceDetail';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelType } from '../../components/nav/Panel';
import { PanelGroup } from '../../components/nav/PanelGroup';
import AttachmentPanel from '../../components/panels/AttachmentPanel';
import NotesPanel from '../../components/panels/NotesPanel';
import { PanelType } from '../../components/panels/Panel';
import { PanelGroup } from '../../components/panels/PanelGroup';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
@ -38,7 +37,6 @@ import {
import { useInstance } from '../../hooks/UseInstance';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
import { AttachmentTable } from '../../tables/general/AttachmentTable';
import ManufacturerPartParameterTable from '../../tables/purchasing/ManufacturerPartParameterTable';
import { SupplierPartTable } from '../../tables/purchasing/SupplierPartTable';
@ -187,29 +185,14 @@ export default function ManufacturerPartDetail() {
<Skeleton />
)
},
{
name: 'attachments',
label: t`Attachments`,
icon: <IconPaperclip />,
content: (
<AttachmentTable
model_type={ModelType.manufacturerpart}
model_id={manufacturerPart?.pk}
/>
)
},
{
name: 'notes',
label: t`Notes`,
icon: <IconNotes />,
content: (
<NotesEditor
modelType={ModelType.manufacturerpart}
modelId={manufacturerPart.pk}
editable={user.hasChangeRole(UserRoles.purchase_order)}
/>
)
}
AttachmentPanel({
model_type: ModelType.manufacturerpart,
model_id: manufacturerPart?.pk
}),
NotesPanel({
model_type: ModelType.manufacturerpart,
model_id: manufacturerPart?.pk
})
];
}, [manufacturerPart]);

View File

@ -3,7 +3,6 @@ import { Grid, Skeleton, Stack } from '@mantine/core';
import {
IconCurrencyDollar,
IconInfoCircle,
IconNotes,
IconPackages,
IconShoppingCart
} from '@tabler/icons-react';
@ -15,7 +14,6 @@ import { DetailsField, DetailsTable } from '../../components/details/Details';
import DetailsBadge from '../../components/details/DetailsBadge';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import NotesEditor from '../../components/editors/NotesEditor';
import {
BarcodeActionDropdown,
DeleteItemAction,
@ -25,8 +23,9 @@ import {
} from '../../components/items/ActionDropdown';
import InstanceDetail from '../../components/nav/InstanceDetail';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelType } from '../../components/nav/Panel';
import { PanelGroup } from '../../components/nav/PanelGroup';
import NotesPanel from '../../components/panels/NotesPanel';
import { PanelType } from '../../components/panels/Panel';
import { PanelGroup } from '../../components/panels/PanelGroup';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
@ -257,18 +256,10 @@ export default function SupplierPartDetail() {
<Skeleton />
)
},
{
name: 'notes',
label: t`Notes`,
icon: <IconNotes />,
content: (
<NotesEditor
modelType={ModelType.supplierpart}
modelId={supplierPart.pk}
editable={user.hasChangeRole(UserRoles.purchase_order)}
/>
)
}
NotesPanel({
model_type: ModelType.supplierpart,
model_id: supplierPart?.pk
})
];
}, [supplierPart]);

View File

@ -21,8 +21,8 @@ import { ApiIcon } from '../../components/items/ApiIcon';
import InstanceDetail from '../../components/nav/InstanceDetail';
import NavigationTree from '../../components/nav/NavigationTree';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelType } from '../../components/nav/Panel';
import { PanelGroup } from '../../components/nav/PanelGroup';
import { PanelType } from '../../components/panels/Panel';
import { PanelGroup } from '../../components/panels/PanelGroup';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';

View File

@ -59,8 +59,10 @@ import { StylishText } from '../../components/items/StylishText';
import InstanceDetail from '../../components/nav/InstanceDetail';
import NavigationTree from '../../components/nav/NavigationTree';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelType } from '../../components/nav/Panel';
import { PanelGroup } from '../../components/nav/PanelGroup';
import AttachmentPanel from '../../components/panels/AttachmentPanel';
import NotesPanel from '../../components/panels/NotesPanel';
import { PanelType } from '../../components/panels/Panel';
import { PanelGroup } from '../../components/panels/PanelGroup';
import { RenderPart } from '../../components/render/Part';
import { formatPriceRange } from '../../defaults/formatters';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
@ -90,7 +92,6 @@ import { BomTable } from '../../tables/bom/BomTable';
import { UsedInTable } from '../../tables/bom/UsedInTable';
import BuildAllocatedStockTable from '../../tables/build/BuildAllocatedStockTable';
import { BuildOrderTable } from '../../tables/build/BuildOrderTable';
import { AttachmentTable } from '../../tables/general/AttachmentTable';
import { PartParameterTable } from '../../tables/part/PartParameterTable';
import PartPurchaseOrdersTable from '../../tables/part/PartPurchaseOrdersTable';
import PartTestTemplateTable from '../../tables/part/PartTestTemplateTable';
@ -742,26 +743,14 @@ export default function PartDetail() {
icon: <IconLayersLinked />,
content: <RelatedPartTable partId={part.pk ?? -1} />
},
{
name: 'attachments',
label: t`Attachments`,
icon: <IconPaperclip />,
content: (
<AttachmentTable model_type={ModelType.part} model_id={part?.pk} />
)
},
{
name: 'notes',
label: t`Notes`,
icon: <IconNotes />,
content: (
<NotesEditor
modelType={ModelType.part}
modelId={part.pk}
editable={user.hasChangeRole(UserRoles.part)}
/>
)
}
AttachmentPanel({
model_type: ModelType.part,
model_id: part?.pk
}),
NotesPanel({
model_type: ModelType.part,
model_id: part?.pk
})
];
}, [id, part, user, globalSettings, userSettings]);

View File

@ -1,12 +1,6 @@
import { t } from '@lingui/macro';
import { Accordion, Grid, Skeleton, Stack } from '@mantine/core';
import {
IconInfoCircle,
IconList,
IconNotes,
IconPackages,
IconPaperclip
} from '@tabler/icons-react';
import { IconInfoCircle, IconList, IconPackages } from '@tabler/icons-react';
import { ReactNode, useMemo } from 'react';
import { useParams } from 'react-router-dom';
@ -16,7 +10,6 @@ import { PrintingActions } from '../../components/buttons/PrintingActions';
import { DetailsField, DetailsTable } from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import NotesEditor from '../../components/editors/NotesEditor';
import {
BarcodeActionDropdown,
CancelItemAction,
@ -28,8 +21,10 @@ import {
import { StylishText } from '../../components/items/StylishText';
import InstanceDetail from '../../components/nav/InstanceDetail';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelType } from '../../components/nav/Panel';
import { PanelGroup } from '../../components/nav/PanelGroup';
import AttachmentPanel from '../../components/panels/AttachmentPanel';
import NotesPanel from '../../components/panels/NotesPanel';
import { PanelType } from '../../components/panels/Panel';
import { PanelGroup } from '../../components/panels/PanelGroup';
import { StatusRenderer } from '../../components/render/StatusRenderer';
import { formatCurrency } from '../../defaults/formatters';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
@ -45,7 +40,6 @@ import useStatusCodes from '../../hooks/UseStatusCodes';
import { apiUrl } from '../../states/ApiState';
import { useGlobalSettingsState } from '../../states/SettingsState';
import { useUserState } from '../../states/UserState';
import { AttachmentTable } from '../../tables/general/AttachmentTable';
import ExtraLineItemTable from '../../tables/general/ExtraLineItemTable';
import { PurchaseOrderLineItemTable } from '../../tables/purchasing/PurchaseOrderLineItemTable';
import { StockItemTable } from '../../tables/stock/StockItemTable';
@ -304,29 +298,14 @@ export default function PurchaseOrderDetail() {
/>
)
},
{
name: 'attachments',
label: t`Attachments`,
icon: <IconPaperclip />,
content: (
<AttachmentTable
model_type={ModelType.purchaseorder}
model_id={order.pk}
/>
)
},
{
name: 'notes',
label: t`Notes`,
icon: <IconNotes />,
content: (
<NotesEditor
modelType={ModelType.purchaseorder}
modelId={order.pk}
editable={user.hasChangeRole(UserRoles.purchase_order)}
/>
)
}
AttachmentPanel({
model_type: ModelType.purchaseorder,
model_id: order.pk
}),
NotesPanel({
model_type: ModelType.purchaseorder,
model_id: order.pk
})
];
}, [order, id, user]);

View File

@ -9,7 +9,7 @@ import { useMemo } from 'react';
import PermissionDenied from '../../components/errors/PermissionDenied';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup } from '../../components/nav/PanelGroup';
import { PanelGroup } from '../../components/panels/PanelGroup';
import { UserRoles } from '../../enums/Roles';
import { useUserState } from '../../states/UserState';
import { CompanyTable } from '../../tables/company/CompanyTable';

View File

@ -1,11 +1,6 @@
import { t } from '@lingui/macro';
import { Accordion, Grid, Skeleton, Stack } from '@mantine/core';
import {
IconInfoCircle,
IconList,
IconNotes,
IconPaperclip
} from '@tabler/icons-react';
import { IconInfoCircle, IconList } from '@tabler/icons-react';
import { ReactNode, useMemo } from 'react';
import { useParams } from 'react-router-dom';
@ -15,7 +10,6 @@ import { PrintingActions } from '../../components/buttons/PrintingActions';
import { DetailsField, DetailsTable } from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import NotesEditor from '../../components/editors/NotesEditor';
import {
BarcodeActionDropdown,
CancelItemAction,
@ -27,8 +21,10 @@ import {
import { StylishText } from '../../components/items/StylishText';
import InstanceDetail from '../../components/nav/InstanceDetail';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelType } from '../../components/nav/Panel';
import { PanelGroup } from '../../components/nav/PanelGroup';
import AttachmentPanel from '../../components/panels/AttachmentPanel';
import NotesPanel from '../../components/panels/NotesPanel';
import { PanelType } from '../../components/panels/Panel';
import { PanelGroup } from '../../components/panels/PanelGroup';
import { StatusRenderer } from '../../components/render/StatusRenderer';
import { formatCurrency } from '../../defaults/formatters';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
@ -44,7 +40,6 @@ import useStatusCodes from '../../hooks/UseStatusCodes';
import { apiUrl } from '../../states/ApiState';
import { useGlobalSettingsState } from '../../states/SettingsState';
import { useUserState } from '../../states/UserState';
import { AttachmentTable } from '../../tables/general/AttachmentTable';
import ExtraLineItemTable from '../../tables/general/ExtraLineItemTable';
import ReturnOrderLineItemTable from '../../tables/sales/ReturnOrderLineItemTable';
@ -267,29 +262,14 @@ export default function ReturnOrderDetail() {
</Accordion>
)
},
{
name: 'attachments',
label: t`Attachments`,
icon: <IconPaperclip />,
content: (
<AttachmentTable
model_type={ModelType.returnorder}
model_id={order.pk}
/>
)
},
{
name: 'notes',
label: t`Notes`,
icon: <IconNotes />,
content: (
<NotesEditor
modelType={ModelType.returnorder}
modelId={order.pk}
editable={user.hasChangeRole(UserRoles.return_order)}
/>
)
}
AttachmentPanel({
model_type: ModelType.returnorder,
model_id: order.pk
}),
NotesPanel({
model_type: ModelType.returnorder,
model_id: order.pk
})
];
}, [order, id, user]);

View File

@ -9,7 +9,7 @@ import { useMemo } from 'react';
import PermissionDenied from '../../components/errors/PermissionDenied';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup } from '../../components/nav/PanelGroup';
import { PanelGroup } from '../../components/panels/PanelGroup';
import { UserRoles } from '../../enums/Roles';
import { useUserState } from '../../states/UserState';
import { CompanyTable } from '../../tables/company/CompanyTable';

View File

@ -4,8 +4,6 @@ import {
IconBookmark,
IconInfoCircle,
IconList,
IconNotes,
IconPaperclip,
IconTools,
IconTruckDelivery
} from '@tabler/icons-react';
@ -18,7 +16,6 @@ import { PrintingActions } from '../../components/buttons/PrintingActions';
import { DetailsField, DetailsTable } from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import NotesEditor from '../../components/editors/NotesEditor';
import {
BarcodeActionDropdown,
CancelItemAction,
@ -30,8 +27,10 @@ import {
import { StylishText } from '../../components/items/StylishText';
import InstanceDetail from '../../components/nav/InstanceDetail';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelType } from '../../components/nav/Panel';
import { PanelGroup } from '../../components/nav/PanelGroup';
import AttachmentPanel from '../../components/panels/AttachmentPanel';
import NotesPanel from '../../components/panels/NotesPanel';
import { PanelType } from '../../components/panels/Panel';
import { PanelGroup } from '../../components/panels/PanelGroup';
import { StatusRenderer } from '../../components/render/StatusRenderer';
import { formatCurrency } from '../../defaults/formatters';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
@ -48,7 +47,6 @@ import { apiUrl } from '../../states/ApiState';
import { useGlobalSettingsState } from '../../states/SettingsState';
import { useUserState } from '../../states/UserState';
import { BuildOrderTable } from '../../tables/build/BuildOrderTable';
import { AttachmentTable } from '../../tables/general/AttachmentTable';
import ExtraLineItemTable from '../../tables/general/ExtraLineItemTable';
import SalesOrderAllocationTable from '../../tables/sales/SalesOrderAllocationTable';
import SalesOrderLineItemTable from '../../tables/sales/SalesOrderLineItemTable';
@ -338,29 +336,14 @@ export default function SalesOrderDetail() {
<Skeleton />
)
},
{
name: 'attachments',
label: t`Attachments`,
icon: <IconPaperclip />,
content: (
<AttachmentTable
model_type={ModelType.salesorder}
model_id={order.pk}
/>
)
},
{
name: 'notes',
label: t`Notes`,
icon: <IconNotes />,
content: (
<NotesEditor
modelType={ModelType.salesorder}
modelId={order.pk}
editable={user.hasChangeRole(UserRoles.sales_order)}
/>
)
}
AttachmentPanel({
model_type: ModelType.salesorder,
model_id: order.pk
}),
NotesPanel({
model_type: ModelType.salesorder,
model_id: order.pk
})
];
}, [order, id, user, soStatus]);

View File

@ -0,0 +1,366 @@
import { t } from '@lingui/macro';
import { Grid, Skeleton, Stack } from '@mantine/core';
import { IconInfoCircle, IconPackages } from '@tabler/icons-react';
import { useMemo } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import PrimaryActionButton from '../../components/buttons/PrimaryActionButton';
import { PrintingActions } from '../../components/buttons/PrintingActions';
import { DetailsField, DetailsTable } from '../../components/details/Details';
import DetailsBadge from '../../components/details/DetailsBadge';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import {
BarcodeActionDropdown,
CancelItemAction,
EditItemAction,
OptionsActionDropdown
} from '../../components/items/ActionDropdown';
import InstanceDetail from '../../components/nav/InstanceDetail';
import { PageDetail } from '../../components/nav/PageDetail';
import AttachmentPanel from '../../components/panels/AttachmentPanel';
import NotesPanel from '../../components/panels/NotesPanel';
import { PanelType } from '../../components/panels/Panel';
import { PanelGroup } from '../../components/panels/PanelGroup';
import { formatDate } from '../../defaults/formatters';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import {
useSalesOrderShipmentCompleteFields,
useSalesOrderShipmentFields
} from '../../forms/SalesOrderForms';
import { getDetailUrl } from '../../functions/urls';
import {
useCreateApiFormModal,
useDeleteApiFormModal,
useEditApiFormModal
} from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance';
import { useUserState } from '../../states/UserState';
import SalesOrderAllocationTable from '../../tables/sales/SalesOrderAllocationTable';
export default function SalesOrderShipmentDetail() {
const { id } = useParams();
const user = useUserState();
const navigate = useNavigate();
const {
instance: shipment,
instanceQuery: shipmentQuery,
refreshInstance: refreshShipment,
requestStatus: shipmentStatus
} = useInstance({
endpoint: ApiEndpoints.sales_order_shipment_list,
pk: id,
params: {
order_detail: true
}
});
const {
instance: customer,
instanceQuery: customerQuery,
refreshInstance: refreshCustomer,
requestStatus: customerStatus
} = useInstance({
endpoint: ApiEndpoints.company_list,
pk: shipment.order_detail?.customer,
hasPrimaryKey: true
});
const isPending = useMemo(() => !shipment.shipment_date, [shipment]);
const detailsPanel = useMemo(() => {
if (shipmentQuery.isFetching || customerQuery.isFetching) {
return <Skeleton />;
}
let data: any = {
...shipment,
customer: customer?.pk,
customer_name: customer?.name,
customer_reference: shipment.order_detail?.customer_reference
};
// Top Left: Order / customer information
let tl: DetailsField[] = [
{
type: 'link',
model: ModelType.salesorder,
name: 'order',
label: t`Sales Order`,
icon: 'sales_orders',
model_field: 'reference'
},
{
type: 'link',
name: 'customer',
icon: 'customers',
label: t`Customer`,
model: ModelType.company,
model_field: 'name',
hidden: !data.customer
},
{
type: 'text',
name: 'customer_reference',
icon: 'serial',
label: t`Customer Reference`,
hidden: !data.customer_reference,
copy: true
},
{
type: 'text',
name: 'reference',
icon: 'serial',
label: t`Shipment Reference`,
copy: true
},
{
type: 'text',
name: 'allocated_items',
icon: 'packages',
label: t`Allocated Items`
}
];
// Top right: Shipment information
let tr: DetailsField[] = [
{
type: 'text',
name: 'tracking_number',
label: t`Tracking Number`,
icon: 'trackable',
value_formatter: () => shipment.tracking_number || '---',
copy: !!shipment.tracking_number
},
{
type: 'text',
name: 'invoice_number',
label: t`Invoice Number`,
icon: 'serial',
value_formatter: () => shipment.invoice_number || '---',
copy: !!shipment.invoice_number
},
{
type: 'text',
name: 'shipment_date',
label: t`Shipment Date`,
icon: 'calendar',
value_formatter: () => formatDate(shipment.shipment_date),
hidden: !shipment.shipment_date
},
{
type: 'text',
name: 'delivery_date',
label: t`Delivery Date`,
icon: 'calendar',
value_formatter: () => formatDate(shipment.delivery_date),
hidden: !shipment.delivery_date
},
{
type: 'link',
external: true,
name: 'link',
label: t`Link`,
copy: true,
hidden: !shipment.link
}
];
return (
<>
<ItemDetailsGrid>
<Grid>
<Grid.Col span={4}>
<DetailsImage
appRole={UserRoles.sales_order}
apiPath={ApiEndpoints.company_list}
src={customer?.image}
pk={customer?.pk}
imageActions={{
selectExisting: false,
downloadImage: false,
uploadFile: false,
deleteFile: false
}}
/>
</Grid.Col>
<Grid.Col span={8}>
<DetailsTable fields={tl} item={data} />
</Grid.Col>
</Grid>
<DetailsTable fields={tr} item={data} />
</ItemDetailsGrid>
</>
);
}, [shipment, shipmentQuery, customer, customerQuery]);
const shipmentPanels: PanelType[] = useMemo(() => {
return [
{
name: 'detail',
label: t`Shipment Details`,
icon: <IconInfoCircle />,
content: detailsPanel
},
{
name: 'items',
label: t`Assigned Items`,
icon: <IconPackages />,
content: (
<SalesOrderAllocationTable
shipmentId={shipment.pk}
showPartInfo
allowEdit={isPending}
modelField="item"
modelTarget={ModelType.stockitem}
/>
)
},
AttachmentPanel({
model_type: ModelType.salesordershipment,
model_id: shipment.pk
}),
NotesPanel({
model_type: ModelType.salesordershipment,
model_id: shipment.pk
})
];
}, [isPending, shipment, detailsPanel]);
const editShipmentFields = useSalesOrderShipmentFields({
pending: isPending
});
const editShipment = useEditApiFormModal({
url: ApiEndpoints.sales_order_shipment_list,
pk: shipment.pk,
fields: editShipmentFields,
title: t`Edit Shipment`,
onFormSuccess: refreshShipment
});
const deleteShipment = useDeleteApiFormModal({
url: ApiEndpoints.sales_order_shipment_list,
pk: shipment.pk,
title: t`Cancel Shipment`,
onFormSuccess: () => {
// Shipment has been deleted - navigate back to the sales order
navigate(getDetailUrl(ModelType.salesorder, shipment.order));
}
});
const completeShipmentFields = useSalesOrderShipmentCompleteFields({});
const completeShipment = useCreateApiFormModal({
url: ApiEndpoints.sales_order_shipment_complete,
pk: shipment.pk,
fields: completeShipmentFields,
title: t`Complete Shipment`,
focus: 'tracking_number',
initialData: {
...shipment,
shipment_date: new Date().toISOString().split('T')[0]
},
onFormSuccess: refreshShipment
});
const shipmentBadges = useMemo(() => {
if (shipmentQuery.isFetching) {
return [];
}
return [
<DetailsBadge label={t`Pending`} color="gray" visible={isPending} />,
<DetailsBadge label={t`Shipped`} color="green" visible={!isPending} />,
<DetailsBadge
label={t`Delivered`}
color="blue"
visible={!!shipment.delivery_date}
/>
];
}, [shipment, shipmentQuery]);
const shipmentActions = useMemo(() => {
const canEdit: boolean = user.hasChangePermission(
ModelType.salesordershipment
);
return [
<PrimaryActionButton
title={t`Send Shipment`}
icon="sales_orders"
hidden={!isPending}
color="green"
onClick={() => {
completeShipment.open();
}}
/>,
<BarcodeActionDropdown
model={ModelType.salesordershipment}
pk={shipment.pk}
/>,
<PrintingActions
modelType={ModelType.salesordershipment}
items={[shipment.pk]}
enableLabels
enableReports
/>,
<OptionsActionDropdown
tooltip={t`Shipment Actions`}
actions={[
EditItemAction({
hidden: !canEdit,
onClick: editShipment.open,
tooltip: t`Edit Shipment`
}),
CancelItemAction({
hidden: !isPending,
onClick: deleteShipment.open,
tooltip: t`Cancel Shipment`
})
]}
/>
];
}, [isPending, user, shipment]);
return (
<>
{completeShipment.modal}
{editShipment.modal}
{deleteShipment.modal}
<InstanceDetail
status={shipmentStatus}
loading={shipmentQuery.isFetching || customerQuery.isFetching}
>
<Stack gap="xs">
<PageDetail
title={t`Sales Order Shipment` + `: ${shipment.reference}`}
subtitle={t`Sales Order` + `: ${shipment.order_detail?.reference}`}
breadcrumbs={[
{ name: t`Sales`, url: '/sales/' },
{
name: shipment.order_detail?.reference,
url: getDetailUrl(ModelType.salesorder, shipment.order)
}
]}
badges={shipmentBadges}
imageUrl={customer?.image}
editAction={editShipment.open}
editEnabled={user.hasChangePermission(ModelType.salesordershipment)}
actions={shipmentActions}
/>
<PanelGroup
pageKey="salesordershipment"
panels={shipmentPanels}
model={ModelType.salesordershipment}
instance={shipment}
id={shipment.pk}
/>
</Stack>
</InstanceDetail>
</>
);
}

View File

@ -20,8 +20,8 @@ import { ApiIcon } from '../../components/items/ApiIcon';
import InstanceDetail from '../../components/nav/InstanceDetail';
import NavigationTree from '../../components/nav/NavigationTree';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelType } from '../../components/nav/Panel';
import { PanelGroup } from '../../components/nav/PanelGroup';
import { PanelType } from '../../components/panels/Panel';
import { PanelGroup } from '../../components/panels/PanelGroup';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';

View File

@ -6,9 +6,7 @@ import {
IconChecklist,
IconHistory,
IconInfoCircle,
IconNotes,
IconPackages,
IconPaperclip,
IconSitemap
} from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
@ -22,7 +20,6 @@ import { DetailsField, DetailsTable } from '../../components/details/Details';
import DetailsBadge from '../../components/details/DetailsBadge';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import NotesEditor from '../../components/editors/NotesEditor';
import {
ActionDropdown,
BarcodeActionDropdown,
@ -35,8 +32,10 @@ import { StylishText } from '../../components/items/StylishText';
import InstanceDetail from '../../components/nav/InstanceDetail';
import NavigationTree from '../../components/nav/NavigationTree';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelType } from '../../components/nav/Panel';
import { PanelGroup } from '../../components/nav/PanelGroup';
import AttachmentPanel from '../../components/panels/AttachmentPanel';
import NotesPanel from '../../components/panels/NotesPanel';
import { PanelType } from '../../components/panels/Panel';
import { PanelGroup } from '../../components/panels/PanelGroup';
import { StatusRenderer } from '../../components/render/StatusRenderer';
import { formatCurrency } from '../../defaults/formatters';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
@ -62,7 +61,6 @@ import { useInstance } from '../../hooks/UseInstance';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
import BuildAllocatedStockTable from '../../tables/build/BuildAllocatedStockTable';
import { AttachmentTable } from '../../tables/general/AttachmentTable';
import SalesOrderAllocationTable from '../../tables/sales/SalesOrderAllocationTable';
import InstalledItemsTable from '../../tables/stock/InstalledItemsTable';
import { StockItemTable } from '../../tables/stock/StockItemTable';
@ -480,29 +478,14 @@ export default function StockDetail() {
<Skeleton />
)
},
{
name: 'attachments',
label: t`Attachments`,
icon: <IconPaperclip />,
content: (
<AttachmentTable
model_type={ModelType.stockitem}
model_id={stockitem.pk}
/>
)
},
{
name: 'notes',
label: t`Notes`,
icon: <IconNotes />,
content: (
<NotesEditor
modelType={ModelType.stockitem}
modelId={stockitem.pk}
editable={user.hasChangeRole(UserRoles.stock)}
/>
)
}
AttachmentPanel({
model_type: ModelType.stockitem,
model_id: stockitem.pk
}),
NotesPanel({
model_type: ModelType.stockitem,
model_id: stockitem.pk
})
];
}, [
showSalesAlloctions,

View File

@ -74,6 +74,10 @@ export const SalesOrderDetail = Loadable(
lazy(() => import('./pages/sales/SalesOrderDetail'))
);
export const SalesOrderShipmentDetail = Loadable(
lazy(() => import('./pages/sales/SalesOrderShipmentDetail'))
);
export const ReturnOrderDetail = Loadable(
lazy(() => import('./pages/sales/ReturnOrderDetail'))
);
@ -160,6 +164,7 @@ export const routes = (
<Route index element={<Navigate to="index/" />} />
<Route path="index/*" element={<SalesIndex />} />
<Route path="sales-order/:id/*" element={<SalesOrderDetail />} />
<Route path="shipment/:id/*" element={<SalesOrderShipmentDetail />} />
<Route path="return-order/:id/*" element={<ReturnOrderDetail />} />
<Route path="customer/:id/*" element={<CustomerDetail />} />
</Route>

View File

@ -1,6 +1,12 @@
import { t } from '@lingui/macro';
import { ActionIcon, Menu, Tooltip } from '@mantine/core';
import { IconCopy, IconDots, IconEdit, IconTrash } from '@tabler/icons-react';
import {
IconCircleX,
IconCopy,
IconDots,
IconEdit,
IconTrash
} from '@tabler/icons-react';
import { ReactNode, useMemo, useState } from 'react';
import { cancelEvent } from '../functions/events';
@ -11,7 +17,7 @@ export type RowAction = {
tooltip?: string;
color?: string;
icon?: ReactNode;
onClick: () => void;
onClick: (event: any) => void;
hidden?: boolean;
disabled?: boolean;
};
@ -46,6 +52,16 @@ export function RowDeleteAction(props: RowAction): RowAction {
};
}
// Component for cancelling a row in a table
export function RowCancelAction(props: RowAction): RowAction {
return {
...props,
title: t`Cancel`,
color: 'red',
icon: <IconCircleX />
};
}
/**
* Component for displaying actions for a row in a table.
* Displays a simple dropdown menu with a list of actions.
@ -89,7 +105,7 @@ export function RowActions({
onClick={(event) => {
// Prevent clicking on the action from selecting the row itself
cancelEvent(event);
action.onClick();
action.onClick(event);
setOpened(false);
}}
disabled={action.disabled || false}

View File

@ -316,7 +316,7 @@ export default function BuildLineTable({
)
});
const allowcateStock = useAllocateStockToBuildForm({
const allocateStock = useAllocateStockToBuildForm({
build: build,
outputId: null,
buildId: build.pk,
@ -395,7 +395,7 @@ export default function BuildLineTable({
color: 'green',
onClick: () => {
setSelectedRows([record]);
allowcateStock.open();
allocateStock.open();
}
},
{
@ -465,7 +465,7 @@ export default function BuildLineTable({
!r.bom_item_detail.consumable
)
);
allowcateStock.open();
allocateStock.open();
}}
/>,
<ActionButton
@ -493,7 +493,7 @@ export default function BuildLineTable({
<>
{autoAllocateStock.modal}
{newBuildOrder.modal}
{allowcateStock.modal}
{allocateStock.modal}
{deallocateStock.modal}
<InvenTreeTable
url={apiUrl(ApiEndpoints.build_line_list)}

View File

@ -179,6 +179,7 @@ export default function BuildOutputTable({
url: apiUrl(ApiEndpoints.build_output_create, buildId),
title: t`Add Build Output`,
fields: buildOutputFields,
timeout: 10000,
initialData: {
batch_code: build.batch,
location: build.destination ?? build.part_detail?.default_location

View File

@ -1,8 +1,16 @@
import { t } from '@lingui/macro';
import { useCallback, useMemo } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { AddItemButton } from '../../components/buttons/AddItemButton';
import { YesNoButton } from '../../components/buttons/YesNoButton';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import { useSalesOrderAllocationFields } from '../../forms/SalesOrderForms';
import {
useDeleteApiFormModal,
useEditApiFormModal
} from '../../hooks/UseForm';
import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
@ -15,12 +23,13 @@ import {
} from '../ColumnRenderers';
import { TableFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
import { RowAction } from '../RowActions';
import { RowAction, RowDeleteAction, RowEditAction } from '../RowActions';
export default function SalesOrderAllocationTable({
partId,
stockId,
orderId,
shipmentId,
showPartInfo,
showOrderInfo,
allowEdit,
@ -30,6 +39,7 @@ export default function SalesOrderAllocationTable({
partId?: number;
stockId?: number;
orderId?: number;
shipmentId?: number;
showPartInfo?: boolean;
showOrderInfo?: boolean;
allowEdit?: boolean;
@ -40,7 +50,13 @@ export default function SalesOrderAllocationTable({
const table = useTable('salesorderallocations');
const tableFilters: TableFilter[] = useMemo(() => {
return [];
return [
{
name: 'outstanding',
label: t`Outstanding`,
description: t`Show outstanding allocations`
}
];
}, []);
const tableColumns: TableColumn[] = useMemo(() => {
@ -49,6 +65,7 @@ export default function SalesOrderAllocationTable({
accessor: 'order_detail.reference',
title: t`Sales Order`,
switchable: false,
sortable: true,
hidden: showOrderInfo != true
}),
{
@ -70,65 +87,138 @@ export default function SalesOrderAllocationTable({
switchable: false,
render: (record: any) => PartColumn({ part: record.part_detail })
},
{
accessor: 'quantity',
title: t`Allocated Quantity`,
sortable: true
},
{
accessor: 'serial',
title: t`Serial Number`,
sortable: false,
sortable: true,
switchable: true,
render: (record: any) => record?.item_detail?.serial
},
{
accessor: 'batch',
title: t`Batch Code`,
sortable: false,
sortable: true,
switchable: true,
render: (record: any) => record?.item_detail?.batch
},
{
accessor: 'available',
title: t`Available Quantity`,
sortable: false,
render: (record: any) => record?.item_detail?.quantity
},
{
accessor: 'quantity',
title: t`Allocated Quantity`,
sortable: true
},
LocationColumn({
accessor: 'location_detail',
switchable: true,
sortable: true
})
}),
{
accessor: 'shipment_detail.reference',
title: t`Shipment`,
switchable: true,
sortable: false
},
{
accessor: 'shipment_date',
title: t`Shipped`,
switchable: true,
sortable: false,
render: (record: any) => (
<YesNoButton value={!!record.shipment_detail?.shipment_date} />
)
}
];
}, []);
const [selectedAllocation, setSelectedAllocation] = useState<number>(0);
const editAllocationFields = useSalesOrderAllocationFields({
shipmentId: shipmentId
});
const editAllocation = useEditApiFormModal({
url: ApiEndpoints.sales_order_allocation_list,
pk: selectedAllocation,
fields: editAllocationFields,
title: t`Edit Allocation`,
table: table
});
const deleteAllocation = useDeleteApiFormModal({
url: ApiEndpoints.sales_order_allocation_list,
pk: selectedAllocation,
title: t`Delete Allocation`,
table: table
});
const rowActions = useCallback(
(record: any): RowAction[] => {
return [];
// Do not allow "shipped" items to be manipulated
const isShipped = !!record.shipment_detail?.shipment_date;
if (isShipped || !allowEdit) {
return [];
}
return [
RowEditAction({
tooltip: t`Edit Allocation`,
onClick: () => {
setSelectedAllocation(record.pk);
editAllocation.open();
}
}),
RowDeleteAction({
tooltip: t`Delete Allocation`,
onClick: () => {
setSelectedAllocation(record.pk);
deleteAllocation.open();
}
})
];
},
[user]
[allowEdit, user]
);
const tableActions = useMemo(() => {
if (!allowEdit) {
return [];
}
return [];
}, [allowEdit, user]);
return (
<InvenTreeTable
url={apiUrl(ApiEndpoints.sales_order_allocation_list)}
tableState={table}
columns={tableColumns}
props={{
params: {
part_detail: showPartInfo ?? false,
order_detail: showOrderInfo ?? false,
item_detail: true,
location_detail: true,
part: partId,
order: orderId,
stock_item: stockId
},
rowActions: rowActions,
tableFilters: tableFilters,
modelField: modelField ?? 'order',
modelType: modelTarget ?? ModelType.salesorder
}}
/>
<>
{editAllocation.modal}
{deleteAllocation.modal}
<InvenTreeTable
url={apiUrl(ApiEndpoints.sales_order_allocation_list)}
tableState={table}
columns={tableColumns}
props={{
params: {
part_detail: showPartInfo ?? false,
order_detail: showOrderInfo ?? false,
item_detail: true,
location_detail: true,
part: partId,
order: orderId,
shipment: shipmentId,
item: stockId
},
rowActions: rowActions,
tableActions: tableActions,
tableFilters: tableFilters,
modelField: modelField ?? 'order',
modelType: modelTarget ?? ModelType.salesorder
}}
/>
</>
);
}

View File

@ -1,6 +1,7 @@
import { t } from '@lingui/macro';
import { Text } from '@mantine/core';
import {
IconArrowRight,
IconHash,
IconShoppingCart,
IconSquareArrowRight,
@ -8,6 +9,7 @@ import {
} from '@tabler/icons-react';
import { ReactNode, useCallback, useMemo, useState } from 'react';
import { ActionButton } from '../../components/buttons/ActionButton';
import { AddItemButton } from '../../components/buttons/AddItemButton';
import { ProgressBar } from '../../components/items/ProgressBar';
import { formatCurrency } from '../../defaults/formatters';
@ -16,6 +18,7 @@ import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import { useBuildOrderFields } from '../../forms/BuildForms';
import {
useAllocateToSalesOrderForm,
useSalesOrderAllocateSerialsFields,
useSalesOrderLineItemFields
} from '../../forms/SalesOrderForms';
@ -236,6 +239,7 @@ export default function SalesOrderLineItemTable({
url: ApiEndpoints.sales_order_allocate_serials,
pk: orderId,
title: t`Allocate Serial Numbers`,
initialData: initialData,
fields: allocateSerialFields,
table: table
});
@ -251,6 +255,17 @@ export default function SalesOrderLineItemTable({
modelType: ModelType.build
});
const [selectedItems, setSelectedItems] = useState<any[]>([]);
const allocateStock = useAllocateToSalesOrderForm({
orderId: orderId,
lineItems: selectedItems,
onFormSuccess: () => {
table.refreshTable();
table.clearSelectedRecords();
}
});
const tableActions = useMemo(() => {
return [
<AddItemButton
@ -263,9 +278,22 @@ export default function SalesOrderLineItemTable({
newLine.open();
}}
hidden={!editable || !user.hasAddRole(UserRoles.sales_order)}
/>,
<ActionButton
key="allocate-stock"
tooltip={t`Allocate Stock`}
icon={<IconArrowRight />}
disabled={!table.hasSelectedRecords}
color="green"
onClick={() => {
setSelectedItems(
table.selectedRecords.filter((r) => r.allocated < r.quantity)
);
allocateStock.open();
}}
/>
];
}, [user, orderId]);
}, [user, orderId, table.hasSelectedRecords, table.selectedRecords]);
const rowActions = useCallback(
(record: any): RowAction[] => {
@ -280,7 +308,10 @@ export default function SalesOrderLineItemTable({
title: t`Allocate Stock`,
icon: <IconSquareArrowRight />,
color: 'green',
onClick: notYetImplemented
onClick: () => {
setSelectedItems([record]);
allocateStock.open();
}
},
{
hidden:
@ -288,11 +319,14 @@ export default function SalesOrderLineItemTable({
allocated ||
!editable ||
!user.hasChangeRole(UserRoles.sales_order),
title: t`Allocate Serials`,
title: t`Allocate serials`,
icon: <IconHash />,
color: 'green',
onClick: () => {
setSelectedLine(record.pk);
setInitialData({
quantity: record.quantity - record.allocated
});
allocateBySerials.open();
}
},
@ -356,6 +390,7 @@ export default function SalesOrderLineItemTable({
{newLine.modal}
{newBuildOrder.modal}
{allocateBySerials.modal}
{allocateStock.modal}
<InvenTreeTable
url={apiUrl(ApiEndpoints.sales_order_line_list)}
tableState={table}

View File

@ -1,13 +1,19 @@
import { t } from '@lingui/macro';
import { IconTruckDelivery } from '@tabler/icons-react';
import { IconArrowRight, IconTruckDelivery } from '@tabler/icons-react';
import { useCallback, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { AddItemButton } from '../../components/buttons/AddItemButton';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import { useSalesOrderShipmentFields } from '../../forms/SalesOrderForms';
import {
useSalesOrderShipmentCompleteFields,
useSalesOrderShipmentFields
} from '../../forms/SalesOrderForms';
import { navigateToLink } from '../../functions/navigation';
import { notYetImplemented } from '../../functions/notifications';
import { getDetailUrl } from '../../functions/urls';
import {
useCreateApiFormModal,
useDeleteApiFormModal,
@ -20,7 +26,7 @@ import { TableColumn } from '../Column';
import { DateColumn, LinkColumn, NoteColumn } from '../ColumnRenderers';
import { TableFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
import { RowAction, RowDeleteAction, RowEditAction } from '../RowActions';
import { RowAction, RowCancelAction, RowEditAction } from '../RowActions';
export default function SalesOrderShipmentTable({
orderId
@ -28,12 +34,16 @@ export default function SalesOrderShipmentTable({
orderId: number;
}>) {
const user = useUserState();
const navigate = useNavigate();
const table = useTable('sales-order-shipment');
const [selectedShipment, setSelectedShipment] = useState<number>(0);
const [selectedShipment, setSelectedShipment] = useState<any>({});
const newShipmentFields = useSalesOrderShipmentFields();
const editShipmentFields = useSalesOrderShipmentFields();
const newShipmentFields = useSalesOrderShipmentFields({});
const editShipmentFields = useSalesOrderShipmentFields({});
const completeShipmentFields = useSalesOrderShipmentCompleteFields({});
const newShipment = useCreateApiFormModal({
url: ApiEndpoints.sales_order_shipment_list,
@ -47,33 +57,45 @@ export default function SalesOrderShipmentTable({
const deleteShipment = useDeleteApiFormModal({
url: ApiEndpoints.sales_order_shipment_list,
pk: selectedShipment,
title: t`Delete Shipment`,
pk: selectedShipment.pk,
title: t`Cancel Shipment`,
table: table
});
const editShipment = useEditApiFormModal({
url: ApiEndpoints.sales_order_shipment_list,
pk: selectedShipment,
pk: selectedShipment.pk,
fields: editShipmentFields,
title: t`Edit Shipment`,
table: table
});
const completeShipment = useCreateApiFormModal({
url: ApiEndpoints.sales_order_shipment_complete,
pk: selectedShipment.pk,
fields: completeShipmentFields,
title: t`Complete Shipment`,
table: table,
focus: 'tracking_number',
initialData: {
...selectedShipment,
shipment_date: new Date().toISOString().split('T')[0]
}
});
const tableColumns: TableColumn[] = useMemo(() => {
return [
{
accessor: 'reference',
title: t`Shipment Reference`,
switchable: false
switchable: false,
sortable: true
},
{
accessor: 'allocations',
title: t`Items`,
render: (record: any) => {
let allocations = record?.allocations ?? [];
return allocations.length;
}
accessor: 'allocated_items',
sortable: true,
switchable: false,
title: t`Items`
},
DateColumn({
accessor: 'shipment_date',
@ -91,9 +113,6 @@ export default function SalesOrderShipmentTable({
},
LinkColumn({
accessor: 'link'
}),
NoteColumn({
accessor: 'notes'
})
];
}, []);
@ -103,23 +122,40 @@ export default function SalesOrderShipmentTable({
const shipped: boolean = !!record.shipment_date;
return [
{
title: t`View Shipment`,
icon: <IconArrowRight />,
onClick: (event: any) => {
navigateToLink(
getDetailUrl(ModelType.salesordershipment, record.pk),
navigate,
event
);
}
},
{
hidden: shipped || !user.hasChangeRole(UserRoles.sales_order),
title: t`Complete Shipment`,
color: 'green',
icon: <IconTruckDelivery />,
onClick: notYetImplemented
onClick: () => {
setSelectedShipment(record);
completeShipment.open();
}
},
RowEditAction({
hidden: !user.hasChangeRole(UserRoles.sales_order),
tooltip: t`Edit shipment`,
onClick: () => {
setSelectedShipment(record.pk);
setSelectedShipment(record);
editShipment.open();
}
}),
RowDeleteAction({
hidden: !user.hasDeleteRole(UserRoles.sales_order),
RowCancelAction({
hidden: shipped || !user.hasDeleteRole(UserRoles.sales_order),
tooltip: t`Cancel shipment`,
onClick: () => {
setSelectedShipment(record.pk);
setSelectedShipment(record);
deleteShipment.open();
}
})
@ -161,6 +197,7 @@ export default function SalesOrderShipmentTable({
{newShipment.modal}
{editShipment.modal}
{deleteShipment.modal}
{completeShipment.modal}
<InvenTreeTable
url={apiUrl(ApiEndpoints.sales_order_shipment_list)}
tableState={table}

View File

@ -41,6 +41,92 @@ test('Sales Orders', async ({ page }) => {
await page.getByRole('button', { name: 'Issue Order' }).waitFor();
});
test('Sales Orders - Shipments', async ({ page }) => {
await doQuickLogin(page);
await page.goto(`${baseUrl}/home`);
await page.getByRole('tab', { name: 'Sales' }).click();
await page.getByRole('tab', { name: 'Sales Orders' }).click();
// Click through to a particular sales order
await page.getByRole('tab', { name: 'Sales Orders' }).waitFor();
await page.getByRole('cell', { name: 'SO0006' }).first().click();
await page.getByRole('tab', { name: 'Shipments' }).click();
// Create a new shipment
await page.getByLabel('action-button-add-shipment').click();
await page.getByLabel('text-field-tracking_number').fill('1234567890');
await page.getByLabel('text-field-invoice_number').fill('9876543210');
await page.getByRole('button', { name: 'Submit' }).click();
// Expected field error
await page
.getByText('The fields order, reference must make a unique set')
.first()
.waitFor();
await page.getByRole('button', { name: 'Cancel' }).click();
// Edit one of the existing shipments
await page.getByLabel('row-action-menu-0').click();
await page.getByRole('menuitem', { name: 'Edit' }).click();
// Ensure the form has loaded
await page.waitForTimeout(500);
let tracking_number = await page
.getByLabel('text-field-tracking_number')
.inputValue();
if (!tracking_number) {
tracking_number = '1234567890';
} else if (tracking_number.endsWith('x')) {
// Remove the 'x' from the end of the tracking number
tracking_number = tracking_number.substring(0, tracking_number.length - 1);
} else {
// Add an 'x' to the end of the tracking number
tracking_number += 'x';
}
// Change the tracking number
await page.getByLabel('text-field-tracking_number').fill(tracking_number);
await page.waitForTimeout(250);
await page.getByRole('button', { name: 'Submit' }).click();
// Click through to a particular shipment
await page.getByLabel('row-action-menu-0').click();
await page.getByRole('menuitem', { name: 'View Shipment' }).click();
// Click through the various tabs
await page.getByRole('tab', { name: 'Attachments' }).click();
await page.getByRole('tab', { name: 'Notes' }).click();
await page.getByRole('tab', { name: 'Assigned Items' }).click();
// Ensure assigned items table loads correctly
await page.getByRole('cell', { name: 'BATCH-001' }).first().waitFor();
await page.getByRole('tab', { name: 'Shipment Details' }).click();
// The "new" tracking number should be visible
await page.getByText(tracking_number).waitFor();
// Link back to sales order
await page.getByRole('link', { name: 'SO0006' }).click();
// Let's try to allocate some stock
await page.getByRole('tab', { name: 'Line Items' }).click();
await page.getByLabel('row-action-menu-1').click();
await page.getByRole('menuitem', { name: 'Allocate stock' }).click();
await page
.getByText('Select the source location for the stock allocation')
.waitFor();
await page.getByLabel('number-field-quantity').fill('123');
await page.getByLabel('related-field-stock_item').click();
await page.getByText('Quantity: 42').click();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('This field is required.').waitFor();
await page.getByRole('button', { name: 'Cancel' }).click();
});
test('Purchase Orders', async ({ page }) => {
await doQuickLogin(page);