2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-10-30 12:45:42 +00:00

[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
This commit is contained in:
Oliver
2025-10-24 13:39:57 +11:00
committed by GitHub
parent 435d34568b
commit 6df97e83f5
14 changed files with 295 additions and 76 deletions

View File

@@ -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

View File

@@ -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': _(

View File

@@ -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."""

View File

@@ -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

View File

@@ -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())

View File

@@ -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',
)

View File

@@ -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,

View File

@@ -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'
]}
/>
)

View File

@@ -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: <IconTruckDelivery />,
icon: <IconCubeSend />,
content: (
<SalesOrderShipmentTable
orderId={order.pk}

View File

@@ -1,6 +1,11 @@
import { t } from '@lingui/core/macro';
import { Grid, Skeleton, Stack, Text } from '@mantine/core';
import { IconBookmark, IconInfoCircle } from '@tabler/icons-react';
import { Alert, Grid, Skeleton, Stack, Text } from '@mantine/core';
import {
IconBookmark,
IconCircleCheck,
IconCircleX,
IconInfoCircle
} from '@tabler/icons-react';
import { useMemo } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
@@ -31,6 +36,7 @@ import NotesPanel from '../../components/panels/NotesPanel';
import type { PanelType } from '../../components/panels/Panel';
import { PanelGroup } from '../../components/panels/PanelGroup';
import { RenderAddress } from '../../components/render/Company';
import { RenderUser } from '../../components/render/User';
import { formatDate } from '../../defaults/formatters';
import {
useSalesOrderShipmentCompleteFields,
@@ -50,6 +56,8 @@ export default function SalesOrderShipmentDetail() {
const user = useUserState();
const navigate = useNavigate();
const userId = useMemo(() => 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 <Skeleton />;
@@ -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 ? (
<RenderUser instance={shipment.checked_by_detail} />
) : (
<Text size='sm' c='red'>{t`Not checked`}</Text>
)
},
{
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: (
<Alert color='green' icon={<IconCircleCheck />} title={t`Check Shipment`}>
<Text>{t`Marking the shipment as checked indicates that you have verified that all items included in this shipment are correct`}</Text>
</Alert>
),
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: (
<Alert color='red' icon={<IconCircleX />} title={t`Uncheck Shipment`}>
<Text>{t`Marking the shipment as unchecked indicates that the shipment requires further verification`}</Text>
</Alert>
),
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}
/>,
<DetailsBadge
key='checked'
label={t`Checked`}
color='green'
visible={isPending && isChecked}
/>,
<DetailsBadge
key='not-checked'
label={t`Not Checked`}
color='red'
visible={isPending && !isChecked}
/>,
<DetailsBadge
key='shipped'
label={t`Shipped`}
@@ -323,7 +397,7 @@ export default function SalesOrderShipmentDetail() {
visible={!!shipment.delivery_date}
/>
];
}, [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: <IconCircleCheck color='green' />,
onClick: checkShipment.open
},
{
hidden: !isPending || !isChecked,
name: t`Uncheck`,
tooltip: t`Mark shipment as unchecked`,
icon: <IconCircleX color='red' />,
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}
<InstanceDetail
query={shipmentQuery}
requiredRole={UserRoles.sales_order}

View File

@@ -107,6 +107,13 @@ export default function SalesOrderShipmentTable({
switchable: false,
title: t`Items`
},
{
accessor: 'checked',
title: t`Checked`,
switchable: true,
sortable: false,
render: (record: any) => <YesNoButton value={!!record.checked_by} />
},
{
accessor: 'shipped',
title: t`Shipped`,
@@ -114,6 +121,13 @@ export default function SalesOrderShipmentTable({
sortable: false,
render: (record: any) => <YesNoButton value={!!record.shipment_date} />
},
{
accessor: 'delivered',
title: t`Delivered`,
switchable: true,
sortable: false,
render: (record: any) => <YesNoButton value={!!record.delivery_date} />
},
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`,