2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-30 04:26: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 information."""
# InvenTree API version # 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.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ INVENTREE_API_TEXT = """
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 266 - 2024-10-07 : https://github.com/inventree/InvenTree/pull/8249
- Tweak SalesOrderShipment API for more efficient data retrieval - 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['part_detail'] = str2bool(params.get('part_detail', False))
kwargs['order_detail'] = str2bool(params.get('order_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)) kwargs['customer_detail'] = str2bool(params.get('customer_detail', False))
except AttributeError: except AttributeError:
@ -889,18 +888,83 @@ class SalesOrderAllocate(SalesOrderContextMixin, CreateAPI):
serializer_class = serializers.SalesOrderShipmentAllocationSerializer serializer_class = serializers.SalesOrderShipmentAllocationSerializer
class SalesOrderAllocationDetail(RetrieveUpdateDestroyAPI): class SalesOrderAllocationFilter(rest_filters.FilterSet):
"""API endpoint for detali view of a SalesOrderAllocation object.""" """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() queryset = models.SalesOrderAllocation.objects.all()
serializer_class = serializers.SalesOrderAllocationSerializer 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.""" """API endpoint for listing SalesOrderAllocation objects."""
queryset = models.SalesOrderAllocation.objects.all() filterset_class = SalesOrderAllocationFilter
serializer_class = serializers.SalesOrderAllocationSerializer 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): def get_serializer(self, *args, **kwargs):
"""Return the serializer instance for this endpoint. """Return the serializer instance for this endpoint.
@ -920,53 +984,9 @@ class SalesOrderAllocationList(ListAPI):
return self.serializer_class(*args, **kwargs) return self.serializer_class(*args, **kwargs)
def filter_queryset(self, queryset):
"""Custom queryset filtering."""
queryset = super().filter_queryset(queryset)
# Filter by order class SalesOrderAllocationDetail(SalesOrderAllocationMixin, RetrieveUpdateDestroyAPI):
params = self.request.query_params """API endpoint for detali view of a SalesOrderAllocation object."""
# 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 SalesOrderShipmentFilter(rest_filters.FilterSet): class SalesOrderShipmentFilter(rest_filters.FilterSet):
@ -1005,13 +1025,7 @@ class SalesOrderShipmentMixin:
"""Return annotated queryset for this endpoint.""" """Return annotated queryset for this endpoint."""
queryset = super().get_queryset(*args, **kwargs) queryset = super().get_queryset(*args, **kwargs)
queryset = queryset.prefetch_related( queryset = serializers.SalesOrderShipmentSerializer.annotate_queryset(queryset)
'order',
'order__customer',
'allocations',
'allocations__item',
'allocations__item__part',
)
return queryset return queryset
@ -1020,10 +1034,8 @@ class SalesOrderShipmentList(SalesOrderShipmentMixin, ListCreateAPI):
"""API list endpoint for SalesOrderShipment model.""" """API list endpoint for SalesOrderShipment model."""
filterset_class = SalesOrderShipmentFilter filterset_class = SalesOrderShipmentFilter
filter_backends = SEARCH_ORDER_FILTER_ALIAS filter_backends = SEARCH_ORDER_FILTER_ALIAS
ordering_fields = ['reference', 'delivery_date', 'shipment_date', 'allocated_items']
ordering_fields = ['delivery_date', 'shipment_date']
class SalesOrderShipmentDetail(SalesOrderShipmentMixin, RetrieveUpdateDestroyAPI): class SalesOrderShipmentDetail(SalesOrderShipmentMixin, RetrieveUpdateDestroyAPI):

View File

@ -1923,13 +1923,6 @@ class SalesOrderShipment(
trigger_event('salesordershipment.completed', id=self.pk) 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): class SalesOrderExtraLine(OrderExtraLine):
"""Model for a single ExtraLine in a SalesOrder. """Model for a single ExtraLine in a SalesOrder.

View File

@ -1027,88 +1027,6 @@ class SalesOrderIssueSerializer(OrderAdjustSerializer):
self.order.issue_order() 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() @register_importer()
class SalesOrderLineItemSerializer( class SalesOrderLineItemSerializer(
DataImportExportSerializerMixin, DataImportExportSerializerMixin,
@ -1125,7 +1043,6 @@ class SalesOrderLineItemSerializer(
fields = [ fields = [
'pk', 'pk',
'allocated', 'allocated',
'allocations',
'customer_detail', 'customer_detail',
'quantity', 'quantity',
'reference', 'reference',
@ -1154,7 +1071,6 @@ class SalesOrderLineItemSerializer(
""" """
part_detail = kwargs.pop('part_detail', False) part_detail = kwargs.pop('part_detail', False)
order_detail = kwargs.pop('order_detail', False) order_detail = kwargs.pop('order_detail', False)
allocations = kwargs.pop('allocations', False)
customer_detail = kwargs.pop('customer_detail', False) customer_detail = kwargs.pop('customer_detail', False)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -1165,9 +1081,6 @@ class SalesOrderLineItemSerializer(
if order_detail is not True: if order_detail is not True:
self.fields.pop('order_detail', None) self.fields.pop('order_detail', None)
if allocations is not True:
self.fields.pop('allocations', None)
if customer_detail is not True: if customer_detail is not True:
self.fields.pop('customer_detail', None) self.fields.pop('customer_detail', None)
@ -1251,13 +1164,10 @@ class SalesOrderLineItemSerializer(
return queryset return queryset
customer_detail = CompanyBriefSerializer(
source='order.customer', many=False, read_only=True
)
order_detail = SalesOrderSerializer(source='order', 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) part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
allocations = SalesOrderAllocationSerializer( customer_detail = CompanyBriefSerializer(
many=True, read_only=True, location_detail=True source='order.customer', many=False, read_only=True
) )
# Annotated fields # Annotated fields
@ -1293,7 +1203,7 @@ class SalesOrderShipmentSerializer(NotesFieldMixin, InvenTreeModelSerializer):
'pk', 'pk',
'order', 'order',
'order_detail', 'order_detail',
'allocations', 'allocated_items',
'shipment_date', 'shipment_date',
'delivery_date', 'delivery_date',
'checked_by', 'checked_by',
@ -1304,13 +1214,105 @@ class SalesOrderShipmentSerializer(NotesFieldMixin, InvenTreeModelSerializer):
'notes', 'notes',
] ]
allocations = SalesOrderAllocationSerializer( @staticmethod
many=True, read_only=True, location_detail=True 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) 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): class SalesOrderShipmentCompleteSerializer(serializers.ModelSerializer):
"""Serializer for completing (shipping) a SalesOrderShipment.""" """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" %}'); 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}); html += makeDeleteButton('button-shipment-delete', pk, '{% trans "Delete shipment" %}', {disabled: !enable_delete});
@ -1004,10 +1004,19 @@ function loadSalesOrderShipmentTable(table, options={}) {
detailViewByClick: false, detailViewByClick: false,
buttons: constructExpandCollapseButtons(table), buttons: constructExpandCollapseButtons(table),
detailFilter: function(index, row) { detailFilter: function(index, row) {
return row.allocations.length > 0; return row.allocated_items > 0;
}, },
detailFormatter: function(index, row, element) { 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() { onPostBody: function() {
setupShipmentCallbacks(); setupShipmentCallbacks();
@ -1048,17 +1057,10 @@ function loadSalesOrderShipmentTable(table, options={}) {
switchable: false, switchable: false,
}, },
{ {
field: 'allocations', field: 'allocated_items',
title: '{% trans "Items" %}', title: '{% trans "Items" %}',
switchable: false, switchable: false,
sortable: true, sortable: true,
formatter: function(value, row) {
if (row && row.allocations) {
return row.allocations.length;
} else {
return '-';
}
}
}, },
{ {
field: 'shipment_date', field: 'shipment_date',
@ -1630,7 +1632,14 @@ function showAllocationSubTable(index, row, element, options) {
} }
table.bootstrapTable({ table.bootstrapTable({
url: '{% url "api-so-allocation-list" %}',
onPostBody: setupCallbacks, onPostBody: setupCallbacks,
queryParams: {
...options.queryParams,
part_detail: true,
location_detail: true,
order_detail: true,
},
data: row.allocations, data: row.allocations,
showHeader: true, showHeader: true,
columns: [ columns: [
@ -1641,6 +1650,13 @@ function showAllocationSubTable(index, row, element, options) {
return imageHoverIcon(part.thumbnail) + renderLink(part.full_name, `/part/${part.pk}/`); 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', field: 'allocated',
title: '{% trans "Stock Item" %}', title: '{% trans "Stock Item" %}',
@ -2289,7 +2305,16 @@ function loadSalesOrderLineItemTable(table, options={}) {
}, },
detailFormatter: function(index, row, element) { detailFormatter: function(index, row, element) {
if (options.open) { if (options.open) {
return showAllocationSubTable(index, row, element, options); return showAllocationSubTable(
index, row, element,
{
...options,
queryParams: {
part: row.part,
order: row.order,
}
}
);
} else { } else {
return showFulfilledSubTable(index, row, element, options); return showFulfilledSubTable(index, row, element, options);
} }

View File

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

View File

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

View File

@ -1,8 +1,10 @@
import { Trans, t } from '@lingui/macro'; 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 { useCallback, useEffect, useMemo } from 'react';
import { FieldValues, UseControllerReturn } from 'react-hook-form'; import { FieldValues, UseControllerReturn } from 'react-hook-form';
import { identifierString } from '../../../functions/conversion';
import { InvenTreeIcon } from '../../../functions/icons'; import { InvenTreeIcon } from '../../../functions/icons';
import { StandaloneField } from '../StandaloneField'; import { StandaloneField } from '../StandaloneField';
import { ApiFormFieldType } from './ApiFormField'; import { ApiFormFieldType } from './ApiFormField';
@ -58,8 +60,14 @@ export function TableField({
<Table highlightOnHover striped aria-label={`table-field-${field.name}`}> <Table highlightOnHover striped aria-label={`table-field-${field.name}`}>
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>
{definition.headers?.map((header) => { {definition.headers?.map((header, index) => {
return <Table.Th key={header}>{header}</Table.Th>; return (
<Table.Th
key={`table-header-${identifierString(header)}-${index}`}
>
{header}
</Table.Th>
);
})} })}
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
@ -69,7 +77,17 @@ export function TableField({
// Table fields require render function // Table fields require render function
if (!definition.modelRenderer) { if (!definition.modelRenderer) {
return ( 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 { useLocalState } from '../../states/LocalState';
import { Boundary } from '../Boundary'; import { Boundary } from '../Boundary';
import { StylishText } from '../items/StylishText'; import { StylishText } from '../items/StylishText';
import { PanelType } from './Panel'; import { PanelType } from '../panels/Panel';
/** /**
* Set of properties which define a panel group: * Set of properties which define a panel group:

View File

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

View File

@ -113,12 +113,12 @@ export function RenderSalesOrderShipment({
}: Readonly<{ }: Readonly<{
instance: any; instance: any;
}>): ReactNode { }>): ReactNode {
let order = instance.sales_order_detail || {}; let order = instance.order_detail || {};
return ( return (
<RenderInlineModel <RenderInlineModel
primary={order.reference} 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_line_list = 'order/so-line/',
sales_order_extra_line_list = 'order/so-extra-line/', sales_order_extra_line_list = 'order/so-extra-line/',
sales_order_allocation_list = 'order/so-allocation/', 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_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_list = 'order/ro/',
return_order_issue = 'order/ro/:id/issue/', return_order_issue = 'order/ro/:id/issue/',
return_order_hold = 'order/ro/:id/hold/', return_order_hold = 'order/ro/:id/hold/',

View File

@ -423,6 +423,7 @@ function BuildAllocateLineRow({
if (instance) { if (instance) {
let available = instance.quantity - instance.allocated; let available = instance.quantity - instance.allocated;
if (available < props.item.quantity) {
props.changeFn( props.changeFn(
props.idx, props.idx,
'quantity', 'quantity',
@ -430,8 +431,9 @@ function BuildAllocateLineRow({
); );
} }
} }
}
}; };
}, [props]); }, [record, props]);
const quantityField: ApiFormFieldType = useMemo(() => { const quantityField: ApiFormFieldType = useMemo(() => {
return { 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 { 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 { import {
ApiFormAdjustFilterType, ApiFormAdjustFilterType,
ApiFormFieldSet ApiFormFieldSet,
ApiFormFieldType
} from '../components/forms/fields/ApiFormField'; } 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({ export function useSalesOrderFields({
duplicateOrderId duplicateOrderId
@ -105,6 +117,179 @@ export function useSalesOrderLineItemFields({
return fields; 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({ export function useSalesOrderAllocateSerialsFields({
itemId, itemId,
orderId orderId
@ -122,6 +307,7 @@ export function useSalesOrderAllocateSerialsFields({
serial_numbers: {}, serial_numbers: {},
shipment: { shipment: {
filters: { filters: {
order_detail: true,
order: orderId, order: orderId,
shipped: false shipped: false
} }
@ -130,19 +316,53 @@ export function useSalesOrderAllocateSerialsFields({
}, [itemId, orderId]); }, [itemId, orderId]);
} }
export function useSalesOrderShipmentFields(): ApiFormFieldSet { export function useSalesOrderShipmentFields({
pending
}: {
pending?: boolean;
}): ApiFormFieldSet {
return useMemo(() => { return useMemo(() => {
return { return {
order: { order: {
disabled: true disabled: true
}, },
reference: {}, reference: {},
shipment_date: {}, shipment_date: {
delivery_date: {}, hidden: pending ?? true
},
delivery_date: {
hidden: pending ?? true
},
tracking_number: {}, tracking_number: {},
invoice_number: {}, invoice_number: {},
link: {}, link: {}
notes: {}
}; };
}, []); }, [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 { useEffect, useMemo, useState } from 'react';
import { api } from '../App'; import { api } from '../App';
import { PanelType } from '../components/nav/Panel'; import { PanelType } from '../components/panels/Panel';
import { import {
InvenTreeContext, InvenTreeContext,
useInvenTreeContext useInvenTreeContext

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,12 @@
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { ActionIcon, Menu, Tooltip } from '@mantine/core'; 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 { ReactNode, useMemo, useState } from 'react';
import { cancelEvent } from '../functions/events'; import { cancelEvent } from '../functions/events';
@ -11,7 +17,7 @@ export type RowAction = {
tooltip?: string; tooltip?: string;
color?: string; color?: string;
icon?: ReactNode; icon?: ReactNode;
onClick: () => void; onClick: (event: any) => void;
hidden?: boolean; hidden?: boolean;
disabled?: 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. * Component for displaying actions for a row in a table.
* Displays a simple dropdown menu with a list of actions. * Displays a simple dropdown menu with a list of actions.
@ -89,7 +105,7 @@ export function RowActions({
onClick={(event) => { onClick={(event) => {
// Prevent clicking on the action from selecting the row itself // Prevent clicking on the action from selecting the row itself
cancelEvent(event); cancelEvent(event);
action.onClick(); action.onClick(event);
setOpened(false); setOpened(false);
}} }}
disabled={action.disabled || false} disabled={action.disabled || false}

View File

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

View File

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

View File

@ -1,8 +1,16 @@
import { t } from '@lingui/macro'; 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 { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType'; 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 { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState'; import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState'; import { useUserState } from '../../states/UserState';
@ -15,12 +23,13 @@ import {
} from '../ColumnRenderers'; } from '../ColumnRenderers';
import { TableFilter } from '../Filter'; import { TableFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable'; import { InvenTreeTable } from '../InvenTreeTable';
import { RowAction } from '../RowActions'; import { RowAction, RowDeleteAction, RowEditAction } from '../RowActions';
export default function SalesOrderAllocationTable({ export default function SalesOrderAllocationTable({
partId, partId,
stockId, stockId,
orderId, orderId,
shipmentId,
showPartInfo, showPartInfo,
showOrderInfo, showOrderInfo,
allowEdit, allowEdit,
@ -30,6 +39,7 @@ export default function SalesOrderAllocationTable({
partId?: number; partId?: number;
stockId?: number; stockId?: number;
orderId?: number; orderId?: number;
shipmentId?: number;
showPartInfo?: boolean; showPartInfo?: boolean;
showOrderInfo?: boolean; showOrderInfo?: boolean;
allowEdit?: boolean; allowEdit?: boolean;
@ -40,7 +50,13 @@ export default function SalesOrderAllocationTable({
const table = useTable('salesorderallocations'); const table = useTable('salesorderallocations');
const tableFilters: TableFilter[] = useMemo(() => { const tableFilters: TableFilter[] = useMemo(() => {
return []; return [
{
name: 'outstanding',
label: t`Outstanding`,
description: t`Show outstanding allocations`
}
];
}, []); }, []);
const tableColumns: TableColumn[] = useMemo(() => { const tableColumns: TableColumn[] = useMemo(() => {
@ -49,6 +65,7 @@ export default function SalesOrderAllocationTable({
accessor: 'order_detail.reference', accessor: 'order_detail.reference',
title: t`Sales Order`, title: t`Sales Order`,
switchable: false, switchable: false,
sortable: true,
hidden: showOrderInfo != true hidden: showOrderInfo != true
}), }),
{ {
@ -70,46 +87,116 @@ export default function SalesOrderAllocationTable({
switchable: false, switchable: false,
render: (record: any) => PartColumn({ part: record.part_detail }) render: (record: any) => PartColumn({ part: record.part_detail })
}, },
{
accessor: 'quantity',
title: t`Allocated Quantity`,
sortable: true
},
{ {
accessor: 'serial', accessor: 'serial',
title: t`Serial Number`, title: t`Serial Number`,
sortable: false, sortable: true,
switchable: true, switchable: true,
render: (record: any) => record?.item_detail?.serial render: (record: any) => record?.item_detail?.serial
}, },
{ {
accessor: 'batch', accessor: 'batch',
title: t`Batch Code`, title: t`Batch Code`,
sortable: false, sortable: true,
switchable: true, switchable: true,
render: (record: any) => record?.item_detail?.batch render: (record: any) => record?.item_detail?.batch
}, },
{ {
accessor: 'available', accessor: 'available',
title: t`Available Quantity`, title: t`Available Quantity`,
sortable: false,
render: (record: any) => record?.item_detail?.quantity render: (record: any) => record?.item_detail?.quantity
}, },
{
accessor: 'quantity',
title: t`Allocated Quantity`,
sortable: true
},
LocationColumn({ LocationColumn({
accessor: 'location_detail', accessor: 'location_detail',
switchable: true, switchable: true,
sortable: 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( const rowActions = useCallback(
(record: any): RowAction[] => { (record: any): RowAction[] => {
// Do not allow "shipped" items to be manipulated
const isShipped = !!record.shipment_detail?.shipment_date;
if (isShipped || !allowEdit) {
return []; 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 ( return (
<>
{editAllocation.modal}
{deleteAllocation.modal}
<InvenTreeTable <InvenTreeTable
url={apiUrl(ApiEndpoints.sales_order_allocation_list)} url={apiUrl(ApiEndpoints.sales_order_allocation_list)}
tableState={table} tableState={table}
@ -122,13 +209,16 @@ export default function SalesOrderAllocationTable({
location_detail: true, location_detail: true,
part: partId, part: partId,
order: orderId, order: orderId,
stock_item: stockId shipment: shipmentId,
item: stockId
}, },
rowActions: rowActions, rowActions: rowActions,
tableActions: tableActions,
tableFilters: tableFilters, tableFilters: tableFilters,
modelField: modelField ?? 'order', modelField: modelField ?? 'order',
modelType: modelTarget ?? ModelType.salesorder modelType: modelTarget ?? ModelType.salesorder
}} }}
/> />
</>
); );
} }

View File

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

View File

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

View File

@ -41,6 +41,92 @@ test('Sales Orders', async ({ page }) => {
await page.getByRole('button', { name: 'Issue Order' }).waitFor(); 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 }) => { test('Purchase Orders', async ({ page }) => {
await doQuickLogin(page); await doQuickLogin(page);