diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py
index ac1dff2f8c..eb268f6a8b 100644
--- a/src/backend/InvenTree/InvenTree/api_version.py
+++ b/src/backend/InvenTree/InvenTree/api_version.py
@@ -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
diff --git a/src/backend/InvenTree/order/api.py b/src/backend/InvenTree/order/api.py
index 963c1ee84d..93e217d235 100644
--- a/src/backend/InvenTree/order/api.py
+++ b/src/backend/InvenTree/order/api.py
@@ -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):
diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py
index 7b6a1f3cb5..701d5a66b8 100644
--- a/src/backend/InvenTree/order/models.py
+++ b/src/backend/InvenTree/order/models.py
@@ -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.
diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py
index 9bde2e31f3..6b0d927dae 100644
--- a/src/backend/InvenTree/order/serializers.py
+++ b/src/backend/InvenTree/order/serializers.py
@@ -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."""
diff --git a/src/backend/InvenTree/templates/js/translated/sales_order.js b/src/backend/InvenTree/templates/js/translated/sales_order.js
index 7999946360..01611aec6c 100644
--- a/src/backend/InvenTree/templates/js/translated/sales_order.js
+++ b/src/backend/InvenTree/templates/js/translated/sales_order.js
@@ -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);
}
diff --git a/src/frontend/src/components/forms/fields/ApiFormField.tsx b/src/frontend/src/components/forms/fields/ApiFormField.tsx
index fb9ded1eb8..30a42061aa 100644
--- a/src/frontend/src/components/forms/fields/ApiFormField.tsx
+++ b/src/frontend/src/components/forms/fields/ApiFormField.tsx
@@ -159,7 +159,8 @@ export function ApiFormField({
adjustFilters: undefined,
adjustValue: undefined,
read_only: undefined,
- children: undefined
+ children: undefined,
+ exclude: undefined
};
}, [fieldDefinition]);
diff --git a/src/frontend/src/components/forms/fields/RelatedModelField.tsx b/src/frontend/src/components/forms/fields/RelatedModelField.tsx
index c2d1d7caee..3886c943d4 100644
--- a/src/frontend/src/components/forms/fields/RelatedModelField.tsx
+++ b/src/frontend/src/components/forms/fields/RelatedModelField.tsx
@@ -220,6 +220,7 @@ export function RelatedModelField({
...definition,
onValueChange: undefined,
adjustFilters: undefined,
+ exclude: undefined,
read_only: undefined
};
}, [definition]);
diff --git a/src/frontend/src/components/forms/fields/TableField.tsx b/src/frontend/src/components/forms/fields/TableField.tsx
index 83841eef31..71de02cc5b 100644
--- a/src/frontend/src/components/forms/fields/TableField.tsx
+++ b/src/frontend/src/components/forms/fields/TableField.tsx
@@ -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({
- {definition.headers?.map((header) => {
- return {header};
+ {definition.headers?.map((header, index) => {
+ return (
+
+ {header}
+
+ );
})}
@@ -69,7 +77,17 @@ export function TableField({
// Table fields require render function
if (!definition.modelRenderer) {
return (
- {t`modelRenderer entry required for tables`}
+
+
+ }
+ >
+ {`modelRenderer entry required for tables`}
+
+
+
);
}
diff --git a/src/frontend/src/components/panels/AttachmentPanel.tsx b/src/frontend/src/components/panels/AttachmentPanel.tsx
new file mode 100644
index 0000000000..253b51dea7
--- /dev/null
+++ b/src/frontend/src/components/panels/AttachmentPanel.tsx
@@ -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: ,
+ content:
+ model_type && model_id ? (
+
+ ) : (
+
+ )
+ };
+}
diff --git a/src/frontend/src/components/panels/NotesPanel.tsx b/src/frontend/src/components/panels/NotesPanel.tsx
new file mode 100644
index 0000000000..0aee7ec4bb
--- /dev/null
+++ b/src/frontend/src/components/panels/NotesPanel.tsx
@@ -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: ,
+ content:
+ model_type && model_id ? (
+
+ ) : (
+
+ )
+ };
+}
diff --git a/src/frontend/src/components/nav/Panel.tsx b/src/frontend/src/components/panels/Panel.tsx
similarity index 100%
rename from src/frontend/src/components/nav/Panel.tsx
rename to src/frontend/src/components/panels/Panel.tsx
diff --git a/src/frontend/src/components/nav/PanelGroup.tsx b/src/frontend/src/components/panels/PanelGroup.tsx
similarity index 99%
rename from src/frontend/src/components/nav/PanelGroup.tsx
rename to src/frontend/src/components/panels/PanelGroup.tsx
index 617144c23b..665a91f89b 100644
--- a/src/frontend/src/components/nav/PanelGroup.tsx
+++ b/src/frontend/src/components/panels/PanelGroup.tsx
@@ -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:
diff --git a/src/frontend/src/components/render/ModelType.tsx b/src/frontend/src/components/render/ModelType.tsx
index 38118dc10f..41cc6408d1 100644
--- a/src/frontend/src/components/render/ModelType.tsx
+++ b/src/frontend/src/components/render/ModelType.tsx
@@ -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: {
diff --git a/src/frontend/src/components/render/Order.tsx b/src/frontend/src/components/render/Order.tsx
index 59009451b4..61fcf53edc 100644
--- a/src/frontend/src/components/render/Order.tsx
+++ b/src/frontend/src/components/render/Order.tsx
@@ -113,12 +113,12 @@ export function RenderSalesOrderShipment({
}: Readonly<{
instance: any;
}>): ReactNode {
- let order = instance.sales_order_detail || {};
+ let order = instance.order_detail || {};
return (
);
}
diff --git a/src/frontend/src/enums/ApiEndpoints.tsx b/src/frontend/src/enums/ApiEndpoints.tsx
index 3a3044c3f9..9ee10250f6 100644
--- a/src/frontend/src/enums/ApiEndpoints.tsx
+++ b/src/frontend/src/enums/ApiEndpoints.tsx
@@ -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/',
diff --git a/src/frontend/src/forms/BuildForms.tsx b/src/frontend/src/forms/BuildForms.tsx
index ff68c0eeda..a3e2044933 100644
--- a/src/frontend/src/forms/BuildForms.tsx
+++ b/src/frontend/src/forms/BuildForms.tsx
@@ -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 {
diff --git a/src/frontend/src/forms/SalesOrderForms.tsx b/src/frontend/src/forms/SalesOrderForms.tsx
index 649910d0f8..bce7bf6005 100644
--- a/src/frontend/src/forms/SalesOrderForms.tsx
+++ b/src/frontend/src/forms/SalesOrderForms.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ props.removeFn(props.idx)} />
+
+
+ );
+}
+
+export function useAllocateToSalesOrderForm({
+ orderId,
+ shipmentId,
+ lineItems,
+ onFormSuccess
+}: {
+ orderId: number;
+ shipmentId?: number;
+ lineItems: any[];
+ onFormSuccess: (response: any) => void;
+}) {
+ const [sourceLocation, setSourceLocation] = useState(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 (
+
+ );
+ }
+ },
+ 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]);
}
diff --git a/src/frontend/src/hooks/UsePluginPanels.tsx b/src/frontend/src/hooks/UsePluginPanels.tsx
index b015db4866..f7773b924e 100644
--- a/src/frontend/src/hooks/UsePluginPanels.tsx
+++ b/src/frontend/src/hooks/UsePluginPanels.tsx
@@ -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
diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx
index 0a59c83914..aa8d1c3904 100644
--- a/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx
+++ b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx
@@ -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';
diff --git a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx
index 4a4efbb5fe..249ddba48a 100644
--- a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx
+++ b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx
@@ -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';
diff --git a/src/frontend/src/pages/Index/Settings/UserSettings.tsx b/src/frontend/src/pages/Index/Settings/UserSettings.tsx
index 8312f741ed..f30b63591b 100644
--- a/src/frontend/src/pages/Index/Settings/UserSettings.tsx
+++ b/src/frontend/src/pages/Index/Settings/UserSettings.tsx
@@ -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';
diff --git a/src/frontend/src/pages/Notifications.tsx b/src/frontend/src/pages/Notifications.tsx
index b8962e20ee..4c4cc68a06 100644
--- a/src/frontend/src/pages/Notifications.tsx
+++ b/src/frontend/src/pages/Notifications.tsx
@@ -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';
diff --git a/src/frontend/src/pages/build/BuildDetail.tsx b/src/frontend/src/pages/build/BuildDetail.tsx
index 2c967b57df..00e842b68e 100644
--- a/src/frontend/src/pages/build/BuildDetail.tsx
+++ b/src/frontend/src/pages/build/BuildDetail.tsx
@@ -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: ,
- content: (
-
- )
- },
- {
- name: 'notes',
- label: t`Notes`,
- icon: ,
- content: (
-
- )
- }
+ AttachmentPanel({
+ model_type: ModelType.build,
+ model_id: build.pk
+ }),
+ NotesPanel({
+ model_type: ModelType.build,
+ model_id: build.pk
+ })
];
}, [build, id, user]);
diff --git a/src/frontend/src/pages/company/CompanyDetail.tsx b/src/frontend/src/pages/company/CompanyDetail.tsx
index 4790c6757d..fa46156aac 100644
--- a/src/frontend/src/pages/company/CompanyDetail.tsx
+++ b/src/frontend/src/pages/company/CompanyDetail.tsx
@@ -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) {
icon: ,
content: company?.pk &&
},
- {
- name: 'attachments',
- label: t`Attachments`,
- icon: ,
- content: (
-
- )
- },
- {
- name: 'notes',
- label: t`Notes`,
- icon: ,
- content: (
-
- )
- }
+ AttachmentPanel({
+ model_type: ModelType.company,
+ model_id: company.pk
+ }),
+ NotesPanel({
+ model_type: ModelType.company,
+ model_id: company.pk
+ })
];
}, [id, company, user]);
diff --git a/src/frontend/src/pages/company/ManufacturerPartDetail.tsx b/src/frontend/src/pages/company/ManufacturerPartDetail.tsx
index 7110e1a24d..5d5465eb3b 100644
--- a/src/frontend/src/pages/company/ManufacturerPartDetail.tsx
+++ b/src/frontend/src/pages/company/ManufacturerPartDetail.tsx
@@ -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() {
)
},
- {
- name: 'attachments',
- label: t`Attachments`,
- icon: ,
- content: (
-
- )
- },
- {
- name: 'notes',
- label: t`Notes`,
- icon: ,
- content: (
-
- )
- }
+ AttachmentPanel({
+ model_type: ModelType.manufacturerpart,
+ model_id: manufacturerPart?.pk
+ }),
+ NotesPanel({
+ model_type: ModelType.manufacturerpart,
+ model_id: manufacturerPart?.pk
+ })
];
}, [manufacturerPart]);
diff --git a/src/frontend/src/pages/company/SupplierPartDetail.tsx b/src/frontend/src/pages/company/SupplierPartDetail.tsx
index 93d49233d4..fc5c32f40d 100644
--- a/src/frontend/src/pages/company/SupplierPartDetail.tsx
+++ b/src/frontend/src/pages/company/SupplierPartDetail.tsx
@@ -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() {
)
},
- {
- name: 'notes',
- label: t`Notes`,
- icon: ,
- content: (
-
- )
- }
+ NotesPanel({
+ model_type: ModelType.supplierpart,
+ model_id: supplierPart?.pk
+ })
];
}, [supplierPart]);
diff --git a/src/frontend/src/pages/part/CategoryDetail.tsx b/src/frontend/src/pages/part/CategoryDetail.tsx
index 9a7225464a..b75402c376 100644
--- a/src/frontend/src/pages/part/CategoryDetail.tsx
+++ b/src/frontend/src/pages/part/CategoryDetail.tsx
@@ -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';
diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx
index 7123aac686..9ad5008541 100644
--- a/src/frontend/src/pages/part/PartDetail.tsx
+++ b/src/frontend/src/pages/part/PartDetail.tsx
@@ -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: ,
content:
},
- {
- name: 'attachments',
- label: t`Attachments`,
- icon: ,
- content: (
-
- )
- },
- {
- name: 'notes',
- label: t`Notes`,
- icon: ,
- content: (
-
- )
- }
+ AttachmentPanel({
+ model_type: ModelType.part,
+ model_id: part?.pk
+ }),
+ NotesPanel({
+ model_type: ModelType.part,
+ model_id: part?.pk
+ })
];
}, [id, part, user, globalSettings, userSettings]);
diff --git a/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx b/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx
index 6e5d0a36cb..60d5540585 100644
--- a/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx
+++ b/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx
@@ -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: ,
- content: (
-
- )
- },
- {
- name: 'notes',
- label: t`Notes`,
- icon: ,
- content: (
-
- )
- }
+ AttachmentPanel({
+ model_type: ModelType.purchaseorder,
+ model_id: order.pk
+ }),
+ NotesPanel({
+ model_type: ModelType.purchaseorder,
+ model_id: order.pk
+ })
];
}, [order, id, user]);
diff --git a/src/frontend/src/pages/purchasing/PurchasingIndex.tsx b/src/frontend/src/pages/purchasing/PurchasingIndex.tsx
index 9128c0c34f..4c7d9f93d6 100644
--- a/src/frontend/src/pages/purchasing/PurchasingIndex.tsx
+++ b/src/frontend/src/pages/purchasing/PurchasingIndex.tsx
@@ -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';
diff --git a/src/frontend/src/pages/sales/ReturnOrderDetail.tsx b/src/frontend/src/pages/sales/ReturnOrderDetail.tsx
index 314baad962..0f0ed61daf 100644
--- a/src/frontend/src/pages/sales/ReturnOrderDetail.tsx
+++ b/src/frontend/src/pages/sales/ReturnOrderDetail.tsx
@@ -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() {
)
},
- {
- name: 'attachments',
- label: t`Attachments`,
- icon: ,
- content: (
-
- )
- },
- {
- name: 'notes',
- label: t`Notes`,
- icon: ,
- content: (
-
- )
- }
+ AttachmentPanel({
+ model_type: ModelType.returnorder,
+ model_id: order.pk
+ }),
+ NotesPanel({
+ model_type: ModelType.returnorder,
+ model_id: order.pk
+ })
];
}, [order, id, user]);
diff --git a/src/frontend/src/pages/sales/SalesIndex.tsx b/src/frontend/src/pages/sales/SalesIndex.tsx
index 3a8276ea78..8e8197d9de 100644
--- a/src/frontend/src/pages/sales/SalesIndex.tsx
+++ b/src/frontend/src/pages/sales/SalesIndex.tsx
@@ -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';
diff --git a/src/frontend/src/pages/sales/SalesOrderDetail.tsx b/src/frontend/src/pages/sales/SalesOrderDetail.tsx
index 7dcd920e97..6a49f28380 100644
--- a/src/frontend/src/pages/sales/SalesOrderDetail.tsx
+++ b/src/frontend/src/pages/sales/SalesOrderDetail.tsx
@@ -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() {
)
},
- {
- name: 'attachments',
- label: t`Attachments`,
- icon: ,
- content: (
-
- )
- },
- {
- name: 'notes',
- label: t`Notes`,
- icon: ,
- content: (
-
- )
- }
+ AttachmentPanel({
+ model_type: ModelType.salesorder,
+ model_id: order.pk
+ }),
+ NotesPanel({
+ model_type: ModelType.salesorder,
+ model_id: order.pk
+ })
];
}, [order, id, user, soStatus]);
diff --git a/src/frontend/src/pages/sales/SalesOrderShipmentDetail.tsx b/src/frontend/src/pages/sales/SalesOrderShipmentDetail.tsx
new file mode 100644
index 0000000000..d9bcde769f
--- /dev/null
+++ b/src/frontend/src/pages/sales/SalesOrderShipmentDetail.tsx
@@ -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 ;
+ }
+
+ 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 (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+ }, [shipment, shipmentQuery, customer, customerQuery]);
+
+ const shipmentPanels: PanelType[] = useMemo(() => {
+ return [
+ {
+ name: 'detail',
+ label: t`Shipment Details`,
+ icon: ,
+ content: detailsPanel
+ },
+ {
+ name: 'items',
+ label: t`Assigned Items`,
+ icon: ,
+ content: (
+
+ )
+ },
+ 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 [
+ ,
+ ,
+
+ ];
+ }, [shipment, shipmentQuery]);
+
+ const shipmentActions = useMemo(() => {
+ const canEdit: boolean = user.hasChangePermission(
+ ModelType.salesordershipment
+ );
+
+ return [
+ {
+ completeShipment.open();
+ }}
+ />,
+ ,
+ ,
+
+ ];
+ }, [isPending, user, shipment]);
+
+ return (
+ <>
+ {completeShipment.modal}
+ {editShipment.modal}
+ {deleteShipment.modal}
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/frontend/src/pages/stock/LocationDetail.tsx b/src/frontend/src/pages/stock/LocationDetail.tsx
index 2fdc34bc3c..6d9ca7cc48 100644
--- a/src/frontend/src/pages/stock/LocationDetail.tsx
+++ b/src/frontend/src/pages/stock/LocationDetail.tsx
@@ -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';
diff --git a/src/frontend/src/pages/stock/StockDetail.tsx b/src/frontend/src/pages/stock/StockDetail.tsx
index 46558a6f6d..68b0b726d2 100644
--- a/src/frontend/src/pages/stock/StockDetail.tsx
+++ b/src/frontend/src/pages/stock/StockDetail.tsx
@@ -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() {
)
},
- {
- name: 'attachments',
- label: t`Attachments`,
- icon: ,
- content: (
-
- )
- },
- {
- name: 'notes',
- label: t`Notes`,
- icon: ,
- content: (
-
- )
- }
+ AttachmentPanel({
+ model_type: ModelType.stockitem,
+ model_id: stockitem.pk
+ }),
+ NotesPanel({
+ model_type: ModelType.stockitem,
+ model_id: stockitem.pk
+ })
];
}, [
showSalesAlloctions,
diff --git a/src/frontend/src/router.tsx b/src/frontend/src/router.tsx
index b7a040458b..e3bfb19cf3 100644
--- a/src/frontend/src/router.tsx
+++ b/src/frontend/src/router.tsx
@@ -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 = (
} />
} />
} />
+ } />
} />
} />
diff --git a/src/frontend/src/tables/RowActions.tsx b/src/frontend/src/tables/RowActions.tsx
index 1b82f6c32c..e6c5d51183 100644
--- a/src/frontend/src/tables/RowActions.tsx
+++ b/src/frontend/src/tables/RowActions.tsx
@@ -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:
+ };
+}
+
/**
* 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}
diff --git a/src/frontend/src/tables/build/BuildLineTable.tsx b/src/frontend/src/tables/build/BuildLineTable.tsx
index 41e4e5955f..d147e199b8 100644
--- a/src/frontend/src/tables/build/BuildLineTable.tsx
+++ b/src/frontend/src/tables/build/BuildLineTable.tsx
@@ -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();
}}
/>,
{autoAllocateStock.modal}
{newBuildOrder.modal}
- {allowcateStock.modal}
+ {allocateStock.modal}
{deallocateStock.modal}
{
- 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) => (
+
+ )
+ }
];
}, []);
+ const [selectedAllocation, setSelectedAllocation] = useState(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 (
-
+ <>
+ {editAllocation.modal}
+ {deleteAllocation.modal}
+
+ >
);
}
diff --git a/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx b/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx
index fe08d2153c..8c8fb11983 100644
--- a/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx
+++ b/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx
@@ -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([]);
+
+ const allocateStock = useAllocateToSalesOrderForm({
+ orderId: orderId,
+ lineItems: selectedItems,
+ onFormSuccess: () => {
+ table.refreshTable();
+ table.clearSelectedRecords();
+ }
+ });
+
const tableActions = useMemo(() => {
return [
,
+ }
+ 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: ,
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: ,
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}
) {
const user = useUserState();
+ const navigate = useNavigate();
const table = useTable('sales-order-shipment');
- const [selectedShipment, setSelectedShipment] = useState(0);
+ const [selectedShipment, setSelectedShipment] = useState({});
- 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: ,
+ 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: ,
- 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}
{
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);