[UI] Reimplement calendar views (#8933)
* Add deps for @fullcalendar * Basic calendar setup * Add locale support * Add custom <SegmentedIconControl> component * optional 'control' for panel * Scaffolding out build index page * Fetch build orders based on calendar dates * Display builds * Click to navigate to build * Working on custom header * Implement button functionality * Display current month name * Implement datepicker dropdown * Adjust layout * Custom rendering * Include part detail * Implement hook to track calendar state * Clearing search causes immediate update * Offload core functionality into hook - Adds search input * Spread props correctly * Cleanup * Allow build orders to be edited directly from calendar view * Cleanup * Fix for InvenTreeCalendarProps * Improve date range filtering for Build model * Simplify BuildFilter * Refactoring * PurchaseOrder calendar implementation * Allow edit of purchase orders * Refactor and implement for all order types - Add missing "issue_date" field for SalesOrder - Refactor API for other order types - Fix rendering issues * Bump API version * Add playwright tests for calendar (simple) * Fix migrations * Simplify notifications * Refactor filter components - Make them more generic * Enable custom filters for calendars * Custom filters per table * Display status label * Refactoring * Update playwright tests * Update build order docs * Bug fix for order date editing * Update purchase order docs * Return Order docs * Update date formatting
							
								
								
									
										
											BIN
										
									
								
								docs/docs/assets/images/build/build_calendar.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 113 KiB | 
| Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 71 KiB | 
| Before Width: | Height: | Size: 319 KiB After Width: | Height: | Size: 231 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/docs/assets/images/order/po_calendar.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 106 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/docs/assets/images/order/po_display.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 138 KiB | 
| Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 263 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/docs/assets/images/order/ro_calendar.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 80 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/docs/assets/images/order/ro_display.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 136 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/docs/assets/images/order/ro_list.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 192 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/docs/assets/images/order/so_calendar.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 87 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/docs/assets/images/order/so_display.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 177 KiB | 
| Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 263 KiB | 
							
								
								
									
										14
									
								
								docs/docs/build/build.md
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -12,12 +12,16 @@ A *Build Order* uses the BOM to allocate stock items to the assembly process. As | |||||||
|  |  | ||||||
| ### View Build Orders | ### 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" %} | {% with id="build_display", url="build/build_display.png", description="Display Builds" %} | ||||||
| {% include "img.html" %} | {% include "img.html" %} | ||||||
| {% endwith %} | {% endwith %} | ||||||
|  |  | ||||||
|  | The following view modes are available: | ||||||
|  |  | ||||||
| #### Table View | #### Table View | ||||||
|  |  | ||||||
| *Table View* provides a table of Build Orders, which can be filtered to only show the orders you are interested in. | *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 | ||||||
|  |  | ||||||
| *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 | ## Build Order Details | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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. | 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 <span class="badge inventree nav main"><span class='fas fa-shopping-cart'></span> Buy</span> navigation tab and click on <span class="badge inventree nav main"><span class='fas fa-list'></span> Purchase Orders</span> 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" %} | {% with id="purchase_order_list", url="order/po_list.png", description="Purchase Order List" %} | ||||||
| {% include "img.html" %} | {% include "img.html" %} | ||||||
| {% endwith %} | {% 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 | ### Purchase Order Status Codes | ||||||
|  |  | ||||||
| Each Purchase Order has a specific status code which indicates the current state of the order: | Each Purchase Order has a specific status code which indicates the current state of the order: | ||||||
|   | |||||||
| @@ -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" | !!! 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) |     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 | ### Enable Return Order Functionality | ||||||
|  |  | ||||||
| By default, Return Order functionality is not enabled - it must be enabled by a *staff* user from the settings page: | By default, Return Order functionality is not enabled - it must be enabled by a *staff* user from the settings page: | ||||||
|   | |||||||
| @@ -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. | 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 <span class="badge inventree nav main"><span class='fas fa-truck'></span> Sell</span> navigation tab and click on <span class="badge inventree nav main"><span class='fas fa-list'></span> Sales Orders</span> 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" %} | {% with id="sales_order_list", url="order/so_list.png", description="Sales Order List" %} | ||||||
| {% include "img.html" %} | {% include "img.html" %} | ||||||
| {% endwith %} | {% 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 | ### Sales Order Status Codes | ||||||
|  |  | ||||||
| Each Sales Order has a specific status code, which represents the state of the order: | Each Sales Order has a specific status code, which represents the state of the order: | ||||||
|   | |||||||
| @@ -1,13 +1,16 @@ | |||||||
| """InvenTree API version information.""" | """InvenTree API version information.""" | ||||||
|  |  | ||||||
| # InvenTree API version | # 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.""" | """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" | ||||||
|  |  | ||||||
|  |  | ||||||
| INVENTREE_API_TEXT = """ | 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 | v321 - 2025-03-06 : https://github.com/inventree/InvenTree/pull/9236 | ||||||
|     - Adds conditionally-returned fields to the schema to match API behavior |     - Adds conditionally-returned fields to the schema to match API behavior | ||||||
|     - Removes required flag for nullable read-only fields to match API behavior |     - Removes required flag for nullable read-only fields to match API behavior | ||||||
|   | |||||||
| @@ -242,6 +242,65 @@ class BuildFilter(rest_filters.FilterSet): | |||||||
|         label=_('Completed after'), field_name='completion_date', lookup_expr='gt' |         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: | class BuildMixin: | ||||||
|     """Mixin class for Build API endpoints.""" |     """Mixin class for Build API endpoints.""" | ||||||
| @@ -319,35 +378,6 @@ class BuildList(DataExportViewMixin, BuildMixin, ListCreateAPI): | |||||||
|  |  | ||||||
|         return queryset |         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): |     def get_serializer(self, *args, **kwargs): | ||||||
|         """Add extra context information to the endpoint serializer.""" |         """Add extra context information to the endpoint serializer.""" | ||||||
|         try: |         try: | ||||||
|   | |||||||
| @@ -1,7 +1,6 @@ | |||||||
| """Build database model definitions.""" | """Build database model definitions.""" | ||||||
|  |  | ||||||
| import decimal | import decimal | ||||||
| from datetime import datetime |  | ||||||
|  |  | ||||||
| from django.contrib.auth.models import User | from django.contrib.auth.models import User | ||||||
| from django.core.exceptions import ValidationError | from django.core.exceptions import ValidationError | ||||||
| @@ -197,44 +196,6 @@ class Build( | |||||||
|             'title': str(self), |             '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): |     def __str__(self): | ||||||
|         """String representation of a BuildOrder.""" |         """String representation of a BuildOrder.""" | ||||||
|         return self.reference |         return self.reference | ||||||
|   | |||||||
| @@ -213,6 +213,44 @@ class OrderFilter(rest_filters.FilterSet): | |||||||
|         label=_('Target Date After'), field_name='target_date', lookup_expr='gt' |         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): | class LineItemFilter(rest_filters.FilterSet): | ||||||
|     """Base class for custom API filters for order line item list(s).""" |     """Base class for custom API filters for order line item list(s).""" | ||||||
| @@ -323,23 +361,6 @@ class PurchaseOrderList( | |||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     filterset_class = PurchaseOrderFilter |     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 |     filter_backends = SEARCH_ORDER_FILTER_ALIAS | ||||||
|  |  | ||||||
|     ordering_field_aliases = { |     ordering_field_aliases = { | ||||||
| @@ -791,21 +812,6 @@ class SalesOrderList( | |||||||
|  |  | ||||||
|     filterset_class = SalesOrderFilter |     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 |     filter_backends = SEARCH_ORDER_FILTER_ALIAS | ||||||
|  |  | ||||||
|     ordering_field_aliases = { |     ordering_field_aliases = { | ||||||
|   | |||||||
| @@ -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", | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @@ -1,6 +1,5 @@ | |||||||
| """Order model definitions.""" | """Order model definitions.""" | ||||||
|  |  | ||||||
| from datetime import datetime |  | ||||||
| from decimal import Decimal | from decimal import Decimal | ||||||
|  |  | ||||||
| from django.contrib.auth.models import User | from django.contrib.auth.models import User | ||||||
| @@ -389,6 +388,13 @@ class Order( | |||||||
|         verbose_name=_('Created By'), |         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( |     responsible = models.ForeignKey( | ||||||
|         UserModels.Owner, |         UserModels.Owner, | ||||||
|         on_delete=models.SET_NULL, |         on_delete=models.SET_NULL, | ||||||
| @@ -482,50 +488,6 @@ class PurchaseOrder(TotalPriceMixin, Order): | |||||||
|         """Return the associated barcode model type code for this model.""" |         """Return the associated barcode model type code for this model.""" | ||||||
|         return 'PO' |         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): |     def __str__(self): | ||||||
|         """Render a string representation of this PurchaseOrder.""" |         """Render a string representation of this PurchaseOrder.""" | ||||||
|         return f'{self.reference} - {self.supplier.name if self.supplier else _("deleted")}' |         return f'{self.reference} - {self.supplier.name if self.supplier else _("deleted")}' | ||||||
| @@ -584,13 +546,6 @@ class PurchaseOrder(TotalPriceMixin, Order): | |||||||
|         verbose_name=_('received by'), |         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( |     complete_date = models.DateField( | ||||||
|         blank=True, |         blank=True, | ||||||
|         null=True, |         null=True, | ||||||
| @@ -1054,50 +1009,6 @@ class SalesOrder(TotalPriceMixin, Order): | |||||||
|         """Return the associated barcode model type code for this model.""" |         """Return the associated barcode model type code for this model.""" | ||||||
|         return 'SO' |         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): |     def __str__(self): | ||||||
|         """Render a string representation of this SalesOrder.""" |         """Render a string representation of this SalesOrder.""" | ||||||
|         return f'{self.reference} - {self.customer.name if self.customer else _("deleted")}' |         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'), |         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( |     complete_date = models.DateField( | ||||||
|         blank=True, |         blank=True, | ||||||
|         null=True, |         null=True, | ||||||
|   | |||||||
| @@ -193,6 +193,7 @@ class AbstractOrderSerializer(DataImportExportSerializerMixin, serializers.Seria | |||||||
|             'pk', |             'pk', | ||||||
|             'created_by', |             'created_by', | ||||||
|             'creation_date', |             'creation_date', | ||||||
|  |             'issue_date', | ||||||
|             'start_date', |             'start_date', | ||||||
|             'target_date', |             'target_date', | ||||||
|             'description', |             'description', | ||||||
| @@ -322,7 +323,6 @@ class PurchaseOrderSerializer( | |||||||
|         model = order.models.PurchaseOrder |         model = order.models.PurchaseOrder | ||||||
|  |  | ||||||
|         fields = AbstractOrderSerializer.order_fields([ |         fields = AbstractOrderSerializer.order_fields([ | ||||||
|             'issue_date', |  | ||||||
|             'complete_date', |             'complete_date', | ||||||
|             'supplier', |             'supplier', | ||||||
|             'supplier_detail', |             'supplier_detail', | ||||||
| @@ -1880,7 +1880,6 @@ class ReturnOrderSerializer( | |||||||
|         model = order.models.ReturnOrder |         model = order.models.ReturnOrder | ||||||
|  |  | ||||||
|         fields = AbstractOrderSerializer.order_fields([ |         fields = AbstractOrderSerializer.order_fields([ | ||||||
|             'issue_date', |  | ||||||
|             'complete_date', |             'complete_date', | ||||||
|             'customer', |             'customer', | ||||||
|             'customer_detail', |             'customer_detail', | ||||||
|   | |||||||
| @@ -24,6 +24,10 @@ | |||||||
|         "@fortawesome/free-regular-svg-icons": "^6.6.0", |         "@fortawesome/free-regular-svg-icons": "^6.6.0", | ||||||
|         "@fortawesome/free-solid-svg-icons": "^6.6.0", |         "@fortawesome/free-solid-svg-icons": "^6.6.0", | ||||||
|         "@fortawesome/react-fontawesome": "^0.2.2", |         "@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/core": "^4.11.4", | ||||||
|         "@lingui/react": "^4.11.4", |         "@lingui/react": "^4.11.4", | ||||||
|         "@mantine/carousel": "^7.16.0", |         "@mantine/carousel": "^7.16.0", | ||||||
|   | |||||||
							
								
								
									
										51
									
								
								src/frontend/src/components/buttons/SegmentedIconControl.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -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 ( | ||||||
|  |     <SegmentedControl | ||||||
|  |       value={value} | ||||||
|  |       onChange={onChange} | ||||||
|  |       data={data.map((item) => ({ | ||||||
|  |         value: item.value, | ||||||
|  |         label: ( | ||||||
|  |           <Tooltip label={item.label}> | ||||||
|  |             <ActionIcon | ||||||
|  |               variant='transparent' | ||||||
|  |               color={color} | ||||||
|  |               size={size} | ||||||
|  |               aria-label={`segmented-icon-control-${item.value}`} | ||||||
|  |               onClick={() => onChange(item.value)} | ||||||
|  |             > | ||||||
|  |               {item.icon} | ||||||
|  |             </ActionIcon> | ||||||
|  |           </Tooltip> | ||||||
|  |         ) | ||||||
|  |       }))} | ||||||
|  |     /> | ||||||
|  |   ); | ||||||
|  | } | ||||||
							
								
								
									
										190
									
								
								src/frontend/src/components/calendar/Calendar.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -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<boolean>(false); | ||||||
|  |  | ||||||
|  |   const [filtersVisible, setFiltersVisible] = useState<boolean>(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 && ( | ||||||
|  |         <Boundary label={`InvenTreeCalendarFilterDrawer-${state.name}`}> | ||||||
|  |           <FilterSelectDrawer | ||||||
|  |             title={t`Calendar Filters`} | ||||||
|  |             availableFilters={filters} | ||||||
|  |             filterSet={state.filterSet} | ||||||
|  |             opened={filtersVisible} | ||||||
|  |             onClose={() => setFiltersVisible(false)} | ||||||
|  |           /> | ||||||
|  |         </Boundary> | ||||||
|  |       )} | ||||||
|  |       <Stack gap='xs'> | ||||||
|  |         <Group justify='space-between' gap='xs'> | ||||||
|  |           <Group gap={0} justify='left'> | ||||||
|  |             <ActionButton | ||||||
|  |               icon={<IconChevronLeft />} | ||||||
|  |               onClick={state.prevMonth} | ||||||
|  |               tooltipAlignment='top' | ||||||
|  |               tooltip={t`Previous month`} | ||||||
|  |             /> | ||||||
|  |             <Popover | ||||||
|  |               opened={monthSelectOpened} | ||||||
|  |               onClose={() => setMonthSelectOpened(false)} | ||||||
|  |               position='bottom-start' | ||||||
|  |               shadow='md' | ||||||
|  |             > | ||||||
|  |               <Popover.Target> | ||||||
|  |                 <Tooltip label={t`Select month`} position='top'> | ||||||
|  |                   <Button | ||||||
|  |                     m={0} | ||||||
|  |                     variant='transparent' | ||||||
|  |                     aria-label='calendar-select-month' | ||||||
|  |                     onClick={() => { | ||||||
|  |                       setMonthSelectOpened(!monthSelectOpened); | ||||||
|  |                     }} | ||||||
|  |                   > | ||||||
|  |                     <IconCalendarMonth /> | ||||||
|  |                   </Button> | ||||||
|  |                 </Tooltip> | ||||||
|  |               </Popover.Target> | ||||||
|  |               <Popover.Dropdown> | ||||||
|  |                 <MonthPicker onChange={selectMonth} /> | ||||||
|  |               </Popover.Dropdown> | ||||||
|  |             </Popover> | ||||||
|  |             <ActionButton | ||||||
|  |               icon={<IconChevronRight />} | ||||||
|  |               onClick={state.nextMonth} | ||||||
|  |               tooltipAlignment='top' | ||||||
|  |               tooltip={t`Next month`} | ||||||
|  |             /> | ||||||
|  |             <StylishText size='lg'>{state.monthName}</StylishText> | ||||||
|  |           </Group> | ||||||
|  |           <Group justify='right' gap='xs' wrap='nowrap'> | ||||||
|  |             {enableSearch && ( | ||||||
|  |               <TableSearchInput searchCallback={state.setSearchTerm} /> | ||||||
|  |             )} | ||||||
|  |             {enableFilters && filters && filters.length > 0 && ( | ||||||
|  |               <Indicator | ||||||
|  |                 size='xs' | ||||||
|  |                 label={state.filterSet.activeFilters?.length ?? 0} | ||||||
|  |                 disabled={state.filterSet.activeFilters?.length == 0} | ||||||
|  |               > | ||||||
|  |                 <ActionIcon | ||||||
|  |                   variant='transparent' | ||||||
|  |                   aria-label='calendar-select-filters' | ||||||
|  |                 > | ||||||
|  |                   <Tooltip label={t`Calendar Filters`}> | ||||||
|  |                     <IconFilter | ||||||
|  |                       onClick={() => setFiltersVisible(!filtersVisible)} | ||||||
|  |                     /> | ||||||
|  |                   </Tooltip> | ||||||
|  |                 </ActionIcon> | ||||||
|  |               </Indicator> | ||||||
|  |             )} | ||||||
|  |             {enableDownload && ( | ||||||
|  |               <DownloadAction | ||||||
|  |                 key='download-action' | ||||||
|  |                 downloadCallback={downloadData} | ||||||
|  |               /> | ||||||
|  |             )} | ||||||
|  |           </Group> | ||||||
|  |         </Group> | ||||||
|  |         <Box pos='relative'> | ||||||
|  |           <LoadingOverlay visible={state.query.isFetching} /> | ||||||
|  |           <FullCalendar | ||||||
|  |             ref={state.ref} | ||||||
|  |             plugins={[dayGridPlugin, interactionPlugin]} | ||||||
|  |             initialView='dayGridMonth' | ||||||
|  |             locales={allLocales} | ||||||
|  |             locale={locale} | ||||||
|  |             headerToolbar={false} | ||||||
|  |             footerToolbar={false} | ||||||
|  |             {...calendarProps} | ||||||
|  |             datesSet={datesSet} | ||||||
|  |           /> | ||||||
|  |         </Box> | ||||||
|  |       </Stack> | ||||||
|  |     </> | ||||||
|  |   ); | ||||||
|  | } | ||||||
							
								
								
									
										209
									
								
								src/frontend/src/components/calendar/OrderCalendar.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -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<string, any>; | ||||||
|  |   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<string, string> = {}; | ||||||
|  |  | ||||||
|  |     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: <IconCircleCheck /> | ||||||
|  |           }); | ||||||
|  |         }) | ||||||
|  |         .catch(() => { | ||||||
|  |           info.revert(); | ||||||
|  |           hideNotification('calendar-edit-result'); | ||||||
|  |           showNotification({ | ||||||
|  |             id: 'calendar-edit-result', | ||||||
|  |             message: t`Error updating order`, | ||||||
|  |             color: 'red', | ||||||
|  |             icon: <IconExclamationCircle /> | ||||||
|  |           }); | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   // 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 ( | ||||||
|  |         <Group gap='xs' wrap='nowrap'> | ||||||
|  |           {order.overdue && ( | ||||||
|  |             <ActionIcon | ||||||
|  |               color='orange-7' | ||||||
|  |               variant='transparent' | ||||||
|  |               title={t`Overdue`} | ||||||
|  |             > | ||||||
|  |               <IconCalendarExclamation /> | ||||||
|  |             </ActionIcon> | ||||||
|  |           )} | ||||||
|  |           <Text size='sm' fw={700}> | ||||||
|  |             {order.reference} | ||||||
|  |           </Text> | ||||||
|  |           <Text size='xs'>{order.description ?? order.title}</Text> | ||||||
|  |           <StatusRenderer status={order.status} type={model} /> | ||||||
|  |         </Group> | ||||||
|  |       ); | ||||||
|  |     }, | ||||||
|  |     [calendarState.data, model] | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <Calendar | ||||||
|  |       enableDownload | ||||||
|  |       enableFilters | ||||||
|  |       enableSearch | ||||||
|  |       events={events} | ||||||
|  |       state={calendarState} | ||||||
|  |       filters={calendarFilters} | ||||||
|  |       editable={true} | ||||||
|  |       eventContent={renderOrder} | ||||||
|  |       eventClick={onClickOrder} | ||||||
|  |       eventChange={onEditOrder} | ||||||
|  |     /> | ||||||
|  |   ); | ||||||
|  | } | ||||||
| @@ -1,6 +1,7 @@ | |||||||
| import { t } from '@lingui/macro'; | import { t } from '@lingui/macro'; | ||||||
| import { | import { | ||||||
|   Button, |   Button, | ||||||
|  |   type FloatingPosition, | ||||||
|   Indicator, |   Indicator, | ||||||
|   type IndicatorProps, |   type IndicatorProps, | ||||||
|   Menu, |   Menu, | ||||||
| @@ -43,6 +44,7 @@ export type ActionDropdownItem = { | |||||||
| export function ActionDropdown({ | export function ActionDropdown({ | ||||||
|   icon, |   icon, | ||||||
|   tooltip, |   tooltip, | ||||||
|  |   tooltipPosition, | ||||||
|   actions, |   actions, | ||||||
|   disabled = false, |   disabled = false, | ||||||
|   hidden = false, |   hidden = false, | ||||||
| @@ -50,6 +52,7 @@ export function ActionDropdown({ | |||||||
| }: { | }: { | ||||||
|   icon: ReactNode; |   icon: ReactNode; | ||||||
|   tooltip: string; |   tooltip: string; | ||||||
|  |   tooltipPosition?: FloatingPosition; | ||||||
|   actions: ActionDropdownItem[]; |   actions: ActionDropdownItem[]; | ||||||
|   disabled?: boolean; |   disabled?: boolean; | ||||||
|   hidden?: boolean; |   hidden?: boolean; | ||||||
| @@ -71,7 +74,11 @@ export function ActionDropdown({ | |||||||
|     <Menu position='bottom-end' key={menuName}> |     <Menu position='bottom-end' key={menuName}> | ||||||
|       <Indicator disabled={!indicatorProps} {...indicatorProps?.indicator}> |       <Indicator disabled={!indicatorProps} {...indicatorProps?.indicator}> | ||||||
|         <Menu.Target> |         <Menu.Target> | ||||||
|           <Tooltip label={tooltip} hidden={!tooltip} position='bottom'> |           <Tooltip | ||||||
|  |             label={tooltip} | ||||||
|  |             hidden={!tooltip} | ||||||
|  |             position={tooltipPosition ?? 'bottom'} | ||||||
|  |           > | ||||||
|             <Button |             <Button | ||||||
|               variant={noindicator ? 'transparent' : 'light'} |               variant={noindicator ? 'transparent' : 'light'} | ||||||
|               disabled={disabled} |               disabled={disabled} | ||||||
|   | |||||||
| @@ -6,6 +6,7 @@ import type { ReactNode } from 'react'; | |||||||
| export type PanelType = { | export type PanelType = { | ||||||
|   name: string; |   name: string; | ||||||
|   label: string; |   label: string; | ||||||
|  |   controls?: ReactNode; | ||||||
|   icon?: ReactNode; |   icon?: ReactNode; | ||||||
|   content: ReactNode; |   content: ReactNode; | ||||||
|   hidden?: boolean; |   hidden?: boolean; | ||||||
|   | |||||||
| @@ -227,7 +227,14 @@ function BasePanelGroup({ | |||||||
|                   <Stack gap='md'> |                   <Stack gap='md'> | ||||||
|                     {panel.showHeadline !== false && ( |                     {panel.showHeadline !== false && ( | ||||||
|                       <> |                       <> | ||||||
|                         <StylishText size='xl'>{panel.label}</StylishText> |                         <Group justify='space-between'> | ||||||
|  |                           <StylishText size='xl'>{panel.label}</StylishText> | ||||||
|  |                           {panel.controls && ( | ||||||
|  |                             <Group justify='right' wrap='nowrap'> | ||||||
|  |                               {panel.controls} | ||||||
|  |                             </Group> | ||||||
|  |                           )} | ||||||
|  |                         </Group> | ||||||
|                         <Divider /> |                         <Divider /> | ||||||
|                       </> |                       </> | ||||||
|                     )} |                     )} | ||||||
|   | |||||||
| @@ -115,7 +115,9 @@ export function useStockFields({ | |||||||
|  |  | ||||||
|           if (expiry_days && expiry_days > 0) { |           if (expiry_days && expiry_days > 0) { | ||||||
|             // Adjust the expiry date based on the part default expiry |             // Adjust the expiry date based on the part default expiry | ||||||
|             setExpiryDate(dayjs().add(expiry_days, 'days').toISOString()); |             setExpiryDate( | ||||||
|  |               dayjs().add(expiry_days, 'days').format('YYYY-MM-DD') | ||||||
|  |             ); | ||||||
|           } |           } | ||||||
|         } |         } | ||||||
|       }, |       }, | ||||||
|   | |||||||
							
								
								
									
										166
									
								
								src/frontend/src/hooks/UseCalendar.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,166 @@ | |||||||
|  | import type FullCalendar from '@fullcalendar/react'; | ||||||
|  | import type { DateValue } from '@mantine/dates'; | ||||||
|  | import { type UseQueryResult, useQuery } from '@tanstack/react-query'; | ||||||
|  | import dayjs from 'dayjs'; | ||||||
|  | import { useCallback, useMemo, useRef, useState } from 'react'; | ||||||
|  | import { api } from '../App'; | ||||||
|  | import type { ApiEndpoints } from '../enums/ApiEndpoints'; | ||||||
|  | import { showApiErrorMessage } from '../functions/notifications'; | ||||||
|  | import { apiUrl } from '../states/ApiState'; | ||||||
|  | import { type FilterSetState, useFilterSet } from './UseFilterSet'; | ||||||
|  |  | ||||||
|  | /* | ||||||
|  |  * Type definition for representing the state of a calendar: | ||||||
|  |  * | ||||||
|  |  * ref: A reference to the FullCalendar component | ||||||
|  |  * filterSet: The current filter set state | ||||||
|  |  * monthName: The name of the current month (e.g. "January 2022") | ||||||
|  |  * setMonthName: A function to set the month name | ||||||
|  |  * searchTerm: The current search term for the calendar | ||||||
|  |  * setSearchTerm: A function to set the search term | ||||||
|  |  * startDate: The start date of the current date range | ||||||
|  |  * setStartDate: A function to set the start date | ||||||
|  |  * endDate: The end date of the current date range | ||||||
|  |  * setEndDate: A function to set the end date | ||||||
|  |  * nextMonth: A function to navigate to the next month | ||||||
|  |  * prevMonth: A function to navigate to the previous month | ||||||
|  |  * currentMonth: A function to navigate to the current month | ||||||
|  |  * selectMonth: A function to select a specific month | ||||||
|  |  */ | ||||||
|  | export type CalendarState = { | ||||||
|  |   name: string; | ||||||
|  |   ref: React.RefObject<FullCalendar>; | ||||||
|  |   filterSet: FilterSetState; | ||||||
|  |   monthName: string; | ||||||
|  |   setMonthName: (name: string) => void; | ||||||
|  |   searchTerm: string; | ||||||
|  |   startDate: Date | null; | ||||||
|  |   setStartDate: (date: Date | null) => void; | ||||||
|  |   endDate: Date | null; | ||||||
|  |   setEndDate: (date: Date | null) => void; | ||||||
|  |   setSearchTerm: (term: string) => void; | ||||||
|  |   nextMonth: () => void; | ||||||
|  |   prevMonth: () => void; | ||||||
|  |   currentMonth: () => void; | ||||||
|  |   selectMonth: (date: DateValue) => void; | ||||||
|  |   query: UseQueryResult; | ||||||
|  |   data: any; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default function useCalendar({ | ||||||
|  |   name, | ||||||
|  |   endpoint, | ||||||
|  |   queryParams | ||||||
|  | }: { | ||||||
|  |   name: string; | ||||||
|  |   endpoint: ApiEndpoints; | ||||||
|  |   queryParams?: any; | ||||||
|  | }): CalendarState { | ||||||
|  |   const ref = useRef<FullCalendar | null>(null); | ||||||
|  |  | ||||||
|  |   const filterSet = useFilterSet(`calendar-${name}`); | ||||||
|  |  | ||||||
|  |   const [searchTerm, setSearchTerm] = useState<string>(''); | ||||||
|  |  | ||||||
|  |   const [monthName, setMonthName] = useState<string>(''); | ||||||
|  |  | ||||||
|  |   const [startDate, setStartDate] = useState<Date | null>(null); | ||||||
|  |  | ||||||
|  |   const [endDate, setEndDate] = useState<Date | null>(null); | ||||||
|  |  | ||||||
|  |   // Generate a set of API query filters | ||||||
|  |   const queryFilters = useMemo(() => { | ||||||
|  |     // Expand date range by one month, to ensure we capture all events | ||||||
|  |  | ||||||
|  |     let params = { | ||||||
|  |       ...(queryParams || {}) | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     if (filterSet.activeFilters) { | ||||||
|  |       filterSet.activeFilters.forEach((filter) => { | ||||||
|  |         params[filter.name] = filter.value; | ||||||
|  |       }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     params = { | ||||||
|  |       ...params, | ||||||
|  |       min_date: startDate | ||||||
|  |         ? dayjs(startDate).subtract(1, 'month').format('YYYY-MM-DD') | ||||||
|  |         : null, | ||||||
|  |       max_date: endDate | ||||||
|  |         ? dayjs(endDate).add(1, 'month').format('YYYY-MM-DD') | ||||||
|  |         : null, | ||||||
|  |       search: searchTerm | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     return params; | ||||||
|  |   }, [startDate, endDate, searchTerm, filterSet.activeFilters, queryParams]); | ||||||
|  |  | ||||||
|  |   const query = useQuery({ | ||||||
|  |     enabled: !!startDate && !!endDate, | ||||||
|  |     queryKey: ['calendar', name, endpoint, queryFilters], | ||||||
|  |     queryFn: async () => { | ||||||
|  |       // Fetch data from the API | ||||||
|  |       return api | ||||||
|  |         .get(apiUrl(endpoint), { | ||||||
|  |           params: queryFilters | ||||||
|  |         }) | ||||||
|  |         .then((response) => { | ||||||
|  |           return response.data ?? []; | ||||||
|  |         }) | ||||||
|  |         .catch((error) => { | ||||||
|  |           showApiErrorMessage({ | ||||||
|  |             error: error, | ||||||
|  |             title: 'Error fetching calendar data' | ||||||
|  |           }); | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   // Navigate to the previous month | ||||||
|  |   const prevMonth = useCallback(() => { | ||||||
|  |     ref.current?.getApi().prev(); | ||||||
|  |   }, [ref]); | ||||||
|  |  | ||||||
|  |   // Navigate to the next month | ||||||
|  |   const nextMonth = useCallback(() => { | ||||||
|  |     ref.current?.getApi().next(); | ||||||
|  |   }, [ref]); | ||||||
|  |  | ||||||
|  |   // Navigate to the current month | ||||||
|  |   const currentMonth = useCallback(() => { | ||||||
|  |     ref.current?.getApi().today(); | ||||||
|  |   }, [ref]); | ||||||
|  |  | ||||||
|  |   // Callback to select a specific month from a picker | ||||||
|  |   const selectMonth = useCallback( | ||||||
|  |     (date: DateValue) => { | ||||||
|  |       if (date && ref?.current) { | ||||||
|  |         const api = ref.current.getApi(); | ||||||
|  |  | ||||||
|  |         api.gotoDate(date); | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     [ref] | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   return { | ||||||
|  |     name, | ||||||
|  |     filterSet, | ||||||
|  |     ref, | ||||||
|  |     monthName, | ||||||
|  |     setMonthName, | ||||||
|  |     searchTerm, | ||||||
|  |     setSearchTerm, | ||||||
|  |     nextMonth, | ||||||
|  |     prevMonth, | ||||||
|  |     currentMonth, | ||||||
|  |     selectMonth, | ||||||
|  |     startDate, | ||||||
|  |     setStartDate, | ||||||
|  |     endDate, | ||||||
|  |     setEndDate, | ||||||
|  |     query: query, | ||||||
|  |     data: query.data | ||||||
|  |   }; | ||||||
|  | } | ||||||
							
								
								
									
										40
									
								
								src/frontend/src/hooks/UseFilterSet.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,40 @@ | |||||||
|  | import { useLocalStorage } from '@mantine/hooks'; | ||||||
|  | import { useCallback } from 'react'; | ||||||
|  | import type { TableFilter } from '../tables/Filter'; | ||||||
|  |  | ||||||
|  | /* | ||||||
|  |  * Type definition for representing the state of a group of filters. | ||||||
|  |  * These may be applied to a data view (e.g. table, calendar) to filter the displayed data. | ||||||
|  |  * | ||||||
|  |  * filterKey: A unique key for the filter set | ||||||
|  |  * activeFilters: An array of active filters | ||||||
|  |  * setActiveFilters: A function to set the active filters | ||||||
|  |  * clearActiveFilters: A function to clear all active filters | ||||||
|  |  */ | ||||||
|  | export type FilterSetState = { | ||||||
|  |   filterKey: string; | ||||||
|  |   activeFilters: TableFilter[]; | ||||||
|  |   setActiveFilters: (filters: TableFilter[]) => void; | ||||||
|  |   clearActiveFilters: () => void; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export function useFilterSet(filterKey: string): FilterSetState { | ||||||
|  |   // Array of active filters (saved to local storage) | ||||||
|  |   const [activeFilters, setActiveFilters] = useLocalStorage<TableFilter[]>({ | ||||||
|  |     key: `inventree-filterset-${filterKey}`, | ||||||
|  |     defaultValue: [], | ||||||
|  |     getInitialValueInEffect: false | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   // Callback to clear all active filters from the table | ||||||
|  |   const clearActiveFilters = useCallback(() => { | ||||||
|  |     setActiveFilters([]); | ||||||
|  |   }, []); | ||||||
|  |  | ||||||
|  |   return { | ||||||
|  |     filterKey, | ||||||
|  |     activeFilters, | ||||||
|  |     setActiveFilters, | ||||||
|  |     clearActiveFilters | ||||||
|  |   }; | ||||||
|  | } | ||||||
| @@ -2,7 +2,7 @@ import { randomId, useLocalStorage } from '@mantine/hooks'; | |||||||
| import { useCallback, useMemo, useState } from 'react'; | import { useCallback, useMemo, useState } from 'react'; | ||||||
| import { type SetURLSearchParams, useSearchParams } from 'react-router-dom'; | import { type SetURLSearchParams, useSearchParams } from 'react-router-dom'; | ||||||
|  |  | ||||||
| import type { TableFilter } from '../tables/Filter'; | import { type FilterSetState, useFilterSet } from './UseFilterSet'; | ||||||
|  |  | ||||||
| /* | /* | ||||||
|  * Type definition for representing the state of a table: |  * Type definition for representing the state of a table: | ||||||
| @@ -11,9 +11,7 @@ import type { TableFilter } from '../tables/Filter'; | |||||||
|  * refreshTable: A callback function to externally refresh the table. |  * refreshTable: A callback function to externally refresh the table. | ||||||
|  * isLoading: A boolean flag to indicate if the table is currently loading data |  * isLoading: A boolean flag to indicate if the table is currently loading data | ||||||
|  * setIsLoading: A function to set the isLoading flag |  * setIsLoading: A function to set the isLoading flag | ||||||
|  * activeFilters: An array of active filters (saved to local storage) |  * filterSet: A group of active filters | ||||||
|  * setActiveFilters: A function to set the active filters |  | ||||||
|  * clearActiveFilters: A function to clear all active filters |  | ||||||
|  * queryFilters: A map of query filters (e.g. ?active=true&overdue=false) passed in the URL |  * queryFilters: A map of query filters (e.g. ?active=true&overdue=false) passed in the URL | ||||||
|  * setQueryFilters: A function to set the query filters |  * setQueryFilters: A function to set the query filters | ||||||
|  * clearQueryFilters: A function to clear all query filters |  * clearQueryFilters: A function to clear all query filters | ||||||
| @@ -45,9 +43,7 @@ export type TableState = { | |||||||
|   refreshTable: () => void; |   refreshTable: () => void; | ||||||
|   isLoading: boolean; |   isLoading: boolean; | ||||||
|   setIsLoading: (value: boolean) => void; |   setIsLoading: (value: boolean) => void; | ||||||
|   activeFilters: TableFilter[]; |   filterSet: FilterSetState; | ||||||
|   setActiveFilters: (filters: TableFilter[]) => void; |  | ||||||
|   clearActiveFilters: () => void; |  | ||||||
|   queryFilters: URLSearchParams; |   queryFilters: URLSearchParams; | ||||||
|   setQueryFilters: SetURLSearchParams; |   setQueryFilters: SetURLSearchParams; | ||||||
|   clearQueryFilters: () => void; |   clearQueryFilters: () => void; | ||||||
| @@ -101,17 +97,7 @@ export function useTable(tableName: string, idAccessor = 'pk'): TableState { | |||||||
|     setTableKey(generateTableName()); |     setTableKey(generateTableName()); | ||||||
|   }, [generateTableName]); |   }, [generateTableName]); | ||||||
|  |  | ||||||
|   // Array of active filters (saved to local storage) |   const filterSet: FilterSetState = useFilterSet(`table-${tableName}`); | ||||||
|   const [activeFilters, setActiveFilters] = useLocalStorage<TableFilter[]>({ |  | ||||||
|     key: `inventree-table-filters-${tableName}`, |  | ||||||
|     defaultValue: [], |  | ||||||
|     getInitialValueInEffect: false |  | ||||||
|   }); |  | ||||||
|  |  | ||||||
|   // Callback to clear all active filters from the table |  | ||||||
|   const clearActiveFilters = useCallback(() => { |  | ||||||
|     setActiveFilters([]); |  | ||||||
|   }, []); |  | ||||||
|  |  | ||||||
|   // Array of expanded records |   // Array of expanded records | ||||||
|   const [expandedRecords, setExpandedRecords] = useState<any[]>([]); |   const [expandedRecords, setExpandedRecords] = useState<any[]>([]); | ||||||
| @@ -194,9 +180,7 @@ export function useTable(tableName: string, idAccessor = 'pk'): TableState { | |||||||
|     refreshTable, |     refreshTable, | ||||||
|     isLoading, |     isLoading, | ||||||
|     setIsLoading, |     setIsLoading, | ||||||
|     activeFilters, |     filterSet, | ||||||
|     setActiveFilters, |  | ||||||
|     clearActiveFilters, |  | ||||||
|     queryFilters, |     queryFilters, | ||||||
|     setQueryFilters, |     setQueryFilters, | ||||||
|     clearQueryFilters, |     clearQueryFilters, | ||||||
|   | |||||||
| @@ -181,6 +181,40 @@ export default function BuildDetail() { | |||||||
|         badge: 'owner', |         badge: 'owner', | ||||||
|         hidden: !build.responsible |         hidden: !build.responsible | ||||||
|       }, |       }, | ||||||
|  |       { | ||||||
|  |         type: 'text', | ||||||
|  |         name: 'project_code_label', | ||||||
|  |         label: t`Project Code`, | ||||||
|  |         icon: 'reference', | ||||||
|  |         copy: true, | ||||||
|  |         hidden: !build.project_code | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         type: 'link', | ||||||
|  |         name: 'take_from', | ||||||
|  |         icon: 'location', | ||||||
|  |         model: ModelType.stocklocation, | ||||||
|  |         label: t`Source Location`, | ||||||
|  |         backup_value: t`Any location` | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         type: 'link', | ||||||
|  |         name: 'destination', | ||||||
|  |         icon: 'location', | ||||||
|  |         model: ModelType.stocklocation, | ||||||
|  |         label: t`Destination Location`, | ||||||
|  |         hidden: !build.destination | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         type: 'text', | ||||||
|  |         name: 'batch', | ||||||
|  |         label: t`Batch Code`, | ||||||
|  |         hidden: !build.batch, | ||||||
|  |         copy: true | ||||||
|  |       } | ||||||
|  |     ]; | ||||||
|  |  | ||||||
|  |     const br: DetailsField[] = [ | ||||||
|       { |       { | ||||||
|         type: 'date', |         type: 'date', | ||||||
|         name: 'creation_date', |         name: 'creation_date', | ||||||
| @@ -212,40 +246,6 @@ export default function BuildDetail() { | |||||||
|         icon: 'calendar', |         icon: 'calendar', | ||||||
|         copy: true, |         copy: true, | ||||||
|         hidden: !build.completion_date |         hidden: !build.completion_date | ||||||
|       }, |  | ||||||
|       { |  | ||||||
|         type: 'text', |  | ||||||
|         name: 'project_code_label', |  | ||||||
|         label: t`Project Code`, |  | ||||||
|         icon: 'reference', |  | ||||||
|         copy: true, |  | ||||||
|         hidden: !build.project_code |  | ||||||
|       } |  | ||||||
|     ]; |  | ||||||
|  |  | ||||||
|     const br: DetailsField[] = [ |  | ||||||
|       { |  | ||||||
|         type: 'link', |  | ||||||
|         name: 'take_from', |  | ||||||
|         icon: 'location', |  | ||||||
|         model: ModelType.stocklocation, |  | ||||||
|         label: t`Source Location`, |  | ||||||
|         backup_value: t`Any location` |  | ||||||
|       }, |  | ||||||
|       { |  | ||||||
|         type: 'link', |  | ||||||
|         name: 'destination', |  | ||||||
|         icon: 'location', |  | ||||||
|         model: ModelType.stocklocation, |  | ||||||
|         label: t`Destination Location`, |  | ||||||
|         hidden: !build.destination |  | ||||||
|       }, |  | ||||||
|       { |  | ||||||
|         type: 'text', |  | ||||||
|         name: 'batch', |  | ||||||
|         label: t`Batch Code`, |  | ||||||
|         hidden: !build.batch, |  | ||||||
|         copy: true |  | ||||||
|       } |       } | ||||||
|     ]; |     ]; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,35 +1,89 @@ | |||||||
| import { t } from '@lingui/macro'; | import { t } from '@lingui/macro'; | ||||||
| import { Stack } from '@mantine/core'; | import { Stack } from '@mantine/core'; | ||||||
| import { IconTools } from '@tabler/icons-react'; | import { IconCalendar, IconTable, IconTools } from '@tabler/icons-react'; | ||||||
| import { useMemo } from 'react'; | import { useMemo } from 'react'; | ||||||
|  |  | ||||||
|  | import { useLocalStorage } from '@mantine/hooks'; | ||||||
|  | import SegmentedIconControl from '../../components/buttons/SegmentedIconControl'; | ||||||
|  | import OrderCalendar from '../../components/calendar/OrderCalendar'; | ||||||
| import PermissionDenied from '../../components/errors/PermissionDenied'; | import PermissionDenied from '../../components/errors/PermissionDenied'; | ||||||
| import { PageDetail } from '../../components/nav/PageDetail'; | import { PageDetail } from '../../components/nav/PageDetail'; | ||||||
|  | import type { PanelType } from '../../components/panels/Panel'; | ||||||
| import { PanelGroup } from '../../components/panels/PanelGroup'; | import { PanelGroup } from '../../components/panels/PanelGroup'; | ||||||
|  | import { ModelType } from '../../enums/ModelType'; | ||||||
| import { UserRoles } from '../../enums/Roles'; | import { UserRoles } from '../../enums/Roles'; | ||||||
| import { useUserState } from '../../states/UserState'; | import { useUserState } from '../../states/UserState'; | ||||||
|  | import { PartCategoryFilter, type TableFilter } from '../../tables/Filter'; | ||||||
| import { BuildOrderTable } from '../../tables/build/BuildOrderTable'; | import { BuildOrderTable } from '../../tables/build/BuildOrderTable'; | ||||||
|  |  | ||||||
|  | function BuildOrderCalendar() { | ||||||
|  |   const calendarFilters: TableFilter[] = useMemo(() => { | ||||||
|  |     return [PartCategoryFilter()]; | ||||||
|  |   }, []); | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <OrderCalendar | ||||||
|  |       model={ModelType.build} | ||||||
|  |       role={UserRoles.build} | ||||||
|  |       params={{ outstanding: true }} | ||||||
|  |       filters={calendarFilters} | ||||||
|  |     /> | ||||||
|  |   ); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function BuildOverview({ | ||||||
|  |   view | ||||||
|  | }: { | ||||||
|  |   view: string; | ||||||
|  | }) { | ||||||
|  |   switch (view) { | ||||||
|  |     case 'calendar': | ||||||
|  |       return <BuildOrderCalendar />; | ||||||
|  |     case 'table': | ||||||
|  |     default: | ||||||
|  |       return <BuildOrderTable />; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * Build Order index page |  * Build Order index page | ||||||
|  */ |  */ | ||||||
| export default function BuildIndex() { | export default function BuildIndex() { | ||||||
|   const user = useUserState(); |   const user = useUserState(); | ||||||
|  |  | ||||||
|   if (!user.isLoggedIn() || !user.hasViewRole(UserRoles.build)) { |   const [buildOrderView, setBuildOrderView] = useLocalStorage<string>({ | ||||||
|     return <PermissionDenied />; |     key: 'buildOrderView', | ||||||
|   } |     defaultValue: 'table' | ||||||
|  |   }); | ||||||
|  |  | ||||||
|   const panels = useMemo(() => { |   const panels: PanelType[] = useMemo(() => { | ||||||
|     return [ |     return [ | ||||||
|       { |       { | ||||||
|         name: 'buildorders', |         name: 'buildorders', | ||||||
|         label: t`Build Orders`, |         label: t`Build Orders`, | ||||||
|         content: <BuildOrderTable />, |         content: <BuildOverview view={buildOrderView} />, | ||||||
|         icon: <IconTools /> |         icon: <IconTools />, | ||||||
|  |         controls: ( | ||||||
|  |           <SegmentedIconControl | ||||||
|  |             value={buildOrderView} | ||||||
|  |             onChange={setBuildOrderView} | ||||||
|  |             data={[ | ||||||
|  |               { value: 'table', label: t`Table View`, icon: <IconTable /> }, | ||||||
|  |               { | ||||||
|  |                 value: 'calendar', | ||||||
|  |                 label: t`Calendar View`, | ||||||
|  |                 icon: <IconCalendar /> | ||||||
|  |               } | ||||||
|  |             ]} | ||||||
|  |           /> | ||||||
|  |         ) | ||||||
|       } |       } | ||||||
|     ]; |     ]; | ||||||
|   }, []); |   }, [buildOrderView, setBuildOrderView]); | ||||||
|  |  | ||||||
|  |   if (!user.isLoggedIn() || !user.hasViewRole(UserRoles.build)) { | ||||||
|  |     return <PermissionDenied />; | ||||||
|  |   } | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <Stack> |     <Stack> | ||||||
|   | |||||||
| @@ -12,6 +12,7 @@ import { | |||||||
| import { type ReactNode, useMemo } from 'react'; | import { type ReactNode, useMemo } from 'react'; | ||||||
| import { useNavigate } from 'react-router-dom'; | import { useNavigate } from 'react-router-dom'; | ||||||
|  |  | ||||||
|  | import dayjs from 'dayjs'; | ||||||
| import { formatDate } from '../../defaults/formatters'; | import { formatDate } from '../../defaults/formatters'; | ||||||
| import { ApiEndpoints } from '../../enums/ApiEndpoints'; | import { ApiEndpoints } from '../../enums/ApiEndpoints'; | ||||||
| import { navigateToLink } from '../../functions/navigation'; | import { navigateToLink } from '../../functions/navigation'; | ||||||
| @@ -32,7 +33,7 @@ function ChartTooltip({ label, payload }: Readonly<ChartTooltipProps>) { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   if (label && typeof label == 'number') { |   if (label && typeof label == 'number') { | ||||||
|     label = formatDate(new Date(label).toISOString()); |     label = formatDate(dayjs().format('YYYY-MM-DD')); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   const scheduled = payload.find((item) => item.name == 'scheduled'); |   const scheduled = payload.find((item) => item.name == 'scheduled'); | ||||||
| @@ -154,7 +155,6 @@ export default function PartSchedulingDetail({ | |||||||
|     // Construct initial chart entry (for today) |     // Construct initial chart entry (for today) | ||||||
|     const entries: any[] = [ |     const entries: any[] = [ | ||||||
|       { |       { | ||||||
|         // date: formatDate(today.toISOString()), |  | ||||||
|         date: today.valueOf(), |         date: today.valueOf(), | ||||||
|         delta: 0, |         delta: 0, | ||||||
|         scheduled: stock, |         scheduled: stock, | ||||||
| @@ -282,7 +282,7 @@ export default function PartSchedulingDetail({ | |||||||
|               scale: 'time', |               scale: 'time', | ||||||
|               type: 'number', |               type: 'number', | ||||||
|               tickFormatter: (value: number) => { |               tickFormatter: (value: number) => { | ||||||
|                 return formatDate(new Date(value).toISOString()); |                 return formatDate(dayjs().format('YYYY-MM-DD')); | ||||||
|               } |               } | ||||||
|             }} |             }} | ||||||
|             series={[ |             series={[ | ||||||
|   | |||||||
| @@ -10,6 +10,7 @@ import { | |||||||
| } from '@mantine/core'; | } from '@mantine/core'; | ||||||
| import { useCallback, useMemo, useState } from 'react'; | import { useCallback, useMemo, useState } from 'react'; | ||||||
|  |  | ||||||
|  | import dayjs from 'dayjs'; | ||||||
| import { AddItemButton } from '../../components/buttons/AddItemButton'; | import { AddItemButton } from '../../components/buttons/AddItemButton'; | ||||||
| import { formatDate, formatPriceRange } from '../../defaults/formatters'; | import { formatDate, formatPriceRange } from '../../defaults/formatters'; | ||||||
| import { ApiEndpoints } from '../../enums/ApiEndpoints'; | import { ApiEndpoints } from '../../enums/ApiEndpoints'; | ||||||
| @@ -36,7 +37,7 @@ import { RowDeleteAction, RowEditAction } from '../../tables/RowActions'; | |||||||
| function ChartTooltip({ label, payload }: Readonly<ChartTooltipProps>) { | function ChartTooltip({ label, payload }: Readonly<ChartTooltipProps>) { | ||||||
|   const formattedLabel: string = useMemo(() => { |   const formattedLabel: string = useMemo(() => { | ||||||
|     if (label && typeof label === 'number') { |     if (label && typeof label === 'number') { | ||||||
|       return formatDate(new Date(label).toISOString()) ?? label; |       return formatDate(dayjs().format('YYYY-MM-DD')) ?? label; | ||||||
|     } else if (!!label) { |     } else if (!!label) { | ||||||
|       return label.toString(); |       return label.toString(); | ||||||
|     } else { |     } else { | ||||||
| @@ -253,7 +254,7 @@ export default function PartStocktakeDetail({ | |||||||
|               type: 'number', |               type: 'number', | ||||||
|               domain: chartLimits, |               domain: chartLimits, | ||||||
|               tickFormatter: (value: number) => { |               tickFormatter: (value: number) => { | ||||||
|                 return formatDate(new Date(value).toISOString()); |                 return formatDate(dayjs().format('YYYY-MM-DD')); | ||||||
|               } |               } | ||||||
|             }} |             }} | ||||||
|             series={[ |             series={[ | ||||||
|   | |||||||
| @@ -4,14 +4,20 @@ import { | |||||||
|   IconBuildingFactory2, |   IconBuildingFactory2, | ||||||
|   IconBuildingStore, |   IconBuildingStore, | ||||||
|   IconBuildingWarehouse, |   IconBuildingWarehouse, | ||||||
|  |   IconCalendar, | ||||||
|   IconPackageExport, |   IconPackageExport, | ||||||
|   IconShoppingCart |   IconShoppingCart, | ||||||
|  |   IconTable | ||||||
| } from '@tabler/icons-react'; | } from '@tabler/icons-react'; | ||||||
| import { useMemo } from 'react'; | import { useMemo } from 'react'; | ||||||
|  |  | ||||||
|  | import { useLocalStorage } from '@mantine/hooks'; | ||||||
|  | import SegmentedIconControl from '../../components/buttons/SegmentedIconControl'; | ||||||
|  | import OrderCalendar from '../../components/calendar/OrderCalendar'; | ||||||
| import PermissionDenied from '../../components/errors/PermissionDenied'; | import PermissionDenied from '../../components/errors/PermissionDenied'; | ||||||
| import { PageDetail } from '../../components/nav/PageDetail'; | import { PageDetail } from '../../components/nav/PageDetail'; | ||||||
| import { PanelGroup } from '../../components/panels/PanelGroup'; | import { PanelGroup } from '../../components/panels/PanelGroup'; | ||||||
|  | import { ModelType } from '../../enums/ModelType'; | ||||||
| import { UserRoles } from '../../enums/Roles'; | import { UserRoles } from '../../enums/Roles'; | ||||||
| import { useUserState } from '../../states/UserState'; | import { useUserState } from '../../states/UserState'; | ||||||
| import { CompanyTable } from '../../tables/company/CompanyTable'; | import { CompanyTable } from '../../tables/company/CompanyTable'; | ||||||
| @@ -19,17 +25,56 @@ import { ManufacturerPartTable } from '../../tables/purchasing/ManufacturerPartT | |||||||
| import { PurchaseOrderTable } from '../../tables/purchasing/PurchaseOrderTable'; | import { PurchaseOrderTable } from '../../tables/purchasing/PurchaseOrderTable'; | ||||||
| import { SupplierPartTable } from '../../tables/purchasing/SupplierPartTable'; | import { SupplierPartTable } from '../../tables/purchasing/SupplierPartTable'; | ||||||
|  |  | ||||||
|  | function PurchaseOrderOverview({ | ||||||
|  |   view | ||||||
|  | }: { | ||||||
|  |   view: string; | ||||||
|  | }) { | ||||||
|  |   switch (view) { | ||||||
|  |     case 'calendar': | ||||||
|  |       return ( | ||||||
|  |         <OrderCalendar | ||||||
|  |           model={ModelType.purchaseorder} | ||||||
|  |           role={UserRoles.purchase_order} | ||||||
|  |           params={{ outstanding: true }} | ||||||
|  |         /> | ||||||
|  |       ); | ||||||
|  |     case 'table': | ||||||
|  |     default: | ||||||
|  |       return <PurchaseOrderTable />; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
| export default function PurchasingIndex() { | export default function PurchasingIndex() { | ||||||
|   const user = useUserState(); |   const user = useUserState(); | ||||||
|  |  | ||||||
|  |   const [purchaseOrderView, setpurchaseOrderView] = useLocalStorage<string>({ | ||||||
|  |     key: 'purchaseOrderView', | ||||||
|  |     defaultValue: 'table' | ||||||
|  |   }); | ||||||
|  |  | ||||||
|   const panels = useMemo(() => { |   const panels = useMemo(() => { | ||||||
|     return [ |     return [ | ||||||
|       { |       { | ||||||
|         name: 'purchaseorders', |         name: 'purchaseorders', | ||||||
|         label: t`Purchase Orders`, |         label: t`Purchase Orders`, | ||||||
|         icon: <IconShoppingCart />, |         icon: <IconShoppingCart />, | ||||||
|         content: <PurchaseOrderTable />, |         hidden: !user.hasViewRole(UserRoles.purchase_order), | ||||||
|         hidden: !user.hasViewRole(UserRoles.purchase_order) |         content: <PurchaseOrderOverview view={purchaseOrderView} />, | ||||||
|  |         controls: ( | ||||||
|  |           <SegmentedIconControl | ||||||
|  |             value={purchaseOrderView} | ||||||
|  |             onChange={setpurchaseOrderView} | ||||||
|  |             data={[ | ||||||
|  |               { value: 'table', label: t`Table View`, icon: <IconTable /> }, | ||||||
|  |               { | ||||||
|  |                 value: 'calendar', | ||||||
|  |                 label: t`Calendar View`, | ||||||
|  |                 icon: <IconCalendar /> | ||||||
|  |               } | ||||||
|  |             ]} | ||||||
|  |           /> | ||||||
|  |         ) | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         name: 'suppliers', |         name: 'suppliers', | ||||||
| @@ -66,7 +111,7 @@ export default function PurchasingIndex() { | |||||||
|         content: <ManufacturerPartTable params={{}} /> |         content: <ManufacturerPartTable params={{}} /> | ||||||
|       } |       } | ||||||
|     ]; |     ]; | ||||||
|   }, [user]); |   }, [user, purchaseOrderView]); | ||||||
|  |  | ||||||
|   if (!user.isLoggedIn() || !user.hasViewRole(UserRoles.purchase_order)) { |   if (!user.isLoggedIn() || !user.hasViewRole(UserRoles.purchase_order)) { | ||||||
|     return <PermissionDenied />; |     return <PermissionDenied />; | ||||||
|   | |||||||
| @@ -2,37 +2,121 @@ import { t } from '@lingui/macro'; | |||||||
| import { Stack } from '@mantine/core'; | import { Stack } from '@mantine/core'; | ||||||
| import { | import { | ||||||
|   IconBuildingStore, |   IconBuildingStore, | ||||||
|  |   IconCalendar, | ||||||
|  |   IconTable, | ||||||
|   IconTruckDelivery, |   IconTruckDelivery, | ||||||
|   IconTruckReturn |   IconTruckReturn | ||||||
| } from '@tabler/icons-react'; | } from '@tabler/icons-react'; | ||||||
| import { useMemo } from 'react'; | import { useMemo } from 'react'; | ||||||
|  |  | ||||||
|  | import { useLocalStorage } from '@mantine/hooks'; | ||||||
|  | import SegmentedIconControl from '../../components/buttons/SegmentedIconControl'; | ||||||
|  | import OrderCalendar from '../../components/calendar/OrderCalendar'; | ||||||
| import PermissionDenied from '../../components/errors/PermissionDenied'; | import PermissionDenied from '../../components/errors/PermissionDenied'; | ||||||
| import { PageDetail } from '../../components/nav/PageDetail'; | import { PageDetail } from '../../components/nav/PageDetail'; | ||||||
| import { PanelGroup } from '../../components/panels/PanelGroup'; | import { PanelGroup } from '../../components/panels/PanelGroup'; | ||||||
|  | import { ModelType } from '../../enums/ModelType'; | ||||||
| import { UserRoles } from '../../enums/Roles'; | import { UserRoles } from '../../enums/Roles'; | ||||||
| import { useUserState } from '../../states/UserState'; | import { useUserState } from '../../states/UserState'; | ||||||
| import { CompanyTable } from '../../tables/company/CompanyTable'; | import { CompanyTable } from '../../tables/company/CompanyTable'; | ||||||
| import { ReturnOrderTable } from '../../tables/sales/ReturnOrderTable'; | import { ReturnOrderTable } from '../../tables/sales/ReturnOrderTable'; | ||||||
| import { SalesOrderTable } from '../../tables/sales/SalesOrderTable'; | import { SalesOrderTable } from '../../tables/sales/SalesOrderTable'; | ||||||
|  |  | ||||||
| export default function PurchasingIndex() { | function SalesOrderOverview({ | ||||||
|  |   view | ||||||
|  | }: { | ||||||
|  |   view: string; | ||||||
|  | }) { | ||||||
|  |   switch (view) { | ||||||
|  |     case 'calendar': | ||||||
|  |       return ( | ||||||
|  |         <OrderCalendar | ||||||
|  |           model={ModelType.salesorder} | ||||||
|  |           role={UserRoles.sales_order} | ||||||
|  |           params={{ outstanding: true }} | ||||||
|  |         /> | ||||||
|  |       ); | ||||||
|  |     case 'table': | ||||||
|  |     default: | ||||||
|  |       return <SalesOrderTable />; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function ReturnOrderOverview({ | ||||||
|  |   view | ||||||
|  | }: { | ||||||
|  |   view: string; | ||||||
|  | }) { | ||||||
|  |   switch (view) { | ||||||
|  |     case 'calendar': | ||||||
|  |       return ( | ||||||
|  |         <OrderCalendar | ||||||
|  |           model={ModelType.returnorder} | ||||||
|  |           role={UserRoles.return_order} | ||||||
|  |           params={{ outstanding: true }} | ||||||
|  |         /> | ||||||
|  |       ); | ||||||
|  |     case 'table': | ||||||
|  |     default: | ||||||
|  |       return <ReturnOrderTable />; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export default function SalesIndex() { | ||||||
|   const user = useUserState(); |   const user = useUserState(); | ||||||
|  |  | ||||||
|  |   const [salesOrderView, setSalesOrderView] = useLocalStorage<string>({ | ||||||
|  |     key: 'salesOrderView', | ||||||
|  |     defaultValue: 'table' | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   const [returnOrderView, setReturnOrderView] = useLocalStorage<string>({ | ||||||
|  |     key: 'returnOrderView', | ||||||
|  |     defaultValue: 'table' | ||||||
|  |   }); | ||||||
|  |  | ||||||
|   const panels = useMemo(() => { |   const panels = useMemo(() => { | ||||||
|     return [ |     return [ | ||||||
|       { |       { | ||||||
|         name: 'salesorders', |         name: 'salesorders', | ||||||
|         label: t`Sales Orders`, |         label: t`Sales Orders`, | ||||||
|         icon: <IconTruckDelivery />, |         icon: <IconTruckDelivery />, | ||||||
|         content: <SalesOrderTable />, |         content: <SalesOrderOverview view={salesOrderView} />, | ||||||
|  |         controls: ( | ||||||
|  |           <SegmentedIconControl | ||||||
|  |             value={salesOrderView} | ||||||
|  |             onChange={setSalesOrderView} | ||||||
|  |             data={[ | ||||||
|  |               { value: 'table', label: t`Table View`, icon: <IconTable /> }, | ||||||
|  |               { | ||||||
|  |                 value: 'calendar', | ||||||
|  |                 label: t`Calendar View`, | ||||||
|  |                 icon: <IconCalendar /> | ||||||
|  |               } | ||||||
|  |             ]} | ||||||
|  |           /> | ||||||
|  |         ), | ||||||
|         hidden: !user.hasViewRole(UserRoles.sales_order) |         hidden: !user.hasViewRole(UserRoles.sales_order) | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         name: 'returnorders', |         name: 'returnorders', | ||||||
|         label: t`Return Orders`, |         label: t`Return Orders`, | ||||||
|         icon: <IconTruckReturn />, |         icon: <IconTruckReturn />, | ||||||
|         content: <ReturnOrderTable />, |         content: <ReturnOrderOverview view={returnOrderView} />, | ||||||
|  |         controls: ( | ||||||
|  |           <SegmentedIconControl | ||||||
|  |             value={returnOrderView} | ||||||
|  |             onChange={setReturnOrderView} | ||||||
|  |             data={[ | ||||||
|  |               { value: 'table', label: t`Table View`, icon: <IconTable /> }, | ||||||
|  |               { | ||||||
|  |                 value: 'calendar', | ||||||
|  |                 label: t`Calendar View`, | ||||||
|  |                 icon: <IconCalendar /> | ||||||
|  |               } | ||||||
|  |             ]} | ||||||
|  |           /> | ||||||
|  |         ), | ||||||
|         hidden: !user.hasViewRole(UserRoles.return_order) |         hidden: !user.hasViewRole(UserRoles.return_order) | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
| @@ -44,7 +128,7 @@ export default function PurchasingIndex() { | |||||||
|         ) |         ) | ||||||
|       } |       } | ||||||
|     ]; |     ]; | ||||||
|   }, [user]); |   }, [user, salesOrderView, returnOrderView]); | ||||||
|  |  | ||||||
|   if (!user.isLoggedIn() || !user.hasViewRole(UserRoles.sales_order)) { |   if (!user.isLoggedIn() || !user.hasViewRole(UserRoles.sales_order)) { | ||||||
|     return <PermissionDenied />; |     return <PermissionDenied />; | ||||||
|   | |||||||
| @@ -4,6 +4,7 @@ import { IconBookmark, IconInfoCircle } from '@tabler/icons-react'; | |||||||
| import { useMemo } from 'react'; | import { useMemo } from 'react'; | ||||||
| import { useNavigate, useParams } from 'react-router-dom'; | import { useNavigate, useParams } from 'react-router-dom'; | ||||||
|  |  | ||||||
|  | import dayjs from 'dayjs'; | ||||||
| import PrimaryActionButton from '../../components/buttons/PrimaryActionButton'; | import PrimaryActionButton from '../../components/buttons/PrimaryActionButton'; | ||||||
| import { PrintingActions } from '../../components/buttons/PrintingActions'; | import { PrintingActions } from '../../components/buttons/PrintingActions'; | ||||||
| import { | import { | ||||||
| @@ -263,7 +264,7 @@ export default function SalesOrderShipmentDetail() { | |||||||
|     focus: 'tracking_number', |     focus: 'tracking_number', | ||||||
|     initialData: { |     initialData: { | ||||||
|       ...shipment, |       ...shipment, | ||||||
|       shipment_date: new Date().toISOString().split('T')[0] |       shipment_date: dayjs().format('YYYY-MM-DD') | ||||||
|     }, |     }, | ||||||
|     onFormSuccess: refreshShipment |     onFormSuccess: refreshShipment | ||||||
|   }); |   }); | ||||||
|   | |||||||
| @@ -15,7 +15,7 @@ import { | |||||||
| export function DownloadAction({ | export function DownloadAction({ | ||||||
|   downloadCallback |   downloadCallback | ||||||
| }: Readonly<{ | }: Readonly<{ | ||||||
|   downloadCallback: (fileFormat: string) => void; |   downloadCallback?: (fileFormat: string) => void; | ||||||
| }>) { | }>) { | ||||||
|   const formatOptions = [ |   const formatOptions = [ | ||||||
|     { value: 'csv', label: t`CSV`, icon: <IconFileTypeCsv /> }, |     { value: 'csv', label: t`CSV`, icon: <IconFileTypeCsv /> }, | ||||||
| @@ -27,13 +27,14 @@ export function DownloadAction({ | |||||||
|     return formatOptions.map((format) => ({ |     return formatOptions.map((format) => ({ | ||||||
|       name: format.label, |       name: format.label, | ||||||
|       icon: format.icon, |       icon: format.icon, | ||||||
|       onClick: () => downloadCallback(format.value) |       onClick: () => downloadCallback?.(format.value) | ||||||
|     })); |     })); | ||||||
|   }, [formatOptions, downloadCallback]); |   }, [formatOptions, downloadCallback]); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <ActionDropdown |     <ActionDropdown | ||||||
|       tooltip={t`Download Data`} |       tooltip={t`Download Data`} | ||||||
|  |       tooltipPosition='top-end' | ||||||
|       icon={<IconDownload />} |       icon={<IconDownload />} | ||||||
|       actions={actions} |       actions={actions} | ||||||
|     /> |     /> | ||||||
|   | |||||||
| @@ -335,3 +335,14 @@ export function IssuedByFilter(): TableFilter { | |||||||
|     description: t`Filter by user who issued the order` |     description: t`Filter by user who issued the order` | ||||||
|   }); |   }); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | export function PartCategoryFilter(): TableFilter { | ||||||
|  |   return { | ||||||
|  |     name: 'category', | ||||||
|  |     label: t`Category`, | ||||||
|  |     description: t`Filter by part category`, | ||||||
|  |     apiUrl: apiUrl(ApiEndpoints.category_list), | ||||||
|  |     model: ModelType.partcategory, | ||||||
|  |     modelRenderer: (instance: any) => instance.name | ||||||
|  |   }; | ||||||
|  | } | ||||||
|   | |||||||
| @@ -21,7 +21,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; | |||||||
| import { IconCheck } from '@tabler/icons-react'; | import { IconCheck } from '@tabler/icons-react'; | ||||||
| import { StandaloneField } from '../components/forms/StandaloneField'; | import { StandaloneField } from '../components/forms/StandaloneField'; | ||||||
| import { StylishText } from '../components/items/StylishText'; | import { StylishText } from '../components/items/StylishText'; | ||||||
| import type { TableState } from '../hooks/UseTable'; | import type { FilterSetState } from '../hooks/UseFilterSet'; | ||||||
| import { | import { | ||||||
|   type TableFilter, |   type TableFilter, | ||||||
|   type TableFilterChoice, |   type TableFilterChoice, | ||||||
| @@ -34,16 +34,16 @@ import { | |||||||
|  */ |  */ | ||||||
| function FilterItem({ | function FilterItem({ | ||||||
|   flt, |   flt, | ||||||
|   tableState |   filterSet | ||||||
| }: Readonly<{ | }: Readonly<{ | ||||||
|   flt: TableFilter; |   flt: TableFilter; | ||||||
|   tableState: TableState; |   filterSet: FilterSetState; | ||||||
| }>) { | }>) { | ||||||
|   const removeFilter = useCallback(() => { |   const removeFilter = useCallback(() => { | ||||||
|     const newFilters = tableState.activeFilters.filter( |     const newFilters = filterSet.activeFilters.filter( | ||||||
|       (f) => f.name !== flt.name |       (f) => f.name !== flt.name | ||||||
|     ); |     ); | ||||||
|     tableState.setActiveFilters(newFilters); |     filterSet.setActiveFilters(newFilters); | ||||||
|   }, [flt]); |   }, [flt]); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
| @@ -93,7 +93,7 @@ function FilterElement({ | |||||||
|     case 'api': |     case 'api': | ||||||
|       return ( |       return ( | ||||||
|         <StandaloneField |         <StandaloneField | ||||||
|           fieldName={`filter_value_${filterName}`} |           fieldName={`filter-${filterName}`} | ||||||
|           fieldDefinition={{ |           fieldDefinition={{ | ||||||
|             field_type: 'related field', |             field_type: 'related field', | ||||||
|             api_url: filterProps.apiUrl, |             api_url: filterProps.apiUrl, | ||||||
| @@ -154,19 +154,18 @@ function FilterElement({ | |||||||
| } | } | ||||||
|  |  | ||||||
| function FilterAddGroup({ | function FilterAddGroup({ | ||||||
|   tableState, |   filterSet, | ||||||
|   availableFilters |   availableFilters | ||||||
| }: Readonly<{ | }: Readonly<{ | ||||||
|   tableState: TableState; |   filterSet: FilterSetState; | ||||||
|   availableFilters: TableFilter[]; |   availableFilters: TableFilter[]; | ||||||
| }>) { | }>) { | ||||||
|   const filterOptions: TableFilterChoice[] = useMemo(() => { |   const filterOptions: TableFilterChoice[] = useMemo(() => { | ||||||
|     // List of filter names which are already active on this table |     // List of filter names which are already active on this table | ||||||
|     let activeFilterNames: string[] = []; |     let activeFilterNames: string[] = []; | ||||||
|  |  | ||||||
|     if (tableState.activeFilters && tableState.activeFilters.length > 0) { |     if (filterSet.activeFilters && filterSet.activeFilters.length > 0) { | ||||||
|       activeFilterNames = |       activeFilterNames = filterSet.activeFilters?.map((flt) => flt.name) ?? []; | ||||||
|         tableState.activeFilters?.map((flt) => flt.name) ?? []; |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     return ( |     return ( | ||||||
| @@ -179,7 +178,7 @@ function FilterAddGroup({ | |||||||
|           description: flt.description |           description: flt.description | ||||||
|         })) ?? [] |         })) ?? [] | ||||||
|     ); |     ); | ||||||
|   }, [tableState.activeFilters, availableFilters]); |   }, [filterSet.activeFilters, availableFilters]); | ||||||
|  |  | ||||||
|   const [selectedFilter, setSelectedFilter] = useState<string | null>(null); |   const [selectedFilter, setSelectedFilter] = useState<string | null>(null); | ||||||
|  |  | ||||||
| @@ -232,9 +231,8 @@ function FilterAddGroup({ | |||||||
|       } |       } | ||||||
|  |  | ||||||
|       const filters = |       const filters = | ||||||
|         tableState.activeFilters?.filter( |         filterSet.activeFilters?.filter((flt) => flt.name !== selectedFilter) ?? | ||||||
|           (flt) => flt.name !== selectedFilter |         []; | ||||||
|         ) ?? []; |  | ||||||
|  |  | ||||||
|       const newFilter: TableFilter = { |       const newFilter: TableFilter = { | ||||||
|         ...filter, |         ...filter, | ||||||
| @@ -243,7 +241,7 @@ function FilterAddGroup({ | |||||||
|           displayValue ?? valueOptions.find((v) => v.value === value)?.label |           displayValue ?? valueOptions.find((v) => v.value === value)?.label | ||||||
|       }; |       }; | ||||||
|  |  | ||||||
|       tableState.setActiveFilters([...filters, newFilter]); |       filterSet.setActiveFilters([...filters, newFilter]); | ||||||
|  |  | ||||||
|       // Clear selected filter |       // Clear selected filter | ||||||
|       setSelectedFilter(null); |       setSelectedFilter(null); | ||||||
| @@ -275,13 +273,15 @@ function FilterAddGroup({ | |||||||
| } | } | ||||||
|  |  | ||||||
| export function FilterSelectDrawer({ | export function FilterSelectDrawer({ | ||||||
|  |   title, | ||||||
|   availableFilters, |   availableFilters, | ||||||
|   tableState, |   filterSet, | ||||||
|   opened, |   opened, | ||||||
|   onClose |   onClose | ||||||
| }: Readonly<{ | }: Readonly<{ | ||||||
|  |   title?: string; | ||||||
|   availableFilters: TableFilter[]; |   availableFilters: TableFilter[]; | ||||||
|   tableState: TableState; |   filterSet: FilterSetState; | ||||||
|   opened: boolean; |   opened: boolean; | ||||||
|   onClose: () => void; |   onClose: () => void; | ||||||
| }>) { | }>) { | ||||||
| @@ -290,13 +290,13 @@ export function FilterSelectDrawer({ | |||||||
|   // Hide the "add filter" selection whenever the selected filters change |   // Hide the "add filter" selection whenever the selected filters change | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     setAddFilter(false); |     setAddFilter(false); | ||||||
|   }, [tableState.activeFilters]); |   }, [filterSet.activeFilters]); | ||||||
|  |  | ||||||
|   const hasFilters: boolean = useMemo(() => { |   const hasFilters: boolean = useMemo(() => { | ||||||
|     const filters = tableState?.activeFilters ?? []; |     const filters = filterSet?.activeFilters ?? []; | ||||||
|  |  | ||||||
|     return filters.length > 0; |     return filters.length > 0; | ||||||
|   }, [tableState.activeFilters]); |   }, [filterSet.activeFilters]); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <Drawer |     <Drawer | ||||||
| @@ -308,18 +308,18 @@ export function FilterSelectDrawer({ | |||||||
|       closeButtonProps={{ |       closeButtonProps={{ | ||||||
|         'aria-label': 'filter-drawer-close' |         'aria-label': 'filter-drawer-close' | ||||||
|       }} |       }} | ||||||
|       title={<StylishText size='lg'>{t`Table Filters`}</StylishText>} |       title={<StylishText size='lg'>{title ?? t`Table Filters`}</StylishText>} | ||||||
|     > |     > | ||||||
|       <Stack gap='xs'> |       <Stack gap='xs'> | ||||||
|         {hasFilters && |         {hasFilters && | ||||||
|           tableState.activeFilters?.map((f) => ( |           filterSet.activeFilters?.map((f) => ( | ||||||
|             <FilterItem key={f.name} flt={f} tableState={tableState} /> |             <FilterItem key={f.name} flt={f} filterSet={filterSet} /> | ||||||
|           ))} |           ))} | ||||||
|         {hasFilters && <Divider />} |         {hasFilters && <Divider />} | ||||||
|         {addFilter && ( |         {addFilter && ( | ||||||
|           <Stack gap='xs'> |           <Stack gap='xs'> | ||||||
|             <FilterAddGroup |             <FilterAddGroup | ||||||
|               tableState={tableState} |               filterSet={filterSet} | ||||||
|               availableFilters={availableFilters} |               availableFilters={availableFilters} | ||||||
|             /> |             /> | ||||||
|           </Stack> |           </Stack> | ||||||
| @@ -334,7 +334,7 @@ export function FilterSelectDrawer({ | |||||||
|           </Button> |           </Button> | ||||||
|         )} |         )} | ||||||
|         {!addFilter && |         {!addFilter && | ||||||
|           tableState.activeFilters.length < availableFilters.length && ( |           filterSet.activeFilters.length < availableFilters.length && ( | ||||||
|             <Button |             <Button | ||||||
|               onClick={() => setAddFilter(true)} |               onClick={() => setAddFilter(true)} | ||||||
|               color='green' |               color='green' | ||||||
| @@ -343,9 +343,9 @@ export function FilterSelectDrawer({ | |||||||
|               <Text>{t`Add Filter`}</Text> |               <Text>{t`Add Filter`}</Text> | ||||||
|             </Button> |             </Button> | ||||||
|           )} |           )} | ||||||
|         {!addFilter && tableState.activeFilters.length > 0 && ( |         {!addFilter && filterSet.activeFilters.length > 0 && ( | ||||||
|           <Button |           <Button | ||||||
|             onClick={tableState.clearActiveFilters} |             onClick={filterSet.clearActiveFilters} | ||||||
|             color='red' |             color='red' | ||||||
|             variant='subtle' |             variant='subtle' | ||||||
|           > |           > | ||||||
|   | |||||||
| @@ -359,8 +359,8 @@ export function InvenTreeTable<T extends Record<string, any>>({ | |||||||
|       }; |       }; | ||||||
|  |  | ||||||
|       // Add custom filters |       // Add custom filters | ||||||
|       if (tableState.activeFilters) { |       if (tableState.filterSet.activeFilters) { | ||||||
|         tableState.activeFilters.forEach((flt) => { |         tableState.filterSet.activeFilters.forEach((flt) => { | ||||||
|           queryParams[flt.name] = flt.value; |           queryParams[flt.name] = flt.value; | ||||||
|         }); |         }); | ||||||
|       } |       } | ||||||
| @@ -401,7 +401,7 @@ export function InvenTreeTable<T extends Record<string, any>>({ | |||||||
|     [ |     [ | ||||||
|       tableProps.params, |       tableProps.params, | ||||||
|       tableProps.enablePagination, |       tableProps.enablePagination, | ||||||
|       tableState.activeFilters, |       tableState.filterSet.activeFilters, | ||||||
|       tableState.queryFilters, |       tableState.queryFilters, | ||||||
|       tableState.searchTerm, |       tableState.searchTerm, | ||||||
|       tableState.pageSize, |       tableState.pageSize, | ||||||
| @@ -524,7 +524,7 @@ export function InvenTreeTable<T extends Record<string, any>>({ | |||||||
|       sortStatus.columnAccessor, |       sortStatus.columnAccessor, | ||||||
|       sortStatus.direction, |       sortStatus.direction, | ||||||
|       tableState.tableKey, |       tableState.tableKey, | ||||||
|       tableState.activeFilters, |       tableState.filterSet.activeFilters, | ||||||
|       tableState.searchTerm |       tableState.searchTerm | ||||||
|     ], |     ], | ||||||
|     enabled: !!url && !tableData, |     enabled: !!url && !tableData, | ||||||
| @@ -629,7 +629,7 @@ export function InvenTreeTable<T extends Record<string, any>>({ | |||||||
|     } |     } | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   // pagination refresth table if pageSize changes |   // pagination refresh table if pageSize changes | ||||||
|   function updatePageSize(newData: number) { |   function updatePageSize(newData: number) { | ||||||
|     tableState.setPageSize(newData); |     tableState.setPageSize(newData); | ||||||
|     tableState.setPage(1); |     tableState.setPage(1); | ||||||
|   | |||||||
| @@ -63,8 +63,8 @@ export default function InvenTreeTableHeader({ | |||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     // Add in active filters |     // Add in active filters | ||||||
|     if (tableState.activeFilters) { |     if (tableState.filterSet.activeFilters) { | ||||||
|       tableState.activeFilters.forEach((filter) => { |       tableState.filterSet.activeFilters.forEach((filter) => { | ||||||
|         queryParams[filter.name] = filter.value; |         queryParams[filter.name] = filter.value; | ||||||
|       }); |       }); | ||||||
|     } |     } | ||||||
| @@ -141,7 +141,7 @@ export default function InvenTreeTableHeader({ | |||||||
|         <Boundary label={`InvenTreeTableFilterDrawer-${tableState.tableKey}`}> |         <Boundary label={`InvenTreeTableFilterDrawer-${tableState.tableKey}`}> | ||||||
|           <FilterSelectDrawer |           <FilterSelectDrawer | ||||||
|             availableFilters={filters} |             availableFilters={filters} | ||||||
|             tableState={tableState} |             filterSet={tableState.filterSet} | ||||||
|             opened={filtersVisible} |             opened={filtersVisible} | ||||||
|             onClose={() => setFiltersVisible(false)} |             onClose={() => setFiltersVisible(false)} | ||||||
|           /> |           /> | ||||||
| @@ -216,8 +216,8 @@ export default function InvenTreeTableHeader({ | |||||||
|           {tableProps.enableFilters && filters.length > 0 && ( |           {tableProps.enableFilters && filters.length > 0 && ( | ||||||
|             <Indicator |             <Indicator | ||||||
|               size='xs' |               size='xs' | ||||||
|               label={tableState.activeFilters?.length ?? 0} |               label={tableState.filterSet.activeFilters?.length ?? 0} | ||||||
|               disabled={tableState.activeFilters?.length == 0} |               disabled={tableState.filterSet.activeFilters?.length == 0} | ||||||
|             > |             > | ||||||
|               <ActionIcon |               <ActionIcon | ||||||
|                 disabled={hasCustomFilters} |                 disabled={hasCustomFilters} | ||||||
|   | |||||||
| @@ -28,7 +28,13 @@ export function TableSearchInput({ | |||||||
|       onChange={(event) => setValue(event.target.value)} |       onChange={(event) => setValue(event.target.value)} | ||||||
|       rightSection={ |       rightSection={ | ||||||
|         value.length > 0 ? ( |         value.length > 0 ? ( | ||||||
|           <CloseButton size='xs' onClick={() => setValue('')} /> |           <CloseButton | ||||||
|  |             size='xs' | ||||||
|  |             onClick={() => { | ||||||
|  |               setValue(''); | ||||||
|  |               searchCallback(''); | ||||||
|  |             }} | ||||||
|  |           /> | ||||||
|         ) : null |         ) : null | ||||||
|       } |       } | ||||||
|     /> |     /> | ||||||
|   | |||||||
| @@ -36,6 +36,7 @@ import { | |||||||
|   OrderStatusFilter, |   OrderStatusFilter, | ||||||
|   OutstandingFilter, |   OutstandingFilter, | ||||||
|   OverdueFilter, |   OverdueFilter, | ||||||
|  |   PartCategoryFilter, | ||||||
|   ProjectCodeFilter, |   ProjectCodeFilter, | ||||||
|   ResponsibleFilter, |   ResponsibleFilter, | ||||||
|   StartDateAfterFilter, |   StartDateAfterFilter, | ||||||
| @@ -154,14 +155,7 @@ export function BuildOrderTable({ | |||||||
|       HasProjectCodeFilter(), |       HasProjectCodeFilter(), | ||||||
|       IssuedByFilter(), |       IssuedByFilter(), | ||||||
|       ResponsibleFilter(), |       ResponsibleFilter(), | ||||||
|       { |       PartCategoryFilter() | ||||||
|         name: 'category', |  | ||||||
|         label: t`Category`, |  | ||||||
|         description: t`Filter by part category`, |  | ||||||
|         apiUrl: apiUrl(ApiEndpoints.category_list), |  | ||||||
|         model: ModelType.partcategory, |  | ||||||
|         modelRenderer: (instance: any) => instance.name |  | ||||||
|       } |  | ||||||
|     ]; |     ]; | ||||||
|  |  | ||||||
|     // If we are filtering on a specific part, we can include the "include variants" filter |     // If we are filtering on a specific part, we can include the "include variants" filter | ||||||
|   | |||||||
| @@ -3,6 +3,7 @@ import { IconTruckDelivery } from '@tabler/icons-react'; | |||||||
| import { useCallback, useMemo, useState } from 'react'; | import { useCallback, useMemo, useState } from 'react'; | ||||||
| import { useNavigate } from 'react-router-dom'; | import { useNavigate } from 'react-router-dom'; | ||||||
|  |  | ||||||
|  | import dayjs from 'dayjs'; | ||||||
| import { AddItemButton } from '../../components/buttons/AddItemButton'; | import { AddItemButton } from '../../components/buttons/AddItemButton'; | ||||||
| import { YesNoButton } from '../../components/buttons/YesNoButton'; | import { YesNoButton } from '../../components/buttons/YesNoButton'; | ||||||
| import { ApiEndpoints } from '../../enums/ApiEndpoints'; | import { ApiEndpoints } from '../../enums/ApiEndpoints'; | ||||||
| @@ -82,7 +83,7 @@ export default function SalesOrderShipmentTable({ | |||||||
|     focus: 'tracking_number', |     focus: 'tracking_number', | ||||||
|     initialData: { |     initialData: { | ||||||
|       ...selectedShipment, |       ...selectedShipment, | ||||||
|       shipment_date: new Date().toISOString().split('T')[0] |       shipment_date: dayjs().format('YYYY-MM-DD') | ||||||
|     } |     } | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -101,6 +101,18 @@ export const loadTab = async (page, tabName) => { | |||||||
|   await page.waitForLoadState('networkidle'); |   await page.waitForLoadState('networkidle'); | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | // Activate "table" view in certain contexts | ||||||
|  | export const activateTableView = async (page) => { | ||||||
|  |   await page.getByLabel('segmented-icon-control-table').click(); | ||||||
|  |   await page.waitForLoadState('networkidle'); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | // Activate "calendar" view in certain contexts | ||||||
|  | export const activateCalendarView = async (page) => { | ||||||
|  |   await page.getByLabel('segmented-icon-control-calendar').click(); | ||||||
|  |   await page.waitForLoadState('networkidle'); | ||||||
|  | }; | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * Perform a 'global search' on the provided page, for the provided query text |  * Perform a 'global search' on the provided page, for the provided query text | ||||||
|  */ |  */ | ||||||
|   | |||||||
| @@ -1,6 +1,7 @@ | |||||||
| import { expect } from '@playwright/test'; | import { expect } from '@playwright/test'; | ||||||
| import { test } from '../baseFixtures.ts'; | import { test } from '../baseFixtures.ts'; | ||||||
| import { | import { | ||||||
|  |   activateCalendarView, | ||||||
|   clearTableFilters, |   clearTableFilters, | ||||||
|   getRowFromCell, |   getRowFromCell, | ||||||
|   loadTab, |   loadTab, | ||||||
| @@ -90,6 +91,21 @@ test('Build Order - Basic Tests', async ({ page }) => { | |||||||
|     .waitFor(); |     .waitFor(); | ||||||
| }); | }); | ||||||
|  |  | ||||||
|  | test('Build Order - Calendar', async ({ page }) => { | ||||||
|  |   await doQuickLogin(page); | ||||||
|  |  | ||||||
|  |   await navigate(page, 'manufacturing/index/buildorders'); | ||||||
|  |   await activateCalendarView(page); | ||||||
|  |  | ||||||
|  |   // Check "part category" filter | ||||||
|  |   await page.getByLabel('calendar-select-filters').click(); | ||||||
|  |   await page.getByRole('button', { name: 'Add Filter' }).click(); | ||||||
|  |   await page.getByPlaceholder('Select filter').fill('category'); | ||||||
|  |   await page.getByRole('option', { name: 'Category', exact: true }).click(); | ||||||
|  |   await page.getByLabel('related-field-filter-category').click(); | ||||||
|  |   await page.getByText('Part category, level 1').waitFor(); | ||||||
|  | }); | ||||||
|  |  | ||||||
| test('Build Order - Edit', async ({ page }) => { | test('Build Order - Edit', async ({ page }) => { | ||||||
|   await doQuickLogin(page); |   await doQuickLogin(page); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,6 +1,8 @@ | |||||||
| import { expect } from '@playwright/test'; | import { expect } from '@playwright/test'; | ||||||
| import { test } from '../baseFixtures.ts'; | import { test } from '../baseFixtures.ts'; | ||||||
| import { | import { | ||||||
|  |   activateCalendarView, | ||||||
|  |   activateTableView, | ||||||
|   clearTableFilters, |   clearTableFilters, | ||||||
|   clickButtonIfVisible, |   clickButtonIfVisible, | ||||||
|   clickOnRowMenu, |   clickOnRowMenu, | ||||||
| @@ -11,11 +13,12 @@ import { | |||||||
| } from '../helpers.ts'; | } from '../helpers.ts'; | ||||||
| import { doQuickLogin } from '../login.ts'; | import { doQuickLogin } from '../login.ts'; | ||||||
|  |  | ||||||
| test('Purchase Orders - List', async ({ page }) => { | test('Purchase Orders - Table', async ({ page }) => { | ||||||
|   await doQuickLogin(page); |   await doQuickLogin(page); | ||||||
|  |  | ||||||
|   await page.getByRole('tab', { name: 'Purchasing' }).click(); |   await page.getByRole('tab', { name: 'Purchasing' }).click(); | ||||||
|   await loadTab(page, 'Purchase Orders'); |   await loadTab(page, 'Purchase Orders'); | ||||||
|  |   await activateTableView(page); | ||||||
|  |  | ||||||
|   await clearTableFilters(page); |   await clearTableFilters(page); | ||||||
|  |  | ||||||
| @@ -39,6 +42,30 @@ test('Purchase Orders - List', async ({ page }) => { | |||||||
|   await page.getByText('2025-07-17').waitFor(); // Target Date |   await page.getByText('2025-07-17').waitFor(); // Target Date | ||||||
| }); | }); | ||||||
|  |  | ||||||
|  | test('Purchase Orders - Calendar', async ({ page }) => { | ||||||
|  |   await doQuickLogin(page); | ||||||
|  |  | ||||||
|  |   await page.getByRole('tab', { name: 'Purchasing' }).click(); | ||||||
|  |   await loadTab(page, 'Purchase Orders'); | ||||||
|  |  | ||||||
|  |   // Ensure view is in "calendar" mode | ||||||
|  |   await activateCalendarView(page); | ||||||
|  |  | ||||||
|  |   // Check for expected components | ||||||
|  |   await page.getByLabel('action-button-previous-month').waitFor(); | ||||||
|  |   await page.getByLabel('action-button-next-month').waitFor(); | ||||||
|  |  | ||||||
|  |   await page.getByLabel('calendar-select-month').click(); | ||||||
|  |   await page.getByRole('button', { name: 'Jan' }).waitFor(); | ||||||
|  |   await page.getByRole('button', { name: 'Feb' }).waitFor(); | ||||||
|  |   await page.getByRole('button', { name: 'Dec' }).click(); | ||||||
|  |  | ||||||
|  |   await page.getByText('December').waitFor(); | ||||||
|  |  | ||||||
|  |   // Put back into table view | ||||||
|  |   await activateTableView(page); | ||||||
|  | }); | ||||||
|  |  | ||||||
| test('Purchase Orders - Barcodes', async ({ page }) => { | test('Purchase Orders - Barcodes', async ({ page }) => { | ||||||
|   await doQuickLogin(page); |   await doQuickLogin(page); | ||||||
|  |  | ||||||
| @@ -157,6 +184,7 @@ test('Purchase Orders - Filters', async ({ page }) => { | |||||||
|  |  | ||||||
|   await page.getByRole('tab', { name: 'Purchasing' }).click(); |   await page.getByRole('tab', { name: 'Purchasing' }).click(); | ||||||
|   await loadTab(page, 'Purchase Orders'); |   await loadTab(page, 'Purchase Orders'); | ||||||
|  |   await activateTableView(page); | ||||||
|  |  | ||||||
|   // Open filters drawer |   // Open filters drawer | ||||||
|   await openFilterDrawer(page); |   await openFilterDrawer(page); | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| import { expect, test } from './baseFixtures.js'; | import { expect, test } from './baseFixtures.js'; | ||||||
| import { loadTab, navigate } from './helpers.js'; | import { activateTableView, loadTab, navigate } from './helpers.js'; | ||||||
| import { doQuickLogin } from './login.js'; | import { doQuickLogin } from './login.js'; | ||||||
| import { setPluginState } from './settings.js'; | import { setPluginState } from './settings.js'; | ||||||
|  |  | ||||||
| @@ -59,6 +59,7 @@ test('Report Printing', async ({ page }) => { | |||||||
|   // Navigate to a specific PurchaseOrder |   // Navigate to a specific PurchaseOrder | ||||||
|   await page.getByRole('tab', { name: 'Purchasing' }).click(); |   await page.getByRole('tab', { name: 'Purchasing' }).click(); | ||||||
|   await loadTab(page, 'Purchase Orders'); |   await loadTab(page, 'Purchase Orders'); | ||||||
|  |   await activateTableView(page); | ||||||
|  |  | ||||||
|   await page.getByRole('cell', { name: 'PO0009' }).click(); |   await page.getByRole('cell', { name: 'PO0009' }).click(); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1311,6 +1311,28 @@ | |||||||
|   dependencies: |   dependencies: | ||||||
|     prop-types "^15.8.1" |     prop-types "^15.8.1" | ||||||
|  |  | ||||||
|  | "@fullcalendar/core@^6.1.15": | ||||||
|  |   version "6.1.15" | ||||||
|  |   resolved "https://registry.yarnpkg.com/@fullcalendar/core/-/core-6.1.15.tgz#6c3f5259fc4589870228853072131219bb533f6e" | ||||||
|  |   integrity sha512-BuX7o6ALpLb84cMw1FCB9/cSgF4JbVO894cjJZ6kP74jzbUZNjtwffwRdA+Id8rrLjT30d/7TrkW90k4zbXB5Q== | ||||||
|  |   dependencies: | ||||||
|  |     preact "~10.12.1" | ||||||
|  |  | ||||||
|  | "@fullcalendar/daygrid@^6.1.15": | ||||||
|  |   version "6.1.15" | ||||||
|  |   resolved "https://registry.yarnpkg.com/@fullcalendar/daygrid/-/daygrid-6.1.15.tgz#91208b0955ba805ddad285a53ee6f53855146963" | ||||||
|  |   integrity sha512-j8tL0HhfiVsdtOCLfzK2J0RtSkiad3BYYemwQKq512cx6btz6ZZ2RNc/hVnIxluuWFyvx5sXZwoeTJsFSFTEFA== | ||||||
|  |  | ||||||
|  | "@fullcalendar/interaction@^6.1.15": | ||||||
|  |   version "6.1.15" | ||||||
|  |   resolved "https://registry.yarnpkg.com/@fullcalendar/interaction/-/interaction-6.1.15.tgz#1c685d5c269388d4877b75ab2185e97d7c386cc7" | ||||||
|  |   integrity sha512-DOTSkofizM7QItjgu7W68TvKKvN9PSEEvDJceyMbQDvlXHa7pm/WAVtAc6xSDZ9xmB1QramYoWGLHkCYbTW1rQ== | ||||||
|  |  | ||||||
|  | "@fullcalendar/react@^6.1.15": | ||||||
|  |   version "6.1.15" | ||||||
|  |   resolved "https://registry.yarnpkg.com/@fullcalendar/react/-/react-6.1.15.tgz#3198b4a64e256afd37c9760c8741a9af89ade894" | ||||||
|  |   integrity sha512-L0b9hybS2J4e7lq6G2CD4nqriyLEqOH1tE8iI6JQjAMTVh5JicOo5Mqw+fhU5bJ7hLfMw2K3fksxX3Ul1ssw5w== | ||||||
|  |  | ||||||
| "@istanbuljs/load-nyc-config@^1.0.0", "@istanbuljs/load-nyc-config@^1.1.0": | "@istanbuljs/load-nyc-config@^1.0.0", "@istanbuljs/load-nyc-config@^1.1.0": | ||||||
|   version "1.1.0" |   version "1.1.0" | ||||||
|   resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced" |   resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced" | ||||||
| @@ -4128,6 +4150,11 @@ postcss@^8.4.43, postcss@^8.4.49: | |||||||
|     picocolors "^1.1.1" |     picocolors "^1.1.1" | ||||||
|     source-map-js "^1.2.1" |     source-map-js "^1.2.1" | ||||||
|  |  | ||||||
|  | preact@~10.12.1: | ||||||
|  |   version "10.12.1" | ||||||
|  |   resolved "https://registry.yarnpkg.com/preact/-/preact-10.12.1.tgz#8f9cb5442f560e532729b7d23d42fd1161354a21" | ||||||
|  |   integrity sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg== | ||||||
|  |  | ||||||
| pretty-format@^29.7.0: | pretty-format@^29.7.0: | ||||||
|   version "29.7.0" |   version "29.7.0" | ||||||
|   resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.7.0.tgz#ca42c758310f365bfa71a0bda0a807160b776812" |   resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.7.0.tgz#ca42c758310f365bfa71a0bda0a807160b776812" | ||||||
|   | |||||||