[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
|
||||
|
||||
To navigate to the Build Order display, select *Build* from the main navigation menu:
|
||||
To navigate to the Build Order display, select *Manufacturing* from the main navigation menu, and *Build Orders* from the sidebar.
|
||||
|
||||
The *Build Order Index Page* allows the user to view all build orders:
|
||||
|
||||
{% with id="build_display", url="build/build_display.png", description="Display Builds" %}
|
||||
{% include "img.html" %}
|
||||
{% endwith %}
|
||||
|
||||
The following view modes are available:
|
||||
|
||||
#### Table View
|
||||
|
||||
*Table View* provides a table of Build Orders, which can be filtered to only show the orders you are interested in.
|
||||
@ -28,7 +32,13 @@ To navigate to the Build Order display, select *Build* from the main navigation
|
||||
|
||||
#### Calendar View
|
||||
|
||||
*Calendar View* shows a calendar display with upcoming build orders, based on the various dates specified for each build.
|
||||
*Calendar View* shows a calendar display with outstanding build orders, based on the various dates specified for each order.
|
||||
|
||||
{% with id="build_calendar", url="build/build_calendar.png", description="Build Calendar" %}
|
||||
{% include "img.html" %}
|
||||
{% endwith %}
|
||||
|
||||
The build calendar allows the user to navigate month-by-month and display the filtered build orders
|
||||
|
||||
## Build Order Details
|
||||
|
||||
|
@ -6,12 +6,32 @@ title: Purchase Order
|
||||
|
||||
Purchase orders allow to track which parts are bought from suppliers and manufacturers, therefore converting externally bought items into stock items / inventory.
|
||||
|
||||
To access the purchase order page, click on the <span class="badge inventree nav main"><span class='fas fa-shopping-cart'></span> Buy</span> navigation tab and click on <span class="badge inventree nav main"><span class='fas fa-list'></span> Purchase Orders</span> option in the dropdown list.
|
||||
### View Purchase Orders
|
||||
|
||||
To navigate to the Purchase Order display, select *Purchasing* from the main navigation menu, and *Build Orders* from the sidebar:
|
||||
|
||||
{% with id="purchase_order_display", url="order/po_display.png", description="Purchase Order Display" %}
|
||||
{% include "img.html" %}
|
||||
{% endwith %}
|
||||
|
||||
The following view modes are available:
|
||||
|
||||
#### Table View
|
||||
|
||||
*Table View* provides a list of Purchase Orders, which can be filtered to display a subset of orders according to user supplied parameters.
|
||||
|
||||
{% with id="purchase_order_list", url="order/po_list.png", description="Purchase Order List" %}
|
||||
{% include "img.html" %}
|
||||
{% endwith %}
|
||||
|
||||
#### Calendar View
|
||||
|
||||
*Calendar View* shows a calendar display with outstanding purchase orders, based on the various dates specified for each order.
|
||||
|
||||
{% with id="purchase_order_calendar", url="order/po_calendar.png", description="Purchase Order Calendar" %}
|
||||
{% include "img.html" %}
|
||||
{% endwith %}
|
||||
|
||||
### Purchase Order Status Codes
|
||||
|
||||
Each Purchase Order has a specific status code which indicates the current state of the order:
|
||||
|
@ -9,6 +9,32 @@ Return Orders allow stock items (which have been sold or allocated to a customer
|
||||
!!! tip "An Order By Any Other Name"
|
||||
A Return Order may also be known as an [RMA](https://en.wikipedia.org/wiki/Return_merchandise_authorization)
|
||||
|
||||
### View Return Orders
|
||||
|
||||
To navigate to the Return Order display, select *Sales* from the main navigation menu, and *Return Orders* from the sidebar:
|
||||
|
||||
{% with id="return_order_display", url="order/ro_display.png", description="Return Order Display" %}
|
||||
{% include "img.html" %}
|
||||
{% endwith %}
|
||||
|
||||
The following view modes are available:
|
||||
|
||||
#### Table View
|
||||
|
||||
*Table View* provides a list of Return Orders, which can be filtered to display a subset of orders according to user supplied parameters.
|
||||
|
||||
{% with id="purchase_order_list", url="order/po_list.png", description="Return Order List" %}
|
||||
{% include "img.html" %}
|
||||
{% endwith %}
|
||||
|
||||
#### Calendar View
|
||||
|
||||
*Calendar View* shows a calendar display with outstanding return orders, based on the various dates specified for each order.
|
||||
|
||||
{% with id="return_order_calendar", url="order/ro_calendar.png", description="Return Order Calendar" %}
|
||||
{% include "img.html" %}
|
||||
{% endwith %}
|
||||
|
||||
### Enable Return Order Functionality
|
||||
|
||||
By default, Return Order functionality is not enabled - it must be enabled by a *staff* user from the settings page:
|
||||
|
@ -6,12 +6,32 @@ title: Sales Orders
|
||||
|
||||
Sales orders allow tracking of which stock items are sold to customers, therefore converting stock items / inventory into externally sold items.
|
||||
|
||||
To access the sales order page, click on the <span class="badge inventree nav main"><span class='fas fa-truck'></span> Sell</span> navigation tab and click on <span class="badge inventree nav main"><span class='fas fa-list'></span> Sales Orders</span> option in the dropdown list.
|
||||
### View Sales Orders
|
||||
|
||||
To navigate to the Sales Order display, select *Sales* from the main navigation menu, and *Sales Orders* from the sidebar:
|
||||
|
||||
{% with id="sales_order_display", url="order/so_display.png", description="Sales Order Display" %}
|
||||
{% include "img.html" %}
|
||||
{% endwith %}
|
||||
|
||||
The following view modes are available:
|
||||
|
||||
#### Table View
|
||||
|
||||
*Table View* provides a list of Sales Orders, which can be filtered to display a subset of orders according to user supplied parameters.
|
||||
|
||||
{% with id="sales_order_list", url="order/so_list.png", description="Sales Order List" %}
|
||||
{% include "img.html" %}
|
||||
{% endwith %}
|
||||
|
||||
#### Calendar View
|
||||
|
||||
*Calendar View* shows a calendar display with outstanding sales orders.
|
||||
|
||||
{% with id="sales_order_calendar", url="order/so_calendar.png", description="Sales Order Calendar" %}
|
||||
{% include "img.html" %}
|
||||
{% endwith %}
|
||||
|
||||
### Sales Order Status Codes
|
||||
|
||||
Each Sales Order has a specific status code, which represents the state of the order:
|
||||
|
@ -1,13 +1,16 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 321
|
||||
INVENTREE_API_VERSION = 322
|
||||
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v322 - 2025-03-16 : https://github.com/inventree/InvenTree/pull/8933
|
||||
- Add min_date and max_date query filters for orders, for use in calendar views
|
||||
|
||||
v321 - 2025-03-06 : https://github.com/inventree/InvenTree/pull/9236
|
||||
- Adds conditionally-returned fields to the schema to match API behavior
|
||||
- Removes required flag for nullable read-only fields to match API behavior
|
||||
|
@ -242,6 +242,65 @@ class BuildFilter(rest_filters.FilterSet):
|
||||
label=_('Completed after'), field_name='completion_date', lookup_expr='gt'
|
||||
)
|
||||
|
||||
min_date = InvenTreeDateFilter(label=_('Min Date'), method='filter_min_date')
|
||||
|
||||
def filter_min_date(self, queryset, name, value):
|
||||
"""Filter the queryset to include orders *after* a specified date.
|
||||
|
||||
This filter is used in combination with filter_max_date,
|
||||
to provide a queryset which matches a particular range of dates.
|
||||
|
||||
In particular, this is used in the UI for the calendar view.
|
||||
|
||||
So, we are interested in orders which are active *after* this date:
|
||||
|
||||
- creation_date is set *after* this date (but there is no start date)
|
||||
- start_date is set *after* this date
|
||||
- target_date is set *after* this date
|
||||
|
||||
"""
|
||||
q1 = Q(creation_date__gte=value, start_date__isnull=True)
|
||||
q2 = Q(start_date__gte=value)
|
||||
q3 = Q(target_date__gte=value)
|
||||
|
||||
return queryset.filter(q1 | q2 | q3).distinct()
|
||||
|
||||
max_date = InvenTreeDateFilter(label=_('Max Date'), method='filter_max_date')
|
||||
|
||||
def filter_max_date(self, queryset, name, value):
|
||||
"""Filter the queryset to include orders *before* a specified date.
|
||||
|
||||
This filter is used in combination with filter_min_date,
|
||||
to provide a queryset which matches a particular range of dates.
|
||||
|
||||
In particular, this is used in the UI for the calendar view.
|
||||
|
||||
So, we are interested in orders which are active *before* this date:
|
||||
|
||||
- creation_date is set *before* this date (but there is no start date)
|
||||
- start_date is set *before* this date
|
||||
- target_date is set *before* this date
|
||||
"""
|
||||
q1 = Q(creation_date__lte=value, start_date__isnull=True)
|
||||
q2 = Q(start_date__lte=value)
|
||||
q3 = Q(target_date__lte=value)
|
||||
|
||||
return queryset.filter(q1 | q2 | q3).distinct()
|
||||
|
||||
exclude_tree = rest_filters.ModelChoiceFilter(
|
||||
queryset=Build.objects.all(),
|
||||
method='filter_exclude_tree',
|
||||
label=_('Exclude Tree'),
|
||||
)
|
||||
|
||||
def filter_exclude_tree(self, queryset, name, value):
|
||||
"""Filter by excluding a tree of Build objects."""
|
||||
queryset = queryset.exclude(
|
||||
pk__in=[bld.pk for bld in value.get_descendants(include_self=True)]
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class BuildMixin:
|
||||
"""Mixin class for Build API endpoints."""
|
||||
@ -319,35 +378,6 @@ class BuildList(DataExportViewMixin, BuildMixin, ListCreateAPI):
|
||||
|
||||
return queryset
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
"""Custom query filtering for the BuildList endpoint."""
|
||||
queryset = super().filter_queryset(queryset)
|
||||
|
||||
params = self.request.query_params
|
||||
|
||||
# exclude parent tree
|
||||
exclude_tree = params.get('exclude_tree', None)
|
||||
|
||||
if exclude_tree is not None:
|
||||
try:
|
||||
build = Build.objects.get(pk=exclude_tree)
|
||||
|
||||
queryset = queryset.exclude(
|
||||
pk__in=[bld.pk for bld in build.get_descendants(include_self=True)]
|
||||
)
|
||||
|
||||
except (ValueError, Build.DoesNotExist):
|
||||
pass
|
||||
|
||||
# Filter by 'date range'
|
||||
min_date = params.get('min_date', None)
|
||||
max_date = params.get('max_date', None)
|
||||
|
||||
if min_date is not None and max_date is not None:
|
||||
queryset = Build.filterByDate(queryset, min_date, max_date)
|
||||
|
||||
return queryset
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
"""Add extra context information to the endpoint serializer."""
|
||||
try:
|
||||
|
@ -1,7 +1,6 @@
|
||||
"""Build database model definitions."""
|
||||
|
||||
import decimal
|
||||
from datetime import datetime
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.exceptions import ValidationError
|
||||
@ -197,44 +196,6 @@ class Build(
|
||||
'title': str(self),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def filterByDate(queryset, min_date, max_date):
|
||||
"""Filter by 'minimum and maximum date range'.
|
||||
|
||||
- Specified as min_date, max_date
|
||||
- Both must be specified for filter to be applied
|
||||
"""
|
||||
date_fmt = '%Y-%m-%d' # ISO format date string
|
||||
|
||||
# Ensure that both dates are valid
|
||||
try:
|
||||
min_date = datetime.strptime(str(min_date), date_fmt).date()
|
||||
max_date = datetime.strptime(str(max_date), date_fmt).date()
|
||||
except (ValueError, TypeError):
|
||||
# Date processing error, return queryset unchanged
|
||||
return queryset
|
||||
|
||||
# Order was completed within the specified range
|
||||
completed = (
|
||||
Q(status=BuildStatus.COMPLETE.value)
|
||||
& Q(completion_date__gte=min_date)
|
||||
& Q(completion_date__lte=max_date)
|
||||
)
|
||||
|
||||
# Order target date falls within specified range
|
||||
pending = (
|
||||
Q(status__in=BuildStatusGroups.ACTIVE_CODES)
|
||||
& ~Q(target_date=None)
|
||||
& Q(target_date__gte=min_date)
|
||||
& Q(target_date__lte=max_date)
|
||||
)
|
||||
|
||||
# TODO - Construct a queryset for "overdue" orders
|
||||
|
||||
queryset = queryset.filter(completed | pending)
|
||||
|
||||
return queryset
|
||||
|
||||
def __str__(self):
|
||||
"""String representation of a BuildOrder."""
|
||||
return self.reference
|
||||
|
@ -213,6 +213,44 @@ class OrderFilter(rest_filters.FilterSet):
|
||||
label=_('Target Date After'), field_name='target_date', lookup_expr='gt'
|
||||
)
|
||||
|
||||
min_date = InvenTreeDateFilter(label=_('Min Date'), method='filter_min_date')
|
||||
|
||||
def filter_min_date(self, queryset, name, value):
|
||||
"""Filter the queryset to include orders *after* a specified date.
|
||||
|
||||
This is used in combination with filter_max_date,
|
||||
to provide a queryset which matches a particular range of dates.
|
||||
|
||||
In particular, this is used in the UI for the calendar view.
|
||||
"""
|
||||
q1 = Q(
|
||||
creation_date__gte=value, issue_date__isnull=True, start_date__isnull=True
|
||||
)
|
||||
q2 = Q(issue_date__gte=value, start_date__isnull=True)
|
||||
q3 = Q(start_date__gte=value)
|
||||
q4 = Q(target_date__gte=value)
|
||||
|
||||
return queryset.filter(q1 | q2 | q3 | q4).distinct()
|
||||
|
||||
max_date = InvenTreeDateFilter(label=_('Max Date'), method='filter_max_date')
|
||||
|
||||
def filter_max_date(self, queryset, name, value):
|
||||
"""Filter the queryset to include orders *before* a specified date.
|
||||
|
||||
This is used in combination with filter_min_date,
|
||||
to provide a queryset which matches a particular range of dates.
|
||||
|
||||
In particular, this is used in the UI for the calendar view.
|
||||
"""
|
||||
q1 = Q(
|
||||
creation_date__lte=value, issue_date__isnull=True, start_date__isnull=True
|
||||
)
|
||||
q2 = Q(issue_date__lte=value, start_date__isnull=True)
|
||||
q3 = Q(start_date__lte=value)
|
||||
q4 = Q(target_date__lte=value)
|
||||
|
||||
return queryset.filter(q1 | q2 | q3 | q4).distinct()
|
||||
|
||||
|
||||
class LineItemFilter(rest_filters.FilterSet):
|
||||
"""Base class for custom API filters for order line item list(s)."""
|
||||
@ -323,23 +361,6 @@ class PurchaseOrderList(
|
||||
"""
|
||||
|
||||
filterset_class = PurchaseOrderFilter
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
"""Custom queryset filtering."""
|
||||
# Perform basic filtering
|
||||
queryset = super().filter_queryset(queryset)
|
||||
|
||||
params = self.request.query_params
|
||||
|
||||
# Filter by 'date range'
|
||||
min_date = params.get('min_date', None)
|
||||
max_date = params.get('max_date', None)
|
||||
|
||||
if min_date is not None and max_date is not None:
|
||||
queryset = models.PurchaseOrder.filterByDate(queryset, min_date, max_date)
|
||||
|
||||
return queryset
|
||||
|
||||
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
||||
|
||||
ordering_field_aliases = {
|
||||
@ -791,21 +812,6 @@ class SalesOrderList(
|
||||
|
||||
filterset_class = SalesOrderFilter
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
"""Perform custom filtering operations on the SalesOrder queryset."""
|
||||
queryset = super().filter_queryset(queryset)
|
||||
|
||||
params = self.request.query_params
|
||||
|
||||
# Filter by 'date range'
|
||||
min_date = params.get('min_date', None)
|
||||
max_date = params.get('max_date', None)
|
||||
|
||||
if min_date is not None and max_date is not None:
|
||||
queryset = models.SalesOrder.filterByDate(queryset, min_date, max_date)
|
||||
|
||||
return queryset
|
||||
|
||||
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
||||
|
||||
ordering_field_aliases = {
|
||||
|
@ -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."""
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
@ -389,6 +388,13 @@ class Order(
|
||||
verbose_name=_('Created By'),
|
||||
)
|
||||
|
||||
issue_date = models.DateField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('Issue Date'),
|
||||
help_text=_('Date order was issued'),
|
||||
)
|
||||
|
||||
responsible = models.ForeignKey(
|
||||
UserModels.Owner,
|
||||
on_delete=models.SET_NULL,
|
||||
@ -482,50 +488,6 @@ class PurchaseOrder(TotalPriceMixin, Order):
|
||||
"""Return the associated barcode model type code for this model."""
|
||||
return 'PO'
|
||||
|
||||
@staticmethod
|
||||
def filterByDate(queryset, min_date, max_date):
|
||||
"""Filter by 'minimum and maximum date range'.
|
||||
|
||||
- Specified as min_date, max_date
|
||||
- Both must be specified for filter to be applied
|
||||
- Determine which "interesting" orders exist between these dates
|
||||
|
||||
To be "interesting":
|
||||
- A "received" order where the received date lies within the date range
|
||||
- A "pending" order where the target date lies within the date range
|
||||
- TODO: An "overdue" order where the target date is in the past
|
||||
"""
|
||||
date_fmt = '%Y-%m-%d' # ISO format date string
|
||||
|
||||
# Ensure that both dates are valid
|
||||
try:
|
||||
min_date = datetime.strptime(str(min_date), date_fmt).date()
|
||||
max_date = datetime.strptime(str(max_date), date_fmt).date()
|
||||
except (ValueError, TypeError):
|
||||
# Date processing error, return queryset unchanged
|
||||
return queryset
|
||||
|
||||
# Construct a queryset for "received" orders within the range
|
||||
received = (
|
||||
Q(status=PurchaseOrderStatus.COMPLETE.value)
|
||||
& Q(complete_date__gte=min_date)
|
||||
& Q(complete_date__lte=max_date)
|
||||
)
|
||||
|
||||
# Construct a queryset for "pending" orders within the range
|
||||
pending = (
|
||||
Q(status__in=PurchaseOrderStatusGroups.OPEN)
|
||||
& ~Q(target_date=None)
|
||||
& Q(target_date__gte=min_date)
|
||||
& Q(target_date__lte=max_date)
|
||||
)
|
||||
|
||||
# TODO - Construct a queryset for "overdue" orders within the range
|
||||
|
||||
queryset = queryset.filter(received | pending)
|
||||
|
||||
return queryset
|
||||
|
||||
def __str__(self):
|
||||
"""Render a string representation of this PurchaseOrder."""
|
||||
return f'{self.reference} - {self.supplier.name if self.supplier else _("deleted")}'
|
||||
@ -584,13 +546,6 @@ class PurchaseOrder(TotalPriceMixin, Order):
|
||||
verbose_name=_('received by'),
|
||||
)
|
||||
|
||||
issue_date = models.DateField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('Issue Date'),
|
||||
help_text=_('Date order was issued'),
|
||||
)
|
||||
|
||||
complete_date = models.DateField(
|
||||
blank=True,
|
||||
null=True,
|
||||
@ -1054,50 +1009,6 @@ class SalesOrder(TotalPriceMixin, Order):
|
||||
"""Return the associated barcode model type code for this model."""
|
||||
return 'SO'
|
||||
|
||||
@staticmethod
|
||||
def filterByDate(queryset, min_date, max_date):
|
||||
"""Filter by "minimum and maximum date range".
|
||||
|
||||
- Specified as min_date, max_date
|
||||
- Both must be specified for filter to be applied
|
||||
- Determine which "interesting" orders exist between these dates
|
||||
|
||||
To be "interesting":
|
||||
- A "completed" order where the completion date lies within the date range
|
||||
- A "pending" order where the target date lies within the date range
|
||||
- TODO: An "overdue" order where the target date is in the past
|
||||
"""
|
||||
date_fmt = '%Y-%m-%d' # ISO format date string
|
||||
|
||||
# Ensure that both dates are valid
|
||||
try:
|
||||
min_date = datetime.strptime(str(min_date), date_fmt).date()
|
||||
max_date = datetime.strptime(str(max_date), date_fmt).date()
|
||||
except (ValueError, TypeError):
|
||||
# Date processing error, return queryset unchanged
|
||||
return queryset
|
||||
|
||||
# Construct a queryset for "completed" orders within the range
|
||||
completed = (
|
||||
Q(status__in=SalesOrderStatusGroups.COMPLETE)
|
||||
& Q(shipment_date__gte=min_date)
|
||||
& Q(shipment_date__lte=max_date)
|
||||
)
|
||||
|
||||
# Construct a queryset for "pending" orders within the range
|
||||
pending = (
|
||||
Q(status__in=SalesOrderStatusGroups.OPEN)
|
||||
& ~Q(target_date=None)
|
||||
& Q(target_date__gte=min_date)
|
||||
& Q(target_date__lte=max_date)
|
||||
)
|
||||
|
||||
# TODO: Construct a queryset for "overdue" orders within the range
|
||||
|
||||
queryset = queryset.filter(completed | pending)
|
||||
|
||||
return queryset
|
||||
|
||||
def __str__(self):
|
||||
"""Render a string representation of this SalesOrder."""
|
||||
return f'{self.reference} - {self.customer.name if self.customer else _("deleted")}'
|
||||
@ -2363,13 +2274,6 @@ class ReturnOrder(TotalPriceMixin, Order):
|
||||
help_text=_('Customer order reference code'),
|
||||
)
|
||||
|
||||
issue_date = models.DateField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('Issue Date'),
|
||||
help_text=_('Date order was issued'),
|
||||
)
|
||||
|
||||
complete_date = models.DateField(
|
||||
blank=True,
|
||||
null=True,
|
||||
|
@ -193,6 +193,7 @@ class AbstractOrderSerializer(DataImportExportSerializerMixin, serializers.Seria
|
||||
'pk',
|
||||
'created_by',
|
||||
'creation_date',
|
||||
'issue_date',
|
||||
'start_date',
|
||||
'target_date',
|
||||
'description',
|
||||
@ -322,7 +323,6 @@ class PurchaseOrderSerializer(
|
||||
model = order.models.PurchaseOrder
|
||||
|
||||
fields = AbstractOrderSerializer.order_fields([
|
||||
'issue_date',
|
||||
'complete_date',
|
||||
'supplier',
|
||||
'supplier_detail',
|
||||
@ -1880,7 +1880,6 @@ class ReturnOrderSerializer(
|
||||
model = order.models.ReturnOrder
|
||||
|
||||
fields = AbstractOrderSerializer.order_fields([
|
||||
'issue_date',
|
||||
'complete_date',
|
||||
'customer',
|
||||
'customer_detail',
|
||||
|
@ -24,6 +24,10 @@
|
||||
"@fortawesome/free-regular-svg-icons": "^6.6.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.6.0",
|
||||
"@fortawesome/react-fontawesome": "^0.2.2",
|
||||
"@fullcalendar/core": "^6.1.15",
|
||||
"@fullcalendar/daygrid": "^6.1.15",
|
||||
"@fullcalendar/interaction": "^6.1.15",
|
||||
"@fullcalendar/react": "^6.1.15",
|
||||
"@lingui/core": "^4.11.4",
|
||||
"@lingui/react": "^4.11.4",
|
||||
"@mantine/carousel": "^7.16.0",
|
||||
|
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 {
|
||||
Button,
|
||||
type FloatingPosition,
|
||||
Indicator,
|
||||
type IndicatorProps,
|
||||
Menu,
|
||||
@ -43,6 +44,7 @@ export type ActionDropdownItem = {
|
||||
export function ActionDropdown({
|
||||
icon,
|
||||
tooltip,
|
||||
tooltipPosition,
|
||||
actions,
|
||||
disabled = false,
|
||||
hidden = false,
|
||||
@ -50,6 +52,7 @@ export function ActionDropdown({
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
tooltip: string;
|
||||
tooltipPosition?: FloatingPosition;
|
||||
actions: ActionDropdownItem[];
|
||||
disabled?: boolean;
|
||||
hidden?: boolean;
|
||||
@ -71,7 +74,11 @@ export function ActionDropdown({
|
||||
<Menu position='bottom-end' key={menuName}>
|
||||
<Indicator disabled={!indicatorProps} {...indicatorProps?.indicator}>
|
||||
<Menu.Target>
|
||||
<Tooltip label={tooltip} hidden={!tooltip} position='bottom'>
|
||||
<Tooltip
|
||||
label={tooltip}
|
||||
hidden={!tooltip}
|
||||
position={tooltipPosition ?? 'bottom'}
|
||||
>
|
||||
<Button
|
||||
variant={noindicator ? 'transparent' : 'light'}
|
||||
disabled={disabled}
|
||||
|
@ -6,6 +6,7 @@ import type { ReactNode } from 'react';
|
||||
export type PanelType = {
|
||||
name: string;
|
||||
label: string;
|
||||
controls?: ReactNode;
|
||||
icon?: ReactNode;
|
||||
content: ReactNode;
|
||||
hidden?: boolean;
|
||||
|
@ -227,7 +227,14 @@ function BasePanelGroup({
|
||||
<Stack gap='md'>
|
||||
{panel.showHeadline !== false && (
|
||||
<>
|
||||
<StylishText size='xl'>{panel.label}</StylishText>
|
||||
<Group justify='space-between'>
|
||||
<StylishText size='xl'>{panel.label}</StylishText>
|
||||
{panel.controls && (
|
||||
<Group justify='right' wrap='nowrap'>
|
||||
{panel.controls}
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
|
@ -115,7 +115,9 @@ export function useStockFields({
|
||||
|
||||
if (expiry_days && expiry_days > 0) {
|
||||
// Adjust the expiry date based on the part default expiry
|
||||
setExpiryDate(dayjs().add(expiry_days, 'days').toISOString());
|
||||
setExpiryDate(
|
||||
dayjs().add(expiry_days, 'days').format('YYYY-MM-DD')
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
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 { type SetURLSearchParams, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import type { TableFilter } from '../tables/Filter';
|
||||
import { type FilterSetState, useFilterSet } from './UseFilterSet';
|
||||
|
||||
/*
|
||||
* Type definition for representing the state of a table:
|
||||
@ -11,9 +11,7 @@ import type { TableFilter } from '../tables/Filter';
|
||||
* refreshTable: A callback function to externally refresh the table.
|
||||
* isLoading: A boolean flag to indicate if the table is currently loading data
|
||||
* setIsLoading: A function to set the isLoading flag
|
||||
* activeFilters: An array of active filters (saved to local storage)
|
||||
* setActiveFilters: A function to set the active filters
|
||||
* clearActiveFilters: A function to clear all active filters
|
||||
* filterSet: A group of active filters
|
||||
* queryFilters: A map of query filters (e.g. ?active=true&overdue=false) passed in the URL
|
||||
* setQueryFilters: A function to set the query filters
|
||||
* clearQueryFilters: A function to clear all query filters
|
||||
@ -45,9 +43,7 @@ export type TableState = {
|
||||
refreshTable: () => void;
|
||||
isLoading: boolean;
|
||||
setIsLoading: (value: boolean) => void;
|
||||
activeFilters: TableFilter[];
|
||||
setActiveFilters: (filters: TableFilter[]) => void;
|
||||
clearActiveFilters: () => void;
|
||||
filterSet: FilterSetState;
|
||||
queryFilters: URLSearchParams;
|
||||
setQueryFilters: SetURLSearchParams;
|
||||
clearQueryFilters: () => void;
|
||||
@ -101,17 +97,7 @@ export function useTable(tableName: string, idAccessor = 'pk'): TableState {
|
||||
setTableKey(generateTableName());
|
||||
}, [generateTableName]);
|
||||
|
||||
// Array of active filters (saved to local storage)
|
||||
const [activeFilters, setActiveFilters] = useLocalStorage<TableFilter[]>({
|
||||
key: `inventree-table-filters-${tableName}`,
|
||||
defaultValue: [],
|
||||
getInitialValueInEffect: false
|
||||
});
|
||||
|
||||
// Callback to clear all active filters from the table
|
||||
const clearActiveFilters = useCallback(() => {
|
||||
setActiveFilters([]);
|
||||
}, []);
|
||||
const filterSet: FilterSetState = useFilterSet(`table-${tableName}`);
|
||||
|
||||
// Array of expanded records
|
||||
const [expandedRecords, setExpandedRecords] = useState<any[]>([]);
|
||||
@ -194,9 +180,7 @@ export function useTable(tableName: string, idAccessor = 'pk'): TableState {
|
||||
refreshTable,
|
||||
isLoading,
|
||||
setIsLoading,
|
||||
activeFilters,
|
||||
setActiveFilters,
|
||||
clearActiveFilters,
|
||||
filterSet,
|
||||
queryFilters,
|
||||
setQueryFilters,
|
||||
clearQueryFilters,
|
||||
|
@ -181,6 +181,40 @@ export default function BuildDetail() {
|
||||
badge: 'owner',
|
||||
hidden: !build.responsible
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'project_code_label',
|
||||
label: t`Project Code`,
|
||||
icon: 'reference',
|
||||
copy: true,
|
||||
hidden: !build.project_code
|
||||
},
|
||||
{
|
||||
type: 'link',
|
||||
name: 'take_from',
|
||||
icon: 'location',
|
||||
model: ModelType.stocklocation,
|
||||
label: t`Source Location`,
|
||||
backup_value: t`Any location`
|
||||
},
|
||||
{
|
||||
type: 'link',
|
||||
name: 'destination',
|
||||
icon: 'location',
|
||||
model: ModelType.stocklocation,
|
||||
label: t`Destination Location`,
|
||||
hidden: !build.destination
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'batch',
|
||||
label: t`Batch Code`,
|
||||
hidden: !build.batch,
|
||||
copy: true
|
||||
}
|
||||
];
|
||||
|
||||
const br: DetailsField[] = [
|
||||
{
|
||||
type: 'date',
|
||||
name: 'creation_date',
|
||||
@ -212,40 +246,6 @@ export default function BuildDetail() {
|
||||
icon: 'calendar',
|
||||
copy: true,
|
||||
hidden: !build.completion_date
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'project_code_label',
|
||||
label: t`Project Code`,
|
||||
icon: 'reference',
|
||||
copy: true,
|
||||
hidden: !build.project_code
|
||||
}
|
||||
];
|
||||
|
||||
const br: DetailsField[] = [
|
||||
{
|
||||
type: 'link',
|
||||
name: 'take_from',
|
||||
icon: 'location',
|
||||
model: ModelType.stocklocation,
|
||||
label: t`Source Location`,
|
||||
backup_value: t`Any location`
|
||||
},
|
||||
{
|
||||
type: 'link',
|
||||
name: 'destination',
|
||||
icon: 'location',
|
||||
model: ModelType.stocklocation,
|
||||
label: t`Destination Location`,
|
||||
hidden: !build.destination
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'batch',
|
||||
label: t`Batch Code`,
|
||||
hidden: !build.batch,
|
||||
copy: true
|
||||
}
|
||||
];
|
||||
|
||||
|
@ -1,35 +1,89 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Stack } from '@mantine/core';
|
||||
import { IconTools } from '@tabler/icons-react';
|
||||
import { IconCalendar, IconTable, IconTools } from '@tabler/icons-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useLocalStorage } from '@mantine/hooks';
|
||||
import SegmentedIconControl from '../../components/buttons/SegmentedIconControl';
|
||||
import OrderCalendar from '../../components/calendar/OrderCalendar';
|
||||
import PermissionDenied from '../../components/errors/PermissionDenied';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import type { PanelType } from '../../components/panels/Panel';
|
||||
import { PanelGroup } from '../../components/panels/PanelGroup';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import { PartCategoryFilter, type TableFilter } from '../../tables/Filter';
|
||||
import { BuildOrderTable } from '../../tables/build/BuildOrderTable';
|
||||
|
||||
function BuildOrderCalendar() {
|
||||
const calendarFilters: TableFilter[] = useMemo(() => {
|
||||
return [PartCategoryFilter()];
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<OrderCalendar
|
||||
model={ModelType.build}
|
||||
role={UserRoles.build}
|
||||
params={{ outstanding: true }}
|
||||
filters={calendarFilters}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BuildOverview({
|
||||
view
|
||||
}: {
|
||||
view: string;
|
||||
}) {
|
||||
switch (view) {
|
||||
case 'calendar':
|
||||
return <BuildOrderCalendar />;
|
||||
case 'table':
|
||||
default:
|
||||
return <BuildOrderTable />;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Order index page
|
||||
*/
|
||||
export default function BuildIndex() {
|
||||
const user = useUserState();
|
||||
|
||||
if (!user.isLoggedIn() || !user.hasViewRole(UserRoles.build)) {
|
||||
return <PermissionDenied />;
|
||||
}
|
||||
const [buildOrderView, setBuildOrderView] = useLocalStorage<string>({
|
||||
key: 'buildOrderView',
|
||||
defaultValue: 'table'
|
||||
});
|
||||
|
||||
const panels = useMemo(() => {
|
||||
const panels: PanelType[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
name: 'buildorders',
|
||||
label: t`Build Orders`,
|
||||
content: <BuildOrderTable />,
|
||||
icon: <IconTools />
|
||||
content: <BuildOverview view={buildOrderView} />,
|
||||
icon: <IconTools />,
|
||||
controls: (
|
||||
<SegmentedIconControl
|
||||
value={buildOrderView}
|
||||
onChange={setBuildOrderView}
|
||||
data={[
|
||||
{ value: 'table', label: t`Table View`, icon: <IconTable /> },
|
||||
{
|
||||
value: 'calendar',
|
||||
label: t`Calendar View`,
|
||||
icon: <IconCalendar />
|
||||
}
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
];
|
||||
}, []);
|
||||
}, [buildOrderView, setBuildOrderView]);
|
||||
|
||||
if (!user.isLoggedIn() || !user.hasViewRole(UserRoles.build)) {
|
||||
return <PermissionDenied />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
|
@ -12,6 +12,7 @@ import {
|
||||
import { type ReactNode, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import { formatDate } from '../../defaults/formatters';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { navigateToLink } from '../../functions/navigation';
|
||||
@ -32,7 +33,7 @@ function ChartTooltip({ label, payload }: Readonly<ChartTooltipProps>) {
|
||||
}
|
||||
|
||||
if (label && typeof label == 'number') {
|
||||
label = formatDate(new Date(label).toISOString());
|
||||
label = formatDate(dayjs().format('YYYY-MM-DD'));
|
||||
}
|
||||
|
||||
const scheduled = payload.find((item) => item.name == 'scheduled');
|
||||
@ -154,7 +155,6 @@ export default function PartSchedulingDetail({
|
||||
// Construct initial chart entry (for today)
|
||||
const entries: any[] = [
|
||||
{
|
||||
// date: formatDate(today.toISOString()),
|
||||
date: today.valueOf(),
|
||||
delta: 0,
|
||||
scheduled: stock,
|
||||
@ -282,7 +282,7 @@ export default function PartSchedulingDetail({
|
||||
scale: 'time',
|
||||
type: 'number',
|
||||
tickFormatter: (value: number) => {
|
||||
return formatDate(new Date(value).toISOString());
|
||||
return formatDate(dayjs().format('YYYY-MM-DD'));
|
||||
}
|
||||
}}
|
||||
series={[
|
||||
|
@ -10,6 +10,7 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import { AddItemButton } from '../../components/buttons/AddItemButton';
|
||||
import { formatDate, formatPriceRange } from '../../defaults/formatters';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
@ -36,7 +37,7 @@ import { RowDeleteAction, RowEditAction } from '../../tables/RowActions';
|
||||
function ChartTooltip({ label, payload }: Readonly<ChartTooltipProps>) {
|
||||
const formattedLabel: string = useMemo(() => {
|
||||
if (label && typeof label === 'number') {
|
||||
return formatDate(new Date(label).toISOString()) ?? label;
|
||||
return formatDate(dayjs().format('YYYY-MM-DD')) ?? label;
|
||||
} else if (!!label) {
|
||||
return label.toString();
|
||||
} else {
|
||||
@ -253,7 +254,7 @@ export default function PartStocktakeDetail({
|
||||
type: 'number',
|
||||
domain: chartLimits,
|
||||
tickFormatter: (value: number) => {
|
||||
return formatDate(new Date(value).toISOString());
|
||||
return formatDate(dayjs().format('YYYY-MM-DD'));
|
||||
}
|
||||
}}
|
||||
series={[
|
||||
|
@ -4,14 +4,20 @@ import {
|
||||
IconBuildingFactory2,
|
||||
IconBuildingStore,
|
||||
IconBuildingWarehouse,
|
||||
IconCalendar,
|
||||
IconPackageExport,
|
||||
IconShoppingCart
|
||||
IconShoppingCart,
|
||||
IconTable
|
||||
} from '@tabler/icons-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useLocalStorage } from '@mantine/hooks';
|
||||
import SegmentedIconControl from '../../components/buttons/SegmentedIconControl';
|
||||
import OrderCalendar from '../../components/calendar/OrderCalendar';
|
||||
import PermissionDenied from '../../components/errors/PermissionDenied';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelGroup } from '../../components/panels/PanelGroup';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import { CompanyTable } from '../../tables/company/CompanyTable';
|
||||
@ -19,17 +25,56 @@ import { ManufacturerPartTable } from '../../tables/purchasing/ManufacturerPartT
|
||||
import { PurchaseOrderTable } from '../../tables/purchasing/PurchaseOrderTable';
|
||||
import { SupplierPartTable } from '../../tables/purchasing/SupplierPartTable';
|
||||
|
||||
function PurchaseOrderOverview({
|
||||
view
|
||||
}: {
|
||||
view: string;
|
||||
}) {
|
||||
switch (view) {
|
||||
case 'calendar':
|
||||
return (
|
||||
<OrderCalendar
|
||||
model={ModelType.purchaseorder}
|
||||
role={UserRoles.purchase_order}
|
||||
params={{ outstanding: true }}
|
||||
/>
|
||||
);
|
||||
case 'table':
|
||||
default:
|
||||
return <PurchaseOrderTable />;
|
||||
}
|
||||
}
|
||||
|
||||
export default function PurchasingIndex() {
|
||||
const user = useUserState();
|
||||
|
||||
const [purchaseOrderView, setpurchaseOrderView] = useLocalStorage<string>({
|
||||
key: 'purchaseOrderView',
|
||||
defaultValue: 'table'
|
||||
});
|
||||
|
||||
const panels = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
name: 'purchaseorders',
|
||||
label: t`Purchase Orders`,
|
||||
icon: <IconShoppingCart />,
|
||||
content: <PurchaseOrderTable />,
|
||||
hidden: !user.hasViewRole(UserRoles.purchase_order)
|
||||
hidden: !user.hasViewRole(UserRoles.purchase_order),
|
||||
content: <PurchaseOrderOverview view={purchaseOrderView} />,
|
||||
controls: (
|
||||
<SegmentedIconControl
|
||||
value={purchaseOrderView}
|
||||
onChange={setpurchaseOrderView}
|
||||
data={[
|
||||
{ value: 'table', label: t`Table View`, icon: <IconTable /> },
|
||||
{
|
||||
value: 'calendar',
|
||||
label: t`Calendar View`,
|
||||
icon: <IconCalendar />
|
||||
}
|
||||
]}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'suppliers',
|
||||
@ -66,7 +111,7 @@ export default function PurchasingIndex() {
|
||||
content: <ManufacturerPartTable params={{}} />
|
||||
}
|
||||
];
|
||||
}, [user]);
|
||||
}, [user, purchaseOrderView]);
|
||||
|
||||
if (!user.isLoggedIn() || !user.hasViewRole(UserRoles.purchase_order)) {
|
||||
return <PermissionDenied />;
|
||||
|
@ -2,37 +2,121 @@ import { t } from '@lingui/macro';
|
||||
import { Stack } from '@mantine/core';
|
||||
import {
|
||||
IconBuildingStore,
|
||||
IconCalendar,
|
||||
IconTable,
|
||||
IconTruckDelivery,
|
||||
IconTruckReturn
|
||||
} from '@tabler/icons-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useLocalStorage } from '@mantine/hooks';
|
||||
import SegmentedIconControl from '../../components/buttons/SegmentedIconControl';
|
||||
import OrderCalendar from '../../components/calendar/OrderCalendar';
|
||||
import PermissionDenied from '../../components/errors/PermissionDenied';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelGroup } from '../../components/panels/PanelGroup';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import { CompanyTable } from '../../tables/company/CompanyTable';
|
||||
import { ReturnOrderTable } from '../../tables/sales/ReturnOrderTable';
|
||||
import { SalesOrderTable } from '../../tables/sales/SalesOrderTable';
|
||||
|
||||
export default function PurchasingIndex() {
|
||||
function SalesOrderOverview({
|
||||
view
|
||||
}: {
|
||||
view: string;
|
||||
}) {
|
||||
switch (view) {
|
||||
case 'calendar':
|
||||
return (
|
||||
<OrderCalendar
|
||||
model={ModelType.salesorder}
|
||||
role={UserRoles.sales_order}
|
||||
params={{ outstanding: true }}
|
||||
/>
|
||||
);
|
||||
case 'table':
|
||||
default:
|
||||
return <SalesOrderTable />;
|
||||
}
|
||||
}
|
||||
|
||||
function ReturnOrderOverview({
|
||||
view
|
||||
}: {
|
||||
view: string;
|
||||
}) {
|
||||
switch (view) {
|
||||
case 'calendar':
|
||||
return (
|
||||
<OrderCalendar
|
||||
model={ModelType.returnorder}
|
||||
role={UserRoles.return_order}
|
||||
params={{ outstanding: true }}
|
||||
/>
|
||||
);
|
||||
case 'table':
|
||||
default:
|
||||
return <ReturnOrderTable />;
|
||||
}
|
||||
}
|
||||
|
||||
export default function SalesIndex() {
|
||||
const user = useUserState();
|
||||
|
||||
const [salesOrderView, setSalesOrderView] = useLocalStorage<string>({
|
||||
key: 'salesOrderView',
|
||||
defaultValue: 'table'
|
||||
});
|
||||
|
||||
const [returnOrderView, setReturnOrderView] = useLocalStorage<string>({
|
||||
key: 'returnOrderView',
|
||||
defaultValue: 'table'
|
||||
});
|
||||
|
||||
const panels = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
name: 'salesorders',
|
||||
label: t`Sales Orders`,
|
||||
icon: <IconTruckDelivery />,
|
||||
content: <SalesOrderTable />,
|
||||
content: <SalesOrderOverview view={salesOrderView} />,
|
||||
controls: (
|
||||
<SegmentedIconControl
|
||||
value={salesOrderView}
|
||||
onChange={setSalesOrderView}
|
||||
data={[
|
||||
{ value: 'table', label: t`Table View`, icon: <IconTable /> },
|
||||
{
|
||||
value: 'calendar',
|
||||
label: t`Calendar View`,
|
||||
icon: <IconCalendar />
|
||||
}
|
||||
]}
|
||||
/>
|
||||
),
|
||||
hidden: !user.hasViewRole(UserRoles.sales_order)
|
||||
},
|
||||
{
|
||||
name: 'returnorders',
|
||||
label: t`Return Orders`,
|
||||
icon: <IconTruckReturn />,
|
||||
content: <ReturnOrderTable />,
|
||||
content: <ReturnOrderOverview view={returnOrderView} />,
|
||||
controls: (
|
||||
<SegmentedIconControl
|
||||
value={returnOrderView}
|
||||
onChange={setReturnOrderView}
|
||||
data={[
|
||||
{ value: 'table', label: t`Table View`, icon: <IconTable /> },
|
||||
{
|
||||
value: 'calendar',
|
||||
label: t`Calendar View`,
|
||||
icon: <IconCalendar />
|
||||
}
|
||||
]}
|
||||
/>
|
||||
),
|
||||
hidden: !user.hasViewRole(UserRoles.return_order)
|
||||
},
|
||||
{
|
||||
@ -44,7 +128,7 @@ export default function PurchasingIndex() {
|
||||
)
|
||||
}
|
||||
];
|
||||
}, [user]);
|
||||
}, [user, salesOrderView, returnOrderView]);
|
||||
|
||||
if (!user.isLoggedIn() || !user.hasViewRole(UserRoles.sales_order)) {
|
||||
return <PermissionDenied />;
|
||||
|
@ -4,6 +4,7 @@ import { IconBookmark, IconInfoCircle } from '@tabler/icons-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import PrimaryActionButton from '../../components/buttons/PrimaryActionButton';
|
||||
import { PrintingActions } from '../../components/buttons/PrintingActions';
|
||||
import {
|
||||
@ -263,7 +264,7 @@ export default function SalesOrderShipmentDetail() {
|
||||
focus: 'tracking_number',
|
||||
initialData: {
|
||||
...shipment,
|
||||
shipment_date: new Date().toISOString().split('T')[0]
|
||||
shipment_date: dayjs().format('YYYY-MM-DD')
|
||||
},
|
||||
onFormSuccess: refreshShipment
|
||||
});
|
||||
|
@ -15,7 +15,7 @@ import {
|
||||
export function DownloadAction({
|
||||
downloadCallback
|
||||
}: Readonly<{
|
||||
downloadCallback: (fileFormat: string) => void;
|
||||
downloadCallback?: (fileFormat: string) => void;
|
||||
}>) {
|
||||
const formatOptions = [
|
||||
{ value: 'csv', label: t`CSV`, icon: <IconFileTypeCsv /> },
|
||||
@ -27,13 +27,14 @@ export function DownloadAction({
|
||||
return formatOptions.map((format) => ({
|
||||
name: format.label,
|
||||
icon: format.icon,
|
||||
onClick: () => downloadCallback(format.value)
|
||||
onClick: () => downloadCallback?.(format.value)
|
||||
}));
|
||||
}, [formatOptions, downloadCallback]);
|
||||
|
||||
return (
|
||||
<ActionDropdown
|
||||
tooltip={t`Download Data`}
|
||||
tooltipPosition='top-end'
|
||||
icon={<IconDownload />}
|
||||
actions={actions}
|
||||
/>
|
||||
|
@ -335,3 +335,14 @@ export function IssuedByFilter(): TableFilter {
|
||||
description: t`Filter by user who issued the order`
|
||||
});
|
||||
}
|
||||
|
||||
export function PartCategoryFilter(): TableFilter {
|
||||
return {
|
||||
name: 'category',
|
||||
label: t`Category`,
|
||||
description: t`Filter by part category`,
|
||||
apiUrl: apiUrl(ApiEndpoints.category_list),
|
||||
model: ModelType.partcategory,
|
||||
modelRenderer: (instance: any) => instance.name
|
||||
};
|
||||
}
|
||||
|
@ -21,7 +21,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { IconCheck } from '@tabler/icons-react';
|
||||
import { StandaloneField } from '../components/forms/StandaloneField';
|
||||
import { StylishText } from '../components/items/StylishText';
|
||||
import type { TableState } from '../hooks/UseTable';
|
||||
import type { FilterSetState } from '../hooks/UseFilterSet';
|
||||
import {
|
||||
type TableFilter,
|
||||
type TableFilterChoice,
|
||||
@ -34,16 +34,16 @@ import {
|
||||
*/
|
||||
function FilterItem({
|
||||
flt,
|
||||
tableState
|
||||
filterSet
|
||||
}: Readonly<{
|
||||
flt: TableFilter;
|
||||
tableState: TableState;
|
||||
filterSet: FilterSetState;
|
||||
}>) {
|
||||
const removeFilter = useCallback(() => {
|
||||
const newFilters = tableState.activeFilters.filter(
|
||||
const newFilters = filterSet.activeFilters.filter(
|
||||
(f) => f.name !== flt.name
|
||||
);
|
||||
tableState.setActiveFilters(newFilters);
|
||||
filterSet.setActiveFilters(newFilters);
|
||||
}, [flt]);
|
||||
|
||||
return (
|
||||
@ -93,7 +93,7 @@ function FilterElement({
|
||||
case 'api':
|
||||
return (
|
||||
<StandaloneField
|
||||
fieldName={`filter_value_${filterName}`}
|
||||
fieldName={`filter-${filterName}`}
|
||||
fieldDefinition={{
|
||||
field_type: 'related field',
|
||||
api_url: filterProps.apiUrl,
|
||||
@ -154,19 +154,18 @@ function FilterElement({
|
||||
}
|
||||
|
||||
function FilterAddGroup({
|
||||
tableState,
|
||||
filterSet,
|
||||
availableFilters
|
||||
}: Readonly<{
|
||||
tableState: TableState;
|
||||
filterSet: FilterSetState;
|
||||
availableFilters: TableFilter[];
|
||||
}>) {
|
||||
const filterOptions: TableFilterChoice[] = useMemo(() => {
|
||||
// List of filter names which are already active on this table
|
||||
let activeFilterNames: string[] = [];
|
||||
|
||||
if (tableState.activeFilters && tableState.activeFilters.length > 0) {
|
||||
activeFilterNames =
|
||||
tableState.activeFilters?.map((flt) => flt.name) ?? [];
|
||||
if (filterSet.activeFilters && filterSet.activeFilters.length > 0) {
|
||||
activeFilterNames = filterSet.activeFilters?.map((flt) => flt.name) ?? [];
|
||||
}
|
||||
|
||||
return (
|
||||
@ -179,7 +178,7 @@ function FilterAddGroup({
|
||||
description: flt.description
|
||||
})) ?? []
|
||||
);
|
||||
}, [tableState.activeFilters, availableFilters]);
|
||||
}, [filterSet.activeFilters, availableFilters]);
|
||||
|
||||
const [selectedFilter, setSelectedFilter] = useState<string | null>(null);
|
||||
|
||||
@ -232,9 +231,8 @@ function FilterAddGroup({
|
||||
}
|
||||
|
||||
const filters =
|
||||
tableState.activeFilters?.filter(
|
||||
(flt) => flt.name !== selectedFilter
|
||||
) ?? [];
|
||||
filterSet.activeFilters?.filter((flt) => flt.name !== selectedFilter) ??
|
||||
[];
|
||||
|
||||
const newFilter: TableFilter = {
|
||||
...filter,
|
||||
@ -243,7 +241,7 @@ function FilterAddGroup({
|
||||
displayValue ?? valueOptions.find((v) => v.value === value)?.label
|
||||
};
|
||||
|
||||
tableState.setActiveFilters([...filters, newFilter]);
|
||||
filterSet.setActiveFilters([...filters, newFilter]);
|
||||
|
||||
// Clear selected filter
|
||||
setSelectedFilter(null);
|
||||
@ -275,13 +273,15 @@ function FilterAddGroup({
|
||||
}
|
||||
|
||||
export function FilterSelectDrawer({
|
||||
title,
|
||||
availableFilters,
|
||||
tableState,
|
||||
filterSet,
|
||||
opened,
|
||||
onClose
|
||||
}: Readonly<{
|
||||
title?: string;
|
||||
availableFilters: TableFilter[];
|
||||
tableState: TableState;
|
||||
filterSet: FilterSetState;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}>) {
|
||||
@ -290,13 +290,13 @@ export function FilterSelectDrawer({
|
||||
// Hide the "add filter" selection whenever the selected filters change
|
||||
useEffect(() => {
|
||||
setAddFilter(false);
|
||||
}, [tableState.activeFilters]);
|
||||
}, [filterSet.activeFilters]);
|
||||
|
||||
const hasFilters: boolean = useMemo(() => {
|
||||
const filters = tableState?.activeFilters ?? [];
|
||||
const filters = filterSet?.activeFilters ?? [];
|
||||
|
||||
return filters.length > 0;
|
||||
}, [tableState.activeFilters]);
|
||||
}, [filterSet.activeFilters]);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
@ -308,18 +308,18 @@ export function FilterSelectDrawer({
|
||||
closeButtonProps={{
|
||||
'aria-label': 'filter-drawer-close'
|
||||
}}
|
||||
title={<StylishText size='lg'>{t`Table Filters`}</StylishText>}
|
||||
title={<StylishText size='lg'>{title ?? t`Table Filters`}</StylishText>}
|
||||
>
|
||||
<Stack gap='xs'>
|
||||
{hasFilters &&
|
||||
tableState.activeFilters?.map((f) => (
|
||||
<FilterItem key={f.name} flt={f} tableState={tableState} />
|
||||
filterSet.activeFilters?.map((f) => (
|
||||
<FilterItem key={f.name} flt={f} filterSet={filterSet} />
|
||||
))}
|
||||
{hasFilters && <Divider />}
|
||||
{addFilter && (
|
||||
<Stack gap='xs'>
|
||||
<FilterAddGroup
|
||||
tableState={tableState}
|
||||
filterSet={filterSet}
|
||||
availableFilters={availableFilters}
|
||||
/>
|
||||
</Stack>
|
||||
@ -334,7 +334,7 @@ export function FilterSelectDrawer({
|
||||
</Button>
|
||||
)}
|
||||
{!addFilter &&
|
||||
tableState.activeFilters.length < availableFilters.length && (
|
||||
filterSet.activeFilters.length < availableFilters.length && (
|
||||
<Button
|
||||
onClick={() => setAddFilter(true)}
|
||||
color='green'
|
||||
@ -343,9 +343,9 @@ export function FilterSelectDrawer({
|
||||
<Text>{t`Add Filter`}</Text>
|
||||
</Button>
|
||||
)}
|
||||
{!addFilter && tableState.activeFilters.length > 0 && (
|
||||
{!addFilter && filterSet.activeFilters.length > 0 && (
|
||||
<Button
|
||||
onClick={tableState.clearActiveFilters}
|
||||
onClick={filterSet.clearActiveFilters}
|
||||
color='red'
|
||||
variant='subtle'
|
||||
>
|
||||
|
@ -359,8 +359,8 @@ export function InvenTreeTable<T extends Record<string, any>>({
|
||||
};
|
||||
|
||||
// Add custom filters
|
||||
if (tableState.activeFilters) {
|
||||
tableState.activeFilters.forEach((flt) => {
|
||||
if (tableState.filterSet.activeFilters) {
|
||||
tableState.filterSet.activeFilters.forEach((flt) => {
|
||||
queryParams[flt.name] = flt.value;
|
||||
});
|
||||
}
|
||||
@ -401,7 +401,7 @@ export function InvenTreeTable<T extends Record<string, any>>({
|
||||
[
|
||||
tableProps.params,
|
||||
tableProps.enablePagination,
|
||||
tableState.activeFilters,
|
||||
tableState.filterSet.activeFilters,
|
||||
tableState.queryFilters,
|
||||
tableState.searchTerm,
|
||||
tableState.pageSize,
|
||||
@ -524,7 +524,7 @@ export function InvenTreeTable<T extends Record<string, any>>({
|
||||
sortStatus.columnAccessor,
|
||||
sortStatus.direction,
|
||||
tableState.tableKey,
|
||||
tableState.activeFilters,
|
||||
tableState.filterSet.activeFilters,
|
||||
tableState.searchTerm
|
||||
],
|
||||
enabled: !!url && !tableData,
|
||||
@ -629,7 +629,7 @@ export function InvenTreeTable<T extends Record<string, any>>({
|
||||
}
|
||||
};
|
||||
|
||||
// pagination refresth table if pageSize changes
|
||||
// pagination refresh table if pageSize changes
|
||||
function updatePageSize(newData: number) {
|
||||
tableState.setPageSize(newData);
|
||||
tableState.setPage(1);
|
||||
|
@ -63,8 +63,8 @@ export default function InvenTreeTableHeader({
|
||||
};
|
||||
|
||||
// Add in active filters
|
||||
if (tableState.activeFilters) {
|
||||
tableState.activeFilters.forEach((filter) => {
|
||||
if (tableState.filterSet.activeFilters) {
|
||||
tableState.filterSet.activeFilters.forEach((filter) => {
|
||||
queryParams[filter.name] = filter.value;
|
||||
});
|
||||
}
|
||||
@ -141,7 +141,7 @@ export default function InvenTreeTableHeader({
|
||||
<Boundary label={`InvenTreeTableFilterDrawer-${tableState.tableKey}`}>
|
||||
<FilterSelectDrawer
|
||||
availableFilters={filters}
|
||||
tableState={tableState}
|
||||
filterSet={tableState.filterSet}
|
||||
opened={filtersVisible}
|
||||
onClose={() => setFiltersVisible(false)}
|
||||
/>
|
||||
@ -216,8 +216,8 @@ export default function InvenTreeTableHeader({
|
||||
{tableProps.enableFilters && filters.length > 0 && (
|
||||
<Indicator
|
||||
size='xs'
|
||||
label={tableState.activeFilters?.length ?? 0}
|
||||
disabled={tableState.activeFilters?.length == 0}
|
||||
label={tableState.filterSet.activeFilters?.length ?? 0}
|
||||
disabled={tableState.filterSet.activeFilters?.length == 0}
|
||||
>
|
||||
<ActionIcon
|
||||
disabled={hasCustomFilters}
|
||||
|
@ -28,7 +28,13 @@ export function TableSearchInput({
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
rightSection={
|
||||
value.length > 0 ? (
|
||||
<CloseButton size='xs' onClick={() => setValue('')} />
|
||||
<CloseButton
|
||||
size='xs'
|
||||
onClick={() => {
|
||||
setValue('');
|
||||
searchCallback('');
|
||||
}}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
@ -36,6 +36,7 @@ import {
|
||||
OrderStatusFilter,
|
||||
OutstandingFilter,
|
||||
OverdueFilter,
|
||||
PartCategoryFilter,
|
||||
ProjectCodeFilter,
|
||||
ResponsibleFilter,
|
||||
StartDateAfterFilter,
|
||||
@ -154,14 +155,7 @@ export function BuildOrderTable({
|
||||
HasProjectCodeFilter(),
|
||||
IssuedByFilter(),
|
||||
ResponsibleFilter(),
|
||||
{
|
||||
name: 'category',
|
||||
label: t`Category`,
|
||||
description: t`Filter by part category`,
|
||||
apiUrl: apiUrl(ApiEndpoints.category_list),
|
||||
model: ModelType.partcategory,
|
||||
modelRenderer: (instance: any) => instance.name
|
||||
}
|
||||
PartCategoryFilter()
|
||||
];
|
||||
|
||||
// If we are filtering on a specific part, we can include the "include variants" filter
|
||||
|
@ -3,6 +3,7 @@ import { IconTruckDelivery } from '@tabler/icons-react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import { AddItemButton } from '../../components/buttons/AddItemButton';
|
||||
import { YesNoButton } from '../../components/buttons/YesNoButton';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
@ -82,7 +83,7 @@ export default function SalesOrderShipmentTable({
|
||||
focus: 'tracking_number',
|
||||
initialData: {
|
||||
...selectedShipment,
|
||||
shipment_date: new Date().toISOString().split('T')[0]
|
||||
shipment_date: dayjs().format('YYYY-MM-DD')
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -101,6 +101,18 @@ export const loadTab = async (page, tabName) => {
|
||||
await page.waitForLoadState('networkidle');
|
||||
};
|
||||
|
||||
// Activate "table" view in certain contexts
|
||||
export const activateTableView = async (page) => {
|
||||
await page.getByLabel('segmented-icon-control-table').click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
};
|
||||
|
||||
// Activate "calendar" view in certain contexts
|
||||
export const activateCalendarView = async (page) => {
|
||||
await page.getByLabel('segmented-icon-control-calendar').click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
};
|
||||
|
||||
/**
|
||||
* Perform a 'global search' on the provided page, for the provided query text
|
||||
*/
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { test } from '../baseFixtures.ts';
|
||||
import {
|
||||
activateCalendarView,
|
||||
clearTableFilters,
|
||||
getRowFromCell,
|
||||
loadTab,
|
||||
@ -90,6 +91,21 @@ test('Build Order - Basic Tests', async ({ page }) => {
|
||||
.waitFor();
|
||||
});
|
||||
|
||||
test('Build Order - Calendar', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
await navigate(page, 'manufacturing/index/buildorders');
|
||||
await activateCalendarView(page);
|
||||
|
||||
// Check "part category" filter
|
||||
await page.getByLabel('calendar-select-filters').click();
|
||||
await page.getByRole('button', { name: 'Add Filter' }).click();
|
||||
await page.getByPlaceholder('Select filter').fill('category');
|
||||
await page.getByRole('option', { name: 'Category', exact: true }).click();
|
||||
await page.getByLabel('related-field-filter-category').click();
|
||||
await page.getByText('Part category, level 1').waitFor();
|
||||
});
|
||||
|
||||
test('Build Order - Edit', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
|
@ -1,6 +1,8 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { test } from '../baseFixtures.ts';
|
||||
import {
|
||||
activateCalendarView,
|
||||
activateTableView,
|
||||
clearTableFilters,
|
||||
clickButtonIfVisible,
|
||||
clickOnRowMenu,
|
||||
@ -11,11 +13,12 @@ import {
|
||||
} from '../helpers.ts';
|
||||
import { doQuickLogin } from '../login.ts';
|
||||
|
||||
test('Purchase Orders - List', async ({ page }) => {
|
||||
test('Purchase Orders - Table', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
await page.getByRole('tab', { name: 'Purchasing' }).click();
|
||||
await loadTab(page, 'Purchase Orders');
|
||||
await activateTableView(page);
|
||||
|
||||
await clearTableFilters(page);
|
||||
|
||||
@ -39,6 +42,30 @@ test('Purchase Orders - List', async ({ page }) => {
|
||||
await page.getByText('2025-07-17').waitFor(); // Target Date
|
||||
});
|
||||
|
||||
test('Purchase Orders - Calendar', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
await page.getByRole('tab', { name: 'Purchasing' }).click();
|
||||
await loadTab(page, 'Purchase Orders');
|
||||
|
||||
// Ensure view is in "calendar" mode
|
||||
await activateCalendarView(page);
|
||||
|
||||
// Check for expected components
|
||||
await page.getByLabel('action-button-previous-month').waitFor();
|
||||
await page.getByLabel('action-button-next-month').waitFor();
|
||||
|
||||
await page.getByLabel('calendar-select-month').click();
|
||||
await page.getByRole('button', { name: 'Jan' }).waitFor();
|
||||
await page.getByRole('button', { name: 'Feb' }).waitFor();
|
||||
await page.getByRole('button', { name: 'Dec' }).click();
|
||||
|
||||
await page.getByText('December').waitFor();
|
||||
|
||||
// Put back into table view
|
||||
await activateTableView(page);
|
||||
});
|
||||
|
||||
test('Purchase Orders - Barcodes', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
@ -157,6 +184,7 @@ test('Purchase Orders - Filters', async ({ page }) => {
|
||||
|
||||
await page.getByRole('tab', { name: 'Purchasing' }).click();
|
||||
await loadTab(page, 'Purchase Orders');
|
||||
await activateTableView(page);
|
||||
|
||||
// Open filters drawer
|
||||
await openFilterDrawer(page);
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { expect, test } from './baseFixtures.js';
|
||||
import { loadTab, navigate } from './helpers.js';
|
||||
import { activateTableView, loadTab, navigate } from './helpers.js';
|
||||
import { doQuickLogin } from './login.js';
|
||||
import { setPluginState } from './settings.js';
|
||||
|
||||
@ -59,6 +59,7 @@ test('Report Printing', async ({ page }) => {
|
||||
// Navigate to a specific PurchaseOrder
|
||||
await page.getByRole('tab', { name: 'Purchasing' }).click();
|
||||
await loadTab(page, 'Purchase Orders');
|
||||
await activateTableView(page);
|
||||
|
||||
await page.getByRole('cell', { name: 'PO0009' }).click();
|
||||
|
||||
|
@ -1311,6 +1311,28 @@
|
||||
dependencies:
|
||||
prop-types "^15.8.1"
|
||||
|
||||
"@fullcalendar/core@^6.1.15":
|
||||
version "6.1.15"
|
||||
resolved "https://registry.yarnpkg.com/@fullcalendar/core/-/core-6.1.15.tgz#6c3f5259fc4589870228853072131219bb533f6e"
|
||||
integrity sha512-BuX7o6ALpLb84cMw1FCB9/cSgF4JbVO894cjJZ6kP74jzbUZNjtwffwRdA+Id8rrLjT30d/7TrkW90k4zbXB5Q==
|
||||
dependencies:
|
||||
preact "~10.12.1"
|
||||
|
||||
"@fullcalendar/daygrid@^6.1.15":
|
||||
version "6.1.15"
|
||||
resolved "https://registry.yarnpkg.com/@fullcalendar/daygrid/-/daygrid-6.1.15.tgz#91208b0955ba805ddad285a53ee6f53855146963"
|
||||
integrity sha512-j8tL0HhfiVsdtOCLfzK2J0RtSkiad3BYYemwQKq512cx6btz6ZZ2RNc/hVnIxluuWFyvx5sXZwoeTJsFSFTEFA==
|
||||
|
||||
"@fullcalendar/interaction@^6.1.15":
|
||||
version "6.1.15"
|
||||
resolved "https://registry.yarnpkg.com/@fullcalendar/interaction/-/interaction-6.1.15.tgz#1c685d5c269388d4877b75ab2185e97d7c386cc7"
|
||||
integrity sha512-DOTSkofizM7QItjgu7W68TvKKvN9PSEEvDJceyMbQDvlXHa7pm/WAVtAc6xSDZ9xmB1QramYoWGLHkCYbTW1rQ==
|
||||
|
||||
"@fullcalendar/react@^6.1.15":
|
||||
version "6.1.15"
|
||||
resolved "https://registry.yarnpkg.com/@fullcalendar/react/-/react-6.1.15.tgz#3198b4a64e256afd37c9760c8741a9af89ade894"
|
||||
integrity sha512-L0b9hybS2J4e7lq6G2CD4nqriyLEqOH1tE8iI6JQjAMTVh5JicOo5Mqw+fhU5bJ7hLfMw2K3fksxX3Ul1ssw5w==
|
||||
|
||||
"@istanbuljs/load-nyc-config@^1.0.0", "@istanbuljs/load-nyc-config@^1.1.0":
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced"
|
||||
@ -4128,6 +4150,11 @@ postcss@^8.4.43, postcss@^8.4.49:
|
||||
picocolors "^1.1.1"
|
||||
source-map-js "^1.2.1"
|
||||
|
||||
preact@~10.12.1:
|
||||
version "10.12.1"
|
||||
resolved "https://registry.yarnpkg.com/preact/-/preact-10.12.1.tgz#8f9cb5442f560e532729b7d23d42fd1161354a21"
|
||||
integrity sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==
|
||||
|
||||
pretty-format@^29.7.0:
|
||||
version "29.7.0"
|
||||
resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.7.0.tgz#ca42c758310f365bfa71a0bda0a807160b776812"
|
||||
|