From b364a17cfe4ce35083fa7c6d6dbab62a7e502966 Mon Sep 17 00:00:00 2001 From: Jacob Felknor Date: Thu, 11 Dec 2025 18:54:02 +0000 Subject: [PATCH 01/71] initial skel commit for transfer orders --- src/frontend/src/pages/stock/LocationDetail.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/frontend/src/pages/stock/LocationDetail.tsx b/src/frontend/src/pages/stock/LocationDetail.tsx index 1cdd66d5b579..54e7e45b3fa0 100644 --- a/src/frontend/src/pages/stock/LocationDetail.tsx +++ b/src/frontend/src/pages/stock/LocationDetail.tsx @@ -11,7 +11,8 @@ import { IconListDetails, IconPackages, IconSitemap, - IconTable + IconTable, + IconTransfer } from '@tabler/icons-react'; import { useMemo, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; @@ -220,6 +221,12 @@ export default function Stock() { /> ) }, + { + name: 'transfer-orders', + label: t`Transfer Orders`, + icon: , + content: Hello World + }, { name: 'default_parts', label: t`Default Parts`, From f182557eca2bc09a597642f24dfaf6353f49daa7 Mon Sep 17 00:00:00 2001 From: Jacob Felknor Date: Wed, 7 Jan 2026 22:30:54 +0000 Subject: [PATCH 02/71] initial transfer order backend model --- src/backend/InvenTree/order/admin.py | 39 +++ .../order/migrations/0115_transferorder.py | 288 ++++++++++++++++++ src/backend/InvenTree/order/models.py | 127 ++++++++ src/backend/InvenTree/order/status_codes.py | 27 ++ src/backend/InvenTree/order/validators.py | 14 + 5 files changed, 495 insertions(+) create mode 100644 src/backend/InvenTree/order/migrations/0115_transferorder.py diff --git a/src/backend/InvenTree/order/admin.py b/src/backend/InvenTree/order/admin.py index 70b7b3c13ba4..981a08fa423f 100644 --- a/src/backend/InvenTree/order/admin.py +++ b/src/backend/InvenTree/order/admin.py @@ -170,3 +170,42 @@ class ReturnOrderLineItemAdmin(admin.ModelAdmin): @admin.register(models.ReturnOrderExtraLine) class ReturnOrdeerExtraLineAdmin(GeneralExtraLineAdmin, admin.ModelAdmin): """Admin class for the ReturnOrderExtraLine model.""" + + +# TODO +# class TransferOrderLineItemInlineAdmin(admin.StackedInline): +# """Inline admin class for the TransferOrderLineItem model.""" + +# autocomplete_fields = ['part', 'destination', 'build_order'] + +# model = models.TransferOrderLineItem +# extra = 0 + + +@admin.register(models.TransferOrder) +class TransferOrderAdmin(admin.ModelAdmin): + """Admin class for the TransferOrder model.""" + + exclude = ['reference_int', 'address', 'contact'] + + list_display = ( + 'reference', + 'status', + 'description', + 'take_from', + 'destination', + 'creation_date', + ) + + search_fields = ['reference', 'description'] + + # TODO: + # inlines = [TransferOrderLineItemInlineAdmin] + + autocomplete_fields = [ + 'created_by', + 'take_from', + 'destination', + 'project_code', + 'responsible', + ] diff --git a/src/backend/InvenTree/order/migrations/0115_transferorder.py b/src/backend/InvenTree/order/migrations/0115_transferorder.py new file mode 100644 index 000000000000..6a99484b6ee9 --- /dev/null +++ b/src/backend/InvenTree/order/migrations/0115_transferorder.py @@ -0,0 +1,288 @@ +# Generated by Django 5.2.9 on 2026-01-07 22:07 + +import InvenTree.fields +import InvenTree.models +import django.db.models.deletion +import generic.states.fields +import generic.states.states +import generic.states.transition +import generic.states.validators +import order.status_codes +import order.validators +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("common", "0041_auto_20251203_1244"), + ("company", "0077_delete_manufacturerpartparameter"), + ("order", "0114_purchaseorderextraline_project_code_and_more"), + ("stock", "0116_alter_stockitem_link"), + ("users", "0015_alter_userprofile_type"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="TransferOrder", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "metadata", + models.JSONField( + blank=True, + help_text="JSON metadata field, for use by external plugins", + null=True, + verbose_name="Plugin Metadata", + ), + ), + ("reference_int", models.BigIntegerField(default=0)), + ( + "notes", + InvenTree.fields.InvenTreeNotesField( + blank=True, + help_text="Markdown notes (optional)", + max_length=50000, + null=True, + verbose_name="Notes", + ), + ), + ( + "barcode_data", + models.CharField( + blank=True, + help_text="Third party barcode data", + max_length=500, + verbose_name="Barcode Data", + ), + ), + ( + "barcode_hash", + models.CharField( + blank=True, + help_text="Unique hash of barcode data", + max_length=128, + verbose_name="Barcode Hash", + ), + ), + ( + "description", + models.CharField( + blank=True, + help_text="Order description (optional)", + max_length=250, + verbose_name="Description", + ), + ), + ( + "link", + InvenTree.fields.InvenTreeURLField( + blank=True, + help_text="Link to external page", + max_length=2000, + verbose_name="Link", + ), + ), + ( + "start_date", + models.DateField( + blank=True, + help_text="Scheduled start date for this order", + null=True, + verbose_name="Start date", + ), + ), + ( + "target_date", + models.DateField( + blank=True, + help_text="Expected date for order delivery. Order will be overdue after this date.", + null=True, + verbose_name="Target Date", + ), + ), + ( + "creation_date", + models.DateField( + blank=True, null=True, verbose_name="Creation Date" + ), + ), + ( + "issue_date", + models.DateField( + blank=True, + help_text="Date order was issued", + null=True, + verbose_name="Issue Date", + ), + ), + ( + "reference", + models.CharField( + default=order.validators.generate_next_transfer_order_reference, + help_text="Transfer Order Reference", + max_length=64, + unique=True, + validators=[order.validators.validate_transfer_order_reference], + verbose_name="Reference", + ), + ), + ( + "consume", + models.BooleanField( + default=False, + help_text="Rather than transfer the stock to the destination, consume it", + verbose_name="Consume Stock", + ), + ), + ( + "complete_date", + models.DateField( + blank=True, + help_text="Date order was completed", + null=True, + verbose_name="Completion Date", + ), + ), + ( + "status_custom_key", + generic.states.fields.ExtraInvenTreeCustomStatusModelField( + blank=True, + default=None, + help_text="Additional status information for this item", + null=True, + validators=[ + generic.states.validators.CustomStatusCodeValidator( + status_class=order.status_codes.TransferOrderStatus + ) + ], + verbose_name="Custom status key", + ), + ), + ( + "status", + generic.states.fields.InvenTreeCustomStatusModelField( + choices=[ + (10, "Pending"), + (20, "Placed"), + (25, "On Hold"), + (30, "Complete"), + (40, "Cancelled"), + ], + default=10, + help_text="Transfer order status", + validators=[ + generic.states.validators.CustomStatusCodeValidator( + status_class=order.status_codes.TransferOrderStatus + ) + ], + verbose_name="Status", + ), + ), + ( + "address", + models.ForeignKey( + blank=True, + help_text="Company address for this order", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="company.address", + verbose_name="Address", + ), + ), + ( + "contact", + models.ForeignKey( + blank=True, + help_text="Point of contact for this order", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="company.contact", + verbose_name="Contact", + ), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "destination", + models.ForeignKey( + blank=True, + help_text="Destination for transfered items", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="incoming_transferes", + to="stock.stocklocation", + verbose_name="Destination Location", + ), + ), + ( + "project_code", + models.ForeignKey( + blank=True, + help_text="Select project code for this order", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="common.projectcode", + verbose_name="Project Code", + ), + ), + ( + "responsible", + models.ForeignKey( + blank=True, + help_text="User or group responsible for this order", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="users.owner", + verbose_name="Responsible", + ), + ), + ( + "take_from", + models.ForeignKey( + blank=True, + help_text="Source for transfered items", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="sourcing_transferes", + to="stock.stocklocation", + verbose_name="Source Location", + ), + ), + ], + options={ + "verbose_name": "Transfer Order", + }, + bases=( + generic.states.states.StatusCodeMixin, + generic.states.transition.StateTransitionMixin, + InvenTree.models.InvenTreeAttachmentMixin, + InvenTree.models.InvenTreePermissionCheckMixin, + InvenTree.models.ContentTypeMixin, + InvenTree.models.PluginValidationMixin, + models.Model, + ), + ), + ] diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py index f34c0cb6dda0..9a9d646dcad5 100644 --- a/src/backend/InvenTree/order/models.py +++ b/src/backend/InvenTree/order/models.py @@ -54,6 +54,8 @@ ReturnOrderStatusGroups, SalesOrderStatus, SalesOrderStatusGroups, + TransferOrderStatus, + TransferOrderStatusGroups, ) from part import models as PartModels from plugin.events import trigger_event @@ -3072,3 +3074,128 @@ def get_api_url(): verbose_name=_('Order'), help_text=_('Return Order'), ) + + +class TransferOrder(Order): + """A Transfer Order represents a request to transfer stock from one location to another. It provides a place to queue and review changes before execution. + + Attributes: + take_from: The stock location to source items from (or null to ) + destination: The stock location to move items to + consume: Rather than move the stock, "consume" it. Helpful if you want to queue up removing stock from inventory + """ + + # Global setting for specifying reference pattern + REFERENCE_PATTERN_SETTING = 'TRANSFERORDER_REFERENCE_PATTERN' + # REQUIRE_RESPONSIBLE_SETTING = 'PURCHASEORDER_REQUIRE_RESPONSIBLE' + STATUS_CLASS = TransferOrderStatus + # UNLOCK_SETTING = 'PURCHASEORDER_EDIT_COMPLETED_ORDERS' + + class Meta: + """Model meta options.""" + + verbose_name = _('Transfer Order') + + # TODO: + # def report_context(self) -> TransferOrderReportContext: + # """Return report context data for this TransferOrder.""" + # return {**super().report_context(), 'supplier': self.supplier} + + def get_absolute_url(self) -> str: + """Get the 'web' URL for this order.""" + return pui_url(f'/stock/transfer-order/{self.pk}') + + @staticmethod + def get_api_url() -> str: + """Return the API URL associated with the TransferOrder model.""" + return reverse('api-po-list') + + @classmethod + def get_status_class(cls): + """Return the TransferOrderStatus class.""" + return TransferOrderStatusGroups + + @classmethod + def api_defaults(cls, request=None): + """Return default values for this model when issuing an API OPTIONS request.""" + defaults = { + 'reference': order.validators.generate_next_transfer_order_reference() + } + + return defaults + + # TODO: + # def subscribed_users(self) -> list[User]: + # """Return a list of users subscribed to this TransferOrder. + + # By this, we mean users to are interested in any of the parts associated with this order. + # """ + # subscribed_users = set() + + # for line in self.lines.all(): + # if line.part and line.part.part: + # # Add the part to the list of subscribed users + # for user in line.part.part.get_subscribers(): + # subscribed_users.add(user) + + # return list(subscribed_users) + + def __str__(self): + """Render a string representation of this TransferOrder.""" + return f'{self.reference} - {self.take_from.name if self.take_from else _("deleted")} --> {self.destination.name if self.destination else _("deleted")}' + + reference = models.CharField( + unique=True, + max_length=64, + blank=False, + help_text=_('Transfer Order Reference'), + verbose_name=_('Reference'), + default=order.validators.generate_next_transfer_order_reference, + validators=[order.validators.validate_transfer_order_reference], + ) + + status = InvenTreeCustomStatusModelField( + default=TransferOrderStatus.PENDING.value, + choices=TransferOrderStatus.items(), + status_class=TransferOrderStatus, + verbose_name=_('Status'), + help_text=_('Transfer order status'), + ) + + @property + def status_text(self): + """Return the text representation of the status field.""" + return TransferOrderStatus.text(self.status) + + take_from = models.ForeignKey( + 'stock.StockLocation', + verbose_name=_('Source Location'), + on_delete=models.SET_NULL, + related_name='sourcing_transferes', + blank=True, + null=True, + help_text=_('Source for transferred items'), + ) + + destination = models.ForeignKey( + 'stock.StockLocation', + verbose_name=_('Destination Location'), + on_delete=models.SET_NULL, + related_name='incoming_transferes', + blank=True, + null=True, + help_text=_('Destination for transferred items'), + ) + + consume = models.BooleanField( + default=False, + verbose_name=_('Consume Stock'), + help_text=_('Rather than transfer the stock to the destination, consume it'), + ) + + complete_date = models.DateField( + blank=True, + null=True, + verbose_name=_('Completion Date'), + help_text=_('Date order was completed'), + ) diff --git a/src/backend/InvenTree/order/status_codes.py b/src/backend/InvenTree/order/status_codes.py index 7ec5756b9252..5a02bde6c326 100644 --- a/src/backend/InvenTree/order/status_codes.py +++ b/src/backend/InvenTree/order/status_codes.py @@ -115,3 +115,30 @@ class ReturnOrderLineStatus(StatusCode): # Item is rejected REJECT = 60, _('Reject'), ColorEnum.danger + + +class TransferOrderStatus(StatusCode): + """Defines a set of status codes for a TransferOrder.""" + + # Order status codes + PENDING = 10, _('Pending'), ColorEnum.secondary # Order is pending (not yet placed) + PLACED = 20, _('Placed'), ColorEnum.primary # Order has been placed + ON_HOLD = 25, _('On Hold'), ColorEnum.warning # Order is on hold + COMPLETE = 30, _('Complete'), ColorEnum.success # Order has been completed + CANCELLED = 40, _('Cancelled'), ColorEnum.danger # Order was cancelled + + +class TransferOrderStatusGroups: + """Groups for TransferOrderStatus codes.""" + + # Open orders + OPEN = [ + TransferOrderStatus.PENDING.value, + TransferOrderStatus.ON_HOLD.value, + TransferOrderStatus.PLACED.value, + ] + + # Failed orders + FAILED = [TransferOrderStatus.CANCELLED.value] + + COMPLETE = [TransferOrderStatus.COMPLETE.value] diff --git a/src/backend/InvenTree/order/validators.py b/src/backend/InvenTree/order/validators.py index a4873679bd8a..87c66b243e12 100644 --- a/src/backend/InvenTree/order/validators.py +++ b/src/backend/InvenTree/order/validators.py @@ -22,6 +22,13 @@ def generate_next_return_order_reference(): return ReturnOrder.generate_reference() +def generate_next_transfer_order_reference(): + """Generate the next available TransferOrder reference.""" + from order.models import TransferOrder + + return TransferOrder.generate_reference() + + def validate_sales_order_reference_pattern(pattern): """Validate the SalesOrder reference 'pattern' setting.""" from order.models import SalesOrder @@ -62,3 +69,10 @@ def validate_return_order_reference(value): from order.models import ReturnOrder ReturnOrder.validate_reference_field(value) + + +def validate_transfer_order_reference(value): + """Validate that the ReturnOrder reference field matches the required pattern.""" + from order.models import TransferOrder + + TransferOrder.validate_reference_field(value) From 531b976135c81cd3dcdf18a4b66c3c3d4cce0553 Mon Sep 17 00:00:00 2001 From: Jacob Felknor Date: Wed, 7 Jan 2026 23:11:34 +0000 Subject: [PATCH 03/71] add some serializers, rename PLACED to ISSUED for TransferOrders --- src/backend/InvenTree/order/events.py | 9 ++ src/backend/InvenTree/order/models.py | 156 ++++++++++++++++++-- src/backend/InvenTree/order/serializers.py | 88 +++++++++++ src/backend/InvenTree/order/status_codes.py | 6 +- 4 files changed, 243 insertions(+), 16 deletions(-) diff --git a/src/backend/InvenTree/order/events.py b/src/backend/InvenTree/order/events.py index 0d67a3e11beb..4f17e26e357f 100644 --- a/src/backend/InvenTree/order/events.py +++ b/src/backend/InvenTree/order/events.py @@ -37,3 +37,12 @@ class ReturnOrderEvents(BaseEventEnum): COMPLETED = 'returnorder.completed' CANCELLED = 'returnorder.cancelled' HOLD = 'returnorder.hold' + + +class TransferOrderEvents(BaseEventEnum): + """Event enumeration for the PurchaseOrder models.""" + + ISSUED = 'transferorder.placed' + COMPLETED = 'transferorder.completed' + CANCELLED = 'transferorder.cancelled' + HOLD = 'transferorder.hold' diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py index 9a9d646dcad5..b28b67a0c0a1 100644 --- a/src/backend/InvenTree/order/models.py +++ b/src/backend/InvenTree/order/models.py @@ -45,7 +45,12 @@ ) from InvenTree.helpers import decimal2string, pui_url from InvenTree.helpers_model import notify_responsible -from order.events import PurchaseOrderEvents, ReturnOrderEvents, SalesOrderEvents +from order.events import ( + PurchaseOrderEvents, + ReturnOrderEvents, + SalesOrderEvents, + TransferOrderEvents, +) from order.status_codes import ( PurchaseOrderStatus, PurchaseOrderStatusGroups, @@ -3124,21 +3129,21 @@ def api_defaults(cls, request=None): return defaults - # TODO: - # def subscribed_users(self) -> list[User]: - # """Return a list of users subscribed to this TransferOrder. + def subscribed_users(self) -> list[User]: + """Return a list of users subscribed to this TransferOrder. - # By this, we mean users to are interested in any of the parts associated with this order. - # """ - # subscribed_users = set() + By this, we mean users to are interested in any of the parts associated with this order. + """ + subscribed_users = set() - # for line in self.lines.all(): - # if line.part and line.part.part: - # # Add the part to the list of subscribed users - # for user in line.part.part.get_subscribers(): - # subscribed_users.add(user) + # TODO: add these when I implement line items for the Transfer Order + # for line in self.lines.all(): + # if line.part and line.part.part: + # # Add the part to the list of subscribed users + # for user in line.part.part.get_subscribers(): + # subscribed_users.add(user) - # return list(subscribed_users) + return list(subscribed_users) def __str__(self): """Render a string representation of this TransferOrder.""" @@ -3199,3 +3204,128 @@ def status_text(self): verbose_name=_('Completion Date'), help_text=_('Date order was completed'), ) + + # region state changes + def _action_issue(self, *args, **kwargs): + """Marks the TransferOrder as ISSUED. + + Order must be currently PENDING. + """ + if self.can_issue: + self.status = TransferOrderStatus.ISSUED.value + self.issue_date = InvenTree.helpers.current_date() + self.save() + + trigger_event(TransferOrderEvents.ISSUED, id=self.pk) + + # Notify users that the order has been issued + notify_responsible( + self, + TransferOrder, + exclude=self.created_by, + content=InvenTreeNotificationBodies.NewOrder, + extra_users=self.subscribed_users(), + ) + + def _action_complete(self, *args, **kwargs): + """Marks the TransferOrder as COMPLETE. + + Order must be currently ISSUED. + """ + if self.status == TransferOrderStatus.ISSUED: + self.status = TransferOrderStatus.COMPLETE.value + self.complete_date = InvenTree.helpers.current_date() + + self.save() + + trigger_event(TransferOrderEvents.COMPLETED, id=self.pk) + + @property + def can_issue(self) -> bool: + """Return True if this order can be issued.""" + return self.status in [ + TransferOrderStatus.PENDING.value, + TransferOrderStatus.ON_HOLD.value, + ] + + @transaction.atomic + def issue_order(self): + """Attempt to transition to PLACED status.""" + return self.handle_transition( + self.status, TransferOrderStatus.ISSUED.value, self, self._action_issue + ) + + @transaction.atomic + def complete_order(self): + """Attempt to transition to COMPLETE status.""" + return self.handle_transition( + self.status, TransferOrderStatus.COMPLETE.value, self, self._action_complete + ) + + @transaction.atomic + def hold_order(self): + """Attempt to transition to ON_HOLD status.""" + return self.handle_transition( + self.status, TransferOrderStatus.ON_HOLD.value, self, self._action_hold + ) + + @transaction.atomic + def cancel_order(self): + """Attempt to transition to CANCELLED status.""" + return self.handle_transition( + self.status, TransferOrderStatus.CANCELLED.value, self, self._action_cancel + ) + + @property + def is_pending(self) -> bool: + """Return True if the TransferOrder is 'pending'.""" + return self.status == TransferOrderStatus.PENDING.value + + @property + def is_open(self) -> bool: + """Return True if the TransferOrder is 'open'.""" + return self.status in TransferOrderStatusGroups.OPEN + + @property + def can_cancel(self) -> bool: + """A TransferOrder can only be cancelled under the following circumstances. + + - Status is ISSUED + - Status is PENDING (or ON_HOLD) + """ + return self.status in TransferOrderStatusGroups.OPEN + + def _action_cancel(self, *args, **kwargs): + """Marks the TransferOrder as CANCELLED.""" + if self.can_cancel: + self.status = TransferOrderStatus.CANCELLED.value + self.save() + + trigger_event(TransferOrderEvents.CANCELLED, id=self.pk) + + # Notify users that the order has been canceled + notify_responsible( + self, + TransferOrder, + exclude=self.created_by, + content=InvenTreeNotificationBodies.OrderCanceled, + extra_users=self.subscribed_users(), + ) + + @property + def can_hold(self) -> bool: + """Return True if this order can be placed on hold.""" + return self.status in [ + TransferOrderStatus.PENDING.value, + TransferOrderStatus.ISSUED.value, + ] + + def _action_hold(self, *args, **kwargs): + """Mark this transfer order as 'on hold'.""" + if self.can_hold: + self.status = TransferOrderStatus.ON_HOLD.value + self.save() + + trigger_event(TransferOrderEvents.HOLD, id=self.pk) + + # endregion diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py index 5d0fd37d5bce..afdf1962a36e 100644 --- a/src/backend/InvenTree/order/serializers.py +++ b/src/backend/InvenTree/order/serializers.py @@ -2162,3 +2162,91 @@ class Meta(AbstractExtraLineMeta): source='order', many=False, read_only=True, allow_null=True ) ) + + +@register_importer() +class TransferOrderSerializer( + NotesFieldMixin, + InvenTreeCustomStatusSerializerMixin, + AbstractOrderSerializer, + InvenTreeModelSerializer, +): + """Serializer for a TransferOrder object.""" + + class Meta: + """Metaclass options.""" + + model = order.models.TransferOrder + fields = AbstractOrderSerializer.order_fields([ + 'take_from', + 'destination', + 'consume', + 'complete_date', + ]) + read_only_fields = ['creation_date'] + extra_kwargs = {} + + def skip_create_fields(self): + """Skip these fields when instantiating a new object.""" + fields = super().skip_create_fields() + + return [*fields, 'duplicate'] + + # TODO: + # @staticmethod + # def annotate_queryset(queryset): + # """Custom annotation for the serializer queryset.""" + # queryset = AbstractOrderSerializer.annotate_queryset(queryset) + + # queryset = queryset.annotate( + # completed_lines=SubqueryCount( + # 'lines', filter=~Q(outcome=ReturnOrderLineStatus.PENDING.value) + # ) + # ) + + # queryset = queryset.annotate( + # overdue=Case( + # When( + # order.models.ReturnOrder.overdue_filter(), + # then=Value(True, output_field=BooleanField()), + # ), + # default=Value(False, output_field=BooleanField()), + # ) + # ) + + # return queryset + + +class TransferOrderHoldSerializer(OrderAdjustSerializer): + """Serializer for placing a TransferOrder on hold.""" + + def save(self): + """Save the serializer to 'hold' the order.""" + self.order.hold_order() + + +class TransferOrderIssueSerializer(OrderAdjustSerializer): + """Serializer for issuing a transfer order.""" + + def save(self): + """Save the serializer to 'issue' the order.""" + self.order.issue_order() + + +class TransferOrderCancelSerializer(OrderAdjustSerializer): + """Serializer for cancelling a TransferOrder.""" + + def save(self): + """Save the serializer to 'cancel' the order.""" + if not self.order.can_cancel: + raise ValidationError(_('Order cannot be cancelled')) + + self.order.cancel_order() + + +class TransferOrderCompleteSerializer(OrderAdjustSerializer): + """Serializer for completing a transfer order.""" + + def save(self): + """Save the serializer to 'complete' the order.""" + self.order.complete_order() diff --git a/src/backend/InvenTree/order/status_codes.py b/src/backend/InvenTree/order/status_codes.py index 5a02bde6c326..d8893a1fa3c8 100644 --- a/src/backend/InvenTree/order/status_codes.py +++ b/src/backend/InvenTree/order/status_codes.py @@ -121,8 +121,8 @@ class TransferOrderStatus(StatusCode): """Defines a set of status codes for a TransferOrder.""" # Order status codes - PENDING = 10, _('Pending'), ColorEnum.secondary # Order is pending (not yet placed) - PLACED = 20, _('Placed'), ColorEnum.primary # Order has been placed + PENDING = 10, _('Pending'), ColorEnum.secondary # Order is pending (not yet issued) + ISSUED = 20, _('Issued'), ColorEnum.primary # Order has been issued ON_HOLD = 25, _('On Hold'), ColorEnum.warning # Order is on hold COMPLETE = 30, _('Complete'), ColorEnum.success # Order has been completed CANCELLED = 40, _('Cancelled'), ColorEnum.danger # Order was cancelled @@ -135,7 +135,7 @@ class TransferOrderStatusGroups: OPEN = [ TransferOrderStatus.PENDING.value, TransferOrderStatus.ON_HOLD.value, - TransferOrderStatus.PLACED.value, + TransferOrderStatus.ISSUED.value, ] # Failed orders From e294057965e07b4ca1905d3f2e57fdce7d46944c Mon Sep 17 00:00:00 2001 From: Jacob Felknor Date: Thu, 8 Jan 2026 18:26:09 +0000 Subject: [PATCH 04/71] adding from admin console works --- src/backend/InvenTree/order/admin.py | 1 + src/backend/InvenTree/order/models.py | 22 ++++++++++++---------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/backend/InvenTree/order/admin.py b/src/backend/InvenTree/order/admin.py index 981a08fa423f..0af70a12c90c 100644 --- a/src/backend/InvenTree/order/admin.py +++ b/src/backend/InvenTree/order/admin.py @@ -194,6 +194,7 @@ class TransferOrderAdmin(admin.ModelAdmin): 'description', 'take_from', 'destination', + 'consume', 'creation_date', ) diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py index b28b67a0c0a1..2b0f911f4059 100644 --- a/src/backend/InvenTree/order/models.py +++ b/src/backend/InvenTree/order/models.py @@ -379,11 +379,12 @@ def clean(self): }) # Check that the referenced 'contact' matches the correct 'company' - if self.company and self.contact: - if self.contact.company != self.company: - raise ValidationError({ - 'contact': _('Contact does not match selected company') - }) + if hasattr(self, 'company'): + if self.company and self.contact: + if self.contact.company != self.company: + raise ValidationError({ + 'contact': _('Contact does not match selected company') + }) # Target date should be *after* the start date if self.start_date and self.target_date and self.start_date > self.target_date: @@ -393,11 +394,12 @@ def clean(self): }) # Check that the referenced 'address' matches the correct 'company' - if self.company and self.address: - if self.address.company != self.company: - raise ValidationError({ - 'address': _('Address does not match selected company') - }) + if hasattr(self, 'company'): + if self.company and self.address: + if self.address.company != self.company: + raise ValidationError({ + 'address': _('Address does not match selected company') + }) def clean_line_item(self, line): """Clean a line item for this order. From 89e4d12945fb5ed35ea40b32d08d4e5546df9ac0 Mon Sep 17 00:00:00 2001 From: Jacob Felknor Date: Thu, 8 Jan 2026 20:52:53 +0000 Subject: [PATCH 05/71] simple table list almost working, but we need to add order line items.... --- src/backend/InvenTree/order/api.py | 119 ++++++++++++ src/frontend/lib/enums/ApiEndpoints.tsx | 2 + src/frontend/lib/enums/ModelType.tsx | 2 + .../src/pages/stock/LocationDetail.tsx | 3 +- .../src/tables/stock/TransferOrderTable.tsx | 181 ++++++++++++++++++ 5 files changed, 306 insertions(+), 1 deletion(-) create mode 100644 src/frontend/src/tables/stock/TransferOrderTable.tsx diff --git a/src/backend/InvenTree/order/api.py b/src/backend/InvenTree/order/api.py index 096e8d1dfbcf..ada5014e8081 100644 --- a/src/backend/InvenTree/order/api.py +++ b/src/backend/InvenTree/order/api.py @@ -1723,6 +1723,117 @@ class ReturnOrderExtraLineDetail(RetrieveUpdateDestroyAPI): serializer_class = serializers.ReturnOrderExtraLineSerializer +# class TransferOrderFilter(OrderFilter): +# """Custom API filters for the TransferOrderList endpoint.""" + +# class Meta: +# """Metaclass options.""" + +# model = models.TransferOrder +# fields = ['customer'] + +# include_variants = rest_filters.BooleanFilter( +# label=_('Include Variants'), method='filter_include_variants' +# ) + +# def filter_include_variants(self, queryset, name, value): +# """Filter by whether or not to include variants of the selected part. + +# Note: +# - This filter does nothing by itself, and requires the 'part' filter to be set. +# - Refer to the 'filter_part' method for more information. +# """ +# return queryset + +# part = rest_filters.ModelChoiceFilter( +# queryset=Part.objects.all(), field_name='part', method='filter_part' +# ) + +# @extend_schema_field(OpenApiTypes.INT) +# def filter_part(self, queryset, name, part): +# """Filter by selected 'part'. + +# Note: +# - If 'include_variants' is set to True, then all variants of the selected part will be included. +# - Otherwise, just filter by the selected part. +# """ +# include_variants = str2bool(self.data.get('include_variants', False)) + +# if include_variants: +# parts = part.get_descendants(include_self=True) +# else: +# parts = Part.objects.filter(pk=part.pk) + +# # Now that we have a queryset of parts, find all the matching return orders +# line_items = models.TransferOrderLineItem.objects.filter(item__part__in=parts) + +# # Generate a list of ID values for the matching transfer orders +# transfer_orders = line_items.values_list('order', flat=True).distinct() + +# # Now we have a list of matching IDs, filter the queryset +# return queryset.filter(pk__in=transfer_orders) + +# completed_before = InvenTreeDateFilter( +# label=_('Completed Before'), field_name='complete_date', lookup_expr='lt' +# ) + +# completed_after = InvenTreeDateFilter( +# label=_('Completed After'), field_name='complete_date', lookup_expr='gt' +# ) + + +class TransferOrderMixin(SerializerContextMixin): + """Mixin class for TransferOrder endpoints.""" + + queryset = models.TransferOrder.objects.all() + serializer_class = serializers.TransferOrderSerializer + + def get_queryset(self, *args, **kwargs): + """Return annotated queryset for this endpoint.""" + queryset = super().get_queryset(*args, **kwargs) + queryset = serializers.TransferOrderSerializer.annotate_queryset(queryset) + queryset = queryset.prefetch_related('created_by', 'responsible') + + return queryset + + +class TransferOrderList( + TransferOrderMixin, + OrderCreateMixin, + DataExportViewMixin, + OutputOptionsMixin, + ParameterListMixin, + ListCreateAPI, +): + """API endpoint for accessing a list of TransferOrder objects.""" + + # filterset_class = TransferOrderFilter + filter_backends = SEARCH_ORDER_FILTER_ALIAS + + # output_options = TransferOrderOutputOptions + + ordering_field_aliases = { + 'reference': ['reference_int', 'reference'], + 'project_code': ['project_code__code'], + } + + ordering_fields = [ + 'creation_date', + 'created_by', + 'reference', + # 'line_items', + 'status', + 'start_date', + 'target_date', + 'complete_date', + 'project_code', + ] + + search_fields = ['reference', 'description', 'project_code__code'] + + ordering = '-reference' + + class OrderCalendarExport(ICalFeed): """Calendar export for Purchase/Sales Orders. @@ -2173,6 +2284,14 @@ def item_link(self, item): ), ]), ), + # API endpoints for transfer orders + path( + 'to/', + include([ + # Transfer Order list + path('', TransferOrderList.as_view(), name='api-transfer-order-list') + ]), + ), # API endpoint for subscribing to ICS calendar of purchase/sales/return orders re_path( r'^calendar/(?Ppurchase-order|sales-order|return-order)/calendar.ics', diff --git a/src/frontend/lib/enums/ApiEndpoints.tsx b/src/frontend/lib/enums/ApiEndpoints.tsx index e7adf3f0b8d0..a844a97f6ea7 100644 --- a/src/frontend/lib/enums/ApiEndpoints.tsx +++ b/src/frontend/lib/enums/ApiEndpoints.tsx @@ -197,6 +197,8 @@ export enum ApiEndpoints { return_order_line_list = 'order/ro-line/', return_order_extra_line_list = 'order/ro-extra-line/', + transfer_order_list = 'order/to/', + // Template API endpoints label_list = 'label/template/', label_print = 'label/print/', diff --git a/src/frontend/lib/enums/ModelType.tsx b/src/frontend/lib/enums/ModelType.tsx index 57aa1b7205cf..80eefb13c940 100644 --- a/src/frontend/lib/enums/ModelType.tsx +++ b/src/frontend/lib/enums/ModelType.tsx @@ -24,6 +24,8 @@ export enum ModelType { salesordershipment = 'salesordershipment', returnorder = 'returnorder', returnorderlineitem = 'returnorderlineitem', + transferorder = 'transferorder', + // transferorderlineitem = 'transferorderlineitem', importsession = 'importsession', address = 'address', contact = 'contact', diff --git a/src/frontend/src/pages/stock/LocationDetail.tsx b/src/frontend/src/pages/stock/LocationDetail.tsx index 54e7e45b3fa0..4ee4c862a5a7 100644 --- a/src/frontend/src/pages/stock/LocationDetail.tsx +++ b/src/frontend/src/pages/stock/LocationDetail.tsx @@ -53,6 +53,7 @@ import { PartListTable } from '../../tables/part/PartTable'; import { StockItemTable } from '../../tables/stock/StockItemTable'; import StockLocationParametricTable from '../../tables/stock/StockLocationParametricTable'; import { StockLocationTable } from '../../tables/stock/StockLocationTable'; +import { TransferOrderTable } from '../../tables/stock/TransferOrderTable'; export default function Stock() { const { id: _id } = useParams(); @@ -225,7 +226,7 @@ export default function Stock() { name: 'transfer-orders', label: t`Transfer Orders`, icon: , - content: Hello World + content: }, { name: 'default_parts', diff --git a/src/frontend/src/tables/stock/TransferOrderTable.tsx b/src/frontend/src/tables/stock/TransferOrderTable.tsx new file mode 100644 index 000000000000..01b57efab213 --- /dev/null +++ b/src/frontend/src/tables/stock/TransferOrderTable.tsx @@ -0,0 +1,181 @@ +import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; +import { ModelType } from '@lib/enums/ModelType'; +import { apiUrl } from '@lib/functions/Api'; +import type { TableFilter } from '@lib/types/Filters'; +import { t } from '@lingui/core/macro'; +import { useMemo } from 'react'; +import { useTable } from '../../hooks/UseTable'; +import { useUserState } from '../../states/UserState'; +import { ReferenceColumn, StatusColumn } from '../ColumnRenderers'; +import { + AssignedToMeFilter, + CompletedAfterFilter, + CompletedBeforeFilter, + CreatedAfterFilter, + CreatedBeforeFilter, + CreatedByFilter, + HasProjectCodeFilter, + IncludeVariantsFilter, + MaxDateFilter, + MinDateFilter, + OrderStatusFilter, + OutstandingFilter, + OverdueFilter, + ProjectCodeFilter, + ResponsibleFilter, + StartDateAfterFilter, + StartDateBeforeFilter, + TargetDateAfterFilter, + TargetDateBeforeFilter +} from '../Filter'; +import { InvenTreeTable } from '../InvenTreeTable'; + +export function TransferOrderTable({ + partId, + customerId +}: Readonly<{ + partId?: number; + customerId?: number; +}>) { + const table = useTable( + !!partId ? 'transferorders-part' : 'transferorders-index' + ); + const user = useUserState(); + + const tableFilters: TableFilter[] = useMemo(() => { + const filters: TableFilter[] = [ + OrderStatusFilter({ model: ModelType.returnorder }), + OutstandingFilter(), + OverdueFilter(), + AssignedToMeFilter(), + MinDateFilter(), + MaxDateFilter(), + CreatedBeforeFilter(), + CreatedAfterFilter(), + TargetDateBeforeFilter(), + TargetDateAfterFilter(), + StartDateBeforeFilter(), + StartDateAfterFilter(), + { + name: 'has_target_date', + type: 'boolean', + label: t`Has Target Date`, + description: t`Show orders with a target date` + }, + { + name: 'has_start_date', + type: 'boolean', + label: t`Has Start Date`, + description: t`Show orders with a start date` + }, + CompletedBeforeFilter(), + CompletedAfterFilter(), + HasProjectCodeFilter(), + ProjectCodeFilter(), + ResponsibleFilter(), + CreatedByFilter() + ]; + + if (!!partId) { + filters.push(IncludeVariantsFilter()); + } + + return filters; + }, [partId]); + + const tableColumns = useMemo(() => { + return [ + ReferenceColumn({}), + // { + // accessor: 'customer__name', + // title: t`Customer`, + // sortable: true, + // render: (record: any) => ( + // + // ) + // }, + // { + // accessor: 'customer_reference' + // }, + // DescriptionColumn({}), + // LineItemsProgressColumn({}), + StatusColumn({ model: ModelType.returnorder }) + // ProjectCodeColumn({ + // defaultVisible: false + // }), + // CreationDateColumn({ + // defaultVisible: false + // }), + // CreatedByColumn({ + // defaultVisible: false + // }), + // StartDateColumn({ + // defaultVisible: false + // }), + // TargetDateColumn({}), + // CompletionDateColumn({ + // accessor: 'complete_date' + // }), + // ResponsibleColumn({}), + // { + // accessor: 'total_price', + // title: t`Total Price`, + // sortable: true, + // render: (record: any) => { + // return formatCurrency(record.total_price, { + // currency: record.order_currency || record.customer_detail?.currency + // }); + // } + // } + ]; + }, []); + + // const returnOrderFields = useReturnOrderFields({}); + + // const newReturnOrder = useCreateApiFormModal({ + // url: ApiEndpoints.return_order_list, + // title: t`Add Return Order`, + // fields: returnOrderFields, + // initialData: { + // customer: customerId + // }, + // follow: true, + // modelType: ModelType.returnorder + // }); + + // const tableActions = useMemo(() => { + // return [ + // newReturnOrder.open()} + // hidden={!user.hasAddRole(UserRoles.return_order)} + // /> + // ]; + // }, [user]); + + return ( + <> + {/* {newReturnOrder.modal} */} + + + ); +} From 059c468dd06f344b2cdcac10c9837ecd5f9388ec Mon Sep 17 00:00:00 2001 From: Jacob Felknor Date: Thu, 8 Jan 2026 21:29:24 +0000 Subject: [PATCH 06/71] add other cols to table --- .../src/tables/stock/TransferOrderTable.tsx | 70 +++++++++++-------- 1 file changed, 39 insertions(+), 31 deletions(-) diff --git a/src/frontend/src/tables/stock/TransferOrderTable.tsx b/src/frontend/src/tables/stock/TransferOrderTable.tsx index 01b57efab213..84ab97f62d84 100644 --- a/src/frontend/src/tables/stock/TransferOrderTable.tsx +++ b/src/frontend/src/tables/stock/TransferOrderTable.tsx @@ -6,7 +6,19 @@ import { t } from '@lingui/core/macro'; import { useMemo } from 'react'; import { useTable } from '../../hooks/UseTable'; import { useUserState } from '../../states/UserState'; -import { ReferenceColumn, StatusColumn } from '../ColumnRenderers'; +import { + BooleanColumn, + CompletionDateColumn, + CreatedByColumn, + CreationDateColumn, + DescriptionColumn, + ProjectCodeColumn, + ReferenceColumn, + ResponsibleColumn, + StartDateColumn, + StatusColumn, + TargetDateColumn +} from '../ColumnRenderers'; import { AssignedToMeFilter, CompletedAfterFilter, @@ -97,40 +109,36 @@ export function TransferOrderTable({ // { // accessor: 'customer_reference' // }, - // DescriptionColumn({}), + DescriptionColumn({}), + BooleanColumn({ + accessor: 'consume', + title: t`Consume Stock`, + sortable: true, + switchable: true + }), // LineItemsProgressColumn({}), - StatusColumn({ model: ModelType.returnorder }) - // ProjectCodeColumn({ - // defaultVisible: false - // }), - // CreationDateColumn({ - // defaultVisible: false - // }), - // CreatedByColumn({ - // defaultVisible: false - // }), - // StartDateColumn({ - // defaultVisible: false - // }), - // TargetDateColumn({}), - // CompletionDateColumn({ - // accessor: 'complete_date' - // }), - // ResponsibleColumn({}), - // { - // accessor: 'total_price', - // title: t`Total Price`, - // sortable: true, - // render: (record: any) => { - // return formatCurrency(record.total_price, { - // currency: record.order_currency || record.customer_detail?.currency - // }); - // } - // } + StatusColumn({ model: ModelType.returnorder }), + ProjectCodeColumn({ + defaultVisible: false + }), + CreationDateColumn({ + defaultVisible: false + }), + CreatedByColumn({ + defaultVisible: false + }), + StartDateColumn({ + defaultVisible: false + }), + TargetDateColumn({}), + CompletionDateColumn({ + accessor: 'complete_date' + }), + ResponsibleColumn({}) ]; }, []); - // const returnOrderFields = useReturnOrderFields({}); + // const transferOrderFields = useTransferOrderFields({}); // const newReturnOrder = useCreateApiFormModal({ // url: ApiEndpoints.return_order_list, From 6276d1a4668f7818159b9cf03da1f7ca6d73fd91 Mon Sep 17 00:00:00 2001 From: Jacob Felknor Date: Thu, 8 Jan 2026 21:50:55 +0000 Subject: [PATCH 07/71] add Transfer Order from table view --- src/frontend/lib/enums/Roles.tsx | 3 + src/frontend/src/forms/TransferOrderForms.tsx | 64 +++++++++++++++++++ .../src/tables/stock/TransferOrderTable.tsx | 58 +++++++---------- 3 files changed, 91 insertions(+), 34 deletions(-) create mode 100644 src/frontend/src/forms/TransferOrderForms.tsx diff --git a/src/frontend/lib/enums/Roles.tsx b/src/frontend/lib/enums/Roles.tsx index efa6c4a04bc5..d2a915500085 100644 --- a/src/frontend/lib/enums/Roles.tsx +++ b/src/frontend/lib/enums/Roles.tsx @@ -10,6 +10,7 @@ export enum UserRoles { part_category = 'part_category', purchase_order = 'purchase_order', return_order = 'return_order', + transfer_order = 'transfer_order', sales_order = 'sales_order', stock = 'stock', stock_location = 'stock_location' @@ -39,6 +40,8 @@ export function userRoleLabel(role: UserRoles): string { return t`Purchase Orders`; case UserRoles.return_order: return t`Return Orders`; + case UserRoles.transfer_order: + return t`Transfer Orders`; case UserRoles.sales_order: return t`Sales Orders`; case UserRoles.stock: diff --git a/src/frontend/src/forms/TransferOrderForms.tsx b/src/frontend/src/forms/TransferOrderForms.tsx new file mode 100644 index 000000000000..3ea01167f052 --- /dev/null +++ b/src/frontend/src/forms/TransferOrderForms.tsx @@ -0,0 +1,64 @@ +import type { ApiFormFieldSet } from '@lib/types/Forms'; +import { IconCalendar, IconUsers } from '@tabler/icons-react'; +import { useMemo } from 'react'; +import { useGlobalSettingsState } from '../states/SettingsStates'; + +export function useTransferOrderFields({ + duplicateOrderId +}: { + duplicateOrderId?: number; +}): ApiFormFieldSet { + const globalSettings = useGlobalSettingsState(); + + return useMemo(() => { + const fields: ApiFormFieldSet = { + reference: {}, + description: {}, + project_code: {}, + start_date: { + icon: + }, + target_date: { + icon: + }, + take_from: {}, + destination: { + filters: { + structural: false + } + }, + consume: {}, + link: {}, + responsible: { + filters: { + is_active: true + }, + icon: + } + }; + + // Order duplication fields + if (!!duplicateOrderId) { + fields.duplicate = { + children: { + order_id: { + hidden: true, + value: duplicateOrderId + }, + copy_lines: { + // Cannot duplicate lines from a transfer order! + value: false, + hidden: true + }, + copy_extra_lines: {} + } + }; + } + + if (!globalSettings.isSet('PROJECT_CODES_ENABLED', true)) { + delete fields.project_code; + } + + return fields; + }, [duplicateOrderId, globalSettings]); +} diff --git a/src/frontend/src/tables/stock/TransferOrderTable.tsx b/src/frontend/src/tables/stock/TransferOrderTable.tsx index 84ab97f62d84..f695f7f50335 100644 --- a/src/frontend/src/tables/stock/TransferOrderTable.tsx +++ b/src/frontend/src/tables/stock/TransferOrderTable.tsx @@ -1,9 +1,12 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ModelType } from '@lib/enums/ModelType'; import { apiUrl } from '@lib/functions/Api'; +import { AddItemButton, UserRoles } from '@lib/index'; import type { TableFilter } from '@lib/types/Filters'; import { t } from '@lingui/core/macro'; import { useMemo } from 'react'; +import { useTransferOrderFields } from '../../forms/TransferOrderForms'; +import { useCreateApiFormModal } from '../../hooks/UseForm'; import { useTable } from '../../hooks/UseTable'; import { useUserState } from '../../states/UserState'; import { @@ -98,17 +101,6 @@ export function TransferOrderTable({ const tableColumns = useMemo(() => { return [ ReferenceColumn({}), - // { - // accessor: 'customer__name', - // title: t`Customer`, - // sortable: true, - // render: (record: any) => ( - // - // ) - // }, - // { - // accessor: 'customer_reference' - // }, DescriptionColumn({}), BooleanColumn({ accessor: 'consume', @@ -138,33 +130,31 @@ export function TransferOrderTable({ ]; }, []); - // const transferOrderFields = useTransferOrderFields({}); + const transferOrderFields = useTransferOrderFields({}); - // const newReturnOrder = useCreateApiFormModal({ - // url: ApiEndpoints.return_order_list, - // title: t`Add Return Order`, - // fields: returnOrderFields, - // initialData: { - // customer: customerId - // }, - // follow: true, - // modelType: ModelType.returnorder - // }); + const newTransferOrder = useCreateApiFormModal({ + url: ApiEndpoints.transfer_order_list, + title: t`Add Transfer Order`, + fields: transferOrderFields, + initialData: {}, + follow: true, + modelType: ModelType.transferorder + }); - // const tableActions = useMemo(() => { - // return [ - // newReturnOrder.open()} - // hidden={!user.hasAddRole(UserRoles.return_order)} - // /> - // ]; - // }, [user]); + const tableActions = useMemo(() => { + return [ + newTransferOrder.open()} + hidden={!user.hasAddRole(UserRoles.transfer_order)} + /> + ]; + }, [user]); return ( <> - {/* {newReturnOrder.modal} */} + {newTransferOrder.modal} Date: Thu, 8 Jan 2026 22:27:48 +0000 Subject: [PATCH 08/71] moving towards a detail view --- src/frontend/lib/enums/ApiEndpoints.tsx | 1 + src/frontend/lib/enums/ModelInformation.tsx | 16 ++++++++++++++++ src/frontend/lib/enums/ModelType.tsx | 2 +- src/frontend/src/defaults/backendMappings.tsx | 2 ++ src/frontend/src/functions/icons.tsx | 1 + .../src/tables/stock/TransferOrderTable.tsx | 8 ++++---- 6 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/frontend/lib/enums/ApiEndpoints.tsx b/src/frontend/lib/enums/ApiEndpoints.tsx index a844a97f6ea7..7e06001e8277 100644 --- a/src/frontend/lib/enums/ApiEndpoints.tsx +++ b/src/frontend/lib/enums/ApiEndpoints.tsx @@ -198,6 +198,7 @@ export enum ApiEndpoints { return_order_extra_line_list = 'order/ro-extra-line/', transfer_order_list = 'order/to/', + transfser_order_line_list = 'order/to-line/', // Template API endpoints label_list = 'label/template/', diff --git a/src/frontend/lib/enums/ModelInformation.tsx b/src/frontend/lib/enums/ModelInformation.tsx index 6f93a1ebeb1a..a61011995395 100644 --- a/src/frontend/lib/enums/ModelInformation.tsx +++ b/src/frontend/lib/enums/ModelInformation.tsx @@ -206,6 +206,22 @@ export const ModelInformationDict: ModelDict = { api_endpoint: ApiEndpoints.return_order_line_list, icon: 'return_orders' }, + transferorder: { + label: () => t`Transfer Order`, + label_multiple: () => t`Transfer Orders`, + url_overview: '/stock/location/index/transfer-orders', + url_detail: '/stock/transfer-order/:pk/', + api_endpoint: ApiEndpoints.transfer_order_list, + admin_url: '/order/transferorder/', + supports_barcode: true, + icon: 'transfer_orders' + }, + transferorderlineitem: { + label: () => t`Transfer Order Line Item`, + label_multiple: () => t`Transfer Order Line Items`, + api_endpoint: ApiEndpoints.transfser_order_line_list, + icon: 'transfer-orders' + }, address: { label: () => t`Address`, label_multiple: () => t`Addresses`, diff --git a/src/frontend/lib/enums/ModelType.tsx b/src/frontend/lib/enums/ModelType.tsx index 80eefb13c940..64db3774a13a 100644 --- a/src/frontend/lib/enums/ModelType.tsx +++ b/src/frontend/lib/enums/ModelType.tsx @@ -25,7 +25,7 @@ export enum ModelType { returnorder = 'returnorder', returnorderlineitem = 'returnorderlineitem', transferorder = 'transferorder', - // transferorderlineitem = 'transferorderlineitem', + transferorderlineitem = 'transferorderlineitem', importsession = 'importsession', address = 'address', contact = 'contact', diff --git a/src/frontend/src/defaults/backendMappings.tsx b/src/frontend/src/defaults/backendMappings.tsx index 4754d9abb9f9..058ad80dbf1a 100644 --- a/src/frontend/src/defaults/backendMappings.tsx +++ b/src/frontend/src/defaults/backendMappings.tsx @@ -11,6 +11,8 @@ export const statusCodeList: Record = { PurchaseOrderStatus: ModelType.purchaseorder, ReturnOrderStatus: ModelType.returnorder, ReturnOrderLineStatus: ModelType.returnorderlineitem, + TransferOrderStatus: ModelType.transferorder, + TransferOrderLineStatus: ModelType.transferorderlineitem, SalesOrderStatus: ModelType.salesorder, StockHistoryCode: ModelType.stockhistory, StockStatus: ModelType.stockitem, diff --git a/src/frontend/src/functions/icons.tsx b/src/frontend/src/functions/icons.tsx index b50e04e4cb31..41300500c0ba 100644 --- a/src/frontend/src/functions/icons.tsx +++ b/src/frontend/src/functions/icons.tsx @@ -152,6 +152,7 @@ const icons: InvenTreeIconType = { customers: IconBuildingStore, purchase_orders: IconShoppingCart, return_orders: IconTruckReturn, + transfer_orders: IconTransfer, sales_orders: IconTruckDelivery, scheduling: IconCalendarStats, scrap: IconCircleX, diff --git a/src/frontend/src/tables/stock/TransferOrderTable.tsx b/src/frontend/src/tables/stock/TransferOrderTable.tsx index f695f7f50335..525c76dbdc06 100644 --- a/src/frontend/src/tables/stock/TransferOrderTable.tsx +++ b/src/frontend/src/tables/stock/TransferOrderTable.tsx @@ -59,7 +59,7 @@ export function TransferOrderTable({ const tableFilters: TableFilter[] = useMemo(() => { const filters: TableFilter[] = [ - OrderStatusFilter({ model: ModelType.returnorder }), + OrderStatusFilter({ model: ModelType.transferorder }), OutstandingFilter(), OverdueFilter(), AssignedToMeFilter(), @@ -109,7 +109,7 @@ export function TransferOrderTable({ switchable: true }), // LineItemsProgressColumn({}), - StatusColumn({ model: ModelType.returnorder }), + StatusColumn({ model: ModelType.transferorder }), ProjectCodeColumn({ defaultVisible: false }), @@ -144,8 +144,8 @@ export function TransferOrderTable({ const tableActions = useMemo(() => { return [ newTransferOrder.open()} hidden={!user.hasAddRole(UserRoles.transfer_order)} /> From 0d4ad9535240af84ef62f2b643be9b6190914cd5 Mon Sep 17 00:00:00 2001 From: Jacob Felknor Date: Thu, 8 Jan 2026 23:26:50 +0000 Subject: [PATCH 09/71] wip: adding detail view --- src/backend/InvenTree/order/api.py | 43 +++- .../src/pages/stock/TransferOrderDetail.tsx | 198 ++++++++++++++++++ src/frontend/src/router.tsx | 5 + 3 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 src/frontend/src/pages/stock/TransferOrderDetail.tsx diff --git a/src/backend/InvenTree/order/api.py b/src/backend/InvenTree/order/api.py index ada5014e8081..59432832ba1c 100644 --- a/src/backend/InvenTree/order/api.py +++ b/src/backend/InvenTree/order/api.py @@ -1834,6 +1834,14 @@ class TransferOrderList( ordering = '-reference' +class TransferOrderDetail( + TransferOrderMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI +): + """API endpoint for detail view of a single TransferOrder object.""" + + # output_options = TransferOrderOutputOptions + + class OrderCalendarExport(ICalFeed): """Calendar export for Purchase/Sales Orders. @@ -2288,8 +2296,41 @@ def item_link(self, item): path( 'to/', include([ + # Transfer Order detail endpoints + path( + '/', + include([ + # path( + # 'cancel/', + # TransferOrderCancel.as_view(), + # name='api-transfer-order-cancel', + # ), + # path('hold/', TransferOrderHold.as_view(), name='api-ro-hold'), + # path( + # 'complete/', + # TransferOrderComplete.as_view(), + # name='api-transfer-order-complete', + # ), + # path( + # 'issue/', + # TransferOrderIssue.as_view(), + # name='api-transfer-order-issue', + # ), + # path( + # 'receive/', + # TransferOrderReceive.as_view(), + # name='api-transfer-order-receive', + # ), + # meta_path(models.TransferOrder), + path( + '', + TransferOrderDetail.as_view(), + name='api-transfer-order-detail', + ) + ]), + ), # Transfer Order list - path('', TransferOrderList.as_view(), name='api-transfer-order-list') + path('', TransferOrderList.as_view(), name='api-transfer-order-list'), ]), ), # API endpoint for subscribing to ICS calendar of purchase/sales/return orders diff --git a/src/frontend/src/pages/stock/TransferOrderDetail.tsx b/src/frontend/src/pages/stock/TransferOrderDetail.tsx new file mode 100644 index 000000000000..779421e57d5b --- /dev/null +++ b/src/frontend/src/pages/stock/TransferOrderDetail.tsx @@ -0,0 +1,198 @@ +import { t } from '@lingui/core/macro'; +import { Grid, Skeleton, Stack } from '@mantine/core'; +import { type ReactNode, useMemo } from 'react'; +import { useParams } from 'react-router-dom'; + +import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; +import { ModelType } from '@lib/enums/ModelType'; +import { UserRoles } from '@lib/enums/Roles'; +import { + type DetailsField, + DetailsTable +} from '../../components/details/Details'; +import { ItemDetailsGrid } from '../../components/details/ItemDetails'; +import InstanceDetail from '../../components/nav/InstanceDetail'; +import { PageDetail } from '../../components/nav/PageDetail'; +import type { PanelType } from '../../components/panels/Panel'; +import { PanelGroup } from '../../components/panels/PanelGroup'; +import { StatusRenderer } from '../../components/render/StatusRenderer'; +import { useInstance } from '../../hooks/UseInstance'; +import useStatusCodes from '../../hooks/UseStatusCodes'; +import { useGlobalSettingsState } from '../../states/SettingsStates'; +import { useUserState } from '../../states/UserState'; + +export default function TransferOrderDetail() { + const { id } = useParams(); + + const user = useUserState(); + + const globalSettings = useGlobalSettingsState(); + + const { + instance: order, + instanceQuery, + refreshInstance + } = useInstance({ + endpoint: ApiEndpoints.transfer_order_list, + pk: id, + params: {} + }); + + const toStatus = useStatusCodes({ modelType: ModelType.transferorder }); + + const orderOpen = useMemo(() => { + return ( + order.status == toStatus.PENDING || + order.status == toStatus.ISSUED || + order.status == toStatus.ON_HOLD + ); + }, [order, toStatus]); + + // TODO: does this make any sense for Transfer Orders??? + // const lineItemsEditable: boolean = useMemo(() => { + // if (orderOpen) { + // return true; + // } else { + // return globalSettings.isSet('TRANSFERORDER_EDIT_COMPLETED_ORDERS'); + // } + // }, [orderOpen, globalSettings]); + + const detailsPanel = useMemo(() => { + if (instanceQuery.isFetching) { + return ; + } + + const tl: DetailsField[] = [ + { + type: 'text', + name: 'reference', + label: t`Reference`, + copy: true + }, + { + type: 'link', + name: 'take_from', + icon: 'location', + label: t`Source Location`, + model: ModelType.stocklocation + }, + { + type: 'link', + name: 'destination', + icon: 'location', + label: t`Destination Location`, + model: ModelType.stocklocation, + hidden: !order.destination + }, + { + type: 'text', + name: 'description', + label: t`Description`, + copy: true + }, + { + type: 'status', + name: 'status', + label: t`Status`, + model: ModelType.transferorder + }, + { + type: 'status', + name: 'status_custom_key', + label: t`Custom Status`, + model: ModelType.transferorder, + icon: 'status', + hidden: + !order.status_custom_key || order.status_custom_key == order.status + } + ]; + + return ( + + + {/* TODO: what image do we show for a Transfer Order? */} + {/* */} + + + + + {/* + + */} + + ); + }, [order, instanceQuery]); + + const orderPanels: PanelType[] = useMemo(() => { + return []; + }, [order, id, user]); + + const orderBadges: ReactNode[] = useMemo(() => { + return instanceQuery.isLoading + ? [] + : [ + + ]; + }, [order, instanceQuery]); + + const orderActions = useMemo(() => { + return []; + }, [user, order, orderOpen, toStatus]); + + const subtitle: string = useMemo(() => { + const t = order.take_from?.name || ''; + const d = order.destination?.name || ''; + return `${t} → ${d}`; + }, [order]); + + return ( + <> + {/* {editReturnOrder.modal} */} + {/* {issueOrder.modal} */} + {/* {cancelOrder.modal} */} + {/* {holdOrder.modal} */} + {/* {completeOrder.modal} */} + {/* {duplicateReturnOrder.modal} */} + + + + + + + + ); +} diff --git a/src/frontend/src/router.tsx b/src/frontend/src/router.tsx index 49b580fcfaf8..ddeb3a213657 100644 --- a/src/frontend/src/router.tsx +++ b/src/frontend/src/router.tsx @@ -88,6 +88,10 @@ export const ReturnOrderDetail = Loadable( lazy(() => import('./pages/sales/ReturnOrderDetail')) ); +export const TransferOrderDetail = Loadable( + lazy(() => import('./pages/stock/TransferOrderDetail')) +); + export const Scan = Loadable(lazy(() => import('./pages/Index/Scan'))); export const ErrorPage = Loadable(lazy(() => import('./pages/ErrorPage'))); @@ -169,6 +173,7 @@ export const routes = ( } /> } /> } /> + } /> } /> From 749965c4b22e5d8e8478ab3c620941e87afd3b1b Mon Sep 17 00:00:00 2001 From: Jacob Felknor Date: Fri, 9 Jan 2026 18:04:07 +0000 Subject: [PATCH 10/71] add take from and destination serializer details --- src/backend/InvenTree/order/serializers.py | 10 +++++++ .../src/pages/stock/TransferOrderDetail.tsx | 29 +++++++++++++++++-- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py index afdf1962a36e..b72b252987d3 100644 --- a/src/backend/InvenTree/order/serializers.py +++ b/src/backend/InvenTree/order/serializers.py @@ -2179,7 +2179,9 @@ class Meta: model = order.models.TransferOrder fields = AbstractOrderSerializer.order_fields([ 'take_from', + 'take_from_detail', 'destination', + 'destination_detail', 'consume', 'complete_date', ]) @@ -2192,6 +2194,14 @@ def skip_create_fields(self): return [*fields, 'duplicate'] + take_from_detail = enable_filter( + stock.serializers.LocationSerializer(source='take_from'), default_include=True + ) + + destination_detail = enable_filter( + stock.serializers.LocationSerializer(source='destination'), default_include=True + ) + # TODO: # @staticmethod # def annotate_queryset(queryset): diff --git a/src/frontend/src/pages/stock/TransferOrderDetail.tsx b/src/frontend/src/pages/stock/TransferOrderDetail.tsx index 779421e57d5b..94d0725bae3f 100644 --- a/src/frontend/src/pages/stock/TransferOrderDetail.tsx +++ b/src/frontend/src/pages/stock/TransferOrderDetail.tsx @@ -6,6 +6,7 @@ import { useParams } from 'react-router-dom'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ModelType } from '@lib/enums/ModelType'; import { UserRoles } from '@lib/enums/Roles'; +import { IconInfoCircle } from '@tabler/icons-react'; import { type DetailsField, DetailsTable @@ -13,8 +14,11 @@ import { import { ItemDetailsGrid } from '../../components/details/ItemDetails'; 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 type { PanelType } from '../../components/panels/Panel'; import { PanelGroup } from '../../components/panels/PanelGroup'; +import ParametersPanel from '../../components/panels/ParametersPanel'; import { StatusRenderer } from '../../components/render/StatusRenderer'; import { useInstance } from '../../hooks/UseInstance'; import useStatusCodes from '../../hooks/UseStatusCodes'; @@ -129,7 +133,26 @@ export default function TransferOrderDetail() { }, [order, instanceQuery]); const orderPanels: PanelType[] = useMemo(() => { - return []; + return [ + { + name: 'detail', + label: t`Order Details`, + icon: , + content: detailsPanel + }, + ParametersPanel({ + model_type: ModelType.transferorder, + model_id: order.pk + }), + AttachmentPanel({ + model_type: ModelType.transferorder, + model_id: order.pk + }), + NotesPanel({ + model_type: ModelType.transferorder, + model_id: order.pk + }) + ]; }, [order, id, user]); const orderBadges: ReactNode[] = useMemo(() => { @@ -149,8 +172,8 @@ export default function TransferOrderDetail() { }, [user, order, orderOpen, toStatus]); const subtitle: string = useMemo(() => { - const t = order.take_from?.name || ''; - const d = order.destination?.name || ''; + const t = order.take_from_detail?.pathstring || ''; + const d = order.destination_detail?.pathstring || ''; return `${t} → ${d}`; }, [order]); From 5c9e1c53eabdc4b291b1983a16a265303d56766d Mon Sep 17 00:00:00 2001 From: Jacob Felknor Date: Fri, 9 Jan 2026 18:15:35 +0000 Subject: [PATCH 11/71] add other detail grid items --- .../src/pages/stock/TransferOrderDetail.tsx | 99 +++++++++++++++++-- 1 file changed, 92 insertions(+), 7 deletions(-) diff --git a/src/frontend/src/pages/stock/TransferOrderDetail.tsx b/src/frontend/src/pages/stock/TransferOrderDetail.tsx index 94d0725bae3f..f69b4ea64bcc 100644 --- a/src/frontend/src/pages/stock/TransferOrderDetail.tsx +++ b/src/frontend/src/pages/stock/TransferOrderDetail.tsx @@ -111,23 +111,108 @@ export default function TransferOrderDetail() { } ]; + const tr: DetailsField[] = [ + { + type: 'text', + name: 'line_items', + label: t`Line Items`, + icon: 'list' + }, + { + type: 'progressbar', + name: 'completed', + icon: 'progress', + label: t`Completed Line Items`, + total: order.line_items, + progress: order.completed_lines + } + ]; + + const bl: DetailsField[] = [ + { + type: 'link', + external: true, + name: 'link', + label: t`Link`, + copy: true, + hidden: !order.link + }, + { + type: 'text', + name: 'project_code_label', + label: t`Project Code`, + icon: 'reference', + copy: true, + hidden: !order.project_code + }, + { + type: 'text', + name: 'responsible', + label: t`Responsible`, + badge: 'owner', + hidden: !order.responsible + } + ]; + + const br: DetailsField[] = [ + { + type: 'date', + name: 'creation_date', + label: t`Creation Date`, + icon: 'calendar', + copy: true, + hidden: !order.creation_date + }, + { + type: 'date', + name: 'issue_date', + label: t`Issue Date`, + icon: 'calendar', + copy: true, + hidden: !order.issue_date + }, + { + type: 'date', + name: 'start_date', + label: t`Start Date`, + icon: 'calendar', + copy: true, + hidden: !order.start_date + }, + { + type: 'date', + name: 'target_date', + label: t`Target Date`, + copy: true, + hidden: !order.target_date + }, + { + type: 'date', + name: 'complete_date', + icon: 'calendar_check', + label: t`Completion Date`, + copy: true, + hidden: !order.complete_date + } + ]; + return ( {/* TODO: what image do we show for a Transfer Order? */} {/* */} - {/* - - */} + + + ); }, [order, instanceQuery]); From 3bcaf777f0a08dca3dbc8a72f3016ea9a8e3d34d Mon Sep 17 00:00:00 2001 From: Jacob Felknor Date: Fri, 9 Jan 2026 18:48:11 +0000 Subject: [PATCH 12/71] edit/duplicate transfer order --- src/backend/InvenTree/order/api.py | 63 +++++++- src/backend/InvenTree/order/serializers.py | 10 +- src/frontend/lib/enums/ApiEndpoints.tsx | 1 + src/frontend/src/functions/icons.tsx | 2 + .../src/pages/stock/TransferOrderDetail.tsx | 134 +++++++++++++++++- 5 files changed, 197 insertions(+), 13 deletions(-) diff --git a/src/backend/InvenTree/order/api.py b/src/backend/InvenTree/order/api.py index 59432832ba1c..52858b16787a 100644 --- a/src/backend/InvenTree/order/api.py +++ b/src/backend/InvenTree/order/api.py @@ -1842,6 +1842,57 @@ class TransferOrderDetail( # output_options = TransferOrderOutputOptions +class TransferOrderContextMixin: + """Simple mixin class to add a TransferOrder to the serializer context.""" + + queryset = models.TransferOrder.objects.all() + + def get_serializer_context(self): + """Add the TransferOrder object to the serializer context.""" + context = super().get_serializer_context() + + # Pass the Transfer instance through to the serializer for validation + try: + context['order'] = models.TransferOrder.objects.get( + pk=self.kwargs.get('pk', None) + ) + except Exception: + pass + + context['request'] = self.request + + return context + + +# class ReturnOrderCancel(ReturnOrderContextMixin, CreateAPI): +# """API endpoint to cancel a ReturnOrder.""" + +# serializer_class = serializers.ReturnOrderCancelSerializer + +# class ReturnOrderHold(ReturnOrderContextMixin, CreateAPI): +# """API endpoint to hold a ReturnOrder.""" + +# serializer_class = serializers.ReturnOrderHoldSerializer + +# class ReturnOrderComplete(ReturnOrderContextMixin, CreateAPI): +# """API endpoint to complete a ReturnOrder.""" + +# serializer_class = serializers.ReturnOrderCompleteSerializer + + +class TransferOrderIssue(TransferOrderContextMixin, CreateAPI): + """API endpoint to issue a Transfer Order.""" + + serializer_class = serializers.ReturnOrderIssueSerializer + + +# class ReturnOrderReceive(ReturnOrderContextMixin, CreateAPI): +# """API endpoint to receive items against a ReturnOrder.""" + +# queryset = models.ReturnOrder.objects.none() +# serializer_class = serializers.ReturnOrderReceiveSerializer + + class OrderCalendarExport(ICalFeed): """Calendar export for Purchase/Sales Orders. @@ -2311,11 +2362,11 @@ def item_link(self, item): # TransferOrderComplete.as_view(), # name='api-transfer-order-complete', # ), - # path( - # 'issue/', - # TransferOrderIssue.as_view(), - # name='api-transfer-order-issue', - # ), + path( + 'issue/', + TransferOrderIssue.as_view(), + name='api-transfer-order-issue', + ), # path( # 'receive/', # TransferOrderReceive.as_view(), @@ -2326,7 +2377,7 @@ def item_link(self, item): '', TransferOrderDetail.as_view(), name='api-transfer-order-detail', - ) + ), ]), ), # Transfer Order list diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py index b72b252987d3..c7ed89a155c4 100644 --- a/src/backend/InvenTree/order/serializers.py +++ b/src/backend/InvenTree/order/serializers.py @@ -2195,11 +2195,17 @@ def skip_create_fields(self): return [*fields, 'duplicate'] take_from_detail = enable_filter( - stock.serializers.LocationSerializer(source='take_from'), default_include=True + stock.serializers.LocationSerializer( + source='take_from', many=False, read_only=True, allow_null=True + ), + default_include=True, ) destination_detail = enable_filter( - stock.serializers.LocationSerializer(source='destination'), default_include=True + stock.serializers.LocationSerializer( + source='destination', many=False, read_only=True, allow_null=True + ), + default_include=True, ) # TODO: diff --git a/src/frontend/lib/enums/ApiEndpoints.tsx b/src/frontend/lib/enums/ApiEndpoints.tsx index 7e06001e8277..88858d873906 100644 --- a/src/frontend/lib/enums/ApiEndpoints.tsx +++ b/src/frontend/lib/enums/ApiEndpoints.tsx @@ -199,6 +199,7 @@ export enum ApiEndpoints { transfer_order_list = 'order/to/', transfser_order_line_list = 'order/to-line/', + transfer_order_issue = 'order/to/:id/issue/', // Template API endpoints label_list = 'label/template/', diff --git a/src/frontend/src/functions/icons.tsx b/src/frontend/src/functions/icons.tsx index 41300500c0ba..20228972c5d6 100644 --- a/src/frontend/src/functions/icons.tsx +++ b/src/frontend/src/functions/icons.tsx @@ -21,6 +21,7 @@ import { IconCancel, IconCheck, IconCircleCheck, + IconCircleDashedCheck, IconCircleMinus, IconCirclePlus, IconCircleX, @@ -147,6 +148,7 @@ const icons: InvenTreeIconType = { build_order: IconTools, builds: IconTools, used_in: IconStack2, + consume: IconCircleDashedCheck, manufacturers: IconBuildingFactory2, suppliers: IconBuilding, customers: IconBuildingStore, diff --git a/src/frontend/src/pages/stock/TransferOrderDetail.tsx b/src/frontend/src/pages/stock/TransferOrderDetail.tsx index f69b4ea64bcc..767f3c0f1147 100644 --- a/src/frontend/src/pages/stock/TransferOrderDetail.tsx +++ b/src/frontend/src/pages/stock/TransferOrderDetail.tsx @@ -6,12 +6,20 @@ import { useParams } from 'react-router-dom'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ModelType } from '@lib/enums/ModelType'; import { UserRoles } from '@lib/enums/Roles'; +import { apiUrl } from '@lib/index'; import { IconInfoCircle } from '@tabler/icons-react'; +import AdminButton from '../../components/buttons/AdminButton'; +import PrimaryActionButton from '../../components/buttons/PrimaryActionButton'; import { type DetailsField, DetailsTable } from '../../components/details/Details'; import { ItemDetailsGrid } from '../../components/details/ItemDetails'; +import { + DuplicateItemAction, + EditItemAction, + OptionsActionDropdown +} from '../../components/items/ActionDropdown'; import InstanceDetail from '../../components/nav/InstanceDetail'; import { PageDetail } from '../../components/nav/PageDetail'; import AttachmentPanel from '../../components/panels/AttachmentPanel'; @@ -20,6 +28,11 @@ import type { PanelType } from '../../components/panels/Panel'; import { PanelGroup } from '../../components/panels/PanelGroup'; import ParametersPanel from '../../components/panels/ParametersPanel'; import { StatusRenderer } from '../../components/render/StatusRenderer'; +import { useTransferOrderFields } from '../../forms/TransferOrderForms'; +import { + useCreateApiFormModal, + useEditApiFormModal +} from '../../hooks/UseForm'; import { useInstance } from '../../hooks/UseInstance'; import useStatusCodes from '../../hooks/UseStatusCodes'; import { useGlobalSettingsState } from '../../states/SettingsStates'; @@ -112,6 +125,12 @@ export default function TransferOrderDetail() { ]; const tr: DetailsField[] = [ + { + type: 'boolean', + name: 'consume', + icon: 'consume', + label: t`Consume Stock` + }, { type: 'text', name: 'line_items', @@ -252,8 +271,113 @@ export default function TransferOrderDetail() { ]; }, [order, instanceQuery]); + const transferOrderFields = useTransferOrderFields({}); + + const duplicateTransferOrderFields = useTransferOrderFields({ + duplicateOrderId: order.pk + }); + + const editTransferOrder = useEditApiFormModal({ + url: ApiEndpoints.transfer_order_list, + pk: order.pk, + title: t`Edit Transfer Order`, + fields: transferOrderFields, + onFormSuccess: () => { + refreshInstance(); + } + }); + + const duplicateTransferOrderInitialData = useMemo(() => { + const data = { ...order }; + // if we set the reference to null/undefined, it will be left blank in the form + // if we omit the reference altogether, it will be auto-generated via reference pattern + // from the OPTIONS response + delete data.reference; + return data; + }, [order]); + + const duplicateTransferOrder = useCreateApiFormModal({ + url: ApiEndpoints.transfer_order_list, + title: t`Add Transfer Order`, + fields: duplicateTransferOrderFields, + initialData: duplicateTransferOrderInitialData, + modelType: ModelType.transferorder, + follow: true + }); + + const issueOrder = useCreateApiFormModal({ + url: apiUrl(ApiEndpoints.transfer_order_issue, order.pk), + title: t`Issue Transfer Order`, + onFormSuccess: refreshInstance, + preFormWarning: t`Issue this order`, + successMessage: t`Order issued` + }); + const orderActions = useMemo(() => { - return []; + const canEdit: boolean = user.hasChangeRole(UserRoles.transfer_order); + + const canIssue: boolean = + canEdit && + (order.status == toStatus.PENDING || order.status == toStatus.ON_HOLD); + + const canHold: boolean = + canEdit && + (order.status == toStatus.PENDING || order.status == toStatus.ISSUED); + + const canCancel: boolean = + canEdit && + (order.status == toStatus.PENDING || order.status == toStatus.ON_HOLD); + + const canComplete: boolean = canEdit && order.status == toStatus.ISSUED; + + return [ +