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 [ +