diff --git a/docs/docs/assets/images/build/build_calendar.png b/docs/docs/assets/images/build/build_calendar.png new file mode 100644 index 0000000000..07047a19e5 Binary files /dev/null and b/docs/docs/assets/images/build/build_calendar.png differ diff --git a/docs/docs/assets/images/build/build_display.png b/docs/docs/assets/images/build/build_display.png index c8d01916b3..bcfa770905 100644 Binary files a/docs/docs/assets/images/build/build_display.png and b/docs/docs/assets/images/build/build_display.png differ diff --git a/docs/docs/assets/images/build/build_list.png b/docs/docs/assets/images/build/build_list.png index d9003a6729..236d7a4a1e 100644 Binary files a/docs/docs/assets/images/build/build_list.png and b/docs/docs/assets/images/build/build_list.png differ diff --git a/docs/docs/assets/images/order/po_calendar.png b/docs/docs/assets/images/order/po_calendar.png new file mode 100644 index 0000000000..181009f290 Binary files /dev/null and b/docs/docs/assets/images/order/po_calendar.png differ diff --git a/docs/docs/assets/images/order/po_display.png b/docs/docs/assets/images/order/po_display.png new file mode 100644 index 0000000000..9f928d4771 Binary files /dev/null and b/docs/docs/assets/images/order/po_display.png differ diff --git a/docs/docs/assets/images/order/po_list.png b/docs/docs/assets/images/order/po_list.png index 6b1b5349e3..1cd809ea23 100644 Binary files a/docs/docs/assets/images/order/po_list.png and b/docs/docs/assets/images/order/po_list.png differ diff --git a/docs/docs/assets/images/order/ro_calendar.png b/docs/docs/assets/images/order/ro_calendar.png new file mode 100644 index 0000000000..cdb87a0457 Binary files /dev/null and b/docs/docs/assets/images/order/ro_calendar.png differ diff --git a/docs/docs/assets/images/order/ro_display.png b/docs/docs/assets/images/order/ro_display.png new file mode 100644 index 0000000000..4580c54deb Binary files /dev/null and b/docs/docs/assets/images/order/ro_display.png differ diff --git a/docs/docs/assets/images/order/ro_list.png b/docs/docs/assets/images/order/ro_list.png new file mode 100644 index 0000000000..2adf57e5c8 Binary files /dev/null and b/docs/docs/assets/images/order/ro_list.png differ diff --git a/docs/docs/assets/images/order/so_calendar.png b/docs/docs/assets/images/order/so_calendar.png new file mode 100644 index 0000000000..428f420fd1 Binary files /dev/null and b/docs/docs/assets/images/order/so_calendar.png differ diff --git a/docs/docs/assets/images/order/so_display.png b/docs/docs/assets/images/order/so_display.png new file mode 100644 index 0000000000..2b6204e578 Binary files /dev/null and b/docs/docs/assets/images/order/so_display.png differ diff --git a/docs/docs/assets/images/order/so_list.png b/docs/docs/assets/images/order/so_list.png index 9104a21483..5ef076b6f9 100644 Binary files a/docs/docs/assets/images/order/so_list.png and b/docs/docs/assets/images/order/so_list.png differ diff --git a/docs/docs/build/build.md b/docs/docs/build/build.md index d800f153a6..cfa1931bdf 100644 --- a/docs/docs/build/build.md +++ b/docs/docs/build/build.md @@ -12,12 +12,16 @@ A *Build Order* uses the BOM to allocate stock items to the assembly process. As ### View Build Orders -To navigate to the Build Order display, select *Build* from the main navigation menu: +To navigate to the Build Order display, select *Manufacturing* from the main navigation menu, and *Build Orders* from the sidebar. + +The *Build Order Index Page* allows the user to view all build orders: {% with id="build_display", url="build/build_display.png", description="Display Builds" %} {% include "img.html" %} {% endwith %} +The following view modes are available: + #### Table View *Table View* provides a table of Build Orders, which can be filtered to only show the orders you are interested in. @@ -28,7 +32,13 @@ To navigate to the Build Order display, select *Build* from the main navigation #### Calendar View -*Calendar View* shows a calendar display with upcoming build orders, based on the various dates specified for each build. +*Calendar View* shows a calendar display with outstanding build orders, based on the various dates specified for each order. + +{% with id="build_calendar", url="build/build_calendar.png", description="Build Calendar" %} +{% include "img.html" %} +{% endwith %} + +The build calendar allows the user to navigate month-by-month and display the filtered build orders ## Build Order Details diff --git a/docs/docs/order/purchase_order.md b/docs/docs/order/purchase_order.md index f2d61eddb2..e78bc2c54e 100644 --- a/docs/docs/order/purchase_order.md +++ b/docs/docs/order/purchase_order.md @@ -6,12 +6,32 @@ title: Purchase Order Purchase orders allow to track which parts are bought from suppliers and manufacturers, therefore converting externally bought items into stock items / inventory. -To access the purchase order page, click on the <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" %} {% include "img.html" %} {% endwith %} +#### Calendar View + +*Calendar View* shows a calendar display with outstanding purchase orders, based on the various dates specified for each order. + +{% with id="purchase_order_calendar", url="order/po_calendar.png", description="Purchase Order Calendar" %} +{% include "img.html" %} +{% endwith %} + ### Purchase Order Status Codes Each Purchase Order has a specific status code which indicates the current state of the order: diff --git a/docs/docs/order/return_order.md b/docs/docs/order/return_order.md index a188dd076f..d2e3a3bfe3 100644 --- a/docs/docs/order/return_order.md +++ b/docs/docs/order/return_order.md @@ -9,6 +9,32 @@ Return Orders allow stock items (which have been sold or allocated to a customer !!! tip "An Order By Any Other Name" A Return Order may also be known as an [RMA](https://en.wikipedia.org/wiki/Return_merchandise_authorization) +### View Return Orders + +To navigate to the Return Order display, select *Sales* from the main navigation menu, and *Return Orders* from the sidebar: + +{% with id="return_order_display", url="order/ro_display.png", description="Return Order Display" %} +{% include "img.html" %} +{% endwith %} + +The following view modes are available: + +#### Table View + +*Table View* provides a list of Return Orders, which can be filtered to display a subset of orders according to user supplied parameters. + +{% with id="purchase_order_list", url="order/po_list.png", description="Return Order List" %} +{% include "img.html" %} +{% endwith %} + +#### Calendar View + +*Calendar View* shows a calendar display with outstanding return orders, based on the various dates specified for each order. + +{% with id="return_order_calendar", url="order/ro_calendar.png", description="Return Order Calendar" %} +{% include "img.html" %} +{% endwith %} + ### Enable Return Order Functionality By default, Return Order functionality is not enabled - it must be enabled by a *staff* user from the settings page: diff --git a/docs/docs/order/sales_order.md b/docs/docs/order/sales_order.md index fca9a46f7b..1923ea5d9f 100644 --- a/docs/docs/order/sales_order.md +++ b/docs/docs/order/sales_order.md @@ -6,12 +6,32 @@ title: Sales Orders Sales orders allow tracking of which stock items are sold to customers, therefore converting stock items / inventory into externally sold items. -To access the sales order page, click on the <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" %} {% include "img.html" %} {% endwith %} +#### Calendar View + +*Calendar View* shows a calendar display with outstanding sales orders. + +{% with id="sales_order_calendar", url="order/so_calendar.png", description="Sales Order Calendar" %} +{% include "img.html" %} +{% endwith %} + ### Sales Order Status Codes Each Sales Order has a specific status code, which represents the state of the order: diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index d918dc2b06..cf06a17cfb 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,13 +1,16 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 321 +INVENTREE_API_VERSION = 322 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v322 - 2025-03-16 : https://github.com/inventree/InvenTree/pull/8933 + - Add min_date and max_date query filters for orders, for use in calendar views + v321 - 2025-03-06 : https://github.com/inventree/InvenTree/pull/9236 - Adds conditionally-returned fields to the schema to match API behavior - Removes required flag for nullable read-only fields to match API behavior diff --git a/src/backend/InvenTree/build/api.py b/src/backend/InvenTree/build/api.py index 4f8274b5ea..c902abe12f 100644 --- a/src/backend/InvenTree/build/api.py +++ b/src/backend/InvenTree/build/api.py @@ -242,6 +242,65 @@ class BuildFilter(rest_filters.FilterSet): label=_('Completed after'), field_name='completion_date', lookup_expr='gt' ) + min_date = InvenTreeDateFilter(label=_('Min Date'), method='filter_min_date') + + def filter_min_date(self, queryset, name, value): + """Filter the queryset to include orders *after* a specified date. + + This filter is used in combination with filter_max_date, + to provide a queryset which matches a particular range of dates. + + In particular, this is used in the UI for the calendar view. + + So, we are interested in orders which are active *after* this date: + + - creation_date is set *after* this date (but there is no start date) + - start_date is set *after* this date + - target_date is set *after* this date + + """ + q1 = Q(creation_date__gte=value, start_date__isnull=True) + q2 = Q(start_date__gte=value) + q3 = Q(target_date__gte=value) + + return queryset.filter(q1 | q2 | q3).distinct() + + max_date = InvenTreeDateFilter(label=_('Max Date'), method='filter_max_date') + + def filter_max_date(self, queryset, name, value): + """Filter the queryset to include orders *before* a specified date. + + This filter is used in combination with filter_min_date, + to provide a queryset which matches a particular range of dates. + + In particular, this is used in the UI for the calendar view. + + So, we are interested in orders which are active *before* this date: + + - creation_date is set *before* this date (but there is no start date) + - start_date is set *before* this date + - target_date is set *before* this date + """ + q1 = Q(creation_date__lte=value, start_date__isnull=True) + q2 = Q(start_date__lte=value) + q3 = Q(target_date__lte=value) + + return queryset.filter(q1 | q2 | q3).distinct() + + exclude_tree = rest_filters.ModelChoiceFilter( + queryset=Build.objects.all(), + method='filter_exclude_tree', + label=_('Exclude Tree'), + ) + + def filter_exclude_tree(self, queryset, name, value): + """Filter by excluding a tree of Build objects.""" + queryset = queryset.exclude( + pk__in=[bld.pk for bld in value.get_descendants(include_self=True)] + ) + + return queryset + class BuildMixin: """Mixin class for Build API endpoints.""" @@ -319,35 +378,6 @@ class BuildList(DataExportViewMixin, BuildMixin, ListCreateAPI): return queryset - def filter_queryset(self, queryset): - """Custom query filtering for the BuildList endpoint.""" - queryset = super().filter_queryset(queryset) - - params = self.request.query_params - - # exclude parent tree - exclude_tree = params.get('exclude_tree', None) - - if exclude_tree is not None: - try: - build = Build.objects.get(pk=exclude_tree) - - queryset = queryset.exclude( - pk__in=[bld.pk for bld in build.get_descendants(include_self=True)] - ) - - except (ValueError, Build.DoesNotExist): - pass - - # Filter by 'date range' - min_date = params.get('min_date', None) - max_date = params.get('max_date', None) - - if min_date is not None and max_date is not None: - queryset = Build.filterByDate(queryset, min_date, max_date) - - return queryset - def get_serializer(self, *args, **kwargs): """Add extra context information to the endpoint serializer.""" try: diff --git a/src/backend/InvenTree/build/models.py b/src/backend/InvenTree/build/models.py index 0002620112..ab2d0663dc 100644 --- a/src/backend/InvenTree/build/models.py +++ b/src/backend/InvenTree/build/models.py @@ -1,7 +1,6 @@ """Build database model definitions.""" import decimal -from datetime import datetime from django.contrib.auth.models import User from django.core.exceptions import ValidationError @@ -197,44 +196,6 @@ class Build( 'title': str(self), } - @staticmethod - def filterByDate(queryset, min_date, max_date): - """Filter by 'minimum and maximum date range'. - - - Specified as min_date, max_date - - Both must be specified for filter to be applied - """ - date_fmt = '%Y-%m-%d' # ISO format date string - - # Ensure that both dates are valid - try: - min_date = datetime.strptime(str(min_date), date_fmt).date() - max_date = datetime.strptime(str(max_date), date_fmt).date() - except (ValueError, TypeError): - # Date processing error, return queryset unchanged - return queryset - - # Order was completed within the specified range - completed = ( - Q(status=BuildStatus.COMPLETE.value) - & Q(completion_date__gte=min_date) - & Q(completion_date__lte=max_date) - ) - - # Order target date falls within specified range - pending = ( - Q(status__in=BuildStatusGroups.ACTIVE_CODES) - & ~Q(target_date=None) - & Q(target_date__gte=min_date) - & Q(target_date__lte=max_date) - ) - - # TODO - Construct a queryset for "overdue" orders - - queryset = queryset.filter(completed | pending) - - return queryset - def __str__(self): """String representation of a BuildOrder.""" return self.reference diff --git a/src/backend/InvenTree/order/api.py b/src/backend/InvenTree/order/api.py index 031a5bf575..71fc522886 100644 --- a/src/backend/InvenTree/order/api.py +++ b/src/backend/InvenTree/order/api.py @@ -213,6 +213,44 @@ class OrderFilter(rest_filters.FilterSet): label=_('Target Date After'), field_name='target_date', lookup_expr='gt' ) + min_date = InvenTreeDateFilter(label=_('Min Date'), method='filter_min_date') + + def filter_min_date(self, queryset, name, value): + """Filter the queryset to include orders *after* a specified date. + + This is used in combination with filter_max_date, + to provide a queryset which matches a particular range of dates. + + In particular, this is used in the UI for the calendar view. + """ + q1 = Q( + creation_date__gte=value, issue_date__isnull=True, start_date__isnull=True + ) + q2 = Q(issue_date__gte=value, start_date__isnull=True) + q3 = Q(start_date__gte=value) + q4 = Q(target_date__gte=value) + + return queryset.filter(q1 | q2 | q3 | q4).distinct() + + max_date = InvenTreeDateFilter(label=_('Max Date'), method='filter_max_date') + + def filter_max_date(self, queryset, name, value): + """Filter the queryset to include orders *before* a specified date. + + This is used in combination with filter_min_date, + to provide a queryset which matches a particular range of dates. + + In particular, this is used in the UI for the calendar view. + """ + q1 = Q( + creation_date__lte=value, issue_date__isnull=True, start_date__isnull=True + ) + q2 = Q(issue_date__lte=value, start_date__isnull=True) + q3 = Q(start_date__lte=value) + q4 = Q(target_date__lte=value) + + return queryset.filter(q1 | q2 | q3 | q4).distinct() + class LineItemFilter(rest_filters.FilterSet): """Base class for custom API filters for order line item list(s).""" @@ -323,23 +361,6 @@ class PurchaseOrderList( """ filterset_class = PurchaseOrderFilter - - def filter_queryset(self, queryset): - """Custom queryset filtering.""" - # Perform basic filtering - queryset = super().filter_queryset(queryset) - - params = self.request.query_params - - # Filter by 'date range' - min_date = params.get('min_date', None) - max_date = params.get('max_date', None) - - if min_date is not None and max_date is not None: - queryset = models.PurchaseOrder.filterByDate(queryset, min_date, max_date) - - return queryset - filter_backends = SEARCH_ORDER_FILTER_ALIAS ordering_field_aliases = { @@ -791,21 +812,6 @@ class SalesOrderList( filterset_class = SalesOrderFilter - def filter_queryset(self, queryset): - """Perform custom filtering operations on the SalesOrder queryset.""" - queryset = super().filter_queryset(queryset) - - params = self.request.query_params - - # Filter by 'date range' - min_date = params.get('min_date', None) - max_date = params.get('max_date', None) - - if min_date is not None and max_date is not None: - queryset = models.SalesOrder.filterByDate(queryset, min_date, max_date) - - return queryset - filter_backends = SEARCH_ORDER_FILTER_ALIAS ordering_field_aliases = { diff --git a/src/backend/InvenTree/order/migrations/0109_salesorder_issue_date.py b/src/backend/InvenTree/order/migrations/0109_salesorder_issue_date.py new file mode 100644 index 0000000000..4d8efd0626 --- /dev/null +++ b/src/backend/InvenTree/order/migrations/0109_salesorder_issue_date.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.19 on 2025-02-23 04:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("order", "0108_alter_purchaseorder_link_and_more_squashed_0109_alter_purchaseorderextraline_link_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="salesorder", + name="issue_date", + field=models.DateField( + blank=True, + help_text="Date order was issued", + null=True, + verbose_name="Issue Date", + ), + ), + ] diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py index b04776a36f..d7c299bf8c 100644 --- a/src/backend/InvenTree/order/models.py +++ b/src/backend/InvenTree/order/models.py @@ -1,6 +1,5 @@ """Order model definitions.""" -from datetime import datetime from decimal import Decimal from django.contrib.auth.models import User @@ -389,6 +388,13 @@ class Order( verbose_name=_('Created By'), ) + issue_date = models.DateField( + blank=True, + null=True, + verbose_name=_('Issue Date'), + help_text=_('Date order was issued'), + ) + responsible = models.ForeignKey( UserModels.Owner, on_delete=models.SET_NULL, @@ -482,50 +488,6 @@ class PurchaseOrder(TotalPriceMixin, Order): """Return the associated barcode model type code for this model.""" return 'PO' - @staticmethod - def filterByDate(queryset, min_date, max_date): - """Filter by 'minimum and maximum date range'. - - - Specified as min_date, max_date - - Both must be specified for filter to be applied - - Determine which "interesting" orders exist between these dates - - To be "interesting": - - A "received" order where the received date lies within the date range - - A "pending" order where the target date lies within the date range - - TODO: An "overdue" order where the target date is in the past - """ - date_fmt = '%Y-%m-%d' # ISO format date string - - # Ensure that both dates are valid - try: - min_date = datetime.strptime(str(min_date), date_fmt).date() - max_date = datetime.strptime(str(max_date), date_fmt).date() - except (ValueError, TypeError): - # Date processing error, return queryset unchanged - return queryset - - # Construct a queryset for "received" orders within the range - received = ( - Q(status=PurchaseOrderStatus.COMPLETE.value) - & Q(complete_date__gte=min_date) - & Q(complete_date__lte=max_date) - ) - - # Construct a queryset for "pending" orders within the range - pending = ( - Q(status__in=PurchaseOrderStatusGroups.OPEN) - & ~Q(target_date=None) - & Q(target_date__gte=min_date) - & Q(target_date__lte=max_date) - ) - - # TODO - Construct a queryset for "overdue" orders within the range - - queryset = queryset.filter(received | pending) - - return queryset - def __str__(self): """Render a string representation of this PurchaseOrder.""" return f'{self.reference} - {self.supplier.name if self.supplier else _("deleted")}' @@ -584,13 +546,6 @@ class PurchaseOrder(TotalPriceMixin, Order): verbose_name=_('received by'), ) - issue_date = models.DateField( - blank=True, - null=True, - verbose_name=_('Issue Date'), - help_text=_('Date order was issued'), - ) - complete_date = models.DateField( blank=True, null=True, @@ -1054,50 +1009,6 @@ class SalesOrder(TotalPriceMixin, Order): """Return the associated barcode model type code for this model.""" return 'SO' - @staticmethod - def filterByDate(queryset, min_date, max_date): - """Filter by "minimum and maximum date range". - - - Specified as min_date, max_date - - Both must be specified for filter to be applied - - Determine which "interesting" orders exist between these dates - - To be "interesting": - - A "completed" order where the completion date lies within the date range - - A "pending" order where the target date lies within the date range - - TODO: An "overdue" order where the target date is in the past - """ - date_fmt = '%Y-%m-%d' # ISO format date string - - # Ensure that both dates are valid - try: - min_date = datetime.strptime(str(min_date), date_fmt).date() - max_date = datetime.strptime(str(max_date), date_fmt).date() - except (ValueError, TypeError): - # Date processing error, return queryset unchanged - return queryset - - # Construct a queryset for "completed" orders within the range - completed = ( - Q(status__in=SalesOrderStatusGroups.COMPLETE) - & Q(shipment_date__gte=min_date) - & Q(shipment_date__lte=max_date) - ) - - # Construct a queryset for "pending" orders within the range - pending = ( - Q(status__in=SalesOrderStatusGroups.OPEN) - & ~Q(target_date=None) - & Q(target_date__gte=min_date) - & Q(target_date__lte=max_date) - ) - - # TODO: Construct a queryset for "overdue" orders within the range - - queryset = queryset.filter(completed | pending) - - return queryset - def __str__(self): """Render a string representation of this SalesOrder.""" return f'{self.reference} - {self.customer.name if self.customer else _("deleted")}' @@ -2363,13 +2274,6 @@ class ReturnOrder(TotalPriceMixin, Order): help_text=_('Customer order reference code'), ) - issue_date = models.DateField( - blank=True, - null=True, - verbose_name=_('Issue Date'), - help_text=_('Date order was issued'), - ) - complete_date = models.DateField( blank=True, null=True, diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py index 5e105f4f5a..36f689ae89 100644 --- a/src/backend/InvenTree/order/serializers.py +++ b/src/backend/InvenTree/order/serializers.py @@ -193,6 +193,7 @@ class AbstractOrderSerializer(DataImportExportSerializerMixin, serializers.Seria 'pk', 'created_by', 'creation_date', + 'issue_date', 'start_date', 'target_date', 'description', @@ -322,7 +323,6 @@ class PurchaseOrderSerializer( model = order.models.PurchaseOrder fields = AbstractOrderSerializer.order_fields([ - 'issue_date', 'complete_date', 'supplier', 'supplier_detail', @@ -1880,7 +1880,6 @@ class ReturnOrderSerializer( model = order.models.ReturnOrder fields = AbstractOrderSerializer.order_fields([ - 'issue_date', 'complete_date', 'customer', 'customer_detail', diff --git a/src/frontend/package.json b/src/frontend/package.json index a04ec2a858..2a6f5e5f24 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -24,6 +24,10 @@ "@fortawesome/free-regular-svg-icons": "^6.6.0", "@fortawesome/free-solid-svg-icons": "^6.6.0", "@fortawesome/react-fontawesome": "^0.2.2", + "@fullcalendar/core": "^6.1.15", + "@fullcalendar/daygrid": "^6.1.15", + "@fullcalendar/interaction": "^6.1.15", + "@fullcalendar/react": "^6.1.15", "@lingui/core": "^4.11.4", "@lingui/react": "^4.11.4", "@mantine/carousel": "^7.16.0", diff --git a/src/frontend/src/components/buttons/SegmentedIconControl.tsx b/src/frontend/src/components/buttons/SegmentedIconControl.tsx new file mode 100644 index 0000000000..9cdbfc9132 --- /dev/null +++ b/src/frontend/src/components/buttons/SegmentedIconControl.tsx @@ -0,0 +1,51 @@ +import { + ActionIcon, + type MantineColor, + type MantineSize, + SegmentedControl, + Tooltip +} from '@mantine/core'; +import type { ReactNode } from 'react'; + +export type SegmentedIconControlItem = { + label: string; + value: string; + icon: ReactNode; +}; + +export default function SegmentedIconControl({ + data, + value, + size = 'sm', + color, + onChange +}: { + data: SegmentedIconControlItem[]; + value: string; + size?: MantineSize; + color?: MantineColor; + onChange: (value: string) => void; +}) { + return ( + <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> + ) + }))} + /> + ); +} diff --git a/src/frontend/src/components/calendar/Calendar.tsx b/src/frontend/src/components/calendar/Calendar.tsx new file mode 100644 index 0000000000..9d0e8b3909 --- /dev/null +++ b/src/frontend/src/components/calendar/Calendar.tsx @@ -0,0 +1,190 @@ +import type { CalendarOptions, DatesSetArg } from '@fullcalendar/core'; +import allLocales from '@fullcalendar/core/locales-all'; +import dayGridPlugin from '@fullcalendar/daygrid'; +import interactionPlugin from '@fullcalendar/interaction'; +import FullCalendar from '@fullcalendar/react'; + +import { t } from '@lingui/macro'; +import { + ActionIcon, + Box, + Button, + Group, + Indicator, + LoadingOverlay, + Popover, + Stack, + Tooltip +} from '@mantine/core'; +import { type DateValue, MonthPicker } from '@mantine/dates'; +import { + IconCalendarMonth, + IconChevronLeft, + IconChevronRight, + IconFilter +} from '@tabler/icons-react'; +import { useCallback, useState } from 'react'; +import type { CalendarState } from '../../hooks/UseCalendar'; +import { useLocalState } from '../../states/LocalState'; +import { DownloadAction } from '../../tables/DownloadAction'; +import type { TableFilter } from '../../tables/Filter'; +import { FilterSelectDrawer } from '../../tables/FilterSelectDrawer'; +import { TableSearchInput } from '../../tables/Search'; +import { Boundary } from '../Boundary'; +import { ActionButton } from '../buttons/ActionButton'; +import { StylishText } from '../items/StylishText'; + +export interface InvenTreeCalendarProps extends CalendarOptions { + downloadData?: (fileFormat: string) => void; + enableDownload?: boolean; + enableFilters?: boolean; + enableSearch?: boolean; + filters?: TableFilter[]; + isLoading?: boolean; + state: CalendarState; +} + +export default function Calendar({ + downloadData, + enableDownload, + enableFilters = false, + enableSearch, + isLoading, + filters, + state, + ...calendarProps +}: InvenTreeCalendarProps) { + const [monthSelectOpened, setMonthSelectOpened] = useState<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> + </> + ); +} diff --git a/src/frontend/src/components/calendar/OrderCalendar.tsx b/src/frontend/src/components/calendar/OrderCalendar.tsx new file mode 100644 index 0000000000..b00134ddcc --- /dev/null +++ b/src/frontend/src/components/calendar/OrderCalendar.tsx @@ -0,0 +1,209 @@ +import type { + EventChangeArg, + EventClickArg, + EventContentArg +} from '@fullcalendar/core'; +import { t } from '@lingui/macro'; +import { ActionIcon, Group, Text } from '@mantine/core'; +import { hideNotification, showNotification } from '@mantine/notifications'; +import { + IconCalendarExclamation, + IconCircleCheck, + IconExclamationCircle +} from '@tabler/icons-react'; +import dayjs from 'dayjs'; +import { useCallback, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { api } from '../../App'; +import type { ModelType } from '../../enums/ModelType'; +import type { UserRoles } from '../../enums/Roles'; +import { navigateToLink } from '../../functions/navigation'; +import { getDetailUrl } from '../../functions/urls'; +import useCalendar from '../../hooks/UseCalendar'; +import { apiUrl } from '../../states/ApiState'; +import { useUserState } from '../../states/UserState'; +import { + AssignedToMeFilter, + HasProjectCodeFilter, + OrderStatusFilter, + ProjectCodeFilter, + ResponsibleFilter, + type TableFilter +} from '../../tables/Filter'; +import { ModelInformationDict } from '../render/ModelType'; +import { StatusRenderer } from '../render/StatusRenderer'; +import Calendar from './Calendar'; + +/** + * A generic calendar component for displaying orders + * This can be used for the following order types: + * - BuildOrder + * - PurchaseOrder + * - SalesOrder + * - ReturnOrder + */ + +export default function OrderCalendar({ + model, + role, + params, + filters +}: { + model: ModelType; + role: UserRoles; + params: Record<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} + /> + ); +} diff --git a/src/frontend/src/components/items/ActionDropdown.tsx b/src/frontend/src/components/items/ActionDropdown.tsx index a18f4ec1d2..e7183974b8 100644 --- a/src/frontend/src/components/items/ActionDropdown.tsx +++ b/src/frontend/src/components/items/ActionDropdown.tsx @@ -1,6 +1,7 @@ import { t } from '@lingui/macro'; import { Button, + type FloatingPosition, Indicator, type IndicatorProps, Menu, @@ -43,6 +44,7 @@ export type ActionDropdownItem = { export function ActionDropdown({ icon, tooltip, + tooltipPosition, actions, disabled = false, hidden = false, @@ -50,6 +52,7 @@ export function ActionDropdown({ }: { icon: ReactNode; tooltip: string; + tooltipPosition?: FloatingPosition; actions: ActionDropdownItem[]; disabled?: boolean; hidden?: boolean; @@ -71,7 +74,11 @@ export function ActionDropdown({ <Menu position='bottom-end' key={menuName}> <Indicator disabled={!indicatorProps} {...indicatorProps?.indicator}> <Menu.Target> - <Tooltip label={tooltip} hidden={!tooltip} position='bottom'> + <Tooltip + label={tooltip} + hidden={!tooltip} + position={tooltipPosition ?? 'bottom'} + > <Button variant={noindicator ? 'transparent' : 'light'} disabled={disabled} diff --git a/src/frontend/src/components/panels/Panel.tsx b/src/frontend/src/components/panels/Panel.tsx index 824200ef0b..a1e407cd5f 100644 --- a/src/frontend/src/components/panels/Panel.tsx +++ b/src/frontend/src/components/panels/Panel.tsx @@ -6,6 +6,7 @@ import type { ReactNode } from 'react'; export type PanelType = { name: string; label: string; + controls?: ReactNode; icon?: ReactNode; content: ReactNode; hidden?: boolean; diff --git a/src/frontend/src/components/panels/PanelGroup.tsx b/src/frontend/src/components/panels/PanelGroup.tsx index e7dd751cf3..dcdc60594a 100644 --- a/src/frontend/src/components/panels/PanelGroup.tsx +++ b/src/frontend/src/components/panels/PanelGroup.tsx @@ -227,7 +227,14 @@ function BasePanelGroup({ <Stack gap='md'> {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 /> </> )} diff --git a/src/frontend/src/forms/StockForms.tsx b/src/frontend/src/forms/StockForms.tsx index 8dbf466dbd..a0a1018d4e 100644 --- a/src/frontend/src/forms/StockForms.tsx +++ b/src/frontend/src/forms/StockForms.tsx @@ -115,7 +115,9 @@ export function useStockFields({ if (expiry_days && expiry_days > 0) { // 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') + ); } } }, diff --git a/src/frontend/src/hooks/UseCalendar.tsx b/src/frontend/src/hooks/UseCalendar.tsx new file mode 100644 index 0000000000..dfa163616e --- /dev/null +++ b/src/frontend/src/hooks/UseCalendar.tsx @@ -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 + }; +} diff --git a/src/frontend/src/hooks/UseFilterSet.tsx b/src/frontend/src/hooks/UseFilterSet.tsx new file mode 100644 index 0000000000..41a6d9b295 --- /dev/null +++ b/src/frontend/src/hooks/UseFilterSet.tsx @@ -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 + }; +} diff --git a/src/frontend/src/hooks/UseTable.tsx b/src/frontend/src/hooks/UseTable.tsx index eaa43a08ce..4099014040 100644 --- a/src/frontend/src/hooks/UseTable.tsx +++ b/src/frontend/src/hooks/UseTable.tsx @@ -2,7 +2,7 @@ import { randomId, useLocalStorage } from '@mantine/hooks'; import { useCallback, useMemo, useState } from 'react'; 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: @@ -11,9 +11,7 @@ import type { TableFilter } from '../tables/Filter'; * refreshTable: A callback function to externally refresh the table. * isLoading: A boolean flag to indicate if the table is currently loading data * setIsLoading: A function to set the isLoading flag - * activeFilters: An array of active filters (saved to local storage) - * setActiveFilters: A function to set the active filters - * clearActiveFilters: A function to clear all active filters + * filterSet: A group of active filters * queryFilters: A map of query filters (e.g. ?active=true&overdue=false) passed in the URL * setQueryFilters: A function to set the query filters * clearQueryFilters: A function to clear all query filters @@ -45,9 +43,7 @@ export type TableState = { refreshTable: () => void; isLoading: boolean; setIsLoading: (value: boolean) => void; - activeFilters: TableFilter[]; - setActiveFilters: (filters: TableFilter[]) => void; - clearActiveFilters: () => void; + filterSet: FilterSetState; queryFilters: URLSearchParams; setQueryFilters: SetURLSearchParams; clearQueryFilters: () => void; @@ -101,17 +97,7 @@ export function useTable(tableName: string, idAccessor = 'pk'): TableState { setTableKey(generateTableName()); }, [generateTableName]); - // Array of active filters (saved to local storage) - 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([]); - }, []); + const filterSet: FilterSetState = useFilterSet(`table-${tableName}`); // Array of expanded records const [expandedRecords, setExpandedRecords] = useState<any[]>([]); @@ -194,9 +180,7 @@ export function useTable(tableName: string, idAccessor = 'pk'): TableState { refreshTable, isLoading, setIsLoading, - activeFilters, - setActiveFilters, - clearActiveFilters, + filterSet, queryFilters, setQueryFilters, clearQueryFilters, diff --git a/src/frontend/src/pages/build/BuildDetail.tsx b/src/frontend/src/pages/build/BuildDetail.tsx index 310e190632..02c013c757 100644 --- a/src/frontend/src/pages/build/BuildDetail.tsx +++ b/src/frontend/src/pages/build/BuildDetail.tsx @@ -181,6 +181,40 @@ export default function BuildDetail() { badge: 'owner', 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', name: 'creation_date', @@ -212,40 +246,6 @@ export default function BuildDetail() { icon: 'calendar', copy: true, 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 } ]; diff --git a/src/frontend/src/pages/build/BuildIndex.tsx b/src/frontend/src/pages/build/BuildIndex.tsx index c2be829f62..e67f1958a3 100644 --- a/src/frontend/src/pages/build/BuildIndex.tsx +++ b/src/frontend/src/pages/build/BuildIndex.tsx @@ -1,35 +1,89 @@ import { t } from '@lingui/macro'; import { Stack } from '@mantine/core'; -import { IconTools } from '@tabler/icons-react'; +import { IconCalendar, IconTable, IconTools } from '@tabler/icons-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 { PageDetail } from '../../components/nav/PageDetail'; +import type { PanelType } from '../../components/panels/Panel'; import { PanelGroup } from '../../components/panels/PanelGroup'; +import { ModelType } from '../../enums/ModelType'; import { UserRoles } from '../../enums/Roles'; import { useUserState } from '../../states/UserState'; +import { PartCategoryFilter, type TableFilter } from '../../tables/Filter'; 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 */ export default function BuildIndex() { const user = useUserState(); - if (!user.isLoggedIn() || !user.hasViewRole(UserRoles.build)) { - return <PermissionDenied />; - } + const [buildOrderView, setBuildOrderView] = useLocalStorage<string>({ + key: 'buildOrderView', + defaultValue: 'table' + }); - const panels = useMemo(() => { + const panels: PanelType[] = useMemo(() => { return [ { name: 'buildorders', label: t`Build Orders`, - content: <BuildOrderTable />, - icon: <IconTools /> + content: <BuildOverview view={buildOrderView} />, + 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 ( <Stack> diff --git a/src/frontend/src/pages/part/PartSchedulingDetail.tsx b/src/frontend/src/pages/part/PartSchedulingDetail.tsx index 1ec87409d4..26e65bfac9 100644 --- a/src/frontend/src/pages/part/PartSchedulingDetail.tsx +++ b/src/frontend/src/pages/part/PartSchedulingDetail.tsx @@ -12,6 +12,7 @@ import { import { type ReactNode, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; +import dayjs from 'dayjs'; import { formatDate } from '../../defaults/formatters'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { navigateToLink } from '../../functions/navigation'; @@ -32,7 +33,7 @@ function ChartTooltip({ label, payload }: Readonly<ChartTooltipProps>) { } 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'); @@ -154,7 +155,6 @@ export default function PartSchedulingDetail({ // Construct initial chart entry (for today) const entries: any[] = [ { - // date: formatDate(today.toISOString()), date: today.valueOf(), delta: 0, scheduled: stock, @@ -282,7 +282,7 @@ export default function PartSchedulingDetail({ scale: 'time', type: 'number', tickFormatter: (value: number) => { - return formatDate(new Date(value).toISOString()); + return formatDate(dayjs().format('YYYY-MM-DD')); } }} series={[ diff --git a/src/frontend/src/pages/part/PartStocktakeDetail.tsx b/src/frontend/src/pages/part/PartStocktakeDetail.tsx index 78613a448d..51a47e4ec6 100644 --- a/src/frontend/src/pages/part/PartStocktakeDetail.tsx +++ b/src/frontend/src/pages/part/PartStocktakeDetail.tsx @@ -10,6 +10,7 @@ import { } from '@mantine/core'; import { useCallback, useMemo, useState } from 'react'; +import dayjs from 'dayjs'; import { AddItemButton } from '../../components/buttons/AddItemButton'; import { formatDate, formatPriceRange } from '../../defaults/formatters'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; @@ -36,7 +37,7 @@ import { RowDeleteAction, RowEditAction } from '../../tables/RowActions'; function ChartTooltip({ label, payload }: Readonly<ChartTooltipProps>) { const formattedLabel: string = useMemo(() => { if (label && typeof label === 'number') { - return formatDate(new Date(label).toISOString()) ?? label; + return formatDate(dayjs().format('YYYY-MM-DD')) ?? label; } else if (!!label) { return label.toString(); } else { @@ -253,7 +254,7 @@ export default function PartStocktakeDetail({ type: 'number', domain: chartLimits, tickFormatter: (value: number) => { - return formatDate(new Date(value).toISOString()); + return formatDate(dayjs().format('YYYY-MM-DD')); } }} series={[ diff --git a/src/frontend/src/pages/purchasing/PurchasingIndex.tsx b/src/frontend/src/pages/purchasing/PurchasingIndex.tsx index b540664a3c..4f10bf577d 100644 --- a/src/frontend/src/pages/purchasing/PurchasingIndex.tsx +++ b/src/frontend/src/pages/purchasing/PurchasingIndex.tsx @@ -4,14 +4,20 @@ import { IconBuildingFactory2, IconBuildingStore, IconBuildingWarehouse, + IconCalendar, IconPackageExport, - IconShoppingCart + IconShoppingCart, + IconTable } from '@tabler/icons-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 { PageDetail } from '../../components/nav/PageDetail'; import { PanelGroup } from '../../components/panels/PanelGroup'; +import { ModelType } from '../../enums/ModelType'; import { UserRoles } from '../../enums/Roles'; import { useUserState } from '../../states/UserState'; import { CompanyTable } from '../../tables/company/CompanyTable'; @@ -19,17 +25,56 @@ import { ManufacturerPartTable } from '../../tables/purchasing/ManufacturerPartT import { PurchaseOrderTable } from '../../tables/purchasing/PurchaseOrderTable'; 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() { const user = useUserState(); + const [purchaseOrderView, setpurchaseOrderView] = useLocalStorage<string>({ + key: 'purchaseOrderView', + defaultValue: 'table' + }); + const panels = useMemo(() => { return [ { name: 'purchaseorders', label: t`Purchase Orders`, 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', @@ -66,7 +111,7 @@ export default function PurchasingIndex() { content: <ManufacturerPartTable params={{}} /> } ]; - }, [user]); + }, [user, purchaseOrderView]); if (!user.isLoggedIn() || !user.hasViewRole(UserRoles.purchase_order)) { return <PermissionDenied />; diff --git a/src/frontend/src/pages/sales/SalesIndex.tsx b/src/frontend/src/pages/sales/SalesIndex.tsx index 3b0f26815b..4fab306a59 100644 --- a/src/frontend/src/pages/sales/SalesIndex.tsx +++ b/src/frontend/src/pages/sales/SalesIndex.tsx @@ -2,37 +2,121 @@ import { t } from '@lingui/macro'; import { Stack } from '@mantine/core'; import { IconBuildingStore, + IconCalendar, + IconTable, IconTruckDelivery, IconTruckReturn } from '@tabler/icons-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 { PageDetail } from '../../components/nav/PageDetail'; import { PanelGroup } from '../../components/panels/PanelGroup'; +import { ModelType } from '../../enums/ModelType'; import { UserRoles } from '../../enums/Roles'; import { useUserState } from '../../states/UserState'; import { CompanyTable } from '../../tables/company/CompanyTable'; import { ReturnOrderTable } from '../../tables/sales/ReturnOrderTable'; 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 [salesOrderView, setSalesOrderView] = useLocalStorage<string>({ + key: 'salesOrderView', + defaultValue: 'table' + }); + + const [returnOrderView, setReturnOrderView] = useLocalStorage<string>({ + key: 'returnOrderView', + defaultValue: 'table' + }); + const panels = useMemo(() => { return [ { name: 'salesorders', label: t`Sales Orders`, 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) }, { name: 'returnorders', label: t`Return Orders`, 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) }, { @@ -44,7 +128,7 @@ export default function PurchasingIndex() { ) } ]; - }, [user]); + }, [user, salesOrderView, returnOrderView]); if (!user.isLoggedIn() || !user.hasViewRole(UserRoles.sales_order)) { return <PermissionDenied />; diff --git a/src/frontend/src/pages/sales/SalesOrderShipmentDetail.tsx b/src/frontend/src/pages/sales/SalesOrderShipmentDetail.tsx index af149a82aa..bcf40c747f 100644 --- a/src/frontend/src/pages/sales/SalesOrderShipmentDetail.tsx +++ b/src/frontend/src/pages/sales/SalesOrderShipmentDetail.tsx @@ -4,6 +4,7 @@ import { IconBookmark, IconInfoCircle } from '@tabler/icons-react'; import { useMemo } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; +import dayjs from 'dayjs'; import PrimaryActionButton from '../../components/buttons/PrimaryActionButton'; import { PrintingActions } from '../../components/buttons/PrintingActions'; import { @@ -263,7 +264,7 @@ export default function SalesOrderShipmentDetail() { focus: 'tracking_number', initialData: { ...shipment, - shipment_date: new Date().toISOString().split('T')[0] + shipment_date: dayjs().format('YYYY-MM-DD') }, onFormSuccess: refreshShipment }); diff --git a/src/frontend/src/tables/DownloadAction.tsx b/src/frontend/src/tables/DownloadAction.tsx index c7ec67fe7d..239aa1c219 100644 --- a/src/frontend/src/tables/DownloadAction.tsx +++ b/src/frontend/src/tables/DownloadAction.tsx @@ -15,7 +15,7 @@ import { export function DownloadAction({ downloadCallback }: Readonly<{ - downloadCallback: (fileFormat: string) => void; + downloadCallback?: (fileFormat: string) => void; }>) { const formatOptions = [ { value: 'csv', label: t`CSV`, icon: <IconFileTypeCsv /> }, @@ -27,13 +27,14 @@ export function DownloadAction({ return formatOptions.map((format) => ({ name: format.label, icon: format.icon, - onClick: () => downloadCallback(format.value) + onClick: () => downloadCallback?.(format.value) })); }, [formatOptions, downloadCallback]); return ( <ActionDropdown tooltip={t`Download Data`} + tooltipPosition='top-end' icon={<IconDownload />} actions={actions} /> diff --git a/src/frontend/src/tables/Filter.tsx b/src/frontend/src/tables/Filter.tsx index 3437ec473d..6a3ca357f7 100644 --- a/src/frontend/src/tables/Filter.tsx +++ b/src/frontend/src/tables/Filter.tsx @@ -335,3 +335,14 @@ export function IssuedByFilter(): TableFilter { 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 + }; +} diff --git a/src/frontend/src/tables/FilterSelectDrawer.tsx b/src/frontend/src/tables/FilterSelectDrawer.tsx index 8db116da91..00ab03fae5 100644 --- a/src/frontend/src/tables/FilterSelectDrawer.tsx +++ b/src/frontend/src/tables/FilterSelectDrawer.tsx @@ -21,7 +21,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { IconCheck } from '@tabler/icons-react'; import { StandaloneField } from '../components/forms/StandaloneField'; import { StylishText } from '../components/items/StylishText'; -import type { TableState } from '../hooks/UseTable'; +import type { FilterSetState } from '../hooks/UseFilterSet'; import { type TableFilter, type TableFilterChoice, @@ -34,16 +34,16 @@ import { */ function FilterItem({ flt, - tableState + filterSet }: Readonly<{ flt: TableFilter; - tableState: TableState; + filterSet: FilterSetState; }>) { const removeFilter = useCallback(() => { - const newFilters = tableState.activeFilters.filter( + const newFilters = filterSet.activeFilters.filter( (f) => f.name !== flt.name ); - tableState.setActiveFilters(newFilters); + filterSet.setActiveFilters(newFilters); }, [flt]); return ( @@ -93,7 +93,7 @@ function FilterElement({ case 'api': return ( <StandaloneField - fieldName={`filter_value_${filterName}`} + fieldName={`filter-${filterName}`} fieldDefinition={{ field_type: 'related field', api_url: filterProps.apiUrl, @@ -154,19 +154,18 @@ function FilterElement({ } function FilterAddGroup({ - tableState, + filterSet, availableFilters }: Readonly<{ - tableState: TableState; + filterSet: FilterSetState; availableFilters: TableFilter[]; }>) { const filterOptions: TableFilterChoice[] = useMemo(() => { // List of filter names which are already active on this table let activeFilterNames: string[] = []; - if (tableState.activeFilters && tableState.activeFilters.length > 0) { - activeFilterNames = - tableState.activeFilters?.map((flt) => flt.name) ?? []; + if (filterSet.activeFilters && filterSet.activeFilters.length > 0) { + activeFilterNames = filterSet.activeFilters?.map((flt) => flt.name) ?? []; } return ( @@ -179,7 +178,7 @@ function FilterAddGroup({ description: flt.description })) ?? [] ); - }, [tableState.activeFilters, availableFilters]); + }, [filterSet.activeFilters, availableFilters]); const [selectedFilter, setSelectedFilter] = useState<string | null>(null); @@ -232,9 +231,8 @@ function FilterAddGroup({ } const filters = - tableState.activeFilters?.filter( - (flt) => flt.name !== selectedFilter - ) ?? []; + filterSet.activeFilters?.filter((flt) => flt.name !== selectedFilter) ?? + []; const newFilter: TableFilter = { ...filter, @@ -243,7 +241,7 @@ function FilterAddGroup({ displayValue ?? valueOptions.find((v) => v.value === value)?.label }; - tableState.setActiveFilters([...filters, newFilter]); + filterSet.setActiveFilters([...filters, newFilter]); // Clear selected filter setSelectedFilter(null); @@ -275,13 +273,15 @@ function FilterAddGroup({ } export function FilterSelectDrawer({ + title, availableFilters, - tableState, + filterSet, opened, onClose }: Readonly<{ + title?: string; availableFilters: TableFilter[]; - tableState: TableState; + filterSet: FilterSetState; opened: boolean; onClose: () => void; }>) { @@ -290,13 +290,13 @@ export function FilterSelectDrawer({ // Hide the "add filter" selection whenever the selected filters change useEffect(() => { setAddFilter(false); - }, [tableState.activeFilters]); + }, [filterSet.activeFilters]); const hasFilters: boolean = useMemo(() => { - const filters = tableState?.activeFilters ?? []; + const filters = filterSet?.activeFilters ?? []; return filters.length > 0; - }, [tableState.activeFilters]); + }, [filterSet.activeFilters]); return ( <Drawer @@ -308,18 +308,18 @@ export function FilterSelectDrawer({ closeButtonProps={{ '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'> {hasFilters && - tableState.activeFilters?.map((f) => ( - <FilterItem key={f.name} flt={f} tableState={tableState} /> + filterSet.activeFilters?.map((f) => ( + <FilterItem key={f.name} flt={f} filterSet={filterSet} /> ))} {hasFilters && <Divider />} {addFilter && ( <Stack gap='xs'> <FilterAddGroup - tableState={tableState} + filterSet={filterSet} availableFilters={availableFilters} /> </Stack> @@ -334,7 +334,7 @@ export function FilterSelectDrawer({ </Button> )} {!addFilter && - tableState.activeFilters.length < availableFilters.length && ( + filterSet.activeFilters.length < availableFilters.length && ( <Button onClick={() => setAddFilter(true)} color='green' @@ -343,9 +343,9 @@ export function FilterSelectDrawer({ <Text>{t`Add Filter`}</Text> </Button> )} - {!addFilter && tableState.activeFilters.length > 0 && ( + {!addFilter && filterSet.activeFilters.length > 0 && ( <Button - onClick={tableState.clearActiveFilters} + onClick={filterSet.clearActiveFilters} color='red' variant='subtle' > diff --git a/src/frontend/src/tables/InvenTreeTable.tsx b/src/frontend/src/tables/InvenTreeTable.tsx index 79b9c44b17..637da628c8 100644 --- a/src/frontend/src/tables/InvenTreeTable.tsx +++ b/src/frontend/src/tables/InvenTreeTable.tsx @@ -359,8 +359,8 @@ export function InvenTreeTable<T extends Record<string, any>>({ }; // Add custom filters - if (tableState.activeFilters) { - tableState.activeFilters.forEach((flt) => { + if (tableState.filterSet.activeFilters) { + tableState.filterSet.activeFilters.forEach((flt) => { queryParams[flt.name] = flt.value; }); } @@ -401,7 +401,7 @@ export function InvenTreeTable<T extends Record<string, any>>({ [ tableProps.params, tableProps.enablePagination, - tableState.activeFilters, + tableState.filterSet.activeFilters, tableState.queryFilters, tableState.searchTerm, tableState.pageSize, @@ -524,7 +524,7 @@ export function InvenTreeTable<T extends Record<string, any>>({ sortStatus.columnAccessor, sortStatus.direction, tableState.tableKey, - tableState.activeFilters, + tableState.filterSet.activeFilters, tableState.searchTerm ], 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) { tableState.setPageSize(newData); tableState.setPage(1); diff --git a/src/frontend/src/tables/InvenTreeTableHeader.tsx b/src/frontend/src/tables/InvenTreeTableHeader.tsx index bfc1d15894..19daef8a32 100644 --- a/src/frontend/src/tables/InvenTreeTableHeader.tsx +++ b/src/frontend/src/tables/InvenTreeTableHeader.tsx @@ -63,8 +63,8 @@ export default function InvenTreeTableHeader({ }; // Add in active filters - if (tableState.activeFilters) { - tableState.activeFilters.forEach((filter) => { + if (tableState.filterSet.activeFilters) { + tableState.filterSet.activeFilters.forEach((filter) => { queryParams[filter.name] = filter.value; }); } @@ -141,7 +141,7 @@ export default function InvenTreeTableHeader({ <Boundary label={`InvenTreeTableFilterDrawer-${tableState.tableKey}`}> <FilterSelectDrawer availableFilters={filters} - tableState={tableState} + filterSet={tableState.filterSet} opened={filtersVisible} onClose={() => setFiltersVisible(false)} /> @@ -216,8 +216,8 @@ export default function InvenTreeTableHeader({ {tableProps.enableFilters && filters.length > 0 && ( <Indicator size='xs' - label={tableState.activeFilters?.length ?? 0} - disabled={tableState.activeFilters?.length == 0} + label={tableState.filterSet.activeFilters?.length ?? 0} + disabled={tableState.filterSet.activeFilters?.length == 0} > <ActionIcon disabled={hasCustomFilters} diff --git a/src/frontend/src/tables/Search.tsx b/src/frontend/src/tables/Search.tsx index 3f6acc7dd3..9051fc687d 100644 --- a/src/frontend/src/tables/Search.tsx +++ b/src/frontend/src/tables/Search.tsx @@ -28,7 +28,13 @@ export function TableSearchInput({ onChange={(event) => setValue(event.target.value)} rightSection={ value.length > 0 ? ( - <CloseButton size='xs' onClick={() => setValue('')} /> + <CloseButton + size='xs' + onClick={() => { + setValue(''); + searchCallback(''); + }} + /> ) : null } /> diff --git a/src/frontend/src/tables/build/BuildOrderTable.tsx b/src/frontend/src/tables/build/BuildOrderTable.tsx index 8ebe095753..c4e5d11339 100644 --- a/src/frontend/src/tables/build/BuildOrderTable.tsx +++ b/src/frontend/src/tables/build/BuildOrderTable.tsx @@ -36,6 +36,7 @@ import { OrderStatusFilter, OutstandingFilter, OverdueFilter, + PartCategoryFilter, ProjectCodeFilter, ResponsibleFilter, StartDateAfterFilter, @@ -154,14 +155,7 @@ export function BuildOrderTable({ HasProjectCodeFilter(), IssuedByFilter(), ResponsibleFilter(), - { - name: 'category', - label: t`Category`, - description: t`Filter by part category`, - apiUrl: apiUrl(ApiEndpoints.category_list), - model: ModelType.partcategory, - modelRenderer: (instance: any) => instance.name - } + PartCategoryFilter() ]; // If we are filtering on a specific part, we can include the "include variants" filter diff --git a/src/frontend/src/tables/sales/SalesOrderShipmentTable.tsx b/src/frontend/src/tables/sales/SalesOrderShipmentTable.tsx index d9e3de429a..244282b53b 100644 --- a/src/frontend/src/tables/sales/SalesOrderShipmentTable.tsx +++ b/src/frontend/src/tables/sales/SalesOrderShipmentTable.tsx @@ -3,6 +3,7 @@ import { IconTruckDelivery } from '@tabler/icons-react'; import { useCallback, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; +import dayjs from 'dayjs'; import { AddItemButton } from '../../components/buttons/AddItemButton'; import { YesNoButton } from '../../components/buttons/YesNoButton'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; @@ -82,7 +83,7 @@ export default function SalesOrderShipmentTable({ focus: 'tracking_number', initialData: { ...selectedShipment, - shipment_date: new Date().toISOString().split('T')[0] + shipment_date: dayjs().format('YYYY-MM-DD') } }); diff --git a/src/frontend/tests/helpers.ts b/src/frontend/tests/helpers.ts index 46aba50ef9..68b55f4afd 100644 --- a/src/frontend/tests/helpers.ts +++ b/src/frontend/tests/helpers.ts @@ -101,6 +101,18 @@ export const loadTab = async (page, tabName) => { 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 */ diff --git a/src/frontend/tests/pages/pui_build.spec.ts b/src/frontend/tests/pages/pui_build.spec.ts index 244b57c833..0925f9e69a 100644 --- a/src/frontend/tests/pages/pui_build.spec.ts +++ b/src/frontend/tests/pages/pui_build.spec.ts @@ -1,6 +1,7 @@ import { expect } from '@playwright/test'; import { test } from '../baseFixtures.ts'; import { + activateCalendarView, clearTableFilters, getRowFromCell, loadTab, @@ -90,6 +91,21 @@ test('Build Order - Basic Tests', async ({ page }) => { .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 }) => { await doQuickLogin(page); diff --git a/src/frontend/tests/pages/pui_purchase_order.spec.ts b/src/frontend/tests/pages/pui_purchase_order.spec.ts index 47e3a73769..6ed9189dc4 100644 --- a/src/frontend/tests/pages/pui_purchase_order.spec.ts +++ b/src/frontend/tests/pages/pui_purchase_order.spec.ts @@ -1,6 +1,8 @@ import { expect } from '@playwright/test'; import { test } from '../baseFixtures.ts'; import { + activateCalendarView, + activateTableView, clearTableFilters, clickButtonIfVisible, clickOnRowMenu, @@ -11,11 +13,12 @@ import { } from '../helpers.ts'; import { doQuickLogin } from '../login.ts'; -test('Purchase Orders - List', async ({ page }) => { +test('Purchase Orders - Table', async ({ page }) => { await doQuickLogin(page); await page.getByRole('tab', { name: 'Purchasing' }).click(); await loadTab(page, 'Purchase Orders'); + await activateTableView(page); await clearTableFilters(page); @@ -39,6 +42,30 @@ test('Purchase Orders - List', async ({ page }) => { 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 }) => { await doQuickLogin(page); @@ -157,6 +184,7 @@ test('Purchase Orders - Filters', async ({ page }) => { await page.getByRole('tab', { name: 'Purchasing' }).click(); await loadTab(page, 'Purchase Orders'); + await activateTableView(page); // Open filters drawer await openFilterDrawer(page); diff --git a/src/frontend/tests/pui_printing.spec.ts b/src/frontend/tests/pui_printing.spec.ts index 917ee149b3..ae50369b9a 100644 --- a/src/frontend/tests/pui_printing.spec.ts +++ b/src/frontend/tests/pui_printing.spec.ts @@ -1,5 +1,5 @@ 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 { setPluginState } from './settings.js'; @@ -59,6 +59,7 @@ test('Report Printing', async ({ page }) => { // Navigate to a specific PurchaseOrder await page.getByRole('tab', { name: 'Purchasing' }).click(); await loadTab(page, 'Purchase Orders'); + await activateTableView(page); await page.getByRole('cell', { name: 'PO0009' }).click(); diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index 44e87a6c50..684b678365 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -1311,6 +1311,28 @@ dependencies: 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": version "1.1.0" 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" 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: version "29.7.0" resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.7.0.tgz#ca42c758310f365bfa71a0bda0a807160b776812"