diff --git a/docs/docs/assets/images/build/build_calendar.png b/docs/docs/assets/images/build/build_calendar.png
new file mode 100644
index 0000000000..07047a19e5
Binary files /dev/null and b/docs/docs/assets/images/build/build_calendar.png differ
diff --git a/docs/docs/assets/images/build/build_display.png b/docs/docs/assets/images/build/build_display.png
index c8d01916b3..bcfa770905 100644
Binary files a/docs/docs/assets/images/build/build_display.png and b/docs/docs/assets/images/build/build_display.png differ
diff --git a/docs/docs/assets/images/build/build_list.png b/docs/docs/assets/images/build/build_list.png
index d9003a6729..236d7a4a1e 100644
Binary files a/docs/docs/assets/images/build/build_list.png and b/docs/docs/assets/images/build/build_list.png differ
diff --git a/docs/docs/assets/images/order/po_calendar.png b/docs/docs/assets/images/order/po_calendar.png
new file mode 100644
index 0000000000..181009f290
Binary files /dev/null and b/docs/docs/assets/images/order/po_calendar.png differ
diff --git a/docs/docs/assets/images/order/po_display.png b/docs/docs/assets/images/order/po_display.png
new file mode 100644
index 0000000000..9f928d4771
Binary files /dev/null and b/docs/docs/assets/images/order/po_display.png differ
diff --git a/docs/docs/assets/images/order/po_list.png b/docs/docs/assets/images/order/po_list.png
index 6b1b5349e3..1cd809ea23 100644
Binary files a/docs/docs/assets/images/order/po_list.png and b/docs/docs/assets/images/order/po_list.png differ
diff --git a/docs/docs/assets/images/order/ro_calendar.png b/docs/docs/assets/images/order/ro_calendar.png
new file mode 100644
index 0000000000..cdb87a0457
Binary files /dev/null and b/docs/docs/assets/images/order/ro_calendar.png differ
diff --git a/docs/docs/assets/images/order/ro_display.png b/docs/docs/assets/images/order/ro_display.png
new file mode 100644
index 0000000000..4580c54deb
Binary files /dev/null and b/docs/docs/assets/images/order/ro_display.png differ
diff --git a/docs/docs/assets/images/order/ro_list.png b/docs/docs/assets/images/order/ro_list.png
new file mode 100644
index 0000000000..2adf57e5c8
Binary files /dev/null and b/docs/docs/assets/images/order/ro_list.png differ
diff --git a/docs/docs/assets/images/order/so_calendar.png b/docs/docs/assets/images/order/so_calendar.png
new file mode 100644
index 0000000000..428f420fd1
Binary files /dev/null and b/docs/docs/assets/images/order/so_calendar.png differ
diff --git a/docs/docs/assets/images/order/so_display.png b/docs/docs/assets/images/order/so_display.png
new file mode 100644
index 0000000000..2b6204e578
Binary files /dev/null and b/docs/docs/assets/images/order/so_display.png differ
diff --git a/docs/docs/assets/images/order/so_list.png b/docs/docs/assets/images/order/so_list.png
index 9104a21483..5ef076b6f9 100644
Binary files a/docs/docs/assets/images/order/so_list.png and b/docs/docs/assets/images/order/so_list.png differ
diff --git a/docs/docs/build/build.md b/docs/docs/build/build.md
index d800f153a6..cfa1931bdf 100644
--- a/docs/docs/build/build.md
+++ b/docs/docs/build/build.md
@@ -12,12 +12,16 @@ A *Build Order* uses the BOM to allocate stock items to the assembly process. As
### View Build Orders
-To navigate to the Build Order display, select *Build* from the main navigation menu:
+To navigate to the Build Order display, select *Manufacturing* from the main navigation menu, and *Build Orders* from the sidebar.
+
+The *Build Order Index Page* allows the user to view all build orders:
{% with id="build_display", url="build/build_display.png", description="Display Builds" %}
{% include "img.html" %}
{% endwith %}
+The following view modes are available:
+
#### Table View
*Table View* provides a table of Build Orders, which can be filtered to only show the orders you are interested in.
@@ -28,7 +32,13 @@ To navigate to the Build Order display, select *Build* from the main navigation
#### Calendar View
-*Calendar View* shows a calendar display with upcoming build orders, based on the various dates specified for each build.
+*Calendar View* shows a calendar display with outstanding build orders, based on the various dates specified for each order.
+
+{% with id="build_calendar", url="build/build_calendar.png", description="Build Calendar" %}
+{% include "img.html" %}
+{% endwith %}
+
+The build calendar allows the user to navigate month-by-month and display the filtered build orders
## Build Order Details
diff --git a/docs/docs/order/purchase_order.md b/docs/docs/order/purchase_order.md
index f2d61eddb2..e78bc2c54e 100644
--- a/docs/docs/order/purchase_order.md
+++ b/docs/docs/order/purchase_order.md
@@ -6,12 +6,32 @@ title: Purchase Order
Purchase orders allow to track which parts are bought from suppliers and manufacturers, therefore converting externally bought items into stock items / inventory.
-To access the purchase order page, click on the Buy navigation tab and click on Purchase Orders option in the dropdown list.
+### View Purchase Orders
+
+To navigate to the Purchase Order display, select *Purchasing* from the main navigation menu, and *Build Orders* from the sidebar:
+
+{% with id="purchase_order_display", url="order/po_display.png", description="Purchase Order Display" %}
+{% include "img.html" %}
+{% endwith %}
+
+The following view modes are available:
+
+#### Table View
+
+*Table View* provides a list of Purchase Orders, which can be filtered to display a subset of orders according to user supplied parameters.
{% with id="purchase_order_list", url="order/po_list.png", description="Purchase Order List" %}
{% include "img.html" %}
{% endwith %}
+#### Calendar View
+
+*Calendar View* shows a calendar display with outstanding purchase orders, based on the various dates specified for each order.
+
+{% with id="purchase_order_calendar", url="order/po_calendar.png", description="Purchase Order Calendar" %}
+{% include "img.html" %}
+{% endwith %}
+
### Purchase Order Status Codes
Each Purchase Order has a specific status code which indicates the current state of the order:
diff --git a/docs/docs/order/return_order.md b/docs/docs/order/return_order.md
index a188dd076f..d2e3a3bfe3 100644
--- a/docs/docs/order/return_order.md
+++ b/docs/docs/order/return_order.md
@@ -9,6 +9,32 @@ Return Orders allow stock items (which have been sold or allocated to a customer
!!! tip "An Order By Any Other Name"
A Return Order may also be known as an [RMA](https://en.wikipedia.org/wiki/Return_merchandise_authorization)
+### View Return Orders
+
+To navigate to the Return Order display, select *Sales* from the main navigation menu, and *Return Orders* from the sidebar:
+
+{% with id="return_order_display", url="order/ro_display.png", description="Return Order Display" %}
+{% include "img.html" %}
+{% endwith %}
+
+The following view modes are available:
+
+#### Table View
+
+*Table View* provides a list of Return Orders, which can be filtered to display a subset of orders according to user supplied parameters.
+
+{% with id="purchase_order_list", url="order/po_list.png", description="Return Order List" %}
+{% include "img.html" %}
+{% endwith %}
+
+#### Calendar View
+
+*Calendar View* shows a calendar display with outstanding return orders, based on the various dates specified for each order.
+
+{% with id="return_order_calendar", url="order/ro_calendar.png", description="Return Order Calendar" %}
+{% include "img.html" %}
+{% endwith %}
+
### Enable Return Order Functionality
By default, Return Order functionality is not enabled - it must be enabled by a *staff* user from the settings page:
diff --git a/docs/docs/order/sales_order.md b/docs/docs/order/sales_order.md
index fca9a46f7b..1923ea5d9f 100644
--- a/docs/docs/order/sales_order.md
+++ b/docs/docs/order/sales_order.md
@@ -6,12 +6,32 @@ title: Sales Orders
Sales orders allow tracking of which stock items are sold to customers, therefore converting stock items / inventory into externally sold items.
-To access the sales order page, click on the Sell navigation tab and click on Sales Orders option in the dropdown list.
+### View Sales Orders
+
+To navigate to the Sales Order display, select *Sales* from the main navigation menu, and *Sales Orders* from the sidebar:
+
+{% with id="sales_order_display", url="order/so_display.png", description="Sales Order Display" %}
+{% include "img.html" %}
+{% endwith %}
+
+The following view modes are available:
+
+#### Table View
+
+*Table View* provides a list of Sales Orders, which can be filtered to display a subset of orders according to user supplied parameters.
{% with id="sales_order_list", url="order/so_list.png", description="Sales Order List" %}
{% include "img.html" %}
{% endwith %}
+#### Calendar View
+
+*Calendar View* shows a calendar display with outstanding sales orders.
+
+{% with id="sales_order_calendar", url="order/so_calendar.png", description="Sales Order Calendar" %}
+{% include "img.html" %}
+{% endwith %}
+
### Sales Order Status Codes
Each Sales Order has a specific status code, which represents the state of the order:
diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py
index d918dc2b06..cf06a17cfb 100644
--- a/src/backend/InvenTree/InvenTree/api_version.py
+++ b/src/backend/InvenTree/InvenTree/api_version.py
@@ -1,13 +1,16 @@
"""InvenTree API version information."""
# InvenTree API version
-INVENTREE_API_VERSION = 321
+INVENTREE_API_VERSION = 322
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
+v322 - 2025-03-16 : https://github.com/inventree/InvenTree/pull/8933
+ - Add min_date and max_date query filters for orders, for use in calendar views
+
v321 - 2025-03-06 : https://github.com/inventree/InvenTree/pull/9236
- Adds conditionally-returned fields to the schema to match API behavior
- Removes required flag for nullable read-only fields to match API behavior
diff --git a/src/backend/InvenTree/build/api.py b/src/backend/InvenTree/build/api.py
index 4f8274b5ea..c902abe12f 100644
--- a/src/backend/InvenTree/build/api.py
+++ b/src/backend/InvenTree/build/api.py
@@ -242,6 +242,65 @@ class BuildFilter(rest_filters.FilterSet):
label=_('Completed after'), field_name='completion_date', lookup_expr='gt'
)
+ min_date = InvenTreeDateFilter(label=_('Min Date'), method='filter_min_date')
+
+ def filter_min_date(self, queryset, name, value):
+ """Filter the queryset to include orders *after* a specified date.
+
+ This filter is used in combination with filter_max_date,
+ to provide a queryset which matches a particular range of dates.
+
+ In particular, this is used in the UI for the calendar view.
+
+ So, we are interested in orders which are active *after* this date:
+
+ - creation_date is set *after* this date (but there is no start date)
+ - start_date is set *after* this date
+ - target_date is set *after* this date
+
+ """
+ q1 = Q(creation_date__gte=value, start_date__isnull=True)
+ q2 = Q(start_date__gte=value)
+ q3 = Q(target_date__gte=value)
+
+ return queryset.filter(q1 | q2 | q3).distinct()
+
+ max_date = InvenTreeDateFilter(label=_('Max Date'), method='filter_max_date')
+
+ def filter_max_date(self, queryset, name, value):
+ """Filter the queryset to include orders *before* a specified date.
+
+ This filter is used in combination with filter_min_date,
+ to provide a queryset which matches a particular range of dates.
+
+ In particular, this is used in the UI for the calendar view.
+
+ So, we are interested in orders which are active *before* this date:
+
+ - creation_date is set *before* this date (but there is no start date)
+ - start_date is set *before* this date
+ - target_date is set *before* this date
+ """
+ q1 = Q(creation_date__lte=value, start_date__isnull=True)
+ q2 = Q(start_date__lte=value)
+ q3 = Q(target_date__lte=value)
+
+ return queryset.filter(q1 | q2 | q3).distinct()
+
+ exclude_tree = rest_filters.ModelChoiceFilter(
+ queryset=Build.objects.all(),
+ method='filter_exclude_tree',
+ label=_('Exclude Tree'),
+ )
+
+ def filter_exclude_tree(self, queryset, name, value):
+ """Filter by excluding a tree of Build objects."""
+ queryset = queryset.exclude(
+ pk__in=[bld.pk for bld in value.get_descendants(include_self=True)]
+ )
+
+ return queryset
+
class BuildMixin:
"""Mixin class for Build API endpoints."""
@@ -319,35 +378,6 @@ class BuildList(DataExportViewMixin, BuildMixin, ListCreateAPI):
return queryset
- def filter_queryset(self, queryset):
- """Custom query filtering for the BuildList endpoint."""
- queryset = super().filter_queryset(queryset)
-
- params = self.request.query_params
-
- # exclude parent tree
- exclude_tree = params.get('exclude_tree', None)
-
- if exclude_tree is not None:
- try:
- build = Build.objects.get(pk=exclude_tree)
-
- queryset = queryset.exclude(
- pk__in=[bld.pk for bld in build.get_descendants(include_self=True)]
- )
-
- except (ValueError, Build.DoesNotExist):
- pass
-
- # Filter by 'date range'
- min_date = params.get('min_date', None)
- max_date = params.get('max_date', None)
-
- if min_date is not None and max_date is not None:
- queryset = Build.filterByDate(queryset, min_date, max_date)
-
- return queryset
-
def get_serializer(self, *args, **kwargs):
"""Add extra context information to the endpoint serializer."""
try:
diff --git a/src/backend/InvenTree/build/models.py b/src/backend/InvenTree/build/models.py
index 0002620112..ab2d0663dc 100644
--- a/src/backend/InvenTree/build/models.py
+++ b/src/backend/InvenTree/build/models.py
@@ -1,7 +1,6 @@
"""Build database model definitions."""
import decimal
-from datetime import datetime
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
@@ -197,44 +196,6 @@ class Build(
'title': str(self),
}
- @staticmethod
- def filterByDate(queryset, min_date, max_date):
- """Filter by 'minimum and maximum date range'.
-
- - Specified as min_date, max_date
- - Both must be specified for filter to be applied
- """
- date_fmt = '%Y-%m-%d' # ISO format date string
-
- # Ensure that both dates are valid
- try:
- min_date = datetime.strptime(str(min_date), date_fmt).date()
- max_date = datetime.strptime(str(max_date), date_fmt).date()
- except (ValueError, TypeError):
- # Date processing error, return queryset unchanged
- return queryset
-
- # Order was completed within the specified range
- completed = (
- Q(status=BuildStatus.COMPLETE.value)
- & Q(completion_date__gte=min_date)
- & Q(completion_date__lte=max_date)
- )
-
- # Order target date falls within specified range
- pending = (
- Q(status__in=BuildStatusGroups.ACTIVE_CODES)
- & ~Q(target_date=None)
- & Q(target_date__gte=min_date)
- & Q(target_date__lte=max_date)
- )
-
- # TODO - Construct a queryset for "overdue" orders
-
- queryset = queryset.filter(completed | pending)
-
- return queryset
-
def __str__(self):
"""String representation of a BuildOrder."""
return self.reference
diff --git a/src/backend/InvenTree/order/api.py b/src/backend/InvenTree/order/api.py
index 031a5bf575..71fc522886 100644
--- a/src/backend/InvenTree/order/api.py
+++ b/src/backend/InvenTree/order/api.py
@@ -213,6 +213,44 @@ class OrderFilter(rest_filters.FilterSet):
label=_('Target Date After'), field_name='target_date', lookup_expr='gt'
)
+ min_date = InvenTreeDateFilter(label=_('Min Date'), method='filter_min_date')
+
+ def filter_min_date(self, queryset, name, value):
+ """Filter the queryset to include orders *after* a specified date.
+
+ This is used in combination with filter_max_date,
+ to provide a queryset which matches a particular range of dates.
+
+ In particular, this is used in the UI for the calendar view.
+ """
+ q1 = Q(
+ creation_date__gte=value, issue_date__isnull=True, start_date__isnull=True
+ )
+ q2 = Q(issue_date__gte=value, start_date__isnull=True)
+ q3 = Q(start_date__gte=value)
+ q4 = Q(target_date__gte=value)
+
+ return queryset.filter(q1 | q2 | q3 | q4).distinct()
+
+ max_date = InvenTreeDateFilter(label=_('Max Date'), method='filter_max_date')
+
+ def filter_max_date(self, queryset, name, value):
+ """Filter the queryset to include orders *before* a specified date.
+
+ This is used in combination with filter_min_date,
+ to provide a queryset which matches a particular range of dates.
+
+ In particular, this is used in the UI for the calendar view.
+ """
+ q1 = Q(
+ creation_date__lte=value, issue_date__isnull=True, start_date__isnull=True
+ )
+ q2 = Q(issue_date__lte=value, start_date__isnull=True)
+ q3 = Q(start_date__lte=value)
+ q4 = Q(target_date__lte=value)
+
+ return queryset.filter(q1 | q2 | q3 | q4).distinct()
+
class LineItemFilter(rest_filters.FilterSet):
"""Base class for custom API filters for order line item list(s)."""
@@ -323,23 +361,6 @@ class PurchaseOrderList(
"""
filterset_class = PurchaseOrderFilter
-
- def filter_queryset(self, queryset):
- """Custom queryset filtering."""
- # Perform basic filtering
- queryset = super().filter_queryset(queryset)
-
- params = self.request.query_params
-
- # Filter by 'date range'
- min_date = params.get('min_date', None)
- max_date = params.get('max_date', None)
-
- if min_date is not None and max_date is not None:
- queryset = models.PurchaseOrder.filterByDate(queryset, min_date, max_date)
-
- return queryset
-
filter_backends = SEARCH_ORDER_FILTER_ALIAS
ordering_field_aliases = {
@@ -791,21 +812,6 @@ class SalesOrderList(
filterset_class = SalesOrderFilter
- def filter_queryset(self, queryset):
- """Perform custom filtering operations on the SalesOrder queryset."""
- queryset = super().filter_queryset(queryset)
-
- params = self.request.query_params
-
- # Filter by 'date range'
- min_date = params.get('min_date', None)
- max_date = params.get('max_date', None)
-
- if min_date is not None and max_date is not None:
- queryset = models.SalesOrder.filterByDate(queryset, min_date, max_date)
-
- return queryset
-
filter_backends = SEARCH_ORDER_FILTER_ALIAS
ordering_field_aliases = {
diff --git a/src/backend/InvenTree/order/migrations/0109_salesorder_issue_date.py b/src/backend/InvenTree/order/migrations/0109_salesorder_issue_date.py
new file mode 100644
index 0000000000..4d8efd0626
--- /dev/null
+++ b/src/backend/InvenTree/order/migrations/0109_salesorder_issue_date.py
@@ -0,0 +1,23 @@
+# Generated by Django 4.2.19 on 2025-02-23 04:02
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("order", "0108_alter_purchaseorder_link_and_more_squashed_0109_alter_purchaseorderextraline_link_and_more"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="salesorder",
+ name="issue_date",
+ field=models.DateField(
+ blank=True,
+ help_text="Date order was issued",
+ null=True,
+ verbose_name="Issue Date",
+ ),
+ ),
+ ]
diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py
index b04776a36f..d7c299bf8c 100644
--- a/src/backend/InvenTree/order/models.py
+++ b/src/backend/InvenTree/order/models.py
@@ -1,6 +1,5 @@
"""Order model definitions."""
-from datetime import datetime
from decimal import Decimal
from django.contrib.auth.models import User
@@ -389,6 +388,13 @@ class Order(
verbose_name=_('Created By'),
)
+ issue_date = models.DateField(
+ blank=True,
+ null=True,
+ verbose_name=_('Issue Date'),
+ help_text=_('Date order was issued'),
+ )
+
responsible = models.ForeignKey(
UserModels.Owner,
on_delete=models.SET_NULL,
@@ -482,50 +488,6 @@ class PurchaseOrder(TotalPriceMixin, Order):
"""Return the associated barcode model type code for this model."""
return 'PO'
- @staticmethod
- def filterByDate(queryset, min_date, max_date):
- """Filter by 'minimum and maximum date range'.
-
- - Specified as min_date, max_date
- - Both must be specified for filter to be applied
- - Determine which "interesting" orders exist between these dates
-
- To be "interesting":
- - A "received" order where the received date lies within the date range
- - A "pending" order where the target date lies within the date range
- - TODO: An "overdue" order where the target date is in the past
- """
- date_fmt = '%Y-%m-%d' # ISO format date string
-
- # Ensure that both dates are valid
- try:
- min_date = datetime.strptime(str(min_date), date_fmt).date()
- max_date = datetime.strptime(str(max_date), date_fmt).date()
- except (ValueError, TypeError):
- # Date processing error, return queryset unchanged
- return queryset
-
- # Construct a queryset for "received" orders within the range
- received = (
- Q(status=PurchaseOrderStatus.COMPLETE.value)
- & Q(complete_date__gte=min_date)
- & Q(complete_date__lte=max_date)
- )
-
- # Construct a queryset for "pending" orders within the range
- pending = (
- Q(status__in=PurchaseOrderStatusGroups.OPEN)
- & ~Q(target_date=None)
- & Q(target_date__gte=min_date)
- & Q(target_date__lte=max_date)
- )
-
- # TODO - Construct a queryset for "overdue" orders within the range
-
- queryset = queryset.filter(received | pending)
-
- return queryset
-
def __str__(self):
"""Render a string representation of this PurchaseOrder."""
return f'{self.reference} - {self.supplier.name if self.supplier else _("deleted")}'
@@ -584,13 +546,6 @@ class PurchaseOrder(TotalPriceMixin, Order):
verbose_name=_('received by'),
)
- issue_date = models.DateField(
- blank=True,
- null=True,
- verbose_name=_('Issue Date'),
- help_text=_('Date order was issued'),
- )
-
complete_date = models.DateField(
blank=True,
null=True,
@@ -1054,50 +1009,6 @@ class SalesOrder(TotalPriceMixin, Order):
"""Return the associated barcode model type code for this model."""
return 'SO'
- @staticmethod
- def filterByDate(queryset, min_date, max_date):
- """Filter by "minimum and maximum date range".
-
- - Specified as min_date, max_date
- - Both must be specified for filter to be applied
- - Determine which "interesting" orders exist between these dates
-
- To be "interesting":
- - A "completed" order where the completion date lies within the date range
- - A "pending" order where the target date lies within the date range
- - TODO: An "overdue" order where the target date is in the past
- """
- date_fmt = '%Y-%m-%d' # ISO format date string
-
- # Ensure that both dates are valid
- try:
- min_date = datetime.strptime(str(min_date), date_fmt).date()
- max_date = datetime.strptime(str(max_date), date_fmt).date()
- except (ValueError, TypeError):
- # Date processing error, return queryset unchanged
- return queryset
-
- # Construct a queryset for "completed" orders within the range
- completed = (
- Q(status__in=SalesOrderStatusGroups.COMPLETE)
- & Q(shipment_date__gte=min_date)
- & Q(shipment_date__lte=max_date)
- )
-
- # Construct a queryset for "pending" orders within the range
- pending = (
- Q(status__in=SalesOrderStatusGroups.OPEN)
- & ~Q(target_date=None)
- & Q(target_date__gte=min_date)
- & Q(target_date__lte=max_date)
- )
-
- # TODO: Construct a queryset for "overdue" orders within the range
-
- queryset = queryset.filter(completed | pending)
-
- return queryset
-
def __str__(self):
"""Render a string representation of this SalesOrder."""
return f'{self.reference} - {self.customer.name if self.customer else _("deleted")}'
@@ -2363,13 +2274,6 @@ class ReturnOrder(TotalPriceMixin, Order):
help_text=_('Customer order reference code'),
)
- issue_date = models.DateField(
- blank=True,
- null=True,
- verbose_name=_('Issue Date'),
- help_text=_('Date order was issued'),
- )
-
complete_date = models.DateField(
blank=True,
null=True,
diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py
index 5e105f4f5a..36f689ae89 100644
--- a/src/backend/InvenTree/order/serializers.py
+++ b/src/backend/InvenTree/order/serializers.py
@@ -193,6 +193,7 @@ class AbstractOrderSerializer(DataImportExportSerializerMixin, serializers.Seria
'pk',
'created_by',
'creation_date',
+ 'issue_date',
'start_date',
'target_date',
'description',
@@ -322,7 +323,6 @@ class PurchaseOrderSerializer(
model = order.models.PurchaseOrder
fields = AbstractOrderSerializer.order_fields([
- 'issue_date',
'complete_date',
'supplier',
'supplier_detail',
@@ -1880,7 +1880,6 @@ class ReturnOrderSerializer(
model = order.models.ReturnOrder
fields = AbstractOrderSerializer.order_fields([
- 'issue_date',
'complete_date',
'customer',
'customer_detail',
diff --git a/src/frontend/package.json b/src/frontend/package.json
index a04ec2a858..2a6f5e5f24 100644
--- a/src/frontend/package.json
+++ b/src/frontend/package.json
@@ -24,6 +24,10 @@
"@fortawesome/free-regular-svg-icons": "^6.6.0",
"@fortawesome/free-solid-svg-icons": "^6.6.0",
"@fortawesome/react-fontawesome": "^0.2.2",
+ "@fullcalendar/core": "^6.1.15",
+ "@fullcalendar/daygrid": "^6.1.15",
+ "@fullcalendar/interaction": "^6.1.15",
+ "@fullcalendar/react": "^6.1.15",
"@lingui/core": "^4.11.4",
"@lingui/react": "^4.11.4",
"@mantine/carousel": "^7.16.0",
diff --git a/src/frontend/src/components/buttons/SegmentedIconControl.tsx b/src/frontend/src/components/buttons/SegmentedIconControl.tsx
new file mode 100644
index 0000000000..9cdbfc9132
--- /dev/null
+++ b/src/frontend/src/components/buttons/SegmentedIconControl.tsx
@@ -0,0 +1,51 @@
+import {
+ ActionIcon,
+ type MantineColor,
+ type MantineSize,
+ SegmentedControl,
+ Tooltip
+} from '@mantine/core';
+import type { ReactNode } from 'react';
+
+export type SegmentedIconControlItem = {
+ label: string;
+ value: string;
+ icon: ReactNode;
+};
+
+export default function SegmentedIconControl({
+ data,
+ value,
+ size = 'sm',
+ color,
+ onChange
+}: {
+ data: SegmentedIconControlItem[];
+ value: string;
+ size?: MantineSize;
+ color?: MantineColor;
+ onChange: (value: string) => void;
+}) {
+ return (
+ ({
+ value: item.value,
+ label: (
+
+ onChange(item.value)}
+ >
+ {item.icon}
+
+
+ )
+ }))}
+ />
+ );
+}
diff --git a/src/frontend/src/components/calendar/Calendar.tsx b/src/frontend/src/components/calendar/Calendar.tsx
new file mode 100644
index 0000000000..9d0e8b3909
--- /dev/null
+++ b/src/frontend/src/components/calendar/Calendar.tsx
@@ -0,0 +1,190 @@
+import type { CalendarOptions, DatesSetArg } from '@fullcalendar/core';
+import allLocales from '@fullcalendar/core/locales-all';
+import dayGridPlugin from '@fullcalendar/daygrid';
+import interactionPlugin from '@fullcalendar/interaction';
+import FullCalendar from '@fullcalendar/react';
+
+import { t } from '@lingui/macro';
+import {
+ ActionIcon,
+ Box,
+ Button,
+ Group,
+ Indicator,
+ LoadingOverlay,
+ Popover,
+ Stack,
+ Tooltip
+} from '@mantine/core';
+import { type DateValue, MonthPicker } from '@mantine/dates';
+import {
+ IconCalendarMonth,
+ IconChevronLeft,
+ IconChevronRight,
+ IconFilter
+} from '@tabler/icons-react';
+import { useCallback, useState } from 'react';
+import type { CalendarState } from '../../hooks/UseCalendar';
+import { useLocalState } from '../../states/LocalState';
+import { DownloadAction } from '../../tables/DownloadAction';
+import type { TableFilter } from '../../tables/Filter';
+import { FilterSelectDrawer } from '../../tables/FilterSelectDrawer';
+import { TableSearchInput } from '../../tables/Search';
+import { Boundary } from '../Boundary';
+import { ActionButton } from '../buttons/ActionButton';
+import { StylishText } from '../items/StylishText';
+
+export interface InvenTreeCalendarProps extends CalendarOptions {
+ downloadData?: (fileFormat: string) => void;
+ enableDownload?: boolean;
+ enableFilters?: boolean;
+ enableSearch?: boolean;
+ filters?: TableFilter[];
+ isLoading?: boolean;
+ state: CalendarState;
+}
+
+export default function Calendar({
+ downloadData,
+ enableDownload,
+ enableFilters = false,
+ enableSearch,
+ isLoading,
+ filters,
+ state,
+ ...calendarProps
+}: InvenTreeCalendarProps) {
+ const [monthSelectOpened, setMonthSelectOpened] = useState(false);
+
+ const [filtersVisible, setFiltersVisible] = useState(false);
+
+ const [locale] = useLocalState((s) => [s.language]);
+
+ const selectMonth = useCallback(
+ (date: DateValue) => {
+ state.selectMonth(date);
+ setMonthSelectOpened(false);
+ },
+ [state.selectMonth]
+ );
+
+ // Callback when the calendar date range is adjusted
+ const datesSet = useCallback(
+ (dateInfo: DatesSetArg) => {
+ if (state.ref?.current) {
+ const api = state.ref.current.getApi();
+
+ // Update calendar state
+ state.setMonthName(api.view.title);
+ state.setStartDate(dateInfo.start);
+ state.setEndDate(dateInfo.end);
+ }
+
+ // Pass the dates set to the parent component
+ calendarProps.datesSet?.(dateInfo);
+ },
+ [calendarProps.datesSet, state.ref, state.setMonthName]
+ );
+
+ return (
+ <>
+ {enableFilters && filters && (filters?.length ?? 0) > 0 && (
+
+ setFiltersVisible(false)}
+ />
+
+ )}
+
+
+
+ }
+ onClick={state.prevMonth}
+ tooltipAlignment='top'
+ tooltip={t`Previous month`}
+ />
+ setMonthSelectOpened(false)}
+ position='bottom-start'
+ shadow='md'
+ >
+
+
+
+
+
+
+
+
+
+ }
+ onClick={state.nextMonth}
+ tooltipAlignment='top'
+ tooltip={t`Next month`}
+ />
+ {state.monthName}
+
+
+ {enableSearch && (
+
+ )}
+ {enableFilters && filters && filters.length > 0 && (
+
+
+
+ setFiltersVisible(!filtersVisible)}
+ />
+
+
+
+ )}
+ {enableDownload && (
+
+ )}
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/frontend/src/components/calendar/OrderCalendar.tsx b/src/frontend/src/components/calendar/OrderCalendar.tsx
new file mode 100644
index 0000000000..b00134ddcc
--- /dev/null
+++ b/src/frontend/src/components/calendar/OrderCalendar.tsx
@@ -0,0 +1,209 @@
+import type {
+ EventChangeArg,
+ EventClickArg,
+ EventContentArg
+} from '@fullcalendar/core';
+import { t } from '@lingui/macro';
+import { ActionIcon, Group, Text } from '@mantine/core';
+import { hideNotification, showNotification } from '@mantine/notifications';
+import {
+ IconCalendarExclamation,
+ IconCircleCheck,
+ IconExclamationCircle
+} from '@tabler/icons-react';
+import dayjs from 'dayjs';
+import { useCallback, useMemo } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { api } from '../../App';
+import type { ModelType } from '../../enums/ModelType';
+import type { UserRoles } from '../../enums/Roles';
+import { navigateToLink } from '../../functions/navigation';
+import { getDetailUrl } from '../../functions/urls';
+import useCalendar from '../../hooks/UseCalendar';
+import { apiUrl } from '../../states/ApiState';
+import { useUserState } from '../../states/UserState';
+import {
+ AssignedToMeFilter,
+ HasProjectCodeFilter,
+ OrderStatusFilter,
+ ProjectCodeFilter,
+ ResponsibleFilter,
+ type TableFilter
+} from '../../tables/Filter';
+import { ModelInformationDict } from '../render/ModelType';
+import { StatusRenderer } from '../render/StatusRenderer';
+import Calendar from './Calendar';
+
+/**
+ * A generic calendar component for displaying orders
+ * This can be used for the following order types:
+ * - BuildOrder
+ * - PurchaseOrder
+ * - SalesOrder
+ * - ReturnOrder
+ */
+
+export default function OrderCalendar({
+ model,
+ role,
+ params,
+ filters
+}: {
+ model: ModelType;
+ role: UserRoles;
+ params: Record;
+ filters?: TableFilter[];
+}) {
+ const navigate = useNavigate();
+ const user = useUserState();
+
+ // These filters apply to all order types
+ const orderFilters: TableFilter[] = useMemo(() => {
+ return [
+ OrderStatusFilter({ model: model }),
+ AssignedToMeFilter(),
+ ProjectCodeFilter(),
+ HasProjectCodeFilter(),
+ ResponsibleFilter()
+ ];
+ }, [model]);
+
+ // Complete set of available filters
+ const calendarFilters: TableFilter[] = useMemo(() => {
+ return [...orderFilters, ...(filters ?? [])];
+ }, [orderFilters, filters]);
+
+ const modelInfo = useMemo(() => {
+ return ModelInformationDict[model];
+ }, [model]);
+
+ const canEdit = useMemo(() => {
+ return user.hasChangeRole(role);
+ }, [user, role]);
+
+ const calendarState = useCalendar({
+ endpoint: modelInfo.api_endpoint,
+ name: model.toString(),
+ queryParams: params
+ });
+
+ // Build the events
+ const events = useMemo(() => {
+ const today = dayjs().format('YYYY-MM-DD');
+
+ return (
+ calendarState.data?.map((order: any) => {
+ const start: string =
+ order.start_date || order.issue_date || order.creation_date || today;
+ const end: string = order.target_date || start;
+
+ return {
+ id: order.pk,
+ title: order.reference,
+ description: order.description,
+ start: start,
+ end: end,
+ startEditable: canEdit,
+ durationEditable: canEdit
+ };
+ }) ?? []
+ );
+ }, [calendarState.data, canEdit]);
+
+ // Callback when Order is edited
+ const onEditOrder = (info: EventChangeArg) => {
+ const orderId = info.event.id;
+ const patch: Record = {};
+
+ if (info.event.start && info.event.start != info.oldEvent.start) {
+ patch.start_date = dayjs(info.event.start).format('YYYY-MM-DD');
+ }
+
+ if (info.event.end && info.event.end != info.oldEvent.end) {
+ patch.target_date = dayjs(info.event.end).format('YYYY-MM-DD');
+ }
+
+ if (!!patch) {
+ api
+ .patch(apiUrl(modelInfo.api_endpoint, orderId), patch)
+ .then(() => {
+ hideNotification('calendar-edit-result');
+ showNotification({
+ id: 'calendar-edit-result',
+ message: t`Order Updated`,
+ color: 'green',
+ icon:
+ });
+ })
+ .catch(() => {
+ info.revert();
+ hideNotification('calendar-edit-result');
+ showNotification({
+ id: 'calendar-edit-result',
+ message: t`Error updating order`,
+ color: 'red',
+ icon:
+ });
+ });
+ }
+ };
+
+ // Callback when PurchaseOrder is clicked
+ const onClickOrder = (info: EventClickArg) => {
+ if (!!info.event.id) {
+ navigateToLink(
+ getDetailUrl(model, info.event.id),
+ navigate,
+ info.jsEvent
+ );
+ }
+ };
+
+ const renderOrder = useCallback(
+ (event: EventContentArg) => {
+ const order = calendarState.data?.find(
+ (order: any) => order.pk.toString() == event.event.id.toString()
+ );
+
+ if (!order) {
+ // Fallback to the event title if no order is found
+ return event.event.title;
+ }
+
+ return (
+
+ {order.overdue && (
+
+
+
+ )}
+
+ {order.reference}
+
+ {order.description ?? order.title}
+
+
+ );
+ },
+ [calendarState.data, model]
+ );
+
+ return (
+
+ );
+}
diff --git a/src/frontend/src/components/items/ActionDropdown.tsx b/src/frontend/src/components/items/ActionDropdown.tsx
index a18f4ec1d2..e7183974b8 100644
--- a/src/frontend/src/components/items/ActionDropdown.tsx
+++ b/src/frontend/src/components/items/ActionDropdown.tsx
@@ -1,6 +1,7 @@
import { t } from '@lingui/macro';
import {
Button,
+ type FloatingPosition,
Indicator,
type IndicatorProps,
Menu,
@@ -43,6 +44,7 @@ export type ActionDropdownItem = {
export function ActionDropdown({
icon,
tooltip,
+ tooltipPosition,
actions,
disabled = false,
hidden = false,
@@ -50,6 +52,7 @@ export function ActionDropdown({
}: {
icon: ReactNode;
tooltip: string;
+ tooltipPosition?: FloatingPosition;
actions: ActionDropdownItem[];
disabled?: boolean;
hidden?: boolean;
@@ -71,7 +74,11 @@ export function ActionDropdown({