From 6df97e83f564fdf2064eb4ce88c65c83395d346f Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 24 Oct 2025 13:39:57 +1100 Subject: [PATCH] [UI] Implement "checked_by" for SalesOrderShipment (#10654) * Add "checked" column to SalesOrderStatus table * Add API filter for "checked" status * Add Checked / Not Checked badge * Add actions to check / uncheck shipment * Add modal for changing checked_by status * Display checked_by user * Tweak wording * Bump API version * Update CHANGELOG file * Update docs * Add new global setting - Prevent shipment completion which have not been checked * Test if shipment has been checked * Updated unit tests * Updated type hinting (may as well while I'm here) * Adjust shipment icon * Add "order_outstanding" filter for SalesOrderShipment table --- CHANGELOG.md | 1 + .../assets/images/order/so_shipment_check.png | Bin 0 -> 44299 bytes docs/docs/sales/sales_order.md | 13 ++ .../InvenTree/InvenTree/api_version.py | 7 +- .../InvenTree/common/setting/system.py | 8 + src/backend/InvenTree/order/api.py | 29 ++++ src/backend/InvenTree/order/models.py | 160 +++++++++++------- .../InvenTree/order/test_sales_order.py | 20 +++ src/backend/InvenTree/part/serializers.py | 4 +- src/frontend/src/functions/icons.tsx | 3 +- .../pages/Index/Settings/SystemSettings.tsx | 3 +- .../src/pages/sales/SalesOrderDetail.tsx | 6 +- .../pages/sales/SalesOrderShipmentDetail.tsx | 98 ++++++++++- .../tables/sales/SalesOrderShipmentTable.tsx | 19 +++ 14 files changed, 295 insertions(+), 76 deletions(-) create mode 100644 docs/docs/assets/images/order/so_shipment_check.png diff --git a/CHANGELOG.md b/CHANGELOG.md index 9941037275..c372c0f4bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Expose stock adjustment forms to the UI plugin context in [#10584](https://github.com/inventree/InvenTree/pull/10584) - Allow stock adjustments for "in production" items in [#10600](https://github.com/inventree/InvenTree/pull/10600) - Adds optional shipping address against individual sales order shipments in [#10650](https://github.com/inventree/InvenTree/pull/10650) +- Adds UI elements to "check" and "uncheck" sales order shipments in [#10654](https://github.com/inventree/InvenTree/pull/10654) ### Changed diff --git a/docs/docs/assets/images/order/so_shipment_check.png b/docs/docs/assets/images/order/so_shipment_check.png new file mode 100644 index 0000000000000000000000000000000000000000..35d489160d76bfc456dfdb3ff1f63c9b8600584f GIT binary patch literal 44299 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 icon("truck-loading") }} Pending Shipments tab then click on {{ icon("truck-delivery") }} button shown in the shipment table. @@ -221,3 +233,4 @@ The following [global settings](../settings/global.md) are available for sales o {{ globalsetting("SALESORDER_DEFAULT_SHIPMENT") }} {{ globalsetting("SALESORDER_EDIT_COMPLETED_ORDERS") }} {{ globalsetting("SALESORDER_SHIP_COMPLETE") }} +{{ globalsetting("SALESORDER_SHIPMENT_REQUIRES_CHECK") }} diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 7bbaf7d9e9..992b9d44d9 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,16 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 416 +INVENTREE_API_VERSION = 417 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v417 -> 2025-10-22 : https://github.com/inventree/InvenTree/pull/10654 + - Adds "checked" filter to SalesOrderShipment API endpoint + - Adds "order_status" filter to SalesOrdereShipment API endpoint + - Adds "order_outstanding" filter to SalesOrderShipment API endpoint + v416 -> 2025-10-22 : https://github.com/inventree/InvenTree/pull/10651 - Add missing nullable to make price_breaks (from v412) optional diff --git a/src/backend/InvenTree/common/setting/system.py b/src/backend/InvenTree/common/setting/system.py index 305db33abf..066c8614cb 100644 --- a/src/backend/InvenTree/common/setting/system.py +++ b/src/backend/InvenTree/common/setting/system.py @@ -854,6 +854,14 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { 'default': False, 'validator': bool, }, + 'SALESORDER_SHIPMENT_REQUIRES_CHECK': { + 'name': _('Shipment Requires Checking'), + 'description': _( + 'Prevent completion of shipments until items have been checked' + ), + 'default': False, + 'validator': bool, + }, 'SALESORDER_SHIP_COMPLETE': { 'name': _('Mark Shipped Orders as Complete'), 'description': _( diff --git a/src/backend/InvenTree/order/api.py b/src/backend/InvenTree/order/api.py index 470550e6eb..ddf7dadb57 100644 --- a/src/backend/InvenTree/order/api.py +++ b/src/backend/InvenTree/order/api.py @@ -1335,6 +1335,14 @@ class SalesOrderShipmentFilter(FilterSet): model = models.SalesOrderShipment fields = ['order'] + checked = rest_filters.BooleanFilter(label='checked', method='filter_checked') + + def filter_checked(self, queryset, name, value): + """Filter SalesOrderShipment list by 'checked' status (boolean).""" + if str2bool(value): + return queryset.exclude(checked_by=None) + return queryset.filter(checked_by=None) + shipped = rest_filters.BooleanFilter(label='shipped', method='filter_shipped') def filter_shipped(self, queryset, name, value): @@ -1351,6 +1359,27 @@ class SalesOrderShipmentFilter(FilterSet): return queryset.exclude(delivery_date=None) return queryset.filter(delivery_date=None) + order_outstanding = rest_filters.BooleanFilter( + label=_('Order Outstanding'), method='filter_order_outstanding' + ) + + def filter_order_outstanding(self, queryset, name, value): + """Filter by whether the order is 'outstanding' or not.""" + if str2bool(value): + return queryset.filter(order__status__in=SalesOrderStatusGroups.OPEN) + return queryset.exclude(order__status__in=SalesOrderStatusGroups.OPEN) + + order_status = rest_filters.NumberFilter( + label=_('Order Status'), method='filter_order_status' + ) + + def filter_order_status(self, queryset, name, value): + """Filter by linked SalesOrderrder status.""" + q1 = Q(order__status=value, order__status_custom_key__isnull=True) + q2 = Q(order__status_custom_key=value) + + return queryset.filter(q1 | q2).distinct() + class SalesOrderShipmentMixin: """Mixin class for SalesOrderShipment endpoints.""" diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py index f1c4bb0c67..6126830b1c 100644 --- a/src/backend/InvenTree/order/models.py +++ b/src/backend/InvenTree/order/models.py @@ -336,7 +336,7 @@ class Order( A locked order cannot be modified after it has been completed. - Args: + Arguments: db: If True, check with the database. If False, check the instance (default False). """ if not self.check_complete(db=db): @@ -351,7 +351,7 @@ class Order( def check_complete(self, db: bool = False) -> bool: """Check if this order is 'complete'. - Args: + Arguments: db: If True, check with the database. If False, check the instance (default False). """ status = self.get_db_instance().status if db else self.status @@ -560,12 +560,12 @@ class PurchaseOrder(TotalPriceMixin, Order): """Return report context data for this PurchaseOrder.""" return {**super().report_context(), 'supplier': self.supplier} - def get_absolute_url(self): + def get_absolute_url(self) -> str: """Get the 'web' URL for this order.""" return pui_url(f'/purchasing/purchase-order/{self.pk}') @staticmethod - def get_api_url(): + def get_api_url() -> str: """Return the API URL associated with the PurchaseOrder model.""" return reverse('api-po-list') @@ -584,7 +584,7 @@ class PurchaseOrder(TotalPriceMixin, Order): return defaults @classmethod - def barcode_model_type_code(cls): + def barcode_model_type_code(cls) -> str: """Return the associated barcode model type code for this model.""" return 'PO' @@ -805,10 +805,10 @@ class PurchaseOrder(TotalPriceMixin, Order): @transaction.atomic def issue_order(self): """Equivalent to 'place_order'.""" - self.place_order() + return self.place_order() @property - def can_issue(self): + def can_issue(self) -> bool: """Return True if this order can be issued.""" return self.status in [ PurchaseOrderStatus.PENDING.value, @@ -844,17 +844,17 @@ class PurchaseOrder(TotalPriceMixin, Order): ) @property - def is_pending(self): + def is_pending(self) -> bool: """Return True if the PurchaseOrder is 'pending'.""" return self.status == PurchaseOrderStatus.PENDING.value @property - def is_open(self): + def is_open(self) -> bool: """Return True if the PurchaseOrder is 'open'.""" return self.status in PurchaseOrderStatusGroups.OPEN @property - def can_cancel(self): + def can_cancel(self) -> bool: """A PurchaseOrder can only be cancelled under the following circumstances. - Status is PLACED @@ -880,7 +880,7 @@ class PurchaseOrder(TotalPriceMixin, Order): ) @property - def can_hold(self): + def can_hold(self) -> bool: """Return True if this order can be placed on hold.""" return self.status in [ PurchaseOrderStatus.PENDING.value, @@ -897,34 +897,34 @@ class PurchaseOrder(TotalPriceMixin, Order): # endregion - def pending_line_items(self): + def pending_line_items(self) -> QuerySet: """Return a list of pending line items for this order. Any line item where 'received' < 'quantity' will be returned. """ return self.lines.filter(quantity__gt=F('received')) - def completed_line_items(self): + def completed_line_items(self) -> QuerySet: """Return a list of completed line items against this order.""" return self.lines.filter(quantity__lte=F('received')) @property - def line_count(self): + def line_count(self) -> int: """Return the total number of line items associated with this order.""" return self.lines.count() @property - def completed_line_count(self): + def completed_line_count(self) -> int: """Return the number of complete line items associated with this order.""" return self.completed_line_items().count() @property - def pending_line_count(self): + def pending_line_count(self) -> int: """Return the number of pending line items associated with this order.""" return self.pending_line_items().count() @property - def is_complete(self): + def is_complete(self) -> bool: """Return True if all line items have been received.""" return self.pending_line_items().count() == 0 @@ -1247,12 +1247,12 @@ class SalesOrder(TotalPriceMixin, Order): """Generate report context data for this SalesOrder.""" return {**super().report_context(), 'customer': self.customer} - def get_absolute_url(self): + def get_absolute_url(self) -> str: """Get the 'web' URL for this order.""" return pui_url(f'/sales/sales-order/{self.pk}') @staticmethod - def get_api_url(): + def get_api_url() -> str: """Return the API URL associated with the SalesOrder model.""" return reverse('api-so-list') @@ -1262,14 +1262,14 @@ class SalesOrder(TotalPriceMixin, Order): return SalesOrderStatusGroups @classmethod - def api_defaults(cls, request=None): + def api_defaults(cls, request=None) -> dict: """Return default values for this model when issuing an API OPTIONS request.""" defaults = {'reference': order.validators.generate_next_sales_order_reference()} return defaults @classmethod - def barcode_model_type_code(cls): + def barcode_model_type_code(cls) -> str: """Return the associated barcode model type code for this model.""" return 'SO' @@ -1326,7 +1326,7 @@ class SalesOrder(TotalPriceMixin, Order): ) @property - def status_text(self): + def status_text(self) -> str: """Return the text representation of the status field.""" return SalesOrderStatus.text(self.status) @@ -1351,38 +1351,45 @@ class SalesOrder(TotalPriceMixin, Order): ) @property - def is_pending(self): + def is_pending(self) -> bool: """Return True if this order is 'pending'.""" return self.status == SalesOrderStatus.PENDING @property - def is_open(self): + def is_open(self) -> bool: """Return True if this order is 'open' (either 'pending' or 'in_progress').""" return self.status in SalesOrderStatusGroups.OPEN @property - def stock_allocations(self): + def stock_allocations(self) -> QuerySet: """Return a queryset containing all allocations for this order.""" return SalesOrderAllocation.objects.filter( line__in=[line.pk for line in self.lines.all()] ) - def is_fully_allocated(self): + def is_fully_allocated(self) -> bool: """Return True if all line items are fully allocated.""" return all(line.is_fully_allocated() for line in self.lines.all()) - def is_overallocated(self): + def is_overallocated(self) -> bool: """Return true if any lines in the order are over-allocated.""" return any(line.is_overallocated() for line in self.lines.all()) - def is_completed(self): + def is_completed(self) -> bool: """Check if this order is "shipped" (all line items delivered).""" return all(line.is_completed() for line in self.lines.all()) - def can_complete(self, raise_error=False, allow_incomplete_lines=False): + def can_complete( + self, raise_error: bool = False, allow_incomplete_lines: bool = False + ) -> bool: """Test if this SalesOrder can be completed. - Throws a ValidationError if cannot be completed. + Arguments: + raise_error: If True, raise ValidationError if the order cannot be completed + allow_incomplete_lines: If True, allow incomplete line items when completing the order + + Raises: + ValidationError: If the order cannot be completed, and raise_error is True """ try: if self.status == SalesOrderStatus.COMPLETE.value: @@ -1424,7 +1431,7 @@ class SalesOrder(TotalPriceMixin, Order): self.issue_order() @property - def can_issue(self): + def can_issue(self) -> bool: """Return True if this order can be issued.""" return self.status in [ SalesOrderStatus.PENDING.value, @@ -1450,7 +1457,7 @@ class SalesOrder(TotalPriceMixin, Order): ) @property - def can_hold(self): + def can_hold(self) -> bool: """Return True if this order can be placed on hold.""" return self.status in [ SalesOrderStatus.PENDING.value, @@ -1497,7 +1504,7 @@ class SalesOrder(TotalPriceMixin, Order): return True @property - def can_cancel(self): + def can_cancel(self) -> bool: """Return True if this order can be cancelled.""" return self.is_open @@ -1579,15 +1586,15 @@ class SalesOrder(TotalPriceMixin, Order): # endregion @property - def line_count(self): + def line_count(self) -> int: """Return the total number of lines associated with this order.""" return self.lines.count() - def completed_line_items(self): + def completed_line_items(self) -> QuerySet: """Return a queryset of the completed line items for this order.""" return self.lines.filter(shipped__gte=F('quantity')) - def pending_line_items(self): + def pending_line_items(self) -> QuerySet: """Return a queryset of the pending line items for this order. Note: We exclude "virtual" parts here, as they do not get allocated @@ -1595,28 +1602,28 @@ class SalesOrder(TotalPriceMixin, Order): return self.lines.filter(shipped__lt=F('quantity')).exclude(part__virtual=True) @property - def completed_line_count(self): + def completed_line_count(self) -> int: """Return the number of completed lines for this order.""" return self.completed_line_items().count() @property - def pending_line_count(self): + def pending_line_count(self) -> int: """Return the number of pending (incomplete) lines associated with this order.""" return self.pending_line_items().count() - def completed_shipments(self): + def completed_shipments(self) -> QuerySet: """Return a queryset of the completed shipments for this order.""" return self.shipments.exclude(shipment_date=None) - def pending_shipments(self): + def pending_shipments(self) -> QuerySet: """Return a queryset of the pending shipments for this order.""" return self.shipments.filter(shipment_date=None) - def allocations(self): + def allocations(self) -> QuerySet: """Return a queryset of all allocations for this order.""" return SalesOrderAllocation.objects.filter(line__order=self) - def pending_allocations(self): + def pending_allocations(self) -> QuerySet: """Return a queryset of any pending allocations for this order. Allocations are pending if: @@ -1630,22 +1637,22 @@ class SalesOrder(TotalPriceMixin, Order): return self.allocations().filter(Q1 | Q2).distinct() @property - def shipment_count(self): + def shipment_count(self) -> int: """Return the total number of shipments associated with this order.""" return self.shipments.count() @property - def completed_shipment_count(self): + def completed_shipment_count(self) -> int: """Return the number of completed shipments associated with this order.""" return self.completed_shipments().count() @property - def pending_shipment_count(self): + def pending_shipment_count(self) -> int: """Return the number of pending shipments associated with this order.""" return self.pending_shipments().count() @property - def pending_allocation_count(self): + def pending_allocation_count(self) -> int: """Return the number of pending (non-shipped) allocations.""" return self.pending_allocations().count() @@ -1824,14 +1831,17 @@ class PurchaseOrderLineItem(OrderLineItem): ) @staticmethod - def get_api_url(): + def get_api_url() -> str: """Return the API URL associated with the PurchaseOrderLineItem model.""" return reverse('api-po-line-list') - def clean(self): + def clean(self) -> None: """Custom clean method for the PurchaseOrderLineItem model. - Ensure the supplier part matches the supplier + Raises: + ValidationError: If the SupplierPart does not match the PurchaseOrder supplier + ValidationError: If the linked BuildOrder is not marked as external + ValidationError: If the linked BuildOrder part does not match the line item part """ super().clean() @@ -1963,7 +1973,7 @@ class PurchaseOrderLineItem(OrderLineItem): """Determine if this line item has been fully received.""" return self.received >= self.quantity - def update_pricing(self): + def update_pricing(self) -> None: """Update pricing information based on the supplier part data.""" if self.part: price = self.part.get_price( @@ -1992,7 +2002,7 @@ class PurchaseOrderExtraLine(OrderExtraLine): verbose_name = _('Purchase Order Extra Line') @staticmethod - def get_api_url(): + def get_api_url() -> str: """Return the API URL associated with the PurchaseOrderExtraLine model.""" return reverse('api-po-extra-line-list') @@ -2032,8 +2042,12 @@ class SalesOrderLineItem(OrderLineItem): """Return the API URL associated with the SalesOrderLineItem model.""" return reverse('api-so-line-list') - def clean(self): - """Perform extra validation steps for this SalesOrderLineItem instance.""" + def clean(self) -> None: + """Perform extra validation steps for this SalesOrderLineItem instance. + + Raises: + ValidationError: If the linked part is not salable + """ super().clean() if self.part: @@ -2108,7 +2122,7 @@ class SalesOrderLineItem(OrderLineItem): return query['allocated'] - def is_fully_allocated(self): + def is_fully_allocated(self) -> bool: """Return True if this line item is fully allocated.""" # If the linked part is "virtual", then we cannot allocate stock against it if self.part and self.part.virtual: @@ -2119,11 +2133,11 @@ class SalesOrderLineItem(OrderLineItem): return self.allocated_quantity() >= self.quantity - def is_overallocated(self): + def is_overallocated(self) -> bool: """Return True if this line item is over allocated.""" return self.allocated_quantity() > self.quantity - def is_completed(self): + def is_completed(self) -> bool: """Return True if this line item is completed (has been fully shipped).""" # A "virtual" part is always considered to be "completed" if self.part and self.part.virtual: @@ -2189,8 +2203,12 @@ class SalesOrderShipment( unique_together = ['order', 'reference'] verbose_name = _('Sales Order Shipment') - def clean(self): - """Custom clean method for the SalesOrderShipment class.""" + def clean(self) -> None: + """Custom clean method for the SalesOrderShipment class. + + Raises: + ValidationError: If the shipment address does not match the customer + """ super().clean() if self.order and self.shipment_address: @@ -2200,7 +2218,7 @@ class SalesOrderShipment( }) @staticmethod - def get_api_url(): + def get_api_url() -> str: """Return the API URL associated with the SalesOrderShipment model.""" return reverse('api-so-shipment-list') @@ -2299,16 +2317,24 @@ class SalesOrderShipment( """ return self.shipment_address or self.order.address - def is_complete(self): + def is_checked(self) -> bool: + """Return True if this shipment has been checked.""" + return self.checked_by is not None + + def is_complete(self) -> bool: """Return True if this shipment has already been completed.""" return self.shipment_date is not None - def is_delivered(self): + def is_delivered(self) -> bool: """Return True if this shipment has already been delivered.""" return self.delivery_date is not None - def check_can_complete(self, raise_error=True): - """Check if this shipment is able to be completed.""" + def check_can_complete(self, raise_error: bool = True) -> bool: + """Check if this shipment is able to be completed. + + Arguments: + raise_error: If True, raise ValidationError if cannot complete + """ try: if self.shipment_date: # Shipment has already been sent! @@ -2317,6 +2343,14 @@ class SalesOrderShipment( if self.allocations.count() == 0: raise ValidationError(_('Shipment has no allocated stock items')) + if ( + get_global_setting('SALESORDER_SHIPMENT_REQUIRES_CHECK') + and not self.is_checked() + ): + raise ValidationError( + _('Shipment must be checked before it can be completed') + ) + except ValidationError as e: if raise_error: raise e diff --git a/src/backend/InvenTree/order/test_sales_order.py b/src/backend/InvenTree/order/test_sales_order.py index e922aac5ee..c4cc9d4dc9 100644 --- a/src/backend/InvenTree/order/test_sales_order.py +++ b/src/backend/InvenTree/order/test_sales_order.py @@ -8,6 +8,7 @@ from django.core.exceptions import ValidationError import order.tasks from common.models import InvenTreeSetting, NotificationMessage +from common.settings import set_global_setting from company.models import Address, Company from InvenTree import status_codes as status from InvenTree.unit_test import InvenTreeTestCase, addUserPermission @@ -265,6 +266,25 @@ class SalesOrderTest(InvenTreeTestCase): self.assertIsNone(self.shipment.shipment_date) self.assertFalse(self.shipment.is_complete()) + # Require that the shipment is checked before completion + set_global_setting('SALESORDER_SHIPMENT_REQUIRES_CHECK', True) + + self.assertFalse(self.shipment.is_checked()) + self.assertFalse(self.shipment.check_can_complete(raise_error=False)) + + with self.assertRaises(ValidationError) as err: + self.shipment.complete_shipment(None) + + self.assertIn( + 'Shipment must be checked before it can be completed', + err.exception.messages, + ) + + # Mark the shipment as checked + self.shipment.checked_by = get_user_model().objects.first() + self.shipment.save() + self.assertTrue(self.shipment.is_checked()) + # Mark the shipments as complete self.shipment.complete_shipment(None) self.assertTrue(self.shipment.is_complete()) diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index 16e7d364d0..681cd5a507 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -1042,9 +1042,7 @@ class PartSerializer( ) price_breaks = enable_filter( - PartSalePriceSerializer( - source='salepricebreaks', many=True, read_only=True, allow_null=True - ), + PartSalePriceSerializer(source='salepricebreaks', many=True, read_only=True), False, filter_name='price_breaks', ) diff --git a/src/frontend/src/functions/icons.tsx b/src/frontend/src/functions/icons.tsx index a771428a78..b50e04e4cb 100644 --- a/src/frontend/src/functions/icons.tsx +++ b/src/frontend/src/functions/icons.tsx @@ -31,6 +31,7 @@ import { IconCornerDownLeft, IconCornerDownRight, IconCornerUpRightDouble, + IconCubeSend, IconCurrencyDollar, IconDots, IconEdit, @@ -154,7 +155,7 @@ const icons: InvenTreeIconType = { sales_orders: IconTruckDelivery, scheduling: IconCalendarStats, scrap: IconCircleX, - shipment: IconTruckDelivery, + shipment: IconCubeSend, test_templates: IconTestPipe, test: IconTestPipe, related_parts: IconLayersLinked, diff --git a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx index 6bd56a9e73..918033494a 100644 --- a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx +++ b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx @@ -305,7 +305,8 @@ export default function SystemSettings() { 'SALESORDER_REQUIRE_RESPONSIBLE', 'SALESORDER_DEFAULT_SHIPMENT', 'SALESORDER_EDIT_COMPLETED_ORDERS', - 'SALESORDER_SHIP_COMPLETE' + 'SALESORDER_SHIP_COMPLETE', + 'SALESORDER_SHIPMENT_REQUIRES_CHECK' ]} /> ) diff --git a/src/frontend/src/pages/sales/SalesOrderDetail.tsx b/src/frontend/src/pages/sales/SalesOrderDetail.tsx index 44debd0c5c..bd3178fb3d 100644 --- a/src/frontend/src/pages/sales/SalesOrderDetail.tsx +++ b/src/frontend/src/pages/sales/SalesOrderDetail.tsx @@ -2,10 +2,10 @@ import { t } from '@lingui/core/macro'; import { Accordion, Grid, Skeleton, Stack, Text } from '@mantine/core'; import { IconBookmark, + IconCubeSend, IconInfoCircle, IconList, - IconTools, - IconTruckDelivery + IconTools } from '@tabler/icons-react'; import { type ReactNode, useMemo } from 'react'; import { useParams } from 'react-router-dom'; @@ -394,7 +394,7 @@ export default function SalesOrderDetail() { { name: 'shipments', label: t`Shipments`, - icon: , + icon: , content: ( user.userId(), [user]); + const { instance: shipment, instanceQuery: shipmentQuery, @@ -74,6 +82,8 @@ export default function SalesOrderShipmentDetail() { const isPending = useMemo(() => !shipment.shipment_date, [shipment]); + const isChecked = useMemo(() => !!shipment.checked_by, [shipment]); + const detailsPanel = useMemo(() => { if (shipmentQuery.isFetching || customerQuery.isFetching) { return ; @@ -180,6 +190,18 @@ export default function SalesOrderShipmentDetail() { icon: 'packages', label: t`Allocated Items` }, + { + type: 'text', + name: 'checked_by', + label: t`Checked By`, + icon: 'check', + value_formatter: () => + shipment.checked_by_detail ? ( + + ) : ( + {t`Not checked`} + ) + }, { type: 'text', name: 'shipment_date', @@ -298,6 +320,46 @@ export default function SalesOrderShipmentDetail() { onFormSuccess: refreshShipment }); + const checkShipment = useEditApiFormModal({ + url: ApiEndpoints.sales_order_shipment_list, + pk: shipment.pk, + title: t`Check Shipment`, + preFormContent: ( + } title={t`Check Shipment`}> + {t`Marking the shipment as checked indicates that you have verified that all items included in this shipment are correct`} + + ), + fetchInitialData: false, + fields: { + checked_by: { + hidden: true, + value: userId + } + }, + successMessage: t`Shipment marked as checked`, + onFormSuccess: refreshShipment + }); + + const uncheckShipment = useEditApiFormModal({ + url: ApiEndpoints.sales_order_shipment_list, + pk: shipment.pk, + title: t`Uncheck Shipment`, + preFormContent: ( + } title={t`Uncheck Shipment`}> + {t`Marking the shipment as unchecked indicates that the shipment requires further verification`} + + ), + fetchInitialData: false, + fields: { + checked_by: { + hidden: true, + value: null + } + }, + successMessage: t`Shipment marked as unchecked`, + onFormSuccess: refreshShipment + }); + const shipmentBadges = useMemo(() => { if (shipmentQuery.isFetching) { return []; @@ -310,6 +372,18 @@ export default function SalesOrderShipmentDetail() { color='gray' visible={isPending} />, + , + , ]; - }, [isPending, shipment.deliveryDate, shipmentQuery.isFetching]); + }, [isPending, isChecked, shipment.deliveryDate, shipmentQuery.isFetching]); const shipmentActions = useMemo(() => { const canEdit: boolean = user.hasChangePermission( @@ -363,6 +437,20 @@ export default function SalesOrderShipmentDetail() { onClick: editShipment.open, tooltip: t`Edit Shipment` }), + { + hidden: !isPending || isChecked, + name: t`Check`, + tooltip: t`Mark shipment as checked`, + icon: , + onClick: checkShipment.open + }, + { + hidden: !isPending || !isChecked, + name: t`Uncheck`, + tooltip: t`Mark shipment as unchecked`, + icon: , + onClick: uncheckShipment.open + }, CancelItemAction({ hidden: !isPending, onClick: deleteShipment.open, @@ -371,13 +459,15 @@ export default function SalesOrderShipmentDetail() { ]} /> ]; - }, [isPending, user, shipment]); + }, [isChecked, isPending, user, shipment]); return ( <> {completeShipment.modal} {editShipment.modal} {deleteShipment.modal} + {checkShipment.modal} + {uncheckShipment.modal} + }, { accessor: 'shipped', title: t`Shipped`, @@ -114,6 +121,13 @@ export default function SalesOrderShipmentTable({ sortable: false, render: (record: any) => }, + { + accessor: 'delivered', + title: t`Delivered`, + switchable: true, + sortable: false, + render: (record: any) => + }, DateColumn({ accessor: 'shipment_date', title: t`Shipment Date` @@ -191,6 +205,11 @@ export default function SalesOrderShipmentTable({ const tableFilters: TableFilter[] = useMemo(() => { return [ + { + name: 'checked', + label: t`Checked`, + description: t`Show shipments which have been checked` + }, { name: 'shipped', label: t`Shipped`,