2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-28 03:26:45 +00:00

[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
This commit is contained in:
Oliver 2025-03-16 18:01:51 +11:00 committed by GitHub
parent 191c0b1007
commit 3afafe594b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
54 changed files with 1287 additions and 342 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 319 KiB

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 263 KiB

View File

@ -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

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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 = {

View File

@ -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",
),
),
]

View File

@ -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,

View File

@ -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',

View File

@ -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",

View 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>
)
}))}
/>
);
}

View 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>
</>
);
}

View 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}
/>
);
}

View File

@ -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}

View File

@ -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;

View File

@ -227,7 +227,14 @@ function BasePanelGroup({
<Stack gap='md'> <Stack gap='md'>
{panel.showHeadline !== false && ( {panel.showHeadline !== false && (
<> <>
<StylishText size='xl'>{panel.label}</StylishText> <Group justify='space-between'>
<StylishText size='xl'>{panel.label}</StylishText>
{panel.controls && (
<Group justify='right' wrap='nowrap'>
{panel.controls}
</Group>
)}
</Group>
<Divider /> <Divider />
</> </>
)} )}

View File

@ -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')
);
} }
} }
}, },

View 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
};
}

View 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
};
}

View File

@ -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,

View File

@ -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
} }
]; ];

View File

@ -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>

View File

@ -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={[

View File

@ -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={[

View File

@ -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 />;

View File

@ -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 />;

View File

@ -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
}); });

View File

@ -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}
/> />

View File

@ -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
};
}

View File

@ -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'
> >

View File

@ -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);

View File

@ -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}

View File

@ -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
} }
/> />

View File

@ -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

View File

@ -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')
} }
}); });

View File

@ -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
*/ */

View File

@ -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);

View File

@ -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);

View File

@ -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();

View File

@ -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"