mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-29 12:06:44 +00:00
[PUI] Sales order shipments (#8250)
* Refactor AttachmentPanel into common component * Remove unused imports * Add very basic implementation for SalesOrderShipmentDetail page * Refactor NotesPanel into common component * Fetch customer data * Add some placeholder actions * Updates for shipment detail page * Adjust SalesOrderShipment API * Add badges * Implement API filter for SalesOrderAllocation * Display allocation table on shipment page * Add placeholder action to edit allocations * Improvements for SalesOrderAllocationTable * Improve API db fetch efficiency * Edit / delete pending allocations * Fix for legacy CUI tables * API tweaks * Revert custom attachment code for SalesOrderShipment * Implement "complete shipment" form * Allocate stock item(s) to sales order * Fixes for TableField rendering * Reset sourceLocation when form opens * Updated playwrigh tests * Tweak branch (will be reverted) * Revert github workflow
This commit is contained in:
parent
35969b11a5
commit
33eba14d3f
@ -1,13 +1,17 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 266
|
||||
INVENTREE_API_VERSION = 267
|
||||
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
267 - 2024-10-8 : https://github.com/inventree/InvenTree/pull/8250
|
||||
- Remove "allocations" field from the SalesOrderShipment API endpoint(s)
|
||||
- Add "allocated_items" field to the SalesOrderShipment API endpoint(s)
|
||||
|
||||
266 - 2024-10-07 : https://github.com/inventree/InvenTree/pull/8249
|
||||
- Tweak SalesOrderShipment API for more efficient data retrieval
|
||||
|
||||
|
@ -749,7 +749,6 @@ class SalesOrderLineItemMixin:
|
||||
|
||||
kwargs['part_detail'] = str2bool(params.get('part_detail', False))
|
||||
kwargs['order_detail'] = str2bool(params.get('order_detail', False))
|
||||
kwargs['allocations'] = str2bool(params.get('allocations', False))
|
||||
kwargs['customer_detail'] = str2bool(params.get('customer_detail', False))
|
||||
|
||||
except AttributeError:
|
||||
@ -889,18 +888,83 @@ class SalesOrderAllocate(SalesOrderContextMixin, CreateAPI):
|
||||
serializer_class = serializers.SalesOrderShipmentAllocationSerializer
|
||||
|
||||
|
||||
class SalesOrderAllocationDetail(RetrieveUpdateDestroyAPI):
|
||||
"""API endpoint for detali view of a SalesOrderAllocation object."""
|
||||
class SalesOrderAllocationFilter(rest_filters.FilterSet):
|
||||
"""Custom filterset for the SalesOrderAllocationList endpoint."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
model = models.SalesOrderAllocation
|
||||
fields = ['shipment', 'item']
|
||||
|
||||
order = rest_filters.ModelChoiceFilter(
|
||||
queryset=models.SalesOrder.objects.all(),
|
||||
field_name='line__order',
|
||||
label=_('Order'),
|
||||
)
|
||||
|
||||
part = rest_filters.ModelChoiceFilter(
|
||||
queryset=Part.objects.all(), field_name='item__part', label=_('Part')
|
||||
)
|
||||
|
||||
outstanding = rest_filters.BooleanFilter(
|
||||
label=_('Outstanding'), method='filter_outstanding'
|
||||
)
|
||||
|
||||
def filter_outstanding(self, queryset, name, value):
|
||||
"""Filter by "outstanding" status (boolean)."""
|
||||
if str2bool(value):
|
||||
return queryset.filter(
|
||||
line__order__status__in=SalesOrderStatusGroups.OPEN,
|
||||
shipment__shipment_date=None,
|
||||
)
|
||||
return queryset.exclude(
|
||||
shipment__shipment_date=None,
|
||||
line__order__status__in=SalesOrderStatusGroups.OPEN,
|
||||
)
|
||||
|
||||
|
||||
class SalesOrderAllocationMixin:
|
||||
"""Mixin class for SalesOrderAllocation endpoints."""
|
||||
|
||||
queryset = models.SalesOrderAllocation.objects.all()
|
||||
serializer_class = serializers.SalesOrderAllocationSerializer
|
||||
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
"""Annotate the queryset for this endpoint."""
|
||||
queryset = super().get_queryset(*args, **kwargs)
|
||||
|
||||
class SalesOrderAllocationList(ListAPI):
|
||||
queryset = queryset.prefetch_related(
|
||||
'item',
|
||||
'item__sales_order',
|
||||
'item__part',
|
||||
'item__location',
|
||||
'line__order',
|
||||
'line__part',
|
||||
'shipment',
|
||||
'shipment__order',
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class SalesOrderAllocationList(SalesOrderAllocationMixin, ListAPI):
|
||||
"""API endpoint for listing SalesOrderAllocation objects."""
|
||||
|
||||
queryset = models.SalesOrderAllocation.objects.all()
|
||||
serializer_class = serializers.SalesOrderAllocationSerializer
|
||||
filterset_class = SalesOrderAllocationFilter
|
||||
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
||||
|
||||
ordering_fields = ['quantity', 'part', 'serial', 'batch', 'location', 'order']
|
||||
|
||||
ordering_field_aliases = {
|
||||
'part': 'item__part__name',
|
||||
'serial': ['item__serial_int', 'item__serial'],
|
||||
'batch': 'item__batch',
|
||||
'location': 'item__location__name',
|
||||
'order': 'line__order__reference',
|
||||
}
|
||||
|
||||
search_fields = {'item__part__name', 'item__serial', 'item__batch'}
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
"""Return the serializer instance for this endpoint.
|
||||
@ -920,53 +984,9 @@ class SalesOrderAllocationList(ListAPI):
|
||||
|
||||
return self.serializer_class(*args, **kwargs)
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
"""Custom queryset filtering."""
|
||||
queryset = super().filter_queryset(queryset)
|
||||
|
||||
# Filter by order
|
||||
params = self.request.query_params
|
||||
|
||||
# Filter by "part" reference
|
||||
part = params.get('part', None)
|
||||
|
||||
if part is not None:
|
||||
queryset = queryset.filter(item__part=part)
|
||||
|
||||
# Filter by "order" reference
|
||||
order = params.get('order', None)
|
||||
|
||||
if order is not None:
|
||||
queryset = queryset.filter(line__order=order)
|
||||
|
||||
# Filter by "stock item"
|
||||
item = params.get('item', params.get('stock_item', None))
|
||||
|
||||
if item is not None:
|
||||
queryset = queryset.filter(item=item)
|
||||
|
||||
# Filter by "outstanding" order status
|
||||
outstanding = params.get('outstanding', None)
|
||||
|
||||
if outstanding is not None:
|
||||
outstanding = str2bool(outstanding)
|
||||
|
||||
if outstanding:
|
||||
# Filter only "open" orders
|
||||
# Filter only allocations which have *not* shipped
|
||||
queryset = queryset.filter(
|
||||
line__order__status__in=SalesOrderStatusGroups.OPEN,
|
||||
shipment__shipment_date=None,
|
||||
)
|
||||
else:
|
||||
queryset = queryset.exclude(
|
||||
line__order__status__in=SalesOrderStatusGroups.OPEN,
|
||||
shipment__shipment_date=None,
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
filter_backends = [rest_filters.DjangoFilterBackend]
|
||||
class SalesOrderAllocationDetail(SalesOrderAllocationMixin, RetrieveUpdateDestroyAPI):
|
||||
"""API endpoint for detali view of a SalesOrderAllocation object."""
|
||||
|
||||
|
||||
class SalesOrderShipmentFilter(rest_filters.FilterSet):
|
||||
@ -1005,13 +1025,7 @@ class SalesOrderShipmentMixin:
|
||||
"""Return annotated queryset for this endpoint."""
|
||||
queryset = super().get_queryset(*args, **kwargs)
|
||||
|
||||
queryset = queryset.prefetch_related(
|
||||
'order',
|
||||
'order__customer',
|
||||
'allocations',
|
||||
'allocations__item',
|
||||
'allocations__item__part',
|
||||
)
|
||||
queryset = serializers.SalesOrderShipmentSerializer.annotate_queryset(queryset)
|
||||
|
||||
return queryset
|
||||
|
||||
@ -1020,10 +1034,8 @@ class SalesOrderShipmentList(SalesOrderShipmentMixin, ListCreateAPI):
|
||||
"""API list endpoint for SalesOrderShipment model."""
|
||||
|
||||
filterset_class = SalesOrderShipmentFilter
|
||||
|
||||
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
||||
|
||||
ordering_fields = ['delivery_date', 'shipment_date']
|
||||
ordering_fields = ['reference', 'delivery_date', 'shipment_date', 'allocated_items']
|
||||
|
||||
|
||||
class SalesOrderShipmentDetail(SalesOrderShipmentMixin, RetrieveUpdateDestroyAPI):
|
||||
|
@ -1923,13 +1923,6 @@ class SalesOrderShipment(
|
||||
|
||||
trigger_event('salesordershipment.completed', id=self.pk)
|
||||
|
||||
def create_attachment(self, *args, **kwargs):
|
||||
"""Create an attachment / link on parent order.
|
||||
|
||||
This will only be called when a generated report should be attached to this instance.
|
||||
"""
|
||||
return self.order.create_attachment(*args, **kwargs)
|
||||
|
||||
|
||||
class SalesOrderExtraLine(OrderExtraLine):
|
||||
"""Model for a single ExtraLine in a SalesOrder.
|
||||
|
@ -1027,88 +1027,6 @@ class SalesOrderIssueSerializer(OrderAdjustSerializer):
|
||||
self.order.issue_order()
|
||||
|
||||
|
||||
class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
|
||||
"""Serializer for the SalesOrderAllocation model.
|
||||
|
||||
This includes some fields from the related model objects.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
model = order.models.SalesOrderAllocation
|
||||
|
||||
fields = [
|
||||
'pk',
|
||||
'line',
|
||||
'customer_detail',
|
||||
'serial',
|
||||
'quantity',
|
||||
'location',
|
||||
'location_detail',
|
||||
'item',
|
||||
'item_detail',
|
||||
'order',
|
||||
'order_detail',
|
||||
'part',
|
||||
'part_detail',
|
||||
'shipment',
|
||||
'shipment_date',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Initialization routine for the serializer."""
|
||||
order_detail = kwargs.pop('order_detail', False)
|
||||
part_detail = kwargs.pop('part_detail', True)
|
||||
item_detail = kwargs.pop('item_detail', True)
|
||||
location_detail = kwargs.pop('location_detail', False)
|
||||
customer_detail = kwargs.pop('customer_detail', False)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if not order_detail:
|
||||
self.fields.pop('order_detail', None)
|
||||
|
||||
if not part_detail:
|
||||
self.fields.pop('part_detail', None)
|
||||
|
||||
if not item_detail:
|
||||
self.fields.pop('item_detail', None)
|
||||
|
||||
if not location_detail:
|
||||
self.fields.pop('location_detail', None)
|
||||
|
||||
if not customer_detail:
|
||||
self.fields.pop('customer_detail', None)
|
||||
|
||||
part = serializers.PrimaryKeyRelatedField(source='item.part', read_only=True)
|
||||
order = serializers.PrimaryKeyRelatedField(
|
||||
source='line.order', many=False, read_only=True
|
||||
)
|
||||
serial = serializers.CharField(source='get_serial', read_only=True)
|
||||
quantity = serializers.FloatField(read_only=False)
|
||||
location = serializers.PrimaryKeyRelatedField(
|
||||
source='item.location', many=False, read_only=True
|
||||
)
|
||||
|
||||
# Extra detail fields
|
||||
order_detail = SalesOrderSerializer(source='line.order', many=False, read_only=True)
|
||||
part_detail = PartBriefSerializer(source='item.part', many=False, read_only=True)
|
||||
item_detail = stock.serializers.StockItemSerializerBrief(
|
||||
source='item', many=False, read_only=True
|
||||
)
|
||||
location_detail = stock.serializers.LocationBriefSerializer(
|
||||
source='item.location', many=False, read_only=True
|
||||
)
|
||||
customer_detail = CompanyBriefSerializer(
|
||||
source='line.order.customer', many=False, read_only=True
|
||||
)
|
||||
|
||||
shipment_date = serializers.DateField(
|
||||
source='shipment.shipment_date', read_only=True
|
||||
)
|
||||
|
||||
|
||||
@register_importer()
|
||||
class SalesOrderLineItemSerializer(
|
||||
DataImportExportSerializerMixin,
|
||||
@ -1125,7 +1043,6 @@ class SalesOrderLineItemSerializer(
|
||||
fields = [
|
||||
'pk',
|
||||
'allocated',
|
||||
'allocations',
|
||||
'customer_detail',
|
||||
'quantity',
|
||||
'reference',
|
||||
@ -1154,7 +1071,6 @@ class SalesOrderLineItemSerializer(
|
||||
"""
|
||||
part_detail = kwargs.pop('part_detail', False)
|
||||
order_detail = kwargs.pop('order_detail', False)
|
||||
allocations = kwargs.pop('allocations', False)
|
||||
customer_detail = kwargs.pop('customer_detail', False)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
@ -1165,9 +1081,6 @@ class SalesOrderLineItemSerializer(
|
||||
if order_detail is not True:
|
||||
self.fields.pop('order_detail', None)
|
||||
|
||||
if allocations is not True:
|
||||
self.fields.pop('allocations', None)
|
||||
|
||||
if customer_detail is not True:
|
||||
self.fields.pop('customer_detail', None)
|
||||
|
||||
@ -1251,13 +1164,10 @@ class SalesOrderLineItemSerializer(
|
||||
|
||||
return queryset
|
||||
|
||||
customer_detail = CompanyBriefSerializer(
|
||||
source='order.customer', many=False, read_only=True
|
||||
)
|
||||
order_detail = SalesOrderSerializer(source='order', many=False, read_only=True)
|
||||
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
|
||||
allocations = SalesOrderAllocationSerializer(
|
||||
many=True, read_only=True, location_detail=True
|
||||
customer_detail = CompanyBriefSerializer(
|
||||
source='order.customer', many=False, read_only=True
|
||||
)
|
||||
|
||||
# Annotated fields
|
||||
@ -1293,7 +1203,7 @@ class SalesOrderShipmentSerializer(NotesFieldMixin, InvenTreeModelSerializer):
|
||||
'pk',
|
||||
'order',
|
||||
'order_detail',
|
||||
'allocations',
|
||||
'allocated_items',
|
||||
'shipment_date',
|
||||
'delivery_date',
|
||||
'checked_by',
|
||||
@ -1304,13 +1214,105 @@ class SalesOrderShipmentSerializer(NotesFieldMixin, InvenTreeModelSerializer):
|
||||
'notes',
|
||||
]
|
||||
|
||||
allocations = SalesOrderAllocationSerializer(
|
||||
many=True, read_only=True, location_detail=True
|
||||
@staticmethod
|
||||
def annotate_queryset(queryset):
|
||||
"""Annotate the queryset with extra information."""
|
||||
# Prefetch related objects
|
||||
queryset = queryset.prefetch_related('order', 'order__customer', 'allocations')
|
||||
|
||||
queryset = queryset.annotate(allocated_items=SubqueryCount('allocations'))
|
||||
|
||||
return queryset
|
||||
|
||||
allocated_items = serializers.IntegerField(
|
||||
read_only=True, label=_('Allocated Items')
|
||||
)
|
||||
|
||||
order_detail = SalesOrderSerializer(source='order', read_only=True, many=False)
|
||||
|
||||
|
||||
class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
|
||||
"""Serializer for the SalesOrderAllocation model.
|
||||
|
||||
This includes some fields from the related model objects.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
model = order.models.SalesOrderAllocation
|
||||
|
||||
fields = [
|
||||
'pk',
|
||||
'line',
|
||||
'customer_detail',
|
||||
'serial',
|
||||
'quantity',
|
||||
'location',
|
||||
'location_detail',
|
||||
'item',
|
||||
'item_detail',
|
||||
'order',
|
||||
'order_detail',
|
||||
'part',
|
||||
'part_detail',
|
||||
'shipment',
|
||||
'shipment_detail',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Initialization routine for the serializer."""
|
||||
order_detail = kwargs.pop('order_detail', False)
|
||||
part_detail = kwargs.pop('part_detail', True)
|
||||
item_detail = kwargs.pop('item_detail', True)
|
||||
location_detail = kwargs.pop('location_detail', False)
|
||||
customer_detail = kwargs.pop('customer_detail', False)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if not order_detail:
|
||||
self.fields.pop('order_detail', None)
|
||||
|
||||
if not part_detail:
|
||||
self.fields.pop('part_detail', None)
|
||||
|
||||
if not item_detail:
|
||||
self.fields.pop('item_detail', None)
|
||||
|
||||
if not location_detail:
|
||||
self.fields.pop('location_detail', None)
|
||||
|
||||
if not customer_detail:
|
||||
self.fields.pop('customer_detail', None)
|
||||
|
||||
part = serializers.PrimaryKeyRelatedField(source='item.part', read_only=True)
|
||||
order = serializers.PrimaryKeyRelatedField(
|
||||
source='line.order', many=False, read_only=True
|
||||
)
|
||||
serial = serializers.CharField(source='get_serial', read_only=True)
|
||||
quantity = serializers.FloatField(read_only=False)
|
||||
location = serializers.PrimaryKeyRelatedField(
|
||||
source='item.location', many=False, read_only=True
|
||||
)
|
||||
|
||||
# Extra detail fields
|
||||
order_detail = SalesOrderSerializer(source='line.order', many=False, read_only=True)
|
||||
part_detail = PartBriefSerializer(source='item.part', many=False, read_only=True)
|
||||
item_detail = stock.serializers.StockItemSerializerBrief(
|
||||
source='item', many=False, read_only=True
|
||||
)
|
||||
location_detail = stock.serializers.LocationBriefSerializer(
|
||||
source='item.location', many=False, read_only=True
|
||||
)
|
||||
customer_detail = CompanyBriefSerializer(
|
||||
source='line.order.customer', many=False, read_only=True
|
||||
)
|
||||
|
||||
shipment_detail = SalesOrderShipmentSerializer(
|
||||
source='shipment', many=False, read_only=True
|
||||
)
|
||||
|
||||
|
||||
class SalesOrderShipmentCompleteSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for completing (shipping) a SalesOrderShipment."""
|
||||
|
||||
|
@ -951,7 +951,7 @@ function loadSalesOrderShipmentTable(table, options={}) {
|
||||
html += makeIconButton('fa-truck icon-green', 'button-shipment-ship', pk, '{% trans "Complete shipment" %}');
|
||||
}
|
||||
|
||||
var enable_delete = row.allocations && row.allocations.length == 0;
|
||||
var enable_delete = row.allocated_items == 0;
|
||||
|
||||
html += makeDeleteButton('button-shipment-delete', pk, '{% trans "Delete shipment" %}', {disabled: !enable_delete});
|
||||
|
||||
@ -1004,10 +1004,19 @@ function loadSalesOrderShipmentTable(table, options={}) {
|
||||
detailViewByClick: false,
|
||||
buttons: constructExpandCollapseButtons(table),
|
||||
detailFilter: function(index, row) {
|
||||
return row.allocations.length > 0;
|
||||
return row.allocated_items > 0;
|
||||
},
|
||||
detailFormatter: function(index, row, element) {
|
||||
return showAllocationSubTable(index, row, element, options);
|
||||
return showAllocationSubTable(
|
||||
index, row, element,
|
||||
{
|
||||
...options,
|
||||
queryParams: {
|
||||
shipment: row.pk,
|
||||
order: row.order,
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
onPostBody: function() {
|
||||
setupShipmentCallbacks();
|
||||
@ -1048,17 +1057,10 @@ function loadSalesOrderShipmentTable(table, options={}) {
|
||||
switchable: false,
|
||||
},
|
||||
{
|
||||
field: 'allocations',
|
||||
field: 'allocated_items',
|
||||
title: '{% trans "Items" %}',
|
||||
switchable: false,
|
||||
sortable: true,
|
||||
formatter: function(value, row) {
|
||||
if (row && row.allocations) {
|
||||
return row.allocations.length;
|
||||
} else {
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'shipment_date',
|
||||
@ -1630,7 +1632,14 @@ function showAllocationSubTable(index, row, element, options) {
|
||||
}
|
||||
|
||||
table.bootstrapTable({
|
||||
url: '{% url "api-so-allocation-list" %}',
|
||||
onPostBody: setupCallbacks,
|
||||
queryParams: {
|
||||
...options.queryParams,
|
||||
part_detail: true,
|
||||
location_detail: true,
|
||||
order_detail: true,
|
||||
},
|
||||
data: row.allocations,
|
||||
showHeader: true,
|
||||
columns: [
|
||||
@ -1641,6 +1650,13 @@ function showAllocationSubTable(index, row, element, options) {
|
||||
return imageHoverIcon(part.thumbnail) + renderLink(part.full_name, `/part/${part.pk}/`);
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'shipment',
|
||||
title: '{% trans "Shipment" %}',
|
||||
formatter: function(value, row) {
|
||||
return row.shipment_detail.reference;
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'allocated',
|
||||
title: '{% trans "Stock Item" %}',
|
||||
@ -2289,7 +2305,16 @@ function loadSalesOrderLineItemTable(table, options={}) {
|
||||
},
|
||||
detailFormatter: function(index, row, element) {
|
||||
if (options.open) {
|
||||
return showAllocationSubTable(index, row, element, options);
|
||||
return showAllocationSubTable(
|
||||
index, row, element,
|
||||
{
|
||||
...options,
|
||||
queryParams: {
|
||||
part: row.part,
|
||||
order: row.order,
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
return showFulfilledSubTable(index, row, element, options);
|
||||
}
|
||||
|
@ -159,7 +159,8 @@ export function ApiFormField({
|
||||
adjustFilters: undefined,
|
||||
adjustValue: undefined,
|
||||
read_only: undefined,
|
||||
children: undefined
|
||||
children: undefined,
|
||||
exclude: undefined
|
||||
};
|
||||
}, [fieldDefinition]);
|
||||
|
||||
|
@ -220,6 +220,7 @@ export function RelatedModelField({
|
||||
...definition,
|
||||
onValueChange: undefined,
|
||||
adjustFilters: undefined,
|
||||
exclude: undefined,
|
||||
read_only: undefined
|
||||
};
|
||||
}, [definition]);
|
||||
|
@ -1,8 +1,10 @@
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import { Container, Group, Table } from '@mantine/core';
|
||||
import { Alert, Container, Group, Table } from '@mantine/core';
|
||||
import { IconExclamationCircle } from '@tabler/icons-react';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { FieldValues, UseControllerReturn } from 'react-hook-form';
|
||||
|
||||
import { identifierString } from '../../../functions/conversion';
|
||||
import { InvenTreeIcon } from '../../../functions/icons';
|
||||
import { StandaloneField } from '../StandaloneField';
|
||||
import { ApiFormFieldType } from './ApiFormField';
|
||||
@ -58,8 +60,14 @@ export function TableField({
|
||||
<Table highlightOnHover striped aria-label={`table-field-${field.name}`}>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
{definition.headers?.map((header) => {
|
||||
return <Table.Th key={header}>{header}</Table.Th>;
|
||||
{definition.headers?.map((header, index) => {
|
||||
return (
|
||||
<Table.Th
|
||||
key={`table-header-${identifierString(header)}-${index}`}
|
||||
>
|
||||
{header}
|
||||
</Table.Th>
|
||||
);
|
||||
})}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
@ -69,7 +77,17 @@ export function TableField({
|
||||
// Table fields require render function
|
||||
if (!definition.modelRenderer) {
|
||||
return (
|
||||
<Table.Tr key="table-row-no-renderer">{t`modelRenderer entry required for tables`}</Table.Tr>
|
||||
<Table.Tr key="table-row-no-renderer">
|
||||
<Table.Td colSpan={definition.headers?.length}>
|
||||
<Alert
|
||||
color="red"
|
||||
title={t`Error`}
|
||||
icon={<IconExclamationCircle />}
|
||||
>
|
||||
{`modelRenderer entry required for tables`}
|
||||
</Alert>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
}
|
||||
|
||||
|
27
src/frontend/src/components/panels/AttachmentPanel.tsx
Normal file
27
src/frontend/src/components/panels/AttachmentPanel.tsx
Normal 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 />
|
||||
)
|
||||
};
|
||||
}
|
37
src/frontend/src/components/panels/NotesPanel.tsx
Normal file
37
src/frontend/src/components/panels/NotesPanel.tsx
Normal 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 />
|
||||
)
|
||||
};
|
||||
}
|
@ -28,7 +28,7 @@ import { usePluginPanels } from '../../hooks/UsePluginPanels';
|
||||
import { useLocalState } from '../../states/LocalState';
|
||||
import { Boundary } from '../Boundary';
|
||||
import { StylishText } from '../items/StylishText';
|
||||
import { PanelType } from './Panel';
|
||||
import { PanelType } from '../panels/Panel';
|
||||
|
||||
/**
|
||||
* Set of properties which define a panel group:
|
@ -166,8 +166,8 @@ export const ModelInformationDict: ModelDict = {
|
||||
salesordershipment: {
|
||||
label: () => t`Sales Order Shipment`,
|
||||
label_multiple: () => t`Sales Order Shipments`,
|
||||
url_overview: '/salesordershipment',
|
||||
url_detail: '/salesordershipment/:pk/',
|
||||
url_overview: '/sales/shipment/',
|
||||
url_detail: '/sales/shipment/:pk/',
|
||||
api_endpoint: ApiEndpoints.sales_order_shipment_list
|
||||
},
|
||||
returnorder: {
|
||||
|
@ -113,12 +113,12 @@ export function RenderSalesOrderShipment({
|
||||
}: Readonly<{
|
||||
instance: any;
|
||||
}>): ReactNode {
|
||||
let order = instance.sales_order_detail || {};
|
||||
let order = instance.order_detail || {};
|
||||
|
||||
return (
|
||||
<RenderInlineModel
|
||||
primary={order.reference}
|
||||
secondary={t`Shipment` + ` ${instance.description}`}
|
||||
secondary={t`Shipment` + ` ${instance.reference}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -163,9 +163,12 @@ export enum ApiEndpoints {
|
||||
sales_order_line_list = 'order/so-line/',
|
||||
sales_order_extra_line_list = 'order/so-extra-line/',
|
||||
sales_order_allocation_list = 'order/so-allocation/',
|
||||
sales_order_shipment_list = 'order/so/shipment/',
|
||||
sales_order_allocate = 'order/so/:id/allocate/',
|
||||
sales_order_allocate_serials = 'order/so/:id/allocate-serials/',
|
||||
|
||||
sales_order_shipment_list = 'order/so/shipment/',
|
||||
sales_order_shipment_complete = 'order/so/shipment/:id/ship/',
|
||||
|
||||
return_order_list = 'order/ro/',
|
||||
return_order_issue = 'order/ro/:id/issue/',
|
||||
return_order_hold = 'order/ro/:id/hold/',
|
||||
|
@ -423,15 +423,17 @@ function BuildAllocateLineRow({
|
||||
if (instance) {
|
||||
let available = instance.quantity - instance.allocated;
|
||||
|
||||
props.changeFn(
|
||||
props.idx,
|
||||
'quantity',
|
||||
Math.min(props.item.quantity, available)
|
||||
);
|
||||
if (available < props.item.quantity) {
|
||||
props.changeFn(
|
||||
props.idx,
|
||||
'quantity',
|
||||
Math.min(props.item.quantity, available)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [props]);
|
||||
}, [record, props]);
|
||||
|
||||
const quantityField: ApiFormFieldType = useMemo(() => {
|
||||
return {
|
||||
|
@ -1,10 +1,22 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Table } from '@mantine/core';
|
||||
import { IconAddressBook, IconUser, IconUsers } from '@tabler/icons-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import RemoveRowButton from '../components/buttons/RemoveRowButton';
|
||||
import { StandaloneField } from '../components/forms/StandaloneField';
|
||||
import {
|
||||
ApiFormAdjustFilterType,
|
||||
ApiFormFieldSet
|
||||
ApiFormFieldSet,
|
||||
ApiFormFieldType
|
||||
} from '../components/forms/fields/ApiFormField';
|
||||
import { TableFieldRowProps } from '../components/forms/fields/TableField';
|
||||
import { ProgressBar } from '../components/items/ProgressBar';
|
||||
import { ApiEndpoints } from '../enums/ApiEndpoints';
|
||||
import { ModelType } from '../enums/ModelType';
|
||||
import { useCreateApiFormModal } from '../hooks/UseForm';
|
||||
import { apiUrl } from '../states/ApiState';
|
||||
import { PartColumn } from '../tables/ColumnRenderers';
|
||||
|
||||
export function useSalesOrderFields({
|
||||
duplicateOrderId
|
||||
@ -105,6 +117,179 @@ export function useSalesOrderLineItemFields({
|
||||
return fields;
|
||||
}
|
||||
|
||||
function SalesOrderAllocateLineRow({
|
||||
props,
|
||||
record,
|
||||
sourceLocation
|
||||
}: {
|
||||
props: TableFieldRowProps;
|
||||
record: any;
|
||||
sourceLocation?: number | null;
|
||||
}) {
|
||||
// Statically defined field for selecting the stock item
|
||||
const stockItemField: ApiFormFieldType = useMemo(() => {
|
||||
return {
|
||||
field_type: 'related field',
|
||||
api_url: apiUrl(ApiEndpoints.stock_item_list),
|
||||
model: ModelType.stockitem,
|
||||
filters: {
|
||||
available: true,
|
||||
part_detail: true,
|
||||
location_detail: true,
|
||||
location: sourceLocation,
|
||||
cascade: sourceLocation ? true : undefined,
|
||||
part: record.part
|
||||
},
|
||||
value: props.item.stock_item,
|
||||
name: 'stock_item',
|
||||
onValueChange: (value: any, instance: any) => {
|
||||
props.changeFn(props.idx, 'stock_item', value);
|
||||
|
||||
// Update the allocated quantity based on the selected stock item
|
||||
if (instance) {
|
||||
let available = instance.quantity - instance.allocated;
|
||||
let required = record.quantity - record.allocated;
|
||||
|
||||
let quantity = props.item?.quantity ?? 0;
|
||||
|
||||
quantity = Math.max(quantity, required);
|
||||
quantity = Math.min(quantity, available);
|
||||
|
||||
if (quantity != props.item.quantity) {
|
||||
props.changeFn(props.idx, 'quantity', quantity);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [sourceLocation, record, props]);
|
||||
|
||||
// Statically defined field for selecting the allocation quantity
|
||||
const quantityField: ApiFormFieldType = useMemo(() => {
|
||||
return {
|
||||
field_type: 'number',
|
||||
name: 'quantity',
|
||||
required: true,
|
||||
value: props.item.quantity,
|
||||
onValueChange: (value: any) => {
|
||||
props.changeFn(props.idx, 'quantity', value);
|
||||
}
|
||||
};
|
||||
}, [props]);
|
||||
|
||||
return (
|
||||
<Table.Tr key={`table-row-${props.idx}-${record.pk}`}>
|
||||
<Table.Td>
|
||||
<PartColumn part={record.part_detail} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<ProgressBar
|
||||
value={record.allocated}
|
||||
maximum={record.quantity}
|
||||
progressLabel
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<StandaloneField
|
||||
fieldName="stock_item"
|
||||
fieldDefinition={stockItemField}
|
||||
error={props.rowErrors?.stock_item?.message}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<StandaloneField
|
||||
fieldName="quantity"
|
||||
fieldDefinition={quantityField}
|
||||
error={props.rowErrors?.quantity?.message}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<RemoveRowButton onClick={() => props.removeFn(props.idx)} />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAllocateToSalesOrderForm({
|
||||
orderId,
|
||||
shipmentId,
|
||||
lineItems,
|
||||
onFormSuccess
|
||||
}: {
|
||||
orderId: number;
|
||||
shipmentId?: number;
|
||||
lineItems: any[];
|
||||
onFormSuccess: (response: any) => void;
|
||||
}) {
|
||||
const [sourceLocation, setSourceLocation] = useState<number | null>(null);
|
||||
|
||||
// Reset source location to known state
|
||||
useEffect(() => {
|
||||
setSourceLocation(null);
|
||||
}, [orderId, shipmentId, lineItems]);
|
||||
|
||||
const fields: ApiFormFieldSet = useMemo(() => {
|
||||
return {
|
||||
// Non-submitted field to select the source location
|
||||
source_location: {
|
||||
exclude: true,
|
||||
required: false,
|
||||
field_type: 'related field',
|
||||
api_url: apiUrl(ApiEndpoints.stock_location_list),
|
||||
model: ModelType.stocklocation,
|
||||
label: t`Source Location`,
|
||||
description: t`Select the source location for the stock allocation`,
|
||||
onValueChange: (value: any) => {
|
||||
setSourceLocation(value);
|
||||
}
|
||||
},
|
||||
items: {
|
||||
field_type: 'table',
|
||||
value: [],
|
||||
headers: [t`Part`, t`Allocated`, t`Stock Item`, t`Quantity`],
|
||||
modelRenderer: (row: TableFieldRowProps) => {
|
||||
const record =
|
||||
lineItems.find((item) => item.pk == row.item.line_item) ?? {};
|
||||
|
||||
return (
|
||||
<SalesOrderAllocateLineRow
|
||||
key={`table-row-${row.idx}-${record.pk}`}
|
||||
props={row}
|
||||
record={record}
|
||||
sourceLocation={sourceLocation}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
shipment: {
|
||||
filters: {
|
||||
shipped: false,
|
||||
order_detail: true,
|
||||
order: orderId
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [orderId, shipmentId, lineItems, sourceLocation]);
|
||||
|
||||
return useCreateApiFormModal({
|
||||
title: t`Allocate Stock`,
|
||||
url: ApiEndpoints.sales_order_allocate,
|
||||
pk: orderId,
|
||||
fields: fields,
|
||||
onFormSuccess: onFormSuccess,
|
||||
successMessage: t`Stock items allocated`,
|
||||
size: '80%',
|
||||
initialData: {
|
||||
items: lineItems.map((item) => {
|
||||
return {
|
||||
line_item: item.pk,
|
||||
quantity: 0,
|
||||
stock_item: null
|
||||
};
|
||||
})
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function useSalesOrderAllocateSerialsFields({
|
||||
itemId,
|
||||
orderId
|
||||
@ -122,6 +307,7 @@ export function useSalesOrderAllocateSerialsFields({
|
||||
serial_numbers: {},
|
||||
shipment: {
|
||||
filters: {
|
||||
order_detail: true,
|
||||
order: orderId,
|
||||
shipped: false
|
||||
}
|
||||
@ -130,19 +316,53 @@ export function useSalesOrderAllocateSerialsFields({
|
||||
}, [itemId, orderId]);
|
||||
}
|
||||
|
||||
export function useSalesOrderShipmentFields(): ApiFormFieldSet {
|
||||
export function useSalesOrderShipmentFields({
|
||||
pending
|
||||
}: {
|
||||
pending?: boolean;
|
||||
}): ApiFormFieldSet {
|
||||
return useMemo(() => {
|
||||
return {
|
||||
order: {
|
||||
disabled: true
|
||||
},
|
||||
reference: {},
|
||||
shipment_date: {},
|
||||
delivery_date: {},
|
||||
shipment_date: {
|
||||
hidden: pending ?? true
|
||||
},
|
||||
delivery_date: {
|
||||
hidden: pending ?? true
|
||||
},
|
||||
tracking_number: {},
|
||||
invoice_number: {},
|
||||
link: {},
|
||||
notes: {}
|
||||
link: {}
|
||||
};
|
||||
}, []);
|
||||
}, [pending]);
|
||||
}
|
||||
|
||||
export function useSalesOrderShipmentCompleteFields({
|
||||
shipmentId
|
||||
}: {
|
||||
shipmentId?: number;
|
||||
}): ApiFormFieldSet {
|
||||
return useMemo(() => {
|
||||
return {
|
||||
shipment_date: {},
|
||||
tracking_number: {},
|
||||
invoice_number: {},
|
||||
link: {}
|
||||
};
|
||||
}, [shipmentId]);
|
||||
}
|
||||
|
||||
export function useSalesOrderAllocationFields({
|
||||
shipmentId
|
||||
}: {
|
||||
shipmentId?: number;
|
||||
}): ApiFormFieldSet {
|
||||
return useMemo(() => {
|
||||
return {
|
||||
quantity: {}
|
||||
};
|
||||
}, [shipmentId]);
|
||||
}
|
||||
|
@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { api } from '../App';
|
||||
import { PanelType } from '../components/nav/Panel';
|
||||
import { PanelType } from '../components/panels/Panel';
|
||||
import {
|
||||
InvenTreeContext,
|
||||
useInvenTreeContext
|
||||
|
@ -30,9 +30,9 @@ import { lazy, useMemo } from 'react';
|
||||
|
||||
import PermissionDenied from '../../../../components/errors/PermissionDenied';
|
||||
import { PlaceholderPill } from '../../../../components/items/Placeholder';
|
||||
import { PanelType } from '../../../../components/nav/Panel';
|
||||
import { PanelGroup } from '../../../../components/nav/PanelGroup';
|
||||
import { SettingsHeader } from '../../../../components/nav/SettingsHeader';
|
||||
import { PanelType } from '../../../../components/panels/Panel';
|
||||
import { PanelGroup } from '../../../../components/panels/PanelGroup';
|
||||
import { GlobalSettingList } from '../../../../components/settings/SettingList';
|
||||
import { Loadable } from '../../../../functions/loading';
|
||||
import { useUserState } from '../../../../states/UserState';
|
||||
|
@ -19,9 +19,9 @@ import { useMemo } from 'react';
|
||||
|
||||
import PermissionDenied from '../../../components/errors/PermissionDenied';
|
||||
import { PlaceholderPanel } from '../../../components/items/Placeholder';
|
||||
import { PanelType } from '../../../components/nav/Panel';
|
||||
import { PanelGroup } from '../../../components/nav/PanelGroup';
|
||||
import { SettingsHeader } from '../../../components/nav/SettingsHeader';
|
||||
import { PanelType } from '../../../components/panels/Panel';
|
||||
import { PanelGroup } from '../../../components/panels/PanelGroup';
|
||||
import { GlobalSettingList } from '../../../components/settings/SettingList';
|
||||
import { useServerApiState } from '../../../states/ApiState';
|
||||
import { useUserState } from '../../../states/UserState';
|
||||
|
@ -11,9 +11,9 @@ import {
|
||||
} from '@tabler/icons-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { PanelType } from '../../../components/nav/Panel';
|
||||
import { PanelGroup } from '../../../components/nav/PanelGroup';
|
||||
import { SettingsHeader } from '../../../components/nav/SettingsHeader';
|
||||
import { PanelType } from '../../../components/panels/Panel';
|
||||
import { PanelGroup } from '../../../components/panels/PanelGroup';
|
||||
import { UserSettingList } from '../../../components/settings/SettingList';
|
||||
import { useUserState } from '../../../states/UserState';
|
||||
import { SecurityContent } from './AccountSettings/SecurityContent';
|
||||
|
@ -14,7 +14,7 @@ import { useCallback, useMemo } from 'react';
|
||||
import { api } from '../App';
|
||||
import { ActionButton } from '../components/buttons/ActionButton';
|
||||
import { PageDetail } from '../components/nav/PageDetail';
|
||||
import { PanelGroup } from '../components/nav/PanelGroup';
|
||||
import { PanelGroup } from '../components/panels/PanelGroup';
|
||||
import { ApiEndpoints } from '../enums/ApiEndpoints';
|
||||
import { useTable } from '../hooks/UseTable';
|
||||
import { apiUrl } from '../states/ApiState';
|
||||
|
@ -8,8 +8,6 @@ import {
|
||||
IconList,
|
||||
IconListCheck,
|
||||
IconListNumbers,
|
||||
IconNotes,
|
||||
IconPaperclip,
|
||||
IconReportAnalytics,
|
||||
IconSitemap
|
||||
} from '@tabler/icons-react';
|
||||
@ -22,7 +20,6 @@ import { PrintingActions } from '../../components/buttons/PrintingActions';
|
||||
import { DetailsField, DetailsTable } from '../../components/details/Details';
|
||||
import { DetailsImage } from '../../components/details/DetailsImage';
|
||||
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
|
||||
import NotesEditor from '../../components/editors/NotesEditor';
|
||||
import {
|
||||
BarcodeActionDropdown,
|
||||
CancelItemAction,
|
||||
@ -33,8 +30,10 @@ import {
|
||||
} from '../../components/items/ActionDropdown';
|
||||
import InstanceDetail from '../../components/nav/InstanceDetail';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelType } from '../../components/nav/Panel';
|
||||
import { PanelGroup } from '../../components/nav/PanelGroup';
|
||||
import AttachmentPanel from '../../components/panels/AttachmentPanel';
|
||||
import NotesPanel from '../../components/panels/NotesPanel';
|
||||
import { PanelType } from '../../components/panels/Panel';
|
||||
import { PanelGroup } from '../../components/panels/PanelGroup';
|
||||
import { StatusRenderer } from '../../components/render/StatusRenderer';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
@ -53,7 +52,6 @@ import BuildLineTable from '../../tables/build/BuildLineTable';
|
||||
import { BuildOrderTable } from '../../tables/build/BuildOrderTable';
|
||||
import BuildOrderTestTable from '../../tables/build/BuildOrderTestTable';
|
||||
import BuildOutputTable from '../../tables/build/BuildOutputTable';
|
||||
import { AttachmentTable } from '../../tables/general/AttachmentTable';
|
||||
import { StockItemTable } from '../../tables/stock/StockItemTable';
|
||||
import { TestStatisticsTable } from '../../tables/stock/TestStatisticsTable';
|
||||
|
||||
@ -343,26 +341,14 @@ export default function BuildDetail() {
|
||||
),
|
||||
hidden: !build?.part_detail?.testable
|
||||
},
|
||||
{
|
||||
name: 'attachments',
|
||||
label: t`Attachments`,
|
||||
icon: <IconPaperclip />,
|
||||
content: (
|
||||
<AttachmentTable model_type={ModelType.build} model_id={Number(id)} />
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'notes',
|
||||
label: t`Notes`,
|
||||
icon: <IconNotes />,
|
||||
content: (
|
||||
<NotesEditor
|
||||
modelType={ModelType.build}
|
||||
modelId={build.pk}
|
||||
editable={user.hasChangeRole(UserRoles.build)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
AttachmentPanel({
|
||||
model_type: ModelType.build,
|
||||
model_id: build.pk
|
||||
}),
|
||||
NotesPanel({
|
||||
model_type: ModelType.build,
|
||||
model_id: build.pk
|
||||
})
|
||||
];
|
||||
}, [build, id, user]);
|
||||
|
||||
|
@ -5,10 +5,8 @@ import {
|
||||
IconBuildingWarehouse,
|
||||
IconInfoCircle,
|
||||
IconMap2,
|
||||
IconNotes,
|
||||
IconPackageExport,
|
||||
IconPackages,
|
||||
IconPaperclip,
|
||||
IconShoppingCart,
|
||||
IconTruckDelivery,
|
||||
IconTruckReturn,
|
||||
@ -22,7 +20,6 @@ import { DetailsField, DetailsTable } from '../../components/details/Details';
|
||||
import DetailsBadge from '../../components/details/DetailsBadge';
|
||||
import { DetailsImage } from '../../components/details/DetailsImage';
|
||||
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
|
||||
import NotesEditor from '../../components/editors/NotesEditor';
|
||||
import {
|
||||
DeleteItemAction,
|
||||
EditItemAction,
|
||||
@ -31,8 +28,10 @@ import {
|
||||
import { Breadcrumb } from '../../components/nav/BreadcrumbList';
|
||||
import InstanceDetail from '../../components/nav/InstanceDetail';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelType } from '../../components/nav/Panel';
|
||||
import { PanelGroup } from '../../components/nav/PanelGroup';
|
||||
import AttachmentPanel from '../../components/panels/AttachmentPanel';
|
||||
import NotesPanel from '../../components/panels/NotesPanel';
|
||||
import { PanelType } from '../../components/panels/Panel';
|
||||
import { PanelGroup } from '../../components/panels/PanelGroup';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
@ -46,7 +45,6 @@ import { apiUrl } from '../../states/ApiState';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import { AddressTable } from '../../tables/company/AddressTable';
|
||||
import { ContactTable } from '../../tables/company/ContactTable';
|
||||
import { AttachmentTable } from '../../tables/general/AttachmentTable';
|
||||
import { ManufacturerPartTable } from '../../tables/purchasing/ManufacturerPartTable';
|
||||
import { PurchaseOrderTable } from '../../tables/purchasing/PurchaseOrderTable';
|
||||
import { SupplierPartTable } from '../../tables/purchasing/SupplierPartTable';
|
||||
@ -256,33 +254,14 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
|
||||
icon: <IconMap2 />,
|
||||
content: company?.pk && <AddressTable companyId={company.pk} />
|
||||
},
|
||||
{
|
||||
name: 'attachments',
|
||||
label: t`Attachments`,
|
||||
icon: <IconPaperclip />,
|
||||
content: (
|
||||
<AttachmentTable
|
||||
model_type={ModelType.company}
|
||||
model_id={company.pk}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'notes',
|
||||
label: t`Notes`,
|
||||
icon: <IconNotes />,
|
||||
content: (
|
||||
<NotesEditor
|
||||
modelType={ModelType.company}
|
||||
modelId={company.pk}
|
||||
editable={
|
||||
user.hasChangeRole(UserRoles.purchase_order) ||
|
||||
user.hasChangeRole(UserRoles.sales_order) ||
|
||||
user.hasChangeRole(UserRoles.return_order)
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
AttachmentPanel({
|
||||
model_type: ModelType.company,
|
||||
model_id: company.pk
|
||||
}),
|
||||
NotesPanel({
|
||||
model_type: ModelType.company,
|
||||
model_id: company.pk
|
||||
})
|
||||
];
|
||||
}, [id, company, user]);
|
||||
|
||||
|
@ -3,9 +3,7 @@ import { Grid, Skeleton, Stack } from '@mantine/core';
|
||||
import {
|
||||
IconBuildingWarehouse,
|
||||
IconInfoCircle,
|
||||
IconList,
|
||||
IconNotes,
|
||||
IconPaperclip
|
||||
IconList
|
||||
} from '@tabler/icons-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
@ -14,7 +12,6 @@ import AdminButton from '../../components/buttons/AdminButton';
|
||||
import { DetailsField, DetailsTable } from '../../components/details/Details';
|
||||
import { DetailsImage } from '../../components/details/DetailsImage';
|
||||
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
|
||||
import NotesEditor from '../../components/editors/NotesEditor';
|
||||
import {
|
||||
DeleteItemAction,
|
||||
DuplicateItemAction,
|
||||
@ -23,8 +20,10 @@ import {
|
||||
} from '../../components/items/ActionDropdown';
|
||||
import InstanceDetail from '../../components/nav/InstanceDetail';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelType } from '../../components/nav/Panel';
|
||||
import { PanelGroup } from '../../components/nav/PanelGroup';
|
||||
import AttachmentPanel from '../../components/panels/AttachmentPanel';
|
||||
import NotesPanel from '../../components/panels/NotesPanel';
|
||||
import { PanelType } from '../../components/panels/Panel';
|
||||
import { PanelGroup } from '../../components/panels/PanelGroup';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
@ -38,7 +37,6 @@ import {
|
||||
import { useInstance } from '../../hooks/UseInstance';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import { AttachmentTable } from '../../tables/general/AttachmentTable';
|
||||
import ManufacturerPartParameterTable from '../../tables/purchasing/ManufacturerPartParameterTable';
|
||||
import { SupplierPartTable } from '../../tables/purchasing/SupplierPartTable';
|
||||
|
||||
@ -187,29 +185,14 @@ export default function ManufacturerPartDetail() {
|
||||
<Skeleton />
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'attachments',
|
||||
label: t`Attachments`,
|
||||
icon: <IconPaperclip />,
|
||||
content: (
|
||||
<AttachmentTable
|
||||
model_type={ModelType.manufacturerpart}
|
||||
model_id={manufacturerPart?.pk}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'notes',
|
||||
label: t`Notes`,
|
||||
icon: <IconNotes />,
|
||||
content: (
|
||||
<NotesEditor
|
||||
modelType={ModelType.manufacturerpart}
|
||||
modelId={manufacturerPart.pk}
|
||||
editable={user.hasChangeRole(UserRoles.purchase_order)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
AttachmentPanel({
|
||||
model_type: ModelType.manufacturerpart,
|
||||
model_id: manufacturerPart?.pk
|
||||
}),
|
||||
NotesPanel({
|
||||
model_type: ModelType.manufacturerpart,
|
||||
model_id: manufacturerPart?.pk
|
||||
})
|
||||
];
|
||||
}, [manufacturerPart]);
|
||||
|
||||
|
@ -3,7 +3,6 @@ import { Grid, Skeleton, Stack } from '@mantine/core';
|
||||
import {
|
||||
IconCurrencyDollar,
|
||||
IconInfoCircle,
|
||||
IconNotes,
|
||||
IconPackages,
|
||||
IconShoppingCart
|
||||
} from '@tabler/icons-react';
|
||||
@ -15,7 +14,6 @@ import { DetailsField, DetailsTable } from '../../components/details/Details';
|
||||
import DetailsBadge from '../../components/details/DetailsBadge';
|
||||
import { DetailsImage } from '../../components/details/DetailsImage';
|
||||
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
|
||||
import NotesEditor from '../../components/editors/NotesEditor';
|
||||
import {
|
||||
BarcodeActionDropdown,
|
||||
DeleteItemAction,
|
||||
@ -25,8 +23,9 @@ import {
|
||||
} from '../../components/items/ActionDropdown';
|
||||
import InstanceDetail from '../../components/nav/InstanceDetail';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelType } from '../../components/nav/Panel';
|
||||
import { PanelGroup } from '../../components/nav/PanelGroup';
|
||||
import NotesPanel from '../../components/panels/NotesPanel';
|
||||
import { PanelType } from '../../components/panels/Panel';
|
||||
import { PanelGroup } from '../../components/panels/PanelGroup';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
@ -257,18 +256,10 @@ export default function SupplierPartDetail() {
|
||||
<Skeleton />
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'notes',
|
||||
label: t`Notes`,
|
||||
icon: <IconNotes />,
|
||||
content: (
|
||||
<NotesEditor
|
||||
modelType={ModelType.supplierpart}
|
||||
modelId={supplierPart.pk}
|
||||
editable={user.hasChangeRole(UserRoles.purchase_order)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
NotesPanel({
|
||||
model_type: ModelType.supplierpart,
|
||||
model_id: supplierPart?.pk
|
||||
})
|
||||
];
|
||||
}, [supplierPart]);
|
||||
|
||||
|
@ -21,8 +21,8 @@ import { ApiIcon } from '../../components/items/ApiIcon';
|
||||
import InstanceDetail from '../../components/nav/InstanceDetail';
|
||||
import NavigationTree from '../../components/nav/NavigationTree';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelType } from '../../components/nav/Panel';
|
||||
import { PanelGroup } from '../../components/nav/PanelGroup';
|
||||
import { PanelType } from '../../components/panels/Panel';
|
||||
import { PanelGroup } from '../../components/panels/PanelGroup';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
|
@ -59,8 +59,10 @@ import { StylishText } from '../../components/items/StylishText';
|
||||
import InstanceDetail from '../../components/nav/InstanceDetail';
|
||||
import NavigationTree from '../../components/nav/NavigationTree';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelType } from '../../components/nav/Panel';
|
||||
import { PanelGroup } from '../../components/nav/PanelGroup';
|
||||
import AttachmentPanel from '../../components/panels/AttachmentPanel';
|
||||
import NotesPanel from '../../components/panels/NotesPanel';
|
||||
import { PanelType } from '../../components/panels/Panel';
|
||||
import { PanelGroup } from '../../components/panels/PanelGroup';
|
||||
import { RenderPart } from '../../components/render/Part';
|
||||
import { formatPriceRange } from '../../defaults/formatters';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
@ -90,7 +92,6 @@ import { BomTable } from '../../tables/bom/BomTable';
|
||||
import { UsedInTable } from '../../tables/bom/UsedInTable';
|
||||
import BuildAllocatedStockTable from '../../tables/build/BuildAllocatedStockTable';
|
||||
import { BuildOrderTable } from '../../tables/build/BuildOrderTable';
|
||||
import { AttachmentTable } from '../../tables/general/AttachmentTable';
|
||||
import { PartParameterTable } from '../../tables/part/PartParameterTable';
|
||||
import PartPurchaseOrdersTable from '../../tables/part/PartPurchaseOrdersTable';
|
||||
import PartTestTemplateTable from '../../tables/part/PartTestTemplateTable';
|
||||
@ -742,26 +743,14 @@ export default function PartDetail() {
|
||||
icon: <IconLayersLinked />,
|
||||
content: <RelatedPartTable partId={part.pk ?? -1} />
|
||||
},
|
||||
{
|
||||
name: 'attachments',
|
||||
label: t`Attachments`,
|
||||
icon: <IconPaperclip />,
|
||||
content: (
|
||||
<AttachmentTable model_type={ModelType.part} model_id={part?.pk} />
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'notes',
|
||||
label: t`Notes`,
|
||||
icon: <IconNotes />,
|
||||
content: (
|
||||
<NotesEditor
|
||||
modelType={ModelType.part}
|
||||
modelId={part.pk}
|
||||
editable={user.hasChangeRole(UserRoles.part)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
AttachmentPanel({
|
||||
model_type: ModelType.part,
|
||||
model_id: part?.pk
|
||||
}),
|
||||
NotesPanel({
|
||||
model_type: ModelType.part,
|
||||
model_id: part?.pk
|
||||
})
|
||||
];
|
||||
}, [id, part, user, globalSettings, userSettings]);
|
||||
|
||||
|
@ -1,12 +1,6 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Accordion, Grid, Skeleton, Stack } from '@mantine/core';
|
||||
import {
|
||||
IconInfoCircle,
|
||||
IconList,
|
||||
IconNotes,
|
||||
IconPackages,
|
||||
IconPaperclip
|
||||
} from '@tabler/icons-react';
|
||||
import { IconInfoCircle, IconList, IconPackages } from '@tabler/icons-react';
|
||||
import { ReactNode, useMemo } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
@ -16,7 +10,6 @@ import { PrintingActions } from '../../components/buttons/PrintingActions';
|
||||
import { DetailsField, DetailsTable } from '../../components/details/Details';
|
||||
import { DetailsImage } from '../../components/details/DetailsImage';
|
||||
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
|
||||
import NotesEditor from '../../components/editors/NotesEditor';
|
||||
import {
|
||||
BarcodeActionDropdown,
|
||||
CancelItemAction,
|
||||
@ -28,8 +21,10 @@ import {
|
||||
import { StylishText } from '../../components/items/StylishText';
|
||||
import InstanceDetail from '../../components/nav/InstanceDetail';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelType } from '../../components/nav/Panel';
|
||||
import { PanelGroup } from '../../components/nav/PanelGroup';
|
||||
import AttachmentPanel from '../../components/panels/AttachmentPanel';
|
||||
import NotesPanel from '../../components/panels/NotesPanel';
|
||||
import { PanelType } from '../../components/panels/Panel';
|
||||
import { PanelGroup } from '../../components/panels/PanelGroup';
|
||||
import { StatusRenderer } from '../../components/render/StatusRenderer';
|
||||
import { formatCurrency } from '../../defaults/formatters';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
@ -45,7 +40,6 @@ import useStatusCodes from '../../hooks/UseStatusCodes';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import { useGlobalSettingsState } from '../../states/SettingsState';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import { AttachmentTable } from '../../tables/general/AttachmentTable';
|
||||
import ExtraLineItemTable from '../../tables/general/ExtraLineItemTable';
|
||||
import { PurchaseOrderLineItemTable } from '../../tables/purchasing/PurchaseOrderLineItemTable';
|
||||
import { StockItemTable } from '../../tables/stock/StockItemTable';
|
||||
@ -304,29 +298,14 @@ export default function PurchaseOrderDetail() {
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'attachments',
|
||||
label: t`Attachments`,
|
||||
icon: <IconPaperclip />,
|
||||
content: (
|
||||
<AttachmentTable
|
||||
model_type={ModelType.purchaseorder}
|
||||
model_id={order.pk}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'notes',
|
||||
label: t`Notes`,
|
||||
icon: <IconNotes />,
|
||||
content: (
|
||||
<NotesEditor
|
||||
modelType={ModelType.purchaseorder}
|
||||
modelId={order.pk}
|
||||
editable={user.hasChangeRole(UserRoles.purchase_order)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
AttachmentPanel({
|
||||
model_type: ModelType.purchaseorder,
|
||||
model_id: order.pk
|
||||
}),
|
||||
NotesPanel({
|
||||
model_type: ModelType.purchaseorder,
|
||||
model_id: order.pk
|
||||
})
|
||||
];
|
||||
}, [order, id, user]);
|
||||
|
||||
|
@ -9,7 +9,7 @@ import { useMemo } from 'react';
|
||||
|
||||
import PermissionDenied from '../../components/errors/PermissionDenied';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelGroup } from '../../components/nav/PanelGroup';
|
||||
import { PanelGroup } from '../../components/panels/PanelGroup';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import { CompanyTable } from '../../tables/company/CompanyTable';
|
||||
|
@ -1,11 +1,6 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Accordion, Grid, Skeleton, Stack } from '@mantine/core';
|
||||
import {
|
||||
IconInfoCircle,
|
||||
IconList,
|
||||
IconNotes,
|
||||
IconPaperclip
|
||||
} from '@tabler/icons-react';
|
||||
import { IconInfoCircle, IconList } from '@tabler/icons-react';
|
||||
import { ReactNode, useMemo } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
@ -15,7 +10,6 @@ import { PrintingActions } from '../../components/buttons/PrintingActions';
|
||||
import { DetailsField, DetailsTable } from '../../components/details/Details';
|
||||
import { DetailsImage } from '../../components/details/DetailsImage';
|
||||
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
|
||||
import NotesEditor from '../../components/editors/NotesEditor';
|
||||
import {
|
||||
BarcodeActionDropdown,
|
||||
CancelItemAction,
|
||||
@ -27,8 +21,10 @@ import {
|
||||
import { StylishText } from '../../components/items/StylishText';
|
||||
import InstanceDetail from '../../components/nav/InstanceDetail';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelType } from '../../components/nav/Panel';
|
||||
import { PanelGroup } from '../../components/nav/PanelGroup';
|
||||
import AttachmentPanel from '../../components/panels/AttachmentPanel';
|
||||
import NotesPanel from '../../components/panels/NotesPanel';
|
||||
import { PanelType } from '../../components/panels/Panel';
|
||||
import { PanelGroup } from '../../components/panels/PanelGroup';
|
||||
import { StatusRenderer } from '../../components/render/StatusRenderer';
|
||||
import { formatCurrency } from '../../defaults/formatters';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
@ -44,7 +40,6 @@ import useStatusCodes from '../../hooks/UseStatusCodes';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import { useGlobalSettingsState } from '../../states/SettingsState';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import { AttachmentTable } from '../../tables/general/AttachmentTable';
|
||||
import ExtraLineItemTable from '../../tables/general/ExtraLineItemTable';
|
||||
import ReturnOrderLineItemTable from '../../tables/sales/ReturnOrderLineItemTable';
|
||||
|
||||
@ -267,29 +262,14 @@ export default function ReturnOrderDetail() {
|
||||
</Accordion>
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'attachments',
|
||||
label: t`Attachments`,
|
||||
icon: <IconPaperclip />,
|
||||
content: (
|
||||
<AttachmentTable
|
||||
model_type={ModelType.returnorder}
|
||||
model_id={order.pk}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'notes',
|
||||
label: t`Notes`,
|
||||
icon: <IconNotes />,
|
||||
content: (
|
||||
<NotesEditor
|
||||
modelType={ModelType.returnorder}
|
||||
modelId={order.pk}
|
||||
editable={user.hasChangeRole(UserRoles.return_order)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
AttachmentPanel({
|
||||
model_type: ModelType.returnorder,
|
||||
model_id: order.pk
|
||||
}),
|
||||
NotesPanel({
|
||||
model_type: ModelType.returnorder,
|
||||
model_id: order.pk
|
||||
})
|
||||
];
|
||||
}, [order, id, user]);
|
||||
|
||||
|
@ -9,7 +9,7 @@ import { useMemo } from 'react';
|
||||
|
||||
import PermissionDenied from '../../components/errors/PermissionDenied';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelGroup } from '../../components/nav/PanelGroup';
|
||||
import { PanelGroup } from '../../components/panels/PanelGroup';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import { CompanyTable } from '../../tables/company/CompanyTable';
|
||||
|
@ -4,8 +4,6 @@ import {
|
||||
IconBookmark,
|
||||
IconInfoCircle,
|
||||
IconList,
|
||||
IconNotes,
|
||||
IconPaperclip,
|
||||
IconTools,
|
||||
IconTruckDelivery
|
||||
} from '@tabler/icons-react';
|
||||
@ -18,7 +16,6 @@ import { PrintingActions } from '../../components/buttons/PrintingActions';
|
||||
import { DetailsField, DetailsTable } from '../../components/details/Details';
|
||||
import { DetailsImage } from '../../components/details/DetailsImage';
|
||||
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
|
||||
import NotesEditor from '../../components/editors/NotesEditor';
|
||||
import {
|
||||
BarcodeActionDropdown,
|
||||
CancelItemAction,
|
||||
@ -30,8 +27,10 @@ import {
|
||||
import { StylishText } from '../../components/items/StylishText';
|
||||
import InstanceDetail from '../../components/nav/InstanceDetail';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelType } from '../../components/nav/Panel';
|
||||
import { PanelGroup } from '../../components/nav/PanelGroup';
|
||||
import AttachmentPanel from '../../components/panels/AttachmentPanel';
|
||||
import NotesPanel from '../../components/panels/NotesPanel';
|
||||
import { PanelType } from '../../components/panels/Panel';
|
||||
import { PanelGroup } from '../../components/panels/PanelGroup';
|
||||
import { StatusRenderer } from '../../components/render/StatusRenderer';
|
||||
import { formatCurrency } from '../../defaults/formatters';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
@ -48,7 +47,6 @@ import { apiUrl } from '../../states/ApiState';
|
||||
import { useGlobalSettingsState } from '../../states/SettingsState';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import { BuildOrderTable } from '../../tables/build/BuildOrderTable';
|
||||
import { AttachmentTable } from '../../tables/general/AttachmentTable';
|
||||
import ExtraLineItemTable from '../../tables/general/ExtraLineItemTable';
|
||||
import SalesOrderAllocationTable from '../../tables/sales/SalesOrderAllocationTable';
|
||||
import SalesOrderLineItemTable from '../../tables/sales/SalesOrderLineItemTable';
|
||||
@ -338,29 +336,14 @@ export default function SalesOrderDetail() {
|
||||
<Skeleton />
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'attachments',
|
||||
label: t`Attachments`,
|
||||
icon: <IconPaperclip />,
|
||||
content: (
|
||||
<AttachmentTable
|
||||
model_type={ModelType.salesorder}
|
||||
model_id={order.pk}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'notes',
|
||||
label: t`Notes`,
|
||||
icon: <IconNotes />,
|
||||
content: (
|
||||
<NotesEditor
|
||||
modelType={ModelType.salesorder}
|
||||
modelId={order.pk}
|
||||
editable={user.hasChangeRole(UserRoles.sales_order)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
AttachmentPanel({
|
||||
model_type: ModelType.salesorder,
|
||||
model_id: order.pk
|
||||
}),
|
||||
NotesPanel({
|
||||
model_type: ModelType.salesorder,
|
||||
model_id: order.pk
|
||||
})
|
||||
];
|
||||
}, [order, id, user, soStatus]);
|
||||
|
||||
|
366
src/frontend/src/pages/sales/SalesOrderShipmentDetail.tsx
Normal file
366
src/frontend/src/pages/sales/SalesOrderShipmentDetail.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -20,8 +20,8 @@ import { ApiIcon } from '../../components/items/ApiIcon';
|
||||
import InstanceDetail from '../../components/nav/InstanceDetail';
|
||||
import NavigationTree from '../../components/nav/NavigationTree';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelType } from '../../components/nav/Panel';
|
||||
import { PanelGroup } from '../../components/nav/PanelGroup';
|
||||
import { PanelType } from '../../components/panels/Panel';
|
||||
import { PanelGroup } from '../../components/panels/PanelGroup';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
|
@ -6,9 +6,7 @@ import {
|
||||
IconChecklist,
|
||||
IconHistory,
|
||||
IconInfoCircle,
|
||||
IconNotes,
|
||||
IconPackages,
|
||||
IconPaperclip,
|
||||
IconSitemap
|
||||
} from '@tabler/icons-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
@ -22,7 +20,6 @@ import { DetailsField, DetailsTable } from '../../components/details/Details';
|
||||
import DetailsBadge from '../../components/details/DetailsBadge';
|
||||
import { DetailsImage } from '../../components/details/DetailsImage';
|
||||
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
|
||||
import NotesEditor from '../../components/editors/NotesEditor';
|
||||
import {
|
||||
ActionDropdown,
|
||||
BarcodeActionDropdown,
|
||||
@ -35,8 +32,10 @@ import { StylishText } from '../../components/items/StylishText';
|
||||
import InstanceDetail from '../../components/nav/InstanceDetail';
|
||||
import NavigationTree from '../../components/nav/NavigationTree';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelType } from '../../components/nav/Panel';
|
||||
import { PanelGroup } from '../../components/nav/PanelGroup';
|
||||
import AttachmentPanel from '../../components/panels/AttachmentPanel';
|
||||
import NotesPanel from '../../components/panels/NotesPanel';
|
||||
import { PanelType } from '../../components/panels/Panel';
|
||||
import { PanelGroup } from '../../components/panels/PanelGroup';
|
||||
import { StatusRenderer } from '../../components/render/StatusRenderer';
|
||||
import { formatCurrency } from '../../defaults/formatters';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
@ -62,7 +61,6 @@ import { useInstance } from '../../hooks/UseInstance';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import BuildAllocatedStockTable from '../../tables/build/BuildAllocatedStockTable';
|
||||
import { AttachmentTable } from '../../tables/general/AttachmentTable';
|
||||
import SalesOrderAllocationTable from '../../tables/sales/SalesOrderAllocationTable';
|
||||
import InstalledItemsTable from '../../tables/stock/InstalledItemsTable';
|
||||
import { StockItemTable } from '../../tables/stock/StockItemTable';
|
||||
@ -480,29 +478,14 @@ export default function StockDetail() {
|
||||
<Skeleton />
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'attachments',
|
||||
label: t`Attachments`,
|
||||
icon: <IconPaperclip />,
|
||||
content: (
|
||||
<AttachmentTable
|
||||
model_type={ModelType.stockitem}
|
||||
model_id={stockitem.pk}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'notes',
|
||||
label: t`Notes`,
|
||||
icon: <IconNotes />,
|
||||
content: (
|
||||
<NotesEditor
|
||||
modelType={ModelType.stockitem}
|
||||
modelId={stockitem.pk}
|
||||
editable={user.hasChangeRole(UserRoles.stock)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
AttachmentPanel({
|
||||
model_type: ModelType.stockitem,
|
||||
model_id: stockitem.pk
|
||||
}),
|
||||
NotesPanel({
|
||||
model_type: ModelType.stockitem,
|
||||
model_id: stockitem.pk
|
||||
})
|
||||
];
|
||||
}, [
|
||||
showSalesAlloctions,
|
||||
|
@ -74,6 +74,10 @@ export const SalesOrderDetail = Loadable(
|
||||
lazy(() => import('./pages/sales/SalesOrderDetail'))
|
||||
);
|
||||
|
||||
export const SalesOrderShipmentDetail = Loadable(
|
||||
lazy(() => import('./pages/sales/SalesOrderShipmentDetail'))
|
||||
);
|
||||
|
||||
export const ReturnOrderDetail = Loadable(
|
||||
lazy(() => import('./pages/sales/ReturnOrderDetail'))
|
||||
);
|
||||
@ -160,6 +164,7 @@ export const routes = (
|
||||
<Route index element={<Navigate to="index/" />} />
|
||||
<Route path="index/*" element={<SalesIndex />} />
|
||||
<Route path="sales-order/:id/*" element={<SalesOrderDetail />} />
|
||||
<Route path="shipment/:id/*" element={<SalesOrderShipmentDetail />} />
|
||||
<Route path="return-order/:id/*" element={<ReturnOrderDetail />} />
|
||||
<Route path="customer/:id/*" element={<CustomerDetail />} />
|
||||
</Route>
|
||||
|
@ -1,6 +1,12 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { ActionIcon, Menu, Tooltip } from '@mantine/core';
|
||||
import { IconCopy, IconDots, IconEdit, IconTrash } from '@tabler/icons-react';
|
||||
import {
|
||||
IconCircleX,
|
||||
IconCopy,
|
||||
IconDots,
|
||||
IconEdit,
|
||||
IconTrash
|
||||
} from '@tabler/icons-react';
|
||||
import { ReactNode, useMemo, useState } from 'react';
|
||||
|
||||
import { cancelEvent } from '../functions/events';
|
||||
@ -11,7 +17,7 @@ export type RowAction = {
|
||||
tooltip?: string;
|
||||
color?: string;
|
||||
icon?: ReactNode;
|
||||
onClick: () => void;
|
||||
onClick: (event: any) => void;
|
||||
hidden?: boolean;
|
||||
disabled?: boolean;
|
||||
};
|
||||
@ -46,6 +52,16 @@ export function RowDeleteAction(props: RowAction): RowAction {
|
||||
};
|
||||
}
|
||||
|
||||
// Component for cancelling a row in a table
|
||||
export function RowCancelAction(props: RowAction): RowAction {
|
||||
return {
|
||||
...props,
|
||||
title: t`Cancel`,
|
||||
color: 'red',
|
||||
icon: <IconCircleX />
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for displaying actions for a row in a table.
|
||||
* Displays a simple dropdown menu with a list of actions.
|
||||
@ -89,7 +105,7 @@ export function RowActions({
|
||||
onClick={(event) => {
|
||||
// Prevent clicking on the action from selecting the row itself
|
||||
cancelEvent(event);
|
||||
action.onClick();
|
||||
action.onClick(event);
|
||||
setOpened(false);
|
||||
}}
|
||||
disabled={action.disabled || false}
|
||||
|
@ -316,7 +316,7 @@ export default function BuildLineTable({
|
||||
)
|
||||
});
|
||||
|
||||
const allowcateStock = useAllocateStockToBuildForm({
|
||||
const allocateStock = useAllocateStockToBuildForm({
|
||||
build: build,
|
||||
outputId: null,
|
||||
buildId: build.pk,
|
||||
@ -395,7 +395,7 @@ export default function BuildLineTable({
|
||||
color: 'green',
|
||||
onClick: () => {
|
||||
setSelectedRows([record]);
|
||||
allowcateStock.open();
|
||||
allocateStock.open();
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -465,7 +465,7 @@ export default function BuildLineTable({
|
||||
!r.bom_item_detail.consumable
|
||||
)
|
||||
);
|
||||
allowcateStock.open();
|
||||
allocateStock.open();
|
||||
}}
|
||||
/>,
|
||||
<ActionButton
|
||||
@ -493,7 +493,7 @@ export default function BuildLineTable({
|
||||
<>
|
||||
{autoAllocateStock.modal}
|
||||
{newBuildOrder.modal}
|
||||
{allowcateStock.modal}
|
||||
{allocateStock.modal}
|
||||
{deallocateStock.modal}
|
||||
<InvenTreeTable
|
||||
url={apiUrl(ApiEndpoints.build_line_list)}
|
||||
|
@ -179,6 +179,7 @@ export default function BuildOutputTable({
|
||||
url: apiUrl(ApiEndpoints.build_output_create, buildId),
|
||||
title: t`Add Build Output`,
|
||||
fields: buildOutputFields,
|
||||
timeout: 10000,
|
||||
initialData: {
|
||||
batch_code: build.batch,
|
||||
location: build.destination ?? build.part_detail?.default_location
|
||||
|
@ -1,8 +1,16 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { AddItemButton } from '../../components/buttons/AddItemButton';
|
||||
import { YesNoButton } from '../../components/buttons/YesNoButton';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
import { useSalesOrderAllocationFields } from '../../forms/SalesOrderForms';
|
||||
import {
|
||||
useDeleteApiFormModal,
|
||||
useEditApiFormModal
|
||||
} from '../../hooks/UseForm';
|
||||
import { useTable } from '../../hooks/UseTable';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
@ -15,12 +23,13 @@ import {
|
||||
} from '../ColumnRenderers';
|
||||
import { TableFilter } from '../Filter';
|
||||
import { InvenTreeTable } from '../InvenTreeTable';
|
||||
import { RowAction } from '../RowActions';
|
||||
import { RowAction, RowDeleteAction, RowEditAction } from '../RowActions';
|
||||
|
||||
export default function SalesOrderAllocationTable({
|
||||
partId,
|
||||
stockId,
|
||||
orderId,
|
||||
shipmentId,
|
||||
showPartInfo,
|
||||
showOrderInfo,
|
||||
allowEdit,
|
||||
@ -30,6 +39,7 @@ export default function SalesOrderAllocationTable({
|
||||
partId?: number;
|
||||
stockId?: number;
|
||||
orderId?: number;
|
||||
shipmentId?: number;
|
||||
showPartInfo?: boolean;
|
||||
showOrderInfo?: boolean;
|
||||
allowEdit?: boolean;
|
||||
@ -40,7 +50,13 @@ export default function SalesOrderAllocationTable({
|
||||
const table = useTable('salesorderallocations');
|
||||
|
||||
const tableFilters: TableFilter[] = useMemo(() => {
|
||||
return [];
|
||||
return [
|
||||
{
|
||||
name: 'outstanding',
|
||||
label: t`Outstanding`,
|
||||
description: t`Show outstanding allocations`
|
||||
}
|
||||
];
|
||||
}, []);
|
||||
|
||||
const tableColumns: TableColumn[] = useMemo(() => {
|
||||
@ -49,6 +65,7 @@ export default function SalesOrderAllocationTable({
|
||||
accessor: 'order_detail.reference',
|
||||
title: t`Sales Order`,
|
||||
switchable: false,
|
||||
sortable: true,
|
||||
hidden: showOrderInfo != true
|
||||
}),
|
||||
{
|
||||
@ -70,65 +87,138 @@ export default function SalesOrderAllocationTable({
|
||||
switchable: false,
|
||||
render: (record: any) => PartColumn({ part: record.part_detail })
|
||||
},
|
||||
{
|
||||
accessor: 'quantity',
|
||||
title: t`Allocated Quantity`,
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
accessor: 'serial',
|
||||
title: t`Serial Number`,
|
||||
sortable: false,
|
||||
sortable: true,
|
||||
switchable: true,
|
||||
render: (record: any) => record?.item_detail?.serial
|
||||
},
|
||||
{
|
||||
accessor: 'batch',
|
||||
title: t`Batch Code`,
|
||||
sortable: false,
|
||||
sortable: true,
|
||||
switchable: true,
|
||||
render: (record: any) => record?.item_detail?.batch
|
||||
},
|
||||
{
|
||||
accessor: 'available',
|
||||
title: t`Available Quantity`,
|
||||
sortable: false,
|
||||
render: (record: any) => record?.item_detail?.quantity
|
||||
},
|
||||
{
|
||||
accessor: 'quantity',
|
||||
title: t`Allocated Quantity`,
|
||||
sortable: true
|
||||
},
|
||||
LocationColumn({
|
||||
accessor: 'location_detail',
|
||||
switchable: true,
|
||||
sortable: true
|
||||
})
|
||||
}),
|
||||
{
|
||||
accessor: 'shipment_detail.reference',
|
||||
title: t`Shipment`,
|
||||
switchable: true,
|
||||
sortable: false
|
||||
},
|
||||
{
|
||||
accessor: 'shipment_date',
|
||||
title: t`Shipped`,
|
||||
switchable: true,
|
||||
sortable: false,
|
||||
render: (record: any) => (
|
||||
<YesNoButton value={!!record.shipment_detail?.shipment_date} />
|
||||
)
|
||||
}
|
||||
];
|
||||
}, []);
|
||||
|
||||
const [selectedAllocation, setSelectedAllocation] = useState<number>(0);
|
||||
|
||||
const editAllocationFields = useSalesOrderAllocationFields({
|
||||
shipmentId: shipmentId
|
||||
});
|
||||
|
||||
const editAllocation = useEditApiFormModal({
|
||||
url: ApiEndpoints.sales_order_allocation_list,
|
||||
pk: selectedAllocation,
|
||||
fields: editAllocationFields,
|
||||
title: t`Edit Allocation`,
|
||||
table: table
|
||||
});
|
||||
|
||||
const deleteAllocation = useDeleteApiFormModal({
|
||||
url: ApiEndpoints.sales_order_allocation_list,
|
||||
pk: selectedAllocation,
|
||||
title: t`Delete Allocation`,
|
||||
table: table
|
||||
});
|
||||
|
||||
const rowActions = useCallback(
|
||||
(record: any): RowAction[] => {
|
||||
return [];
|
||||
// Do not allow "shipped" items to be manipulated
|
||||
const isShipped = !!record.shipment_detail?.shipment_date;
|
||||
|
||||
if (isShipped || !allowEdit) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
RowEditAction({
|
||||
tooltip: t`Edit Allocation`,
|
||||
onClick: () => {
|
||||
setSelectedAllocation(record.pk);
|
||||
editAllocation.open();
|
||||
}
|
||||
}),
|
||||
RowDeleteAction({
|
||||
tooltip: t`Delete Allocation`,
|
||||
onClick: () => {
|
||||
setSelectedAllocation(record.pk);
|
||||
deleteAllocation.open();
|
||||
}
|
||||
})
|
||||
];
|
||||
},
|
||||
[user]
|
||||
[allowEdit, user]
|
||||
);
|
||||
|
||||
const tableActions = useMemo(() => {
|
||||
if (!allowEdit) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [allowEdit, user]);
|
||||
|
||||
return (
|
||||
<InvenTreeTable
|
||||
url={apiUrl(ApiEndpoints.sales_order_allocation_list)}
|
||||
tableState={table}
|
||||
columns={tableColumns}
|
||||
props={{
|
||||
params: {
|
||||
part_detail: showPartInfo ?? false,
|
||||
order_detail: showOrderInfo ?? false,
|
||||
item_detail: true,
|
||||
location_detail: true,
|
||||
part: partId,
|
||||
order: orderId,
|
||||
stock_item: stockId
|
||||
},
|
||||
rowActions: rowActions,
|
||||
tableFilters: tableFilters,
|
||||
modelField: modelField ?? 'order',
|
||||
modelType: modelTarget ?? ModelType.salesorder
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
{editAllocation.modal}
|
||||
{deleteAllocation.modal}
|
||||
<InvenTreeTable
|
||||
url={apiUrl(ApiEndpoints.sales_order_allocation_list)}
|
||||
tableState={table}
|
||||
columns={tableColumns}
|
||||
props={{
|
||||
params: {
|
||||
part_detail: showPartInfo ?? false,
|
||||
order_detail: showOrderInfo ?? false,
|
||||
item_detail: true,
|
||||
location_detail: true,
|
||||
part: partId,
|
||||
order: orderId,
|
||||
shipment: shipmentId,
|
||||
item: stockId
|
||||
},
|
||||
rowActions: rowActions,
|
||||
tableActions: tableActions,
|
||||
tableFilters: tableFilters,
|
||||
modelField: modelField ?? 'order',
|
||||
modelType: modelTarget ?? ModelType.salesorder
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Text } from '@mantine/core';
|
||||
import {
|
||||
IconArrowRight,
|
||||
IconHash,
|
||||
IconShoppingCart,
|
||||
IconSquareArrowRight,
|
||||
@ -8,6 +9,7 @@ import {
|
||||
} from '@tabler/icons-react';
|
||||
import { ReactNode, useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { ActionButton } from '../../components/buttons/ActionButton';
|
||||
import { AddItemButton } from '../../components/buttons/AddItemButton';
|
||||
import { ProgressBar } from '../../components/items/ProgressBar';
|
||||
import { formatCurrency } from '../../defaults/formatters';
|
||||
@ -16,6 +18,7 @@ import { ModelType } from '../../enums/ModelType';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
import { useBuildOrderFields } from '../../forms/BuildForms';
|
||||
import {
|
||||
useAllocateToSalesOrderForm,
|
||||
useSalesOrderAllocateSerialsFields,
|
||||
useSalesOrderLineItemFields
|
||||
} from '../../forms/SalesOrderForms';
|
||||
@ -236,6 +239,7 @@ export default function SalesOrderLineItemTable({
|
||||
url: ApiEndpoints.sales_order_allocate_serials,
|
||||
pk: orderId,
|
||||
title: t`Allocate Serial Numbers`,
|
||||
initialData: initialData,
|
||||
fields: allocateSerialFields,
|
||||
table: table
|
||||
});
|
||||
@ -251,6 +255,17 @@ export default function SalesOrderLineItemTable({
|
||||
modelType: ModelType.build
|
||||
});
|
||||
|
||||
const [selectedItems, setSelectedItems] = useState<any[]>([]);
|
||||
|
||||
const allocateStock = useAllocateToSalesOrderForm({
|
||||
orderId: orderId,
|
||||
lineItems: selectedItems,
|
||||
onFormSuccess: () => {
|
||||
table.refreshTable();
|
||||
table.clearSelectedRecords();
|
||||
}
|
||||
});
|
||||
|
||||
const tableActions = useMemo(() => {
|
||||
return [
|
||||
<AddItemButton
|
||||
@ -263,9 +278,22 @@ export default function SalesOrderLineItemTable({
|
||||
newLine.open();
|
||||
}}
|
||||
hidden={!editable || !user.hasAddRole(UserRoles.sales_order)}
|
||||
/>,
|
||||
<ActionButton
|
||||
key="allocate-stock"
|
||||
tooltip={t`Allocate Stock`}
|
||||
icon={<IconArrowRight />}
|
||||
disabled={!table.hasSelectedRecords}
|
||||
color="green"
|
||||
onClick={() => {
|
||||
setSelectedItems(
|
||||
table.selectedRecords.filter((r) => r.allocated < r.quantity)
|
||||
);
|
||||
allocateStock.open();
|
||||
}}
|
||||
/>
|
||||
];
|
||||
}, [user, orderId]);
|
||||
}, [user, orderId, table.hasSelectedRecords, table.selectedRecords]);
|
||||
|
||||
const rowActions = useCallback(
|
||||
(record: any): RowAction[] => {
|
||||
@ -280,7 +308,10 @@ export default function SalesOrderLineItemTable({
|
||||
title: t`Allocate Stock`,
|
||||
icon: <IconSquareArrowRight />,
|
||||
color: 'green',
|
||||
onClick: notYetImplemented
|
||||
onClick: () => {
|
||||
setSelectedItems([record]);
|
||||
allocateStock.open();
|
||||
}
|
||||
},
|
||||
{
|
||||
hidden:
|
||||
@ -288,11 +319,14 @@ export default function SalesOrderLineItemTable({
|
||||
allocated ||
|
||||
!editable ||
|
||||
!user.hasChangeRole(UserRoles.sales_order),
|
||||
title: t`Allocate Serials`,
|
||||
title: t`Allocate serials`,
|
||||
icon: <IconHash />,
|
||||
color: 'green',
|
||||
onClick: () => {
|
||||
setSelectedLine(record.pk);
|
||||
setInitialData({
|
||||
quantity: record.quantity - record.allocated
|
||||
});
|
||||
allocateBySerials.open();
|
||||
}
|
||||
},
|
||||
@ -356,6 +390,7 @@ export default function SalesOrderLineItemTable({
|
||||
{newLine.modal}
|
||||
{newBuildOrder.modal}
|
||||
{allocateBySerials.modal}
|
||||
{allocateStock.modal}
|
||||
<InvenTreeTable
|
||||
url={apiUrl(ApiEndpoints.sales_order_line_list)}
|
||||
tableState={table}
|
||||
|
@ -1,13 +1,19 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { IconTruckDelivery } from '@tabler/icons-react';
|
||||
import { IconArrowRight, IconTruckDelivery } from '@tabler/icons-react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { AddItemButton } from '../../components/buttons/AddItemButton';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
import { useSalesOrderShipmentFields } from '../../forms/SalesOrderForms';
|
||||
import {
|
||||
useSalesOrderShipmentCompleteFields,
|
||||
useSalesOrderShipmentFields
|
||||
} from '../../forms/SalesOrderForms';
|
||||
import { navigateToLink } from '../../functions/navigation';
|
||||
import { notYetImplemented } from '../../functions/notifications';
|
||||
import { getDetailUrl } from '../../functions/urls';
|
||||
import {
|
||||
useCreateApiFormModal,
|
||||
useDeleteApiFormModal,
|
||||
@ -20,7 +26,7 @@ import { TableColumn } from '../Column';
|
||||
import { DateColumn, LinkColumn, NoteColumn } from '../ColumnRenderers';
|
||||
import { TableFilter } from '../Filter';
|
||||
import { InvenTreeTable } from '../InvenTreeTable';
|
||||
import { RowAction, RowDeleteAction, RowEditAction } from '../RowActions';
|
||||
import { RowAction, RowCancelAction, RowEditAction } from '../RowActions';
|
||||
|
||||
export default function SalesOrderShipmentTable({
|
||||
orderId
|
||||
@ -28,12 +34,16 @@ export default function SalesOrderShipmentTable({
|
||||
orderId: number;
|
||||
}>) {
|
||||
const user = useUserState();
|
||||
const navigate = useNavigate();
|
||||
const table = useTable('sales-order-shipment');
|
||||
|
||||
const [selectedShipment, setSelectedShipment] = useState<number>(0);
|
||||
const [selectedShipment, setSelectedShipment] = useState<any>({});
|
||||
|
||||
const newShipmentFields = useSalesOrderShipmentFields();
|
||||
const editShipmentFields = useSalesOrderShipmentFields();
|
||||
const newShipmentFields = useSalesOrderShipmentFields({});
|
||||
|
||||
const editShipmentFields = useSalesOrderShipmentFields({});
|
||||
|
||||
const completeShipmentFields = useSalesOrderShipmentCompleteFields({});
|
||||
|
||||
const newShipment = useCreateApiFormModal({
|
||||
url: ApiEndpoints.sales_order_shipment_list,
|
||||
@ -47,33 +57,45 @@ export default function SalesOrderShipmentTable({
|
||||
|
||||
const deleteShipment = useDeleteApiFormModal({
|
||||
url: ApiEndpoints.sales_order_shipment_list,
|
||||
pk: selectedShipment,
|
||||
title: t`Delete Shipment`,
|
||||
pk: selectedShipment.pk,
|
||||
title: t`Cancel Shipment`,
|
||||
table: table
|
||||
});
|
||||
|
||||
const editShipment = useEditApiFormModal({
|
||||
url: ApiEndpoints.sales_order_shipment_list,
|
||||
pk: selectedShipment,
|
||||
pk: selectedShipment.pk,
|
||||
fields: editShipmentFields,
|
||||
title: t`Edit Shipment`,
|
||||
table: table
|
||||
});
|
||||
|
||||
const completeShipment = useCreateApiFormModal({
|
||||
url: ApiEndpoints.sales_order_shipment_complete,
|
||||
pk: selectedShipment.pk,
|
||||
fields: completeShipmentFields,
|
||||
title: t`Complete Shipment`,
|
||||
table: table,
|
||||
focus: 'tracking_number',
|
||||
initialData: {
|
||||
...selectedShipment,
|
||||
shipment_date: new Date().toISOString().split('T')[0]
|
||||
}
|
||||
});
|
||||
|
||||
const tableColumns: TableColumn[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
accessor: 'reference',
|
||||
title: t`Shipment Reference`,
|
||||
switchable: false
|
||||
switchable: false,
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
accessor: 'allocations',
|
||||
title: t`Items`,
|
||||
render: (record: any) => {
|
||||
let allocations = record?.allocations ?? [];
|
||||
return allocations.length;
|
||||
}
|
||||
accessor: 'allocated_items',
|
||||
sortable: true,
|
||||
switchable: false,
|
||||
title: t`Items`
|
||||
},
|
||||
DateColumn({
|
||||
accessor: 'shipment_date',
|
||||
@ -91,9 +113,6 @@ export default function SalesOrderShipmentTable({
|
||||
},
|
||||
LinkColumn({
|
||||
accessor: 'link'
|
||||
}),
|
||||
NoteColumn({
|
||||
accessor: 'notes'
|
||||
})
|
||||
];
|
||||
}, []);
|
||||
@ -103,23 +122,40 @@ export default function SalesOrderShipmentTable({
|
||||
const shipped: boolean = !!record.shipment_date;
|
||||
|
||||
return [
|
||||
{
|
||||
title: t`View Shipment`,
|
||||
icon: <IconArrowRight />,
|
||||
onClick: (event: any) => {
|
||||
navigateToLink(
|
||||
getDetailUrl(ModelType.salesordershipment, record.pk),
|
||||
navigate,
|
||||
event
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
hidden: shipped || !user.hasChangeRole(UserRoles.sales_order),
|
||||
title: t`Complete Shipment`,
|
||||
color: 'green',
|
||||
icon: <IconTruckDelivery />,
|
||||
onClick: notYetImplemented
|
||||
onClick: () => {
|
||||
setSelectedShipment(record);
|
||||
completeShipment.open();
|
||||
}
|
||||
},
|
||||
RowEditAction({
|
||||
hidden: !user.hasChangeRole(UserRoles.sales_order),
|
||||
tooltip: t`Edit shipment`,
|
||||
onClick: () => {
|
||||
setSelectedShipment(record.pk);
|
||||
setSelectedShipment(record);
|
||||
editShipment.open();
|
||||
}
|
||||
}),
|
||||
RowDeleteAction({
|
||||
hidden: !user.hasDeleteRole(UserRoles.sales_order),
|
||||
RowCancelAction({
|
||||
hidden: shipped || !user.hasDeleteRole(UserRoles.sales_order),
|
||||
tooltip: t`Cancel shipment`,
|
||||
onClick: () => {
|
||||
setSelectedShipment(record.pk);
|
||||
setSelectedShipment(record);
|
||||
deleteShipment.open();
|
||||
}
|
||||
})
|
||||
@ -161,6 +197,7 @@ export default function SalesOrderShipmentTable({
|
||||
{newShipment.modal}
|
||||
{editShipment.modal}
|
||||
{deleteShipment.modal}
|
||||
{completeShipment.modal}
|
||||
<InvenTreeTable
|
||||
url={apiUrl(ApiEndpoints.sales_order_shipment_list)}
|
||||
tableState={table}
|
||||
|
@ -41,6 +41,92 @@ test('Sales Orders', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Issue Order' }).waitFor();
|
||||
});
|
||||
|
||||
test('Sales Orders - Shipments', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
await page.goto(`${baseUrl}/home`);
|
||||
await page.getByRole('tab', { name: 'Sales' }).click();
|
||||
await page.getByRole('tab', { name: 'Sales Orders' }).click();
|
||||
|
||||
// Click through to a particular sales order
|
||||
await page.getByRole('tab', { name: 'Sales Orders' }).waitFor();
|
||||
await page.getByRole('cell', { name: 'SO0006' }).first().click();
|
||||
await page.getByRole('tab', { name: 'Shipments' }).click();
|
||||
|
||||
// Create a new shipment
|
||||
await page.getByLabel('action-button-add-shipment').click();
|
||||
await page.getByLabel('text-field-tracking_number').fill('1234567890');
|
||||
await page.getByLabel('text-field-invoice_number').fill('9876543210');
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Expected field error
|
||||
await page
|
||||
.getByText('The fields order, reference must make a unique set')
|
||||
.first()
|
||||
.waitFor();
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
// Edit one of the existing shipments
|
||||
await page.getByLabel('row-action-menu-0').click();
|
||||
await page.getByRole('menuitem', { name: 'Edit' }).click();
|
||||
|
||||
// Ensure the form has loaded
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
let tracking_number = await page
|
||||
.getByLabel('text-field-tracking_number')
|
||||
.inputValue();
|
||||
|
||||
if (!tracking_number) {
|
||||
tracking_number = '1234567890';
|
||||
} else if (tracking_number.endsWith('x')) {
|
||||
// Remove the 'x' from the end of the tracking number
|
||||
tracking_number = tracking_number.substring(0, tracking_number.length - 1);
|
||||
} else {
|
||||
// Add an 'x' to the end of the tracking number
|
||||
tracking_number += 'x';
|
||||
}
|
||||
|
||||
// Change the tracking number
|
||||
await page.getByLabel('text-field-tracking_number').fill(tracking_number);
|
||||
await page.waitForTimeout(250);
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Click through to a particular shipment
|
||||
await page.getByLabel('row-action-menu-0').click();
|
||||
await page.getByRole('menuitem', { name: 'View Shipment' }).click();
|
||||
|
||||
// Click through the various tabs
|
||||
await page.getByRole('tab', { name: 'Attachments' }).click();
|
||||
await page.getByRole('tab', { name: 'Notes' }).click();
|
||||
await page.getByRole('tab', { name: 'Assigned Items' }).click();
|
||||
|
||||
// Ensure assigned items table loads correctly
|
||||
await page.getByRole('cell', { name: 'BATCH-001' }).first().waitFor();
|
||||
|
||||
await page.getByRole('tab', { name: 'Shipment Details' }).click();
|
||||
|
||||
// The "new" tracking number should be visible
|
||||
await page.getByText(tracking_number).waitFor();
|
||||
|
||||
// Link back to sales order
|
||||
await page.getByRole('link', { name: 'SO0006' }).click();
|
||||
|
||||
// Let's try to allocate some stock
|
||||
await page.getByRole('tab', { name: 'Line Items' }).click();
|
||||
await page.getByLabel('row-action-menu-1').click();
|
||||
await page.getByRole('menuitem', { name: 'Allocate stock' }).click();
|
||||
await page
|
||||
.getByText('Select the source location for the stock allocation')
|
||||
.waitFor();
|
||||
await page.getByLabel('number-field-quantity').fill('123');
|
||||
await page.getByLabel('related-field-stock_item').click();
|
||||
await page.getByText('Quantity: 42').click();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByText('This field is required.').waitFor();
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
});
|
||||
|
||||
test('Purchase Orders', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user