[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 && (
|
||||||
<>
|
<>
|
||||||
|
<Group justify='space-between'>
|
||||||
<StylishText size='xl'>{panel.label}</StylishText>
|
<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"
|
||||||
|