2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-27 19:16:44 +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
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

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

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

@ -6,6 +6,7 @@ import type { ReactNode } from 'react';
export type PanelType = {
name: string;
label: string;
controls?: ReactNode;
icon?: ReactNode;
content: ReactNode;
hidden?: boolean;

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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