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({ -