Merge commit '3393ef4e65267f4f8c5a5a87798553dbe0160a39' into block-notes

This commit is contained in:
Oliver Walters 2026-06-05 12:57:45 +00:00
commit b33e229c17
87 changed files with 1730 additions and 350 deletions

View File

@ -14,6 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- [#12079](https://github.com/inventree/InvenTree/pull/12079) adds the ability to save filter groups for table and calendar views in the user interface. This allows users to save and reuse commonly used filter configurations, improving the usability and efficiency of the interface.
- [#12077](https://github.com/inventree/InvenTree/pull/12077) adds "tags" fields to multiple new model types and a /api/tag/ endpoint for fetching tags. Also adds the ability to filter various model types by tags.
- [#12019](https://github.com/inventree/InvenTree/pull/12019) adds a "location" field to the StockCount API endpoint, allowing users to specify a location when performing a stock count. This field is optional, and if not provided, the stock count will be performed without changing the location of the stock item. If a location is provided, the stock item(s) will be moved to the specified location as part of the stock count operation.
- [#12011](https://github.com/inventree/InvenTree/pull/12011) adds a "creation_date" field to the StockItem API endpoint, allowing users to track when each stock item was created. This field is read-only and is automatically set to the current date and time when a new stock item is created.
- [#12000](https://github.com/inventree/InvenTree/pull/12000) adds support for auto-allocation of stock items against sales orders. This includes both backend and frontend changes, allowing users to trigger auto-allocation via the API or through the UI. The auto-allocation process will attempt to allocate available stock items to the sales order line items, based on the specified stock sorting and allocation rules.

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@ -0,0 +1,82 @@
---
title: Tags
---
## Tags
*Tags* are short, arbitrary labels that can be attached to InvenTree objects to group or classify them in flexible ways that don't require changes to the underlying data model. Unlike [parameters](./parameters.md), tags carry no typed value — they are simply names. A tag can be applied to objects of any supported model type, and tags are shared across the entire InvenTree instance.
!!! note "Shared Tag Namespace"
Tags are global: a tag named `prototype` applied to a Part and the same tag applied to a Build Order refer to the same underlying tag record. Renaming or deleting a tag affects every object to which it is attached.
### Supported Models
Tags can be attached to the following InvenTree objects:
- [Parts](../part/index.md)
- [Supplier Parts](../purchasing/supplier.md#supplier-parts)
- [Manufacturer Parts](../purchasing/manufacturer.md#manufacturer-parts)
- [Companies](./company.md)
- [Stock Items](../stock/index.md#stock-item)
- [Stock Locations](../stock/index.md#stock-location)
- [Build Orders](../manufacturing/build.md)
- [Purchase Orders](../purchasing/purchase_order.md)
- [Sales Orders](../sales/sales_order.md)
- [Return Orders](../sales/return_order.md)
- [Sales Order Shipments](../sales/sales_order.md#shipments)
## Managing Tags
### Adding and Removing Tags
Any object that supports tags will expose a *Tags* field in its detail and edit forms. Tags are entered as a comma-separated list of names and can be freely added or removed at any time. Tag names are case-insensitive — `Prototype`, `prototype`, and `PROTOTYPE` all refer to the same tag.
### Tag Names
Tag names must be unique within the InvenTree instance (case-insensitively). If you type a name that already exists under a different capitalisation, the existing tag is assigned rather than a new one created. Tag names may contain spaces, but leading and trailing whitespace is stripped automatically.
## Filtering by Tags
Tables that support tags can be filtered by one or more tag names. When multiple tags are specified, only objects that carry **all** of the specified tags are returned (AND logic).
For example, filtering a Parts table by the tags `approved` and `prototype` returns only parts tagged with both.
## API Access
### Tag Endpoints
The tag list is available at `/api/tag/`. Individual tags can be retrieved, updated, or deleted at `/api/tag/<id>/`.
The `model_type` query parameter narrows the tag list to tags currently applied to a specific model type:
```
GET /api/tag/?model_type=part
```
### Tags on Model Endpoints
For models that support tags, the `tags` field is returned in the detail endpoint response as a list of tag name strings:
```json
{
"pk": 42,
"name": "Widget",
"tags": ["approved", "prototype"]
}
```
Tags can be updated via a `PATCH` or `POST` request by supplying a JSON-encoded list of tag name strings. The full list of tags replaces the previous set — omitting a tag removes it:
```json
{
"tags": ["approved", "production"]
}
```
Tags can also be used as a filter parameter on list endpoints. Supply a comma-separated list of tag names to the `tags` query parameter:
```
GET /api/part/?tags=approved,prototype
```
This returns only parts tagged with **both** `approved` and `prototype`.

View File

@ -158,6 +158,24 @@ Select the "table filters" button to open the filter selection menu
Table filters are saved across browser sessions, allowing users to maintain their preferred filter settings when returning to the particular table view.
#### Saved Filter Groups
Frequently used combinations of filters can be saved as a named *filter group*, allowing them to be quickly recalled later without having to re-add each filter individually.
The **Saved Filter Groups** panel is displayed at the bottom of the filter drawer. When one or more filters are active, a **Save current filters** button is available. Clicking it opens an inline name input — enter a name and press Enter (or click the confirm icon) to save the group. Press Escape or click the cancel icon to discard.
{{ image("concepts/ui_table_filter_group.png", "Filter Groups") }}
Previously saved filter groups are listed in the panel. Each entry shows the group name alongside two actions:
- **Load** (green reload icon): Replaces the current active filters with the filters stored in that group. The table immediately re-fetches data using the restored filters.
- **Delete** (red × icon): Permanently removes the saved filter group.
Saved filter groups are stored in the browser's local storage and are specific to each table or calendar view, so groups saved for one view are not available in another. They persist across local browser sessions until explicitly deleted. Filter groups are not shared to other devices.
!!! info "Loading a filter group replaces active filters"
Loading a saved filter group replaces all currently active filters with those stored in the group. Any unsaved active filters will be overwritten.
### Data Sorting
Some table columns support data sorting, allowing the dataset to be sorted in ascending or descending order based on the values in that column. To sort a column, click on the column header. Clicking the column header again will toggle the sort order between ascending and descending. The current sort order is indicated by an arrow icon in the column header.

View File

@ -103,6 +103,7 @@ nav:
- Attachments: concepts/attachments.md
- Parameters: concepts/parameters.md
- Notes: concepts/notes.md
- Tags: concepts/tags.md
- Barcodes:
- Barcode Support: barcodes/index.md
- Internal Barcodes: barcodes/internal.md

View File

@ -1,7 +1,7 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 500
INVENTREE_API_VERSION = 501
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
@ -11,6 +11,11 @@ v500 -> 2026-06-02 : https://github.com/inventree/InvenTree/pull/11971
- Adds a generic "Note" model which can be attached to any model type via a generic foreign key relationship
- Allow multiple notes to be attached to a single object, and for notes to be created / edited / deleted via the API
v500 -> 2026-06-03 : https://github.com/inventree/InvenTree/pull/12077
- Adds "tags" fields to multiple new model types
- Adds /api/tag/ endpoint for fetching tags
- Enable filtering various model types by tags
v499 -> 2026-06-01 : https://github.com/inventree/InvenTree/pull/12057
- Fixes search field issues on the BarcodeScanHistory API endpoint

View File

@ -24,6 +24,7 @@ from error_report.models import Error
from mptt.exceptions import InvalidMove
from mptt.models import MPTTModel, TreeForeignKey
from stdimage.models import StdImageField
from taggit.managers import TaggableManager
import common.settings
import InvenTree.exceptions
@ -1296,6 +1297,25 @@ class PathStringMixin(models.Model):
]
class InvenTreeTagsMixin(models.Model):
"""A mixin class for adding tag functionality to a model class.
The following fields are added to any model which implements this mixin:
- tags : A text field for storing comma-separated tags
"""
class Meta:
"""Metaclass options for this mixin.
Note: abstract must be true, as this is only a mixin, not a separate table
"""
abstract = True
tags = TaggableManager(blank=True)
class InvenTreeBarcodeMixin(models.Model):
"""A mixin class for adding barcode functionality to a model class.

View File

@ -698,10 +698,6 @@ class InvenTreeTaggitSerializer(TaggitSerializer):
return self._save_tags(tag_object, to_be_tagged)
class InvenTreeTagModelSerializer(InvenTreeTaggitSerializer, InvenTreeModelSerializer):
"""Combination of InvenTreeTaggitSerializer and InvenTreeModelSerializer."""
class InvenTreeAttachmentSerializerField(serializers.FileField):
"""Override the DRF native FileField serializer, to remove the leading server path.

View File

@ -1169,3 +1169,6 @@ if 'dbbackup' not in STORAGES:
if _media:
MEDIA_URL = _media
PRESIGNED_URL_EXPIRATION = 600
# Taggit settings
TAGGIT_CASE_INSENSITIVE = True

View File

@ -16,6 +16,7 @@ from rest_framework.response import Response
import build.models as build_models
import build.serializers
import common.filters
import common.models
import common.serializers
import part.models as part_models
@ -307,6 +308,8 @@ class BuildFilter(FilterSet):
return queryset
tags = common.filters.TagsFilter()
class BuildMixin:
"""Mixin class for Build API endpoints."""

View File

@ -0,0 +1,29 @@
# Generated by Django 5.2.14 on 2026-06-03 10:07
import taggit.managers
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("build", "0058_buildline_consumed"),
(
"taggit",
"0006_rename_taggeditem_content_type_object_id_taggit_tagg_content_8fc721_idx",
),
]
operations = [
migrations.AddField(
model_name="build",
name="tags",
field=taggit.managers.TaggableManager(
blank=True,
help_text="A comma-separated list of tags.",
through="taggit.TaggedItem",
to="taggit.Tag",
verbose_name="Tags",
),
),
]

View File

@ -80,6 +80,7 @@ class Build(
InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeNoteMixin,
InvenTree.models.InvenTreeTagsMixin,
InvenTree.models.ReferenceIndexingMixin,
StateTransitionMixin,
StatusCodeMixin,

View File

@ -36,6 +36,7 @@ from InvenTree.serializers import (
FilterableSerializerMixin,
InvenTreeDecimalField,
InvenTreeModelSerializer,
InvenTreeTaggitSerializer,
OptionalField,
)
from stock.generators import generate_batch_code
@ -55,6 +56,7 @@ from .status_codes import BuildStatus
class BuildSerializer(
CustomStatusSerializerMixin,
FilterableSerializerMixin,
InvenTreeTaggitSerializer,
DataImportExportSerializerMixin,
InvenTreeCustomStatusSerializerMixin,
InvenTreeModelSerializer,
@ -100,6 +102,7 @@ class BuildSerializer(
'parameters',
'priority',
'level',
'tags',
]
read_only_fields = [
'completed',
@ -124,6 +127,8 @@ class BuildSerializer(
parameters = common.filters.enable_parameters_filter()
tags = common.filters.enable_tags_filter()
part_name = serializers.CharField(
source='part.name', read_only=True, label=_('Part Name')
)

View File

@ -36,6 +36,7 @@ from rest_framework.permissions import IsAdminUser, IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from sql_util.utils import SubqueryCount
from taggit.models import Tag
import common.filters
import common.models
@ -525,6 +526,48 @@ class ProjectCodeDetail(RetrieveUpdateDestroyAPI):
permission_classes = [IsStaffOrReadOnlyScope]
class TagFilter(FilterSet):
"""Custom filters for the TagList API endpoint."""
class Meta:
"""Metaclass options for the filterset."""
model = Tag
fields = []
model_type = rest_filters.CharFilter(method='filter_model_type', label='Model Type')
def filter_model_type(self, queryset, name, value):
"""Filter to tags which have been applied to the given model type."""
ct = common.filters.determine_content_type(value)
if ct is None:
raise ValidationError({'model_type': f'Invalid model type: {value}'})
return queryset.filter(taggit_taggeditem_items__content_type=ct).distinct()
class TagMixin:
"""Mixin class for Tag views."""
serializer_class = common.serializers.TagSerializer
queryset = Tag.objects.all()
permission_classes = [IsStaffOrReadOnlyScope]
class TagList(TagMixin, ListCreateAPI):
"""List view for all tags."""
filterset_class = TagFilter
filter_backends = SEARCH_ORDER_FILTER
ordering_fields = ['name']
search_fields = ['name']
class TagDetail(TagMixin, RetrieveUpdateDestroyAPI):
"""Detail view for a particular tag."""
class CustomUnitViewset(DataExportViewMixin, viewsets.ModelViewSet):
"""List view for custom units."""
@ -766,6 +809,8 @@ class AttachmentFilter(FilterSet):
return queryset.exclude(attachment=None).exclude(attachment='')
return queryset.filter(Q(attachment=None) | Q(attachment='')).distinct()
tags = common.filters.TagsFilter()
class AttachmentMixin:
"""Mixin class for Attachment views."""
@ -1635,6 +1680,14 @@ common_api_urls = [
path('', ProjectCodeList.as_view(), name='api-project-code-list'),
]),
),
# Tags (via django-taggit)
path(
'tag/',
include([
path('<int:pk>/', TagDetail.as_view(), name='api-tag-detail'),
path('', TagList.as_view(), name='api-tag-list'),
]),
),
# Flags
path(
'flags/',

View File

@ -19,6 +19,7 @@ from django.db.models import (
from django.db.models.query import QuerySet
from django.utils.translation import gettext_lazy as _
import django_filters.rest_framework.filters as rest_filters
from rest_framework import serializers
from taggit.serializers import TagListSerializerField
@ -93,6 +94,36 @@ def filter_content_type(
return queryset.filter(q)
class TagsFilter(rest_filters.CharFilter):
"""Filter which accepts a comma-separated list of tag names and returns only objects that have ALL of the specified tags.
Example usage in a FilterSet:
tags = TagsFilter(label=_('Tags'))
Example query:
?tags=apple,banana returns only items tagged with both 'apple' AND 'banana'
"""
def __init__(self, *args, **kwargs):
"""Initialize the filter."""
if 'label' not in kwargs:
kwargs['label'] = _('Tags')
super().__init__(*args, **kwargs)
def filter(self, qs, value):
"""Filter queryset to items matching all provided tag names."""
if not value:
return qs
tag_names = [t.strip() for t in value.split(',') if t.strip()]
for tag in tag_names:
qs = qs.filter(tags__name__iexact=tag)
return qs.distinct()
"""A list of valid operators for filtering part parameters."""
PARAMETER_FILTER_OPERATORS: list[str] = ['gt', 'gte', 'lt', 'lte', 'ne', 'icontains']

View File

@ -52,7 +52,6 @@ from djmoney.contrib.exchange.models import convert_money
from opentelemetry import trace
from PIL import Image
from rest_framework.exceptions import PermissionDenied
from taggit.managers import TaggableManager
import common.validators
import InvenTree.conversion
@ -1889,7 +1888,11 @@ def rename_attachment(instance, filename: str):
)
class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
class Attachment(
InvenTree.models.MetadataMixin,
InvenTree.models.InvenTreeTagsMixin,
InvenTree.models.InvenTreeModel,
):
"""Class which represents an uploaded file attachment.
An attachment can be either an uploaded file, or an external URL.
@ -2122,8 +2125,6 @@ class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel
default=0, verbose_name=_('File size'), help_text=_('File size in bytes')
)
tags = TaggableManager(blank=True)
@property
def basename(self):
"""Base name/path for attachment."""

View File

@ -12,6 +12,7 @@ from error_report.models import Error
from flags.state import flag_state
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
from taggit.models import Tag
import common.filters
import common.models as common_models
@ -28,6 +29,7 @@ from InvenTree.serializers import (
InvenTreeAttachmentSerializerField,
InvenTreeImageSerializerField,
InvenTreeModelSerializer,
InvenTreeTaggitSerializer,
OptionalField,
)
from plugin import registry as plugin_registry
@ -422,6 +424,29 @@ class ProjectCodeSerializer(DataImportExportSerializerMixin, InvenTreeModelSeria
)
@register_importer()
class TagSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer):
"""Serializer for the Tag model."""
class Meta:
"""Meta options for TagSerializer."""
model = Tag
fields = ['pk', 'name', 'slug']
read_only_fields = ['pk', 'slug']
def validate(self, data):
"""Slugify the received name to generate the slug."""
from django.utils.text import slugify
name = data.get('name', None)
if name is not None:
data['slug'] = slugify(name)
return data
@register_importer()
class CustomStateSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer):
"""Serializer for the custom state model."""
@ -720,7 +745,9 @@ class FailedTaskSerializer(InvenTreeModelSerializer):
result = serializers.CharField()
class AttachmentSerializer(FilterableSerializerMixin, InvenTreeModelSerializer):
class AttachmentSerializer(
FilterableSerializerMixin, InvenTreeTaggitSerializer, InvenTreeModelSerializer
):
"""Serializer class for the Attachment model."""
class Meta:

View File

@ -8,6 +8,7 @@ from django.core.files.uploadedfile import SimpleUploadedFile
from django.urls import reverse
from PIL import Image
from taggit.models import Tag
import common.models
from InvenTree.unit_test import InvenTreeAPITestCase
@ -1453,3 +1454,170 @@ class AttachmentThumbnailAPITests(InvenTreeAPITestCase):
set_global_setting(
'INVENTREE_UPLOAD_MAX_SIZE', original_limit, change_user=None
)
class TagAPITests(InvenTreeAPITestCase):
"""Tests for the Tag API endpoints and tag-based filtering."""
roles = 'all'
LIST_URL = 'api-tag-list'
DETAIL_URL = 'api-tag-detail'
def setUp(self):
"""Create a small set of tagged objects for filter testing."""
super().setUp()
from part.models import Part
self.part_a = Part.objects.create(
name='Tagged Part A', description='Part with apple and banana tags'
)
self.part_b = Part.objects.create(
name='Tagged Part B', description='Part with apple tag only'
)
self.part_c = Part.objects.create(
name='Untagged Part C', description='Part with no tags'
)
self.part_a.tags.add('apple', 'banana')
self.part_b.tags.add('apple')
# ------------------------------------------------------------------
# Tag list / CRUD
# ------------------------------------------------------------------
def test_tag_list(self):
"""Tag list endpoint should return all existing tags."""
url = reverse(self.LIST_URL)
response = self.get(url)
names = {t['name'] for t in response.data}
self.assertIn('apple', names)
self.assertIn('banana', names)
def test_tag_create(self):
"""Staff users should be able to create tags via POST."""
url = reverse(self.LIST_URL)
n = Tag.objects.count()
response = self.post(url, {'name': 'cherry'}, expected_code=201)
self.assertEqual(response.data['name'], 'cherry')
self.assertEqual(Tag.objects.count(), n + 1)
def test_tag_create_non_staff(self):
"""Non-staff users must not be able to create tags."""
self.user.is_staff = False
self.user.save()
url = reverse(self.LIST_URL)
self.post(url, {'name': 'forbidden'}, expected_code=403)
def test_tag_edit(self):
"""Staff users should be able to rename a tag via PATCH."""
tag = Tag.objects.get(name='banana')
url = reverse(self.DETAIL_URL, kwargs={'pk': tag.pk})
response = self.patch(url, {'name': 'blueberry'}, expected_code=200)
self.assertEqual(response.data['name'], 'blueberry')
tag.refresh_from_db()
self.assertEqual(tag.name, 'blueberry')
def test_tag_delete(self):
"""Staff users should be able to delete a tag."""
tag = Tag.objects.get(name='banana')
url = reverse(self.DETAIL_URL, kwargs={'pk': tag.pk})
self.delete(url, expected_code=204)
self.assertFalse(Tag.objects.filter(name='banana').exists())
def test_tag_search(self):
"""The list endpoint should support free-text search."""
url = reverse(self.LIST_URL)
response = self.get(url, data={'search': 'app'})
names = [t['name'] for t in response.data]
self.assertIn('apple', names)
self.assertNotIn('banana', names)
# ------------------------------------------------------------------
# Filter by model type
# ------------------------------------------------------------------
def test_tag_filter_model_type(self):
"""Tags applied to a given model type should be returned when filtering by model_type."""
url = reverse(self.LIST_URL)
# Filter for tags applied to Part objects
response = self.get(url, data={'model_type': 'part.part'})
names = {t['name'] for t in response.data}
self.assertIn('apple', names)
self.assertIn('banana', names)
def test_tag_filter_model_type_unrelated(self):
"""Filtering by a model type that has no tagged objects should return an empty list."""
url = reverse(self.LIST_URL)
# StockItem has no tagged objects in this test
response = self.get(url, data={'model_type': 'stock.stockitem'})
self.assertEqual(len(response.data), 0)
def test_tag_filter_model_type_invalid(self):
"""An unrecognised model_type value should return a 400 error."""
url = reverse(self.LIST_URL)
self.get(url, data={'model_type': 'notanapp.notamodel'}, expected_code=400)
# ------------------------------------------------------------------
# Filter Part list by tags
# ------------------------------------------------------------------
def test_part_filter_single_tag(self):
"""Filtering parts by a single tag should return only parts with that tag."""
url = reverse('api-part-list')
response = self.get(url, data={'tags': 'apple'})
pks = {p['pk'] for p in response.data}
self.assertIn(self.part_a.pk, pks)
self.assertIn(self.part_b.pk, pks)
self.assertNotIn(self.part_c.pk, pks)
def test_part_filter_multiple_tags_and(self):
"""Filtering by comma-separated tags should return only parts that have ALL tags."""
url = reverse('api-part-list')
response = self.get(url, data={'tags': 'apple,banana'})
pks = {p['pk'] for p in response.data}
self.assertIn(self.part_a.pk, pks)
self.assertNotIn(self.part_b.pk, pks) # only has 'apple'
self.assertNotIn(self.part_c.pk, pks) # no tags at all
def test_part_filter_tag_case_insensitive(self):
"""Tag filtering should be case-insensitive."""
url = reverse('api-part-list')
response = self.get(url, data={'tags': 'APPLE'})
pks = {p['pk'] for p in response.data}
self.assertIn(self.part_a.pk, pks)
self.assertIn(self.part_b.pk, pks)
def test_part_filter_nonexistent_tag(self):
"""Filtering by a tag that no part has should return an empty result set."""
url = reverse('api-part-list')
response = self.get(url, data={'tags': 'doesnotexist'})
self.assertEqual(len(response.data), 0)
def test_part_filter_tag_whitespace(self):
"""Whitespace around comma-separated tag names should be ignored."""
url = reverse('api-part-list')
response = self.get(url, data={'tags': ' apple , banana '})
pks = {p['pk'] for p in response.data}
self.assertIn(self.part_a.pk, pks)
self.assertNotIn(self.part_b.pk, pks)

View File

@ -7,6 +7,7 @@ from django.utils.translation import gettext_lazy as _
import django_filters.rest_framework.filters as rest_filters
from django_filters.rest_framework.filterset import FilterSet
import common.filters
import part.models
from data_exporter.mixins import DataExportViewMixin
from InvenTree.api import ListCreateDestroyAPIView, ParameterListMixin, meta_path
@ -37,6 +38,18 @@ from .serializers import (
)
class CompanyFilter(FilterSet):
"""Custom API filters for the CompanyList endpoint."""
class Meta:
"""Metaclass options."""
model = Company
fields = ['is_customer', 'is_manufacturer', 'is_supplier', 'name', 'active']
tags = common.filters.TagsFilter()
class CompanyMixin(OutputOptionsMixin):
"""Mixin class for Company API endpoints."""
@ -62,13 +75,7 @@ class CompanyList(CompanyMixin, ParameterListMixin, DataExportViewMixin, ListCre
filter_backends = SEARCH_ORDER_FILTER
filterset_fields = [
'is_customer',
'is_manufacturer',
'is_supplier',
'name',
'active',
]
filterset_class = CompanyFilter
search_fields = ['name', 'description', 'website', 'tax_id']
@ -145,6 +152,8 @@ class ManufacturerPartFilter(FilterSet):
field_name='manufacturer__active', label=_('Manufacturer is Active')
)
tags = common.filters.TagsFilter(label=_('Tags'))
class ManufacturerOutputOptions(OutputConfiguration):
"""Available output options for the ManufacturerPart endpoints."""
@ -299,6 +308,8 @@ class SupplierPartFilter(FilterSet):
else:
return queryset.exclude(in_stock__gt=0)
tags = common.filters.TagsFilter(label=_('Tags'))
class SupplierPartOutputOptions(OutputConfiguration):
"""Available output options for the SupplierPart endpoints."""

View File

@ -0,0 +1,29 @@
# Generated by Django 5.2.14 on 2026-06-03 10:07
import taggit.managers
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("company", "0079_auto_20260212_1054"),
(
"taggit",
"0006_rename_taggeditem_content_type_object_id_taggit_tagg_content_8fc721_idx",
),
]
operations = [
migrations.AddField(
model_name="company",
name="tags",
field=taggit.managers.TaggableManager(
blank=True,
help_text="A comma-separated list of tags.",
through="taggit.TaggedItem",
to="taggit.Tag",
verbose_name="Tags",
),
),
]

View File

@ -17,7 +17,6 @@ from django.utils.translation import gettext_lazy as _
from django.utils.translation import pgettext_lazy as __
from moneyed import CURRENCIES
from taggit.managers import TaggableManager
import common.currency
import common.models
@ -80,6 +79,7 @@ class Company(
InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeParameterMixin,
InvenTree.models.InvenTreeNoteMixin,
InvenTree.models.InvenTreeTagsMixin,
report.mixins.InvenTreeReportMixin,
InvenTree.models.InvenTreeImageMixin,
InvenTree.models.InvenTreeMetadataModel,
@ -487,6 +487,7 @@ class ManufacturerPart(
InvenTree.models.InvenTreeParameterMixin,
InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeNoteMixin,
InvenTree.models.InvenTreeTagsMixin,
InvenTree.models.InvenTreeMetadataModel,
):
"""Represents a unique part as provided by a Manufacturer Each ManufacturerPart is identified by a MPN (Manufacturer Part Number) Each ManufacturerPart is also linked to a Part object. A Part may be available from multiple manufacturers.
@ -559,8 +560,6 @@ class ManufacturerPart(
help_text=_('Manufacturer part description'),
)
tags = TaggableManager(blank=True)
@classmethod
def create(cls, part, manufacturer, mpn, description, link=None):
"""Check if ManufacturerPart instance does not already exist then create it."""
@ -604,6 +603,7 @@ class SupplierPart(
InvenTree.models.MetadataMixin,
InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeNoteMixin,
InvenTree.models.InvenTreeTagsMixin,
common.models.MetaMixin,
InvenTree.models.InvenTreeModel,
):
@ -640,8 +640,6 @@ class SupplierPart(
# This model was moved from the 'Part' app
db_table = 'part_supplierpart'
tags = TaggableManager(blank=True)
@staticmethod
def get_api_url():
"""Return the API URL associated with the SupplierPart model."""

View File

@ -20,7 +20,7 @@ from InvenTree.serializers import (
InvenTreeImageSerializerField,
InvenTreeModelSerializer,
InvenTreeMoneySerializer,
InvenTreeTagModelSerializer,
InvenTreeTaggitSerializer,
OptionalField,
)
@ -105,7 +105,10 @@ class AddressBriefSerializer(InvenTreeModelSerializer):
@register_importer()
class CompanySerializer(
FilterableSerializerMixin, DataImportExportSerializerMixin, InvenTreeModelSerializer
FilterableSerializerMixin,
DataImportExportSerializerMixin,
InvenTreeTaggitSerializer,
InvenTreeModelSerializer,
):
"""Serializer for Company object (full detail)."""
@ -138,6 +141,7 @@ class CompanySerializer(
'primary_address',
'tax_id',
'parameters',
'tags',
]
@staticmethod
@ -180,6 +184,8 @@ class CompanySerializer(
help_text=_('Default currency used for this supplier'), required=True
)
tags = common.filters.enable_tags_filter()
parameters = common.filters.enable_parameters_filter()
@ -202,7 +208,8 @@ class ContactSerializer(DataImportExportSerializerMixin, InvenTreeModelSerialize
class ManufacturerPartSerializer(
FilterableSerializerMixin,
DataImportExportSerializerMixin,
InvenTreeTagModelSerializer,
InvenTreeTaggitSerializer,
InvenTreeModelSerializer,
):
"""Serializer for ManufacturerPart object."""
@ -301,7 +308,8 @@ class SupplierPriceBreakBriefSerializer(
class SupplierPartSerializer(
FilterableSerializerMixin,
DataImportExportSerializerMixin,
InvenTreeTagModelSerializer,
InvenTreeTaggitSerializer,
InvenTreeModelSerializer,
):
"""Serializer for SupplierPart object."""

View File

@ -22,6 +22,7 @@ from rest_framework.exceptions import NotFound
from rest_framework.response import Response
import build.models
import common.filters
import common.models
import common.serializers
import common.settings
@ -277,6 +278,8 @@ class OrderFilter(FilterSet):
return queryset.filter(q1 | q2 | q3 | q4).distinct()
tags = common.filters.TagsFilter()
class LineItemFilter(FilterSet):
"""Base class for custom API filters for order line item list(s)."""
@ -1448,6 +1451,8 @@ class SalesOrderShipmentFilter(FilterSet):
return queryset.filter(q1 | q2).distinct()
tags = common.filters.TagsFilter()
class SalesOrderShipmentMixin:
"""Mixin class for SalesOrderShipment endpoints."""

View File

@ -0,0 +1,73 @@
# Generated by Django 5.2.14 on 2026-06-03 10:07
import taggit.managers
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("order", "0119_transferorderlineitem_line_int"),
(
"taggit",
"0006_rename_taggeditem_content_type_object_id_taggit_tagg_content_8fc721_idx",
),
]
operations = [
migrations.AddField(
model_name="purchaseorder",
name="tags",
field=taggit.managers.TaggableManager(
blank=True,
help_text="A comma-separated list of tags.",
through="taggit.TaggedItem",
to="taggit.Tag",
verbose_name="Tags",
),
),
migrations.AddField(
model_name="returnorder",
name="tags",
field=taggit.managers.TaggableManager(
blank=True,
help_text="A comma-separated list of tags.",
through="taggit.TaggedItem",
to="taggit.Tag",
verbose_name="Tags",
),
),
migrations.AddField(
model_name="salesorder",
name="tags",
field=taggit.managers.TaggableManager(
blank=True,
help_text="A comma-separated list of tags.",
through="taggit.TaggedItem",
to="taggit.Tag",
verbose_name="Tags",
),
),
migrations.AddField(
model_name="salesordershipment",
name="tags",
field=taggit.managers.TaggableManager(
blank=True,
help_text="A comma-separated list of tags.",
through="taggit.TaggedItem",
to="taggit.Tag",
verbose_name="Tags",
),
),
migrations.AddField(
model_name="transferorder",
name="tags",
field=taggit.managers.TaggableManager(
blank=True,
help_text="A comma-separated list of tags.",
through="taggit.TaggedItem",
to="taggit.Tag",
verbose_name="Tags",
),
),
]

View File

@ -300,6 +300,7 @@ class Order(
InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeNoteMixin,
InvenTree.models.InvenTreeTagsMixin,
report.mixins.InvenTreeReportMixin,
InvenTree.models.MetadataMixin,
InvenTree.models.ReferenceIndexingMixin,
@ -2466,6 +2467,7 @@ class SalesOrderShipment(
InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeNoteMixin,
InvenTree.models.InvenTreeTagsMixin,
report.mixins.InvenTreeReportMixin,
InvenTree.models.MetadataMixin,
InvenTree.models.InvenTreeModel,

View File

@ -37,6 +37,7 @@ from InvenTree.serializers import (
InvenTreeDecimalField,
InvenTreeModelSerializer,
InvenTreeMoneySerializer,
InvenTreeTaggitSerializer,
OptionalField,
)
from order.status_codes import (
@ -102,6 +103,7 @@ class DuplicateOrderSerializer(serializers.Serializer):
class AbstractOrderSerializer(
CustomStatusSerializerMixin,
DataImportExportSerializerMixin,
InvenTreeTaggitSerializer,
FilterableSerializerMixin,
serializers.Serializer,
):
@ -171,6 +173,8 @@ class AbstractOrderSerializer(
parameters = common.filters.enable_parameters_filter()
tags = common.filters.enable_tags_filter()
# Boolean field indicating if this order is overdue (Note: must be annotated)
overdue = serializers.BooleanField(read_only=True, allow_null=True)
@ -234,6 +238,7 @@ class AbstractOrderSerializer(
'project_code_label',
'responsible_detail',
'parameters',
'tags',
*extra_fields,
]
@ -1061,6 +1066,7 @@ class SalesOrderSerializer(
TotalPriceMixin,
InvenTreeCustomStatusSerializerMixin,
AbstractOrderSerializer,
InvenTreeTaggitSerializer,
InvenTreeModelSerializer,
):
"""Serializer for the SalesOrder model class."""
@ -1080,6 +1086,7 @@ class SalesOrderSerializer(
'completed_shipments_count',
'allocated_lines',
'updated_at',
'tags',
])
read_only_fields = ['status', 'creation_date', 'shipment_date', 'updated_at']
extra_kwargs = {'order_currency': {'required': False}}
@ -1161,6 +1168,8 @@ class SalesOrderSerializer(
read_only=True, allow_null=True, label=_('Allocated Lines')
)
tags = common.filters.enable_tags_filter()
class SalesOrderIssueSerializer(OrderAdjustSerializer):
"""Serializer for issuing a SalesOrder."""
@ -1357,7 +1366,10 @@ class SalesOrderLineItemSerializer(
@register_importer()
class SalesOrderShipmentSerializer(
DataImportExportSerializerMixin, FilterableSerializerMixin, InvenTreeModelSerializer
DataImportExportSerializerMixin,
FilterableSerializerMixin,
InvenTreeTaggitSerializer,
InvenTreeModelSerializer,
):
"""Serializer for the SalesOrderShipment class."""
@ -1384,6 +1396,7 @@ class SalesOrderShipmentSerializer(
'customer_detail',
'order_detail',
'shipment_address_detail',
'tags',
]
@staticmethod
@ -1453,6 +1466,8 @@ class SalesOrderShipmentSerializer(
parameters = common.filters.enable_parameters_filter()
tags = common.filters.enable_tags_filter()
class SalesOrderAllocationSerializer(
FilterableSerializerMixin, InvenTreeModelSerializer

View File

@ -12,6 +12,7 @@ from drf_spectacular.utils import extend_schema, extend_schema_field
from rest_framework import serializers
from rest_framework.response import Response
import common.filters
import common.serializers
import part.tasks as part_tasks
from data_exporter.mixins import DataExportViewMixin
@ -910,9 +911,7 @@ class PartFilter(FilterSet):
virtual = rest_filters.BooleanFilter()
tags_name = rest_filters.CharFilter(field_name='tags__name', lookup_expr='iexact')
tags_slug = rest_filters.CharFilter(field_name='tags__slug', lookup_expr='iexact')
tags = common.filters.TagsFilter()
# Created date filters
created_before = InvenTreeDateFilter(

View File

@ -32,7 +32,6 @@ from djmoney.contrib.exchange.models import convert_money
from djmoney.money import Money
from mptt.managers import TreeManager
from mptt.models import TreeForeignKey
from taggit.managers import TaggableManager
import common.currency
import common.models
@ -466,6 +465,7 @@ class Part(
InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeNoteMixin,
InvenTree.models.InvenTreeTagsMixin,
report.mixins.InvenTreeReportMixin,
InvenTree.models.InvenTreeImageMixin,
InvenTree.models.MetadataMixin,
@ -520,8 +520,6 @@ class Part(
objects = TreeManager()
tags = TaggableManager(blank=True)
class Meta:
"""Metaclass defines extra model properties."""

View File

@ -373,6 +373,8 @@ class StockLocationFilter(FilterSet):
return queryset
tags = common.filters.TagsFilter(label=_('Tags'))
class StockLocationMixin(SerializerContextMixin):
"""Mixin class for StockLocation API endpoints."""
@ -1041,6 +1043,8 @@ class StockFilter(FilterSet):
children = loc_obj.getUniqueChildren()
return queryset.filter(location__in=children)
tags = common.filters.TagsFilter(label=_('Tags'))
class StockApiMixin(SerializerContextMixin):
"""Mixin class for StockItem API endpoints."""

View File

@ -23,7 +23,6 @@ import structlog
from djmoney.contrib.exchange.models import convert_money
from mptt.managers import TreeManager
from mptt.models import TreeForeignKey
from taggit.managers import TaggableManager
import build.models
import common.models
@ -126,6 +125,7 @@ class StockLocation(
InvenTree.models.PluginValidationMixin,
InvenTree.models.InvenTreeParameterMixin,
InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeTagsMixin,
report.mixins.InvenTreeReportMixin,
InvenTree.models.PathStringMixin,
InvenTree.models.MetadataMixin,
@ -149,8 +149,6 @@ class StockLocation(
verbose_name = _('Stock Location')
verbose_name_plural = _('Stock Locations')
tags = TaggableManager(blank=True)
def delete(self, *args, **kwargs):
"""Custom model deletion routine, which updates any child locations or items.
@ -426,6 +424,7 @@ class StockItem(
InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeNoteMixin,
InvenTree.models.InvenTreeTagsMixin,
StatusCodeMixin,
report.mixins.InvenTreeReportMixin,
common.models.MetaMixin,
@ -619,8 +618,6 @@ class StockItem(
'test_templates': self.part.getTestTemplateMap(),
}
tags = TaggableManager(blank=True)
# A Query filter which will be reused in multiple places to determine if a StockItem is actually "in stock"
# See also: StockItem.in_stock() method
IN_STOCK_FILTER = Q(

View File

@ -313,7 +313,8 @@ class StockItemSerializer(
InvenTree.serializers.FilterableSerializerMixin,
DataImportExportSerializerMixin,
InvenTreeCustomStatusSerializerMixin,
InvenTree.serializers.InvenTreeTagModelSerializer,
InvenTree.serializers.InvenTreeTaggitSerializer,
InvenTree.serializers.InvenTreeModelSerializer,
):
"""Serializer for a StockItem.
@ -1193,7 +1194,8 @@ class LocationDeleteSerializer(serializers.Serializer):
class LocationSerializer(
InvenTree.serializers.FilterableSerializerMixin,
DataImportExportSerializerMixin,
InvenTree.serializers.InvenTreeTagModelSerializer,
InvenTree.serializers.InvenTreeTaggitSerializer,
InvenTree.serializers.InvenTreeModelSerializer,
):
"""Detailed information about a stock location."""

View File

@ -0,0 +1,27 @@
import { ActionIcon, Badge, Group, Paper } from '@mantine/core';
import { IconTag } from '@tabler/icons-react';
export default function TagsList({
tags
}: Readonly<{
tags: string[];
}>) {
if (!tags || tags.length === 0) {
return null;
}
return (
<Paper p='xs' shadow='xs' withBorder>
<Group gap='xs'>
<ActionIcon size='sm' variant='transparent'>
<IconTag />
</ActionIcon>
{tags.map((tag: string) => (
<Badge key={tag} variant='outline' size='sm'>
{tag}
</Badge>
))}
</Group>
</Paper>
);
}

View File

@ -260,6 +260,7 @@ export enum ApiEndpoints {
config_list = 'admin/config/',
parameter_list = 'parameter/',
parameter_template_list = 'parameter/template/',
tag_list = 'tag/',
// Internal system things
system_internal_trace_end = 'system-internal/observability/end'

View File

@ -118,8 +118,8 @@ export const ModelInformationDict: ModelDict = {
icon: 'history'
},
build: {
label: () => t`Build`,
label_multiple: () => t`Builds`,
label: () => t`Build Order`,
label_multiple: () => t`Build Orders`,
url_overview: '/manufacturing/index/buildorders/',
url_detail: '/manufacturing/build-order/:pk/',
api_endpoint: ApiEndpoints.build_order_list,
@ -319,5 +319,11 @@ export const ModelInformationDict: ModelDict = {
url_overview: '/settings/admin/errors',
url_detail: '/settings/admin/errors/:pk/',
icon: 'exclamation'
},
tag: {
label: () => t`Tag`,
label_multiple: () => t`Tags`,
api_endpoint: ApiEndpoints.tag_list,
icon: 'tag'
}
};

View File

@ -38,7 +38,8 @@ export enum ModelType {
contenttype = 'contenttype',
selectionlist = 'selectionlist',
selectionentry = 'selectionentry',
error = 'error'
error = 'error',
tag = 'tag'
}
export enum PluginPanelKey {

View File

@ -1,6 +1,10 @@
import { useLocalStorage } from '@mantine/hooks';
import { useCallback, useEffect, useMemo } from 'react';
import type { FilterSetState, TableFilter } from '../types/Filters';
import type {
FilterSetState,
NamedFilterSet,
TableFilter
} from '../types/Filters';
export default function useFilterSet(
filterKey: string,
@ -16,6 +20,16 @@ export default function useFilterSet(
getInitialValueInEffect: false
});
// Named filter set snapshots (saved to local storage, separate key)
const [storedNamedSets, setStoredNamedSets] = useLocalStorage<
NamedFilterSet[]
>({
key: `inventree-filtersets-${filterKey}`,
defaultValue: [],
sync: false,
getInitialValueInEffect: false
});
useEffect(() => {
if (storedFilters == null) {
setStoredFilters(initialFilters || []);
@ -26,7 +40,6 @@ export default function useFilterSet(
return storedFilters ?? initialFilters ?? [];
}, [storedFilters, initialFilters]);
// Callback to clear all active filters from the table
const clearActiveFilters = useCallback(() => {
setStoredFilters([]);
}, []);
@ -38,10 +51,48 @@ export default function useFilterSet(
[setStoredFilters]
);
const saveFilterSet = useCallback(
(name: string) => {
const snapshot = activeFilters.map(
({ name: n, value, displayValue }) => ({
name: n,
value,
displayValue
})
);
setStoredNamedSets((prev) => {
const without = (prev ?? []).filter((s) => s.name !== name);
return [...without, { name, filters: snapshot }];
});
},
[activeFilters, setStoredNamedSets]
);
const loadFilterSet = useCallback(
(name: string) => {
const saved = (storedNamedSets ?? []).find((s) => s.name === name);
if (saved) {
setStoredFilters(saved.filters as TableFilter[]);
}
},
[storedNamedSets, setStoredFilters]
);
const deleteFilterSet = useCallback(
(name: string) => {
setStoredNamedSets((prev) => (prev ?? []).filter((s) => s.name !== name));
},
[setStoredNamedSets]
);
return {
filterKey,
activeFilters,
setActiveFilters,
clearActiveFilters
clearActiveFilters,
savedFilterSets: storedNamedSets ?? [],
saveFilterSet,
loadFilterSet,
deleteFilterSet
};
}

View File

@ -110,6 +110,7 @@ export { ProgressBar } from './components/ProgressBar';
export { PassFailButton, YesNoButton } from './components/YesNoButton';
export { SearchInput } from './components/SearchInput';
export { TableColumnSelect } from './components/TableColumnSelect';
export { default as TagsList } from './components/TagsList';
export { default as InvenTreeTable } from './components/InvenTreeTable';
export {
RowViewAction,

View File

@ -41,6 +41,7 @@ export type TableFilter = {
name: string;
label?: string;
description?: string;
placeholder?: string;
type?: TableFilterType;
choices?: TableFilterChoice[];
choiceFunction?: () => TableFilterChoice[];
@ -52,6 +53,16 @@ export type TableFilter = {
apiFilter?: Record<string, any>;
model?: ModelType;
modelRenderer?: (instance: any) => string;
transform?: (item: any) => TableFilterChoice;
multi?: boolean;
};
/*
* A named snapshot of a set of active filters, saved to local storage.
*/
export type NamedFilterSet = {
name: string;
filters: Pick<TableFilter, 'name' | 'value' | 'displayValue'>[];
};
/*
@ -62,10 +73,18 @@ export type TableFilter = {
* activeFilters: An array of active filters
* setActiveFilters: A function to set the active filters
* clearActiveFilters: A function to clear all active filters
* savedFilterSets: Named filter set snapshots persisted to local storage
* saveFilterSet: Save the current active filters under a given name
* loadFilterSet: Replace active filters with a previously saved named set
* deleteFilterSet: Remove a saved named filter set by name
*/
export type FilterSetState = {
filterKey: string;
activeFilters: TableFilter[];
setActiveFilters: (filters: TableFilter[]) => void;
clearActiveFilters: () => void;
savedFilterSets: NamedFilterSet[];
saveFilterSet: (name: string) => void;
loadFilterSet: (name: string) => void;
deleteFilterSet: (name: string) => void;
};

View File

@ -99,7 +99,8 @@ export type ApiFormFieldType = {
| 'file upload'
| 'nested object'
| 'dependent field'
| 'table';
| 'table'
| 'tags';
api_url?: string;
pk_field?: string;
model?: ModelType;

View File

@ -32,7 +32,8 @@ import {
IconChevronLeft,
IconChevronRight,
IconDownload,
IconFilter
IconFilter,
IconRefresh
} from '@tabler/icons-react';
import {
type ReactNode,
@ -55,6 +56,7 @@ export interface InvenTreeCalendarProps extends CalendarOptions {
enableDownload?: boolean;
enableFilters?: boolean;
enableSearch?: boolean;
enableRefresh?: boolean;
eventTooltipContent?: (event: EventContentArg) => ReactNode;
filters?: TableFilter[];
isLoading?: boolean;
@ -65,6 +67,7 @@ export default function Calendar({
enableDownload,
enableFilters = false,
enableSearch,
enableRefresh = true,
eventTooltipContent,
isLoading,
filters,
@ -214,6 +217,18 @@ export default function Calendar({
{enableSearch && (
<SearchInput searchCallback={state.setSearchTerm} />
)}
{enableRefresh && (
<ActionIcon
variant='transparent'
aria-label='calendar-refresh'
disabled={isLoading}
onClick={() => state.query.refetch()}
>
<Tooltip label={t`Refresh calendar`} position='top-end'>
<IconRefresh />
</Tooltip>
</ActionIcon>
)}
{enableFilters && filters && filters.length > 0 && (
<Indicator
size='xs'

View File

@ -30,7 +30,7 @@ import {
ProjectCodeFilter,
ResponsibleFilter
} from '../../tables/Filter';
import { StatusRenderer } from '../render/StatusRenderer';
import { StatusRenderer, getStatusColor } from '../render/StatusRenderer';
import Calendar from './Calendar';
/**
@ -107,6 +107,8 @@ export default function OrderCalendar({
order.start_date || order.issue_date || order.creation_date || today;
const end: string = order.target_date || start;
const statusColor = getStatusColor(model, order.status);
return {
order: order,
id: order.pk,
@ -115,7 +117,9 @@ export default function OrderCalendar({
start: start,
end: end,
startEditable: canEdit,
durationEditable: canEdit
durationEditable: canEdit,
backgroundColor: statusColor,
borderColor: statusColor
};
}) ?? []
);
@ -207,6 +211,7 @@ export default function OrderCalendar({
<Calendar
enableDownload
enableFilters
enableRefresh
enableSearch
events={events}
state={calendarState}

View File

@ -17,6 +17,7 @@ import { NestedObjectField } from './NestedObjectField';
import NumberField from './NumberField';
import { RelatedModelField } from './RelatedModelField';
import { TableField } from './TableField';
import TagsField from './TagsField';
import TextField from './TextField';
/**
@ -249,6 +250,10 @@ export function ApiFormField({
control={controller}
/>
);
case 'tags':
return (
<TagsField controller={controller} definition={fieldDefinition} />
);
default:
return (
<Alert color='red' title={t`Error`}>

View File

@ -34,13 +34,13 @@ export function BooleanField({
// Coerce the value to a (stringified) boolean value
const booleanValue: boolean = useMemo(() => {
return isTrue(value);
return isTrue(value ?? definition.default ?? false);
}, [value]);
return (
<Switch
{...definition}
defaultValue={definition.default ?? false}
defaultValue={undefined}
checked={booleanValue}
id={fieldId}
aria-label={`boolean-field-${fieldName}`}

View File

@ -0,0 +1,81 @@
import { TagsInput } from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { useQuery } from '@tanstack/react-query';
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { FieldValues, UseControllerReturn } from 'react-hook-form';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { apiUrl } from '@lib/functions/Api';
import type { ApiFormFieldType } from '@lib/types/Forms';
import { api } from '../../../App';
export default function TagsField({
controller,
definition
}: Readonly<{
controller: UseControllerReturn<FieldValues, any>;
definition: ApiFormFieldType;
}>) {
const {
field,
fieldState: { error }
} = controller;
const [tags, setTags] = useState<string[]>(field.value ?? []);
const [searchValue, setSearchValue] = useState('');
const [debouncedSearch] = useDebouncedValue(searchValue, 250);
// Sync inbound value changes (e.g. when initial data is loaded)
useEffect(() => {
setTags(field.value ?? []);
}, [field.value]);
const onChange = useCallback(
(value: string[]) => {
setTags(value);
field.onChange(value);
definition.onValueChange?.(value);
},
[field, definition]
);
const tagQuery = useQuery({
queryKey: ['tags-autocomplete', debouncedSearch],
queryFn: () =>
api
.get(apiUrl(ApiEndpoints.tag_list), {
params: { search: debouncedSearch, limit: 20 }
})
.then((r) => r.data)
});
const suggestions: string[] = useMemo(() => {
const results: any[] = tagQuery.data?.results ?? tagQuery.data ?? [];
return results.map((tag: any) => String(tag.name));
}, [tagQuery.data]);
const reducedDefinition: any = useMemo(() => {
return {
...definition,
allow_null: undefined,
allow_blank: undefined
};
}, [definition]);
return (
<TagsInput
{...reducedDefinition}
ref={field.ref}
placeholder={definition.placeholder}
aria-label={`tags-field-${field.name}`}
value={tags}
onChange={onChange}
data={suggestions}
searchValue={searchValue}
onSearchChange={setSearchValue}
error={definition.error ?? error?.message}
radius='sm'
splitChars={[',', '\t', '\n', ';', ':', '.', '-']}
/>
);
}

View File

@ -56,6 +56,12 @@ export function RenderError({
return instance && <RenderInlineModel primary={instance.name} />;
}
export function RenderTag({
instance
}: Readonly<InstanceRenderInterface>): ReactNode {
return instance && <RenderInlineModel primary={instance.name} />;
}
export function RenderImportSession({
instance
}: {

View File

@ -55,7 +55,8 @@ import {
RenderParameterTemplate,
RenderProjectCode,
RenderSelectionEntry,
RenderSelectionList
RenderSelectionList,
RenderTag
} from './Generic';
import {
RenderPurchaseOrder,
@ -116,7 +117,8 @@ export const RendererLookup: ModelRendererDict = {
[ModelType.contenttype]: RenderContentType,
[ModelType.selectionlist]: RenderSelectionList,
[ModelType.selectionentry]: RenderSelectionEntry,
[ModelType.error]: RenderError
[ModelType.error]: RenderError,
[ModelType.tag]: RenderTag
};
/**

View File

@ -202,6 +202,31 @@ export const StatusRenderer = ({
return renderStatusLabel(status, statusCodes, options, fallbackStatus);
};
/*
* Return a CSS color string (Mantine CSS variable) for a given status code.
* Used to set event background colors in calendar views.
*/
export function getStatusColor(
type: ModelType | string,
status: string | number
): string {
const statusCodes = getStatusCodes(type);
const fallback = `var(--mantine-color-${statusColorMap['default']}-6)`;
if (!statusCodes) return fallback;
for (const name in statusCodes.values) {
const entry: StatusCodeInterface = statusCodes.values[name];
if (entry?.key == status) {
const mantineColor =
statusColorMap[entry.color] ?? statusColorMap['default'];
return `var(--mantine-color-${mantineColor}-6)`;
}
}
return fallback;
}
/*
* Render the status badge in a table
*/

View File

@ -36,6 +36,7 @@ import {
} from '../hooks/UseGenerator';
import { useGlobalSettingsState } from '../states/SettingsStates';
import { RenderPartColumn } from '../tables/ColumnRenderers';
import { TagsField } from './CommonFields';
/**
* Field set for BuildOrder forms
@ -124,6 +125,7 @@ export function useBuildOrderFields({
},
value: destination
},
tags: TagsField({}),
link: {
icon: <IconLink />
},
@ -341,16 +343,21 @@ export function useCompleteBuildOutputsForm({
);
}, [location, build.destination, build.part_detail]);
// Memoize the outputs once to avoid re-rendering issues
const buildOutputs = useMemo(() => {
return outputs.map((output: any) => {
return {
output: output.pk,
quantity: output.quantity
};
});
}, [outputs]);
const buildOutputCompleteFields: ApiFormFieldSet = useMemo(() => {
return {
outputs: {
field_type: 'table',
value: outputs.map((output: any) => {
return {
output: output.pk,
quantity: output.quantity
};
}),
value: buildOutputs,
modelRenderer: (row: TableFieldRowProps) => {
const record = outputs.find((output) => output.pk == row.item.output);
return (
@ -418,16 +425,21 @@ export function useScrapBuildOutputsForm({
);
}, [location, build.destination, build.part_detail]);
// Memoize the outputs once to avoid re-rendering issues
const buildOutputs = useMemo(() => {
return outputs.map((output: any) => {
return {
output: output.pk,
quantity: output.quantity
};
});
}, [outputs]);
const buildOutputScrapFields: ApiFormFieldSet = useMemo(() => {
return {
outputs: {
field_type: 'table',
value: outputs.map((output: any) => {
return {
output: output.pk,
quantity: output.quantity
};
}),
value: buildOutputs,
modelRenderer: (row: TableFieldRowProps) => {
const record = outputs.find((output) => output.pk == row.item.output);
return (
@ -484,15 +496,20 @@ export function useCancelBuildOutputsForm({
outputs: any[];
onFormSuccess: (response: any) => void;
}) {
// Memoize the outputs once to avoid re-rendering issues
const buildOutputs = useMemo(() => {
return outputs.map((output: any) => {
return {
output: output.pk
};
});
}, [outputs]);
const buildOutputCancelFields: ApiFormFieldSet = useMemo(() => {
return {
outputs: {
field_type: 'table',
value: outputs.map((output: any) => {
return {
output: output.pk
};
}),
value: buildOutputs,
modelRenderer: (row: TableFieldRowProps) => {
const record = outputs.find((output) => output.pk == row.item.output);
return (

View File

@ -0,0 +1,19 @@
import type { ApiFormFieldType } from '@lib/types/Forms';
import { t } from '@lingui/core/macro';
export function TagsField({
label,
description,
placeholder
}: Readonly<{
label?: string;
description?: string;
placeholder?: string;
}>): ApiFormFieldType {
return {
field_type: 'tags',
label: label ?? t`Tags`,
description: description ?? t`Tags for this item`,
placeholder: placeholder ?? t`Select tags`
};
}

View File

@ -13,6 +13,7 @@ import {
IconPhone
} from '@tabler/icons-react';
import { useMemo, useState } from 'react';
import { TagsField } from './CommonFields';
/**
* Field set for SupplierPart instance
@ -82,6 +83,7 @@ export function useSupplierPartFields({
icon: <IconHash />
},
description: {},
tags: TagsField({}),
link: {
icon: <IconLink />
},
@ -117,6 +119,7 @@ export function useManufacturerPartFields() {
},
MPN: {},
description: {},
tags: TagsField({}),
link: {}
};
@ -143,6 +146,7 @@ export function companyFields(): ApiFormFieldSet {
email: {
icon: <IconAt />
},
tags: TagsField({}),
tax_id: {},
is_supplier: {},
is_manufacturer: {},

View File

@ -3,6 +3,7 @@ import { t } from '@lingui/core/macro';
import { IconBuildingStore, IconCopy, IconPackages } from '@tabler/icons-react';
import { useEffect, useMemo, useState } from 'react';
import { useGlobalSettingsState } from '../states/SettingsStates';
import { TagsField } from './CommonFields';
/**
* Construct a set of fields for creating / editing a Part instance
@ -54,6 +55,7 @@ export function usePartFields({
}
},
keywords: {},
tags: TagsField({}),
units: {},
link: {},
default_location: {

View File

@ -57,6 +57,7 @@ import {
useSerialNumberGenerator
} from '../hooks/UseGenerator';
import { useGlobalSettingsState } from '../states/SettingsStates';
import { TagsField } from './CommonFields';
/*
* Construct a set of fields for creating / editing a PurchaseOrderLineItem instance
*/
@ -287,6 +288,7 @@ export function usePurchaseOrderFields({
structural: false
}
},
tags: TagsField({}),
link: {},
contact: {
icon: <IconUser />,

View File

@ -23,6 +23,7 @@ import { Thumbnail } from '../components/images/Thumbnail';
import { useCreateApiFormModal } from '../hooks/UseForm';
import { useGlobalSettingsState } from '../states/SettingsStates';
import { StatusFilterOptions } from '../tables/Filter';
import { TagsField } from './CommonFields';
export function useReturnOrderFields({
duplicateOrderId
@ -52,6 +53,7 @@ export function useReturnOrderFields({
icon: <IconCalendar />
},
link: {},
tags: TagsField({}),
contact: {
icon: <IconUser />,
adjustFilters: (value: ApiFormAdjustFilterType) => {

View File

@ -31,6 +31,7 @@ import { useCreateApiFormModal, useEditApiFormModal } from '../hooks/UseForm';
import { useGlobalSettingsState } from '../states/SettingsStates';
import { useUserState } from '../states/UserState';
import { RenderPartColumn } from '../tables/ColumnRenderers';
import { TagsField } from './CommonFields';
export function useSalesOrderFields({
duplicateOrderId
@ -64,6 +65,7 @@ export function useSalesOrderFields({
target_date: {
icon: <IconCalendar />
},
tags: TagsField({}),
link: {},
contact: {
icon: <IconUser />,
@ -537,6 +539,7 @@ export function useSalesOrderShipmentFields({
},
tracking_number: {},
invoice_number: {},
tags: TagsField({}),
link: {}
};
}, [customerId, pending]);

View File

@ -61,6 +61,7 @@ import {
import useStatusCodes from '../hooks/UseStatusCodes';
import { useGlobalSettingsState } from '../states/SettingsStates';
import { StatusFilterOptions } from '../tables/Filter';
import { TagsField } from './CommonFields';
/**
* Construct a set of fields for creating / editing a StockItem instance
@ -272,6 +273,7 @@ export function useStockFields({
packaging: {
icon: <IconPackage />
},
tags: TagsField({}),
link: {
icon: <IconLink />
},

View File

@ -10,6 +10,7 @@ import type { TableFieldRowProps } from '../components/forms/fields/TableField';
import { useCreateApiFormModal } from '../hooks/UseForm';
import { useGlobalSettingsState } from '../states/SettingsStates';
import { RenderPartColumn } from '../tables/ColumnRenderers';
import { TagsField } from './CommonFields';
export function useTransferOrderFields({
duplicateOrderId
@ -36,6 +37,7 @@ export function useTransferOrderFields({
}
},
consume: {},
tags: TagsField({}),
link: {},
responsible: {
filters: {

View File

@ -21,6 +21,7 @@ import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import { getDetailUrl } from '@lib/functions/Navigation';
import { TagsList } from '@lib/index';
import type { ApiFormFieldSet } from '@lib/types/Forms';
import type { PanelType } from '@lib/types/Panel';
import AdminButton from '../../components/buttons/AdminButton';
@ -228,7 +229,8 @@ export default function BuildDetail() {
endpoint: ApiEndpoints.build_order_list,
pk: id,
params: {
part_detail: true
part_detail: true,
tags: true
},
refetchOnMount: true
});
@ -438,17 +440,20 @@ export default function BuildDetail() {
return (
<ItemDetailsGrid>
<Grid grow>
<DetailsImage
appRole={UserRoles.part}
apiPath={ApiEndpoints.part_list}
src={build.part_detail?.image ?? build.part_detail?.thumbnail}
pk={build.part}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={data} />
</Grid.Col>
</Grid>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={UserRoles.part}
apiPath={ApiEndpoints.part_list}
src={build.part_detail?.image ?? build.part_detail?.thumbnail}
pk={build.part}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={data} />
</Grid.Col>
</Grid>
<TagsList tags={build.tags} />
</Stack>
<DetailsTable fields={tr} item={data} />
<DetailsTable fields={bl} item={data} />
<DetailsTable fields={br} item={data} />
@ -612,6 +617,7 @@ export default function BuildDetail() {
title: t`Edit Build Order`,
modalId: 'edit-build-order',
fields: editBuildOrderFields,
queryParams: new URLSearchParams({ tags: 'true' }),
onFormSuccess: refreshInstance
});

View File

@ -18,6 +18,7 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import { TagsList } from '@lib/index';
import type { PanelType } from '@lib/types/Panel';
import AdminButton from '../../components/buttons/AdminButton';
import { PrintingActions } from '../../components/buttons/PrintingActions';
@ -78,7 +79,9 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
} = useInstance({
endpoint: ApiEndpoints.company_list,
pk: id,
params: {},
params: {
tags: true
},
refetchOnMount: true
});
@ -153,23 +156,26 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
return (
<ItemDetailsGrid>
<Grid grow>
<DetailsImage
appRole={UserRoles.purchase_order}
apiPath={apiUrl(ApiEndpoints.company_list, company.pk)}
src={company.image}
pk={company.pk}
refresh={refreshInstance}
imageActions={{
uploadFile: true,
downloadImage: true,
deleteFile: true
}}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable item={company} fields={tl} />
</Grid.Col>
</Grid>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={UserRoles.purchase_order}
apiPath={apiUrl(ApiEndpoints.company_list, company.pk)}
src={company.image}
pk={company.pk}
refresh={refreshInstance}
imageActions={{
uploadFile: true,
downloadImage: true,
deleteFile: true
}}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable item={company} fields={tl} />
</Grid.Col>
</Grid>
<TagsList tags={company.tags} />
</Stack>
<DetailsTable item={company} fields={tr} />
</ItemDetailsGrid>
);
@ -288,6 +294,7 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
pk: company?.pk,
title: t`Edit Company`,
fields: companyFields(),
queryParams: new URLSearchParams({ tags: 'true' }),
onFormSuccess: refreshInstance
});

View File

@ -8,6 +8,7 @@ import {
import { useMemo } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import TagsList from '@lib/components/TagsList';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
@ -59,7 +60,8 @@ export default function ManufacturerPartDetail() {
hasPrimaryKey: true,
params: {
part_detail: true,
manufacturer_detail: true
manufacturer_detail: true,
tags: true
}
});
@ -133,20 +135,23 @@ export default function ManufacturerPartDetail() {
return (
<ItemDetailsGrid>
<Grid grow>
<DetailsImage
appRole={UserRoles.part}
src={manufacturerPart?.part_detail?.image}
apiPath={apiUrl(
ApiEndpoints.part_list,
manufacturerPart?.part_detail?.pk
)}
pk={manufacturerPart?.part_detail?.pk}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable title={t`Part Details`} fields={tl} item={data} />
</Grid.Col>
</Grid>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={UserRoles.part}
src={manufacturerPart?.part_detail?.image}
apiPath={apiUrl(
ApiEndpoints.part_list,
manufacturerPart?.part_detail?.pk
)}
pk={manufacturerPart?.part_detail?.pk}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable title={t`Part Details`} fields={tl} item={data} />
</Grid.Col>
</Grid>
<TagsList tags={manufacturerPart.tags} />
</Stack>
<DetailsTable title={t`Manufacturer Details`} fields={tr} item={data} />
</ItemDetailsGrid>
);
@ -211,6 +216,7 @@ export default function ManufacturerPartDetail() {
pk: manufacturerPart?.pk,
title: t`Edit Manufacturer Part`,
fields: editManufacturerPartFields,
queryParams: new URLSearchParams({ tags: 'true' }),
onFormSuccess: refreshInstance
});

View File

@ -9,6 +9,7 @@ import {
import { type ReactNode, useMemo } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import TagsList from '@lib/components/TagsList';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
@ -67,7 +68,8 @@ export default function SupplierPartDetail() {
params: {
part_detail: true,
supplier_detail: true,
manufacturer_detail: true
manufacturer_detail: true,
tags: true
}
});
@ -221,20 +223,23 @@ export default function SupplierPartDetail() {
return (
<ItemDetailsGrid>
<Grid grow>
<DetailsImage
appRole={UserRoles.part}
src={supplierPart?.part_detail?.image}
apiPath={apiUrl(
ApiEndpoints.part_list,
supplierPart?.part_detail?.pk
)}
pk={supplierPart?.part_detail?.pk}
/>
<Grid.Col span={8}>
<DetailsTable title={t`Part Details`} fields={tl} item={data} />
</Grid.Col>
</Grid>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={UserRoles.part}
src={supplierPart?.part_detail?.image}
apiPath={apiUrl(
ApiEndpoints.part_list,
supplierPart?.part_detail?.pk
)}
pk={supplierPart?.part_detail?.pk}
/>
<Grid.Col span={8}>
<DetailsTable title={t`Part Details`} fields={tl} item={data} />
</Grid.Col>
</Grid>
<TagsList tags={supplierPart.tags} />
</Stack>
<DetailsTable title={t`Supplier`} fields={bl} item={data} />
<DetailsTable title={t`Packaging`} fields={br} item={data} />
<DetailsTable title={t`Availability`} fields={tr} item={data} />
@ -339,6 +344,7 @@ export default function SupplierPartDetail() {
pk: supplierPart?.pk,
title: t`Edit Supplier Part`,
fields: supplierPartFields,
queryParams: new URLSearchParams({ tags: 'true' }),
onFormSuccess: refreshInstance
});

View File

@ -41,6 +41,7 @@ import { type ReactNode, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import Select from 'react-select';
import TagsList from '@lib/components/TagsList';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
@ -190,7 +191,8 @@ export default function PartDetail() {
endpoint: ApiEndpoints.part_list,
pk: id,
params: {
path_detail: true
path_detail: true,
tags: true
},
refetchOnMount: true
});
@ -612,6 +614,7 @@ export default function PartDetail() {
<DetailsTable fields={tl} item={data} />
</Grid.Col>
</Grid>
<TagsList tags={part.tags} />
{enableRevisionSelection && (
<Paper p='sm' withBorder>
<Stack gap='xs'>
@ -998,6 +1001,7 @@ export default function PartDetail() {
pk: part.pk,
title: t`Edit Part`,
fields: partFields,
queryParams: new URLSearchParams({ tags: 'true' }),
onFormSuccess: refreshInstance
});

View File

@ -9,6 +9,7 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import { TagsList } from '@lib/index';
import type { PanelType } from '@lib/types/Panel';
import AdminButton from '../../components/buttons/AdminButton';
import PrimaryActionButton from '../../components/buttons/PrimaryActionButton';
@ -65,7 +66,8 @@ export default function PurchaseOrderDetail() {
endpoint: ApiEndpoints.purchase_order_list,
pk: id,
params: {
supplier_detail: true
supplier_detail: true,
tags: true
},
refetchOnMount: true
});
@ -89,6 +91,7 @@ export default function PurchaseOrderDetail() {
pk: id,
title: t`Edit Purchase Order`,
fields: purchaseOrderFields,
queryParams: new URLSearchParams({ tags: 'true' }),
onFormSuccess: () => {
refreshInstance();
}
@ -318,17 +321,20 @@ export default function PurchaseOrderDetail() {
return (
<ItemDetailsGrid>
<Grid grow>
<DetailsImage
appRole={UserRoles.purchase_order}
apiPath={ApiEndpoints.company_list}
src={order.supplier_detail?.image}
pk={order.supplier}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={order} />
</Grid.Col>
</Grid>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={UserRoles.purchase_order}
apiPath={ApiEndpoints.company_list}
src={order.supplier_detail?.image}
pk={order.supplier}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={order} />
</Grid.Col>
</Grid>
<TagsList tags={order.tags} />
</Stack>
<DetailsTable fields={tr} item={order} />
<DetailsTable fields={bl} item={order} />
<DetailsTable fields={br} item={order} />

View File

@ -9,6 +9,7 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import { TagsList } from '@lib/index';
import type { PanelType } from '@lib/types/Panel';
import AdminButton from '../../components/buttons/AdminButton';
import PrimaryActionButton from '../../components/buttons/PrimaryActionButton';
@ -66,7 +67,8 @@ export default function ReturnOrderDetail() {
endpoint: ApiEndpoints.return_order_list,
pk: id,
params: {
customer_detail: true
customer_detail: true,
tags: true
}
});
@ -296,17 +298,20 @@ export default function ReturnOrderDetail() {
return (
<ItemDetailsGrid>
<Grid grow>
<DetailsImage
appRole={UserRoles.purchase_order}
apiPath={ApiEndpoints.company_list}
src={order.customer_detail?.image}
pk={order.customer}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={order} />
</Grid.Col>
</Grid>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={UserRoles.purchase_order}
apiPath={ApiEndpoints.company_list}
src={order.customer_detail?.image}
pk={order.customer}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={order} />
</Grid.Col>
</Grid>
<TagsList tags={order.tags} />
</Stack>
<DetailsTable fields={tr} item={order} />
<DetailsTable fields={bl} item={order} />
<DetailsTable fields={br} item={order} />
@ -403,6 +408,7 @@ export default function ReturnOrderDetail() {
pk: order.pk,
title: t`Edit Return Order`,
fields: returnOrderFields,
queryParams: new URLSearchParams({ tags: 'true' }),
onFormSuccess: () => {
refreshInstance();
}

View File

@ -11,6 +11,7 @@ import { type ReactNode, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { StylishText } from '@lib/components/StylishText';
import TagsList from '@lib/components/TagsList';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
@ -75,7 +76,8 @@ export default function SalesOrderDetail() {
endpoint: ApiEndpoints.sales_order_list,
pk: id,
params: {
customer_detail: true
customer_detail: true,
tags: true
}
});
@ -287,17 +289,20 @@ export default function SalesOrderDetail() {
return (
<ItemDetailsGrid>
<Grid grow>
<DetailsImage
appRole={UserRoles.purchase_order}
apiPath={ApiEndpoints.company_list}
src={order.customer_detail?.image}
pk={order.customer}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={order} />
</Grid.Col>
</Grid>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={UserRoles.purchase_order}
apiPath={ApiEndpoints.company_list}
src={order.customer_detail?.image}
pk={order.customer}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={order} />
</Grid.Col>
</Grid>
<TagsList tags={order.tags} />
</Stack>
<DetailsTable fields={tr} item={order} />
<DetailsTable fields={bl} item={order} />
<DetailsTable fields={br} item={order} />
@ -325,6 +330,7 @@ export default function SalesOrderDetail() {
pk: order.pk,
title: t`Edit Sales Order`,
fields: salesOrderFields,
queryParams: new URLSearchParams({ tags: 'true' }),
onFormSuccess: () => {
refreshInstance();
}

View File

@ -13,6 +13,7 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { getDetailUrl } from '@lib/functions/Navigation';
import { TagsList } from '@lib/index';
import type { PanelType } from '@lib/types/Panel';
import AdminButton from '../../components/buttons/AdminButton';
import PrimaryActionButton from '../../components/buttons/PrimaryActionButton';
@ -68,7 +69,8 @@ export default function SalesOrderShipmentDetail() {
endpoint: ApiEndpoints.sales_order_shipment_list,
pk: id,
params: {
order_detail: true
order_detail: true,
tags: true
}
});
@ -221,23 +223,26 @@ export default function SalesOrderShipmentDetail() {
return (
<>
<ItemDetailsGrid>
<Grid grow>
<DetailsImage
appRole={UserRoles.sales_order}
apiPath={ApiEndpoints.company_list}
src={customer?.image}
pk={customer?.pk}
imageActions={{
selectExisting: false,
downloadImage: false,
uploadFile: false,
deleteFile: false
}}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={data} />
</Grid.Col>
</Grid>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={UserRoles.sales_order}
apiPath={ApiEndpoints.company_list}
src={customer?.image}
pk={customer?.pk}
imageActions={{
selectExisting: false,
downloadImage: false,
uploadFile: false,
deleteFile: false
}}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={data} />
</Grid.Col>
</Grid>
<TagsList tags={shipment.tags} />
</Stack>
<DetailsTable fields={tr} item={data} />
<DetailsTable fields={bl} item={data} />
<DetailsTable fields={br} item={data} />
@ -295,6 +300,7 @@ export default function SalesOrderShipmentDetail() {
pk: shipment.pk,
fields: editShipmentFields,
title: t`Edit Shipment`,
queryParams: new URLSearchParams({ tags: 'true' }),
onFormSuccess: refreshShipment
});

View File

@ -3,6 +3,7 @@ import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import { getDetailUrl } from '@lib/functions/Navigation';
import type { TableFilter } from '@lib/index';
import type { StockOperationProps } from '@lib/types/Forms';
import type { PanelType } from '@lib/types/Panel';
import { t } from '@lingui/core/macro';
@ -60,6 +61,21 @@ import { StockLocationTable } from '../../tables/stock/StockLocationTable';
import TransferOrderParametricTable from '../../tables/stock/TransferOrderParametricTable';
import { TransferOrderTable } from '../../tables/stock/TransferOrderTable';
function TransferOrderCalendar() {
const calendarFilters: TableFilter[] = useMemo(() => {
return [];
}, []);
return (
<OrderCalendar
model={ModelType.transferorder}
role={UserRoles.transfer_order}
params={{ outstanding: true }}
filters={calendarFilters}
/>
);
}
export default function Stock() {
const { id: _id } = useParams();
@ -247,13 +263,7 @@ export default function Stock() {
value: 'calendar',
label: t`Calendar View`,
icon: <IconCalendar />,
content: (
<OrderCalendar
model={ModelType.transferorder}
role={UserRoles.transfer_order}
params={{ outstanding: true }}
/>
)
content: <TransferOrderCalendar />
},
{
value: 'parametric',

View File

@ -35,6 +35,7 @@ import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import { getDetailUrl, getOverviewUrl } from '@lib/functions/Navigation';
import { TagsList } from '@lib/index';
import type { ApiFormFieldSet, StockOperationProps } from '@lib/types/Forms';
import type { PanelType } from '@lib/types/Panel';
import { notifications } from '@mantine/notifications';
@ -118,7 +119,8 @@ export default function StockDetail() {
params: {
part_detail: true,
location_detail: true,
path_detail: true
path_detail: true,
tags: true
}
});
@ -445,19 +447,23 @@ export default function StockDetail() {
return (
<ItemDetailsGrid>
<Grid grow>
<DetailsImage
appRole={UserRoles.part}
apiPath={ApiEndpoints.part_list}
src={
stockitem.part_detail?.image ?? stockitem?.part_detail?.thumbnail
}
pk={stockitem.part}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={data} />
</Grid.Col>
</Grid>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={UserRoles.part}
apiPath={ApiEndpoints.part_list}
src={
stockitem.part_detail?.image ??
stockitem?.part_detail?.thumbnail
}
pk={stockitem.part}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={data} />
</Grid.Col>
</Grid>
<TagsList tags={stockitem.tags} />
</Stack>
<DetailsTable fields={tr} item={data} />
<DetailsTable fields={bl} item={data} />
<DetailsTable fields={br} item={data} />
@ -702,6 +708,7 @@ export default function StockDetail() {
title: t`Edit Stock Item`,
modalId: 'edit-stock-item',
fields: editStockItemFields,
queryParams: new URLSearchParams({ tags: 'true' }),
onFormSuccess: refreshInstance
});

View File

@ -6,7 +6,7 @@ import { useParams } from 'react-router-dom';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { type PanelType, apiUrl } from '@lib/index';
import { type PanelType, TagsList, apiUrl } from '@lib/index';
import {
IconBookmark,
IconInfoCircle,
@ -62,7 +62,9 @@ export default function TransferOrderDetail() {
} = useInstance({
endpoint: ApiEndpoints.transfer_order_list,
pk: id,
params: {}
params: {
tags: true
}
});
const toStatus = useStatusCodes({ modelType: ModelType.transferorder });
@ -233,18 +235,21 @@ export default function TransferOrderDetail() {
return (
<ItemDetailsGrid>
<Grid grow>
{/* TODO: what image do we show for a Transfer Order? */}
{/* <DetailsImage
<Stack gap='xs'>
<Grid grow>
{/* TODO: what image do we show for a Transfer Order? */}
{/* <DetailsImage
appRole={UserRoles.transfer_order}
apiPath={ApiEndpoints.transfer_order_list}
src="/static/img/blank_image.png"
pk={order.pk}
/> */}
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={order} />
</Grid.Col>
</Grid>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={order} />
</Grid.Col>
</Grid>
<TagsList tags={order.tags} />
</Stack>
<DetailsTable fields={tr} item={order} />
<DetailsTable fields={bl} item={order} />
<DetailsTable fields={br} item={order} />
@ -369,6 +374,7 @@ export default function TransferOrderDetail() {
pk: order.pk,
title: t`Edit Transfer Order`,
fields: transferOrderFields,
queryParams: new URLSearchParams({ tags: 'true' }),
onFormSuccess: () => {
refreshInstance();
}

View File

@ -399,6 +399,29 @@ export function ResponsibleFilter(): TableFilter {
});
}
export function TagsFilter({
modelType
}: {
modelType?: ModelType;
}): TableFilter {
return {
name: 'tags',
label: t`Tags`,
description: t`Filter by tags`,
placeholder: t`Select tags`,
type: 'api',
multi: true,
apiUrl: apiUrl(ApiEndpoints.tag_list),
model: ModelType.tag,
modelRenderer: (instance: any) => instance.name,
apiFilter: modelType ? { model_type: modelType } : undefined,
transform: (item: any) => ({
value: item.name.toString(),
label: item.name.toString()
})
};
}
export function UserFilter({
name,
label,

View File

@ -7,6 +7,7 @@ import {
Divider,
Drawer,
Group,
MultiSelect,
Paper,
Select,
Space,
@ -16,6 +17,7 @@ import {
Tooltip
} from '@mantine/core';
import { DateInput, type DateValue } from '@mantine/dates';
import { useQuery } from '@tanstack/react-query';
import dayjs from 'dayjs';
import { useCallback, useEffect, useMemo, useState } from 'react';
@ -26,7 +28,13 @@ import type {
TableFilterChoice,
TableFilterType
} from '@lib/types/Filters';
import { IconCheck } from '@tabler/icons-react';
import {
IconCheck,
IconFilterStar,
IconReload,
IconX
} from '@tabler/icons-react';
import { api } from '../App';
import { StandaloneField } from '../components/forms/StandaloneField';
import {
filterDisplayLabel,
@ -106,6 +114,74 @@ function FilterItem({
);
}
/*
* Multi-select element for 'api' filters with multi=true.
* Fetches all options from the API then renders a searchable MultiSelect.
* The user picks multiple values and confirms with an Apply button.
*/
function MultiApiFilterElement({
filterProps,
onValueChange
}: Readonly<{
filterProps: TableFilter;
onValueChange: (value: string | null, displayValue?: any) => void;
}>) {
const [selected, setSelected] = useState<string[]>([]);
const query = useQuery({
queryKey: ['filter-options', filterProps.apiUrl, filterProps.apiFilter],
queryFn: () =>
api
.get(filterProps.apiUrl ?? '', { params: filterProps.apiFilter })
.then((r) => r.data),
enabled: !!filterProps.apiUrl
});
const options: TableFilterChoice[] = useMemo(() => {
const results: any[] = query.data?.results ?? query.data ?? [];
if (filterProps.transform) {
return results.map(filterProps.transform);
}
return results.map((item: any) => ({
value: String(item.pk ?? item.id ?? item.slug ?? item.value),
label: filterProps.modelRenderer?.(item) ?? String(item.name ?? item.pk)
}));
}, [query.data, filterProps.transform, filterProps.modelRenderer]);
const apply = useCallback(() => {
if (!selected.length) return;
const labels = selected.map(
(v) => options.find((o) => o.value === v)?.label ?? v
);
onValueChange(selected.join(','), labels.join(', '));
}, [selected, options, onValueChange]);
return (
<MultiSelect
data={options}
value={selected}
onChange={setSelected}
searchable
label={t`Value`}
placeholder={filterProps.placeholder ?? t`Select one or more values`}
maxDropdownHeight={400}
rightSectionPointerEvents='all'
rightSection={
<ActionIcon
aria-label='apply-tags-filter'
variant='transparent'
color='green'
size='md'
disabled={!selected.length}
onClick={apply}
>
<IconCheck />
</ActionIcon>
}
/>
);
}
function FilterElement({
filterName,
filterProps,
@ -133,6 +209,14 @@ function FilterElement({
switch (filterProps.type) {
case 'api':
if (filterProps.multi) {
return (
<MultiApiFilterElement
filterProps={filterProps}
onValueChange={onValueChange}
/>
);
}
return (
<StandaloneField
fieldName={`filter-${filterName}`}
@ -144,7 +228,15 @@ function FilterElement({
model: filterProps.model,
label: t`Select filter value`,
onValueChange: (value: any, instance: any) => {
onValueChange(value, filterProps.modelRenderer?.(instance));
if (filterProps.transform) {
const choice = filterProps.transform(instance);
onValueChange(choice.value, choice.label);
} else {
onValueChange(
value,
filterProps.modelRenderer?.(instance) ?? value
);
}
}
}}
/>
@ -315,6 +407,77 @@ function FilterAddGroup({
);
}
function SavedFilterSets({
filterSet
}: Readonly<{
filterSet: FilterSetState;
}>) {
if (filterSet.savedFilterSets.length === 0) {
return null;
}
return (
<Stack gap='xs' justify='flex-end'>
<Space h='md' />
<StylishText size='md'>{t`Saved Filter Groups`}</StylishText>
<Divider />
<Stack gap='xs'>
{filterSet.savedFilterSets.map((set) => (
<Paper
key={`filter-group-${set.name}`}
p='sm'
shadow='sm'
radius='xs'
>
<Group justify='space-between' wrap='nowrap'>
<Group gap='xs' wrap='nowrap'>
<ActionIcon size='sm' variant='transparent'>
<IconFilterStar />
</ActionIcon>
<Text size='sm' style={{ flex: 1 }} truncate>
{set.name}
</Text>
</Group>
<Group gap='xs' wrap='nowrap'>
<Tooltip
label={t`Load filter group`}
withinPortal
position='top-end'
>
<ActionIcon
size='sm'
variant='transparent'
color='green'
aria-label={`load-filter-group-${set.name}`}
onClick={() => filterSet.loadFilterSet(set.name)}
>
<IconReload />
</ActionIcon>
</Tooltip>
<Tooltip
label={t`Delete filter group`}
withinPortal
position='top-end'
>
<ActionIcon
size='sm'
variant='transparent'
color='red'
aria-label={`delete-filter-group-${set.name}`}
onClick={() => filterSet.deleteFilterSet(set.name)}
>
<IconX style={{ transform: 'rotate(180deg)' }} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
</Paper>
))}
</Stack>
</Stack>
);
}
export function FilterSelectDrawer({
title,
availableFilters,
@ -329,6 +492,8 @@ export function FilterSelectDrawer({
onClose: () => void;
}>) {
const [addFilter, setAddFilter] = useState<boolean>(false);
const [saving, setSaving] = useState<boolean>(false);
const [saveName, setSaveName] = useState<string>('');
// Hide the "add filter" selection whenever the selected filters change
useEffect(() => {
@ -336,14 +501,21 @@ export function FilterSelectDrawer({
}, [filterSet.activeFilters]);
const hasFilters: boolean = useMemo(() => {
const filters = filterSet?.activeFilters ?? [];
return filters.length > 0;
return (filterSet?.activeFilters ?? []).length > 0;
}, [filterSet.activeFilters]);
const confirmSave = useCallback(() => {
const name = saveName.trim();
if (name) {
filterSet.saveFilterSet(name);
}
setSaveName('');
setSaving(false);
}, [saveName, filterSet]);
return (
<Drawer
size='sm'
size='lg'
position='right'
withCloseButton={true}
opened={opened}
@ -352,55 +524,111 @@ export function FilterSelectDrawer({
'aria-label': 'filter-drawer-close'
}}
title={<StylishText size='lg'>{title ?? t`Table Filters`}</StylishText>}
styles={{ body: { height: '100%', overflow: 'hidden' } }}
>
<Divider />
<Space h='sm' />
<Stack gap='xs'>
{hasFilters &&
filterSet.activeFilters?.map((f) => (
<FilterItem
key={f.name}
flt={f}
filterSet={filterSet}
availableFilters={availableFilters}
/>
))}
{addFilter && (
<Stack gap='xs'>
<FilterAddGroup
filterSet={filterSet}
availableFilters={availableFilters}
/>
</Stack>
)}
{addFilter && (
<Button
onClick={() => setAddFilter(false)}
color='orange'
variant='subtle'
>
<Text>{t`Cancel`}</Text>
</Button>
)}
{!addFilter &&
filterSet.activeFilters.length < availableFilters.length && (
<Stack gap='xs' justify='space-between'>
<Stack gap='xs'>
{hasFilters &&
filterSet.activeFilters?.map((f) => (
<FilterItem
key={f.name}
flt={f}
filterSet={filterSet}
availableFilters={availableFilters}
/>
))}
{addFilter && (
<Stack gap='xs'>
<FilterAddGroup
filterSet={filterSet}
availableFilters={availableFilters}
/>
</Stack>
)}
{addFilter && (
<Button
onClick={() => setAddFilter(true)}
color='green'
variant='subtle'
onClick={() => setAddFilter(false)}
color='orange'
variant='light'
>
<Text>{t`Add Filter`}</Text>
<Text>{t`Cancel`}</Text>
</Button>
)}
{!addFilter && filterSet.activeFilters.length > 0 && (
<Button
onClick={filterSet.clearActiveFilters}
color='red'
variant='subtle'
>
<Text>{t`Clear Filters`}</Text>
</Button>
)}
{!addFilter &&
filterSet.activeFilters.length < availableFilters.length && (
<Button
onClick={() => setAddFilter(true)}
color='green'
variant='light'
>
<Text>{t`Add Filter`}</Text>
</Button>
)}
{!addFilter && hasFilters && (
<Button
onClick={filterSet.clearActiveFilters}
color='red'
variant='light'
>
<Text>{t`Clear Filters`}</Text>
</Button>
)}
{!addFilter &&
hasFilters &&
(saving ? (
<Group gap='xs' wrap='nowrap'>
<TextInput
style={{ flex: 1 }}
aria-label='filter-group-name'
placeholder={t`Group name`}
value={saveName}
onChange={(e) => setSaveName(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') confirmSave();
if (e.key === 'Escape') setSaving(false);
}}
autoFocus
/>
<Tooltip label={t`Save`} withinPortal>
<ActionIcon
aria-label='save-filter-set'
color='green'
size='sm'
variant='transparent'
onClick={confirmSave}
disabled={!saveName.trim()}
>
<IconCheck />
</ActionIcon>
</Tooltip>
<Tooltip label={t`Cancel`} withinPortal>
<ActionIcon
aria-label='cancel-save-filter-set'
color='red'
size='sm'
variant='transparent'
onClick={() => setSaving(false)}
>
<IconX />
</ActionIcon>
</Tooltip>
</Group>
) : (
<Button
color='blue'
variant='light'
onClick={() => setSaving(true)}
>
<Text>{t`Save Filters`}</Text>
</Button>
))}
</Stack>
<Stack gap='xs'>
<SavedFilterSets filterSet={filterSet} />
<Space h='sm' />
</Stack>
</Stack>
</Drawer>
);

View File

@ -255,16 +255,22 @@ export function InvenTreeTableInternal<T extends Record<string, any>>({
// Update column visibility when hiddenColumns change
const dataColumns: any = useMemo(() => {
let cols: TableColumn[] = columns.filter((col) => col?.hidden != true);
// Include all columns (even prop-hidden ones) so useDataTableColumns always
// has the full ordered list. This prevents dynamic columns (e.g. ones whose
// hidden flag depends on async data) from being treated as brand-new columns
// after load, which would place them after the ACTIONS column.
let cols: TableColumn[] = [...columns];
cols = cols.map((col) => {
// If the column is *not* switchable, it is always visible
// Otherwise, check if it is "default hidden"
// Prop-level hidden takes priority (e.g. hidden: !hasTrackedItems).
// For switchable columns, visibility is driven by tableState.hiddenColumns.
// Non-switchable columns are always visible (unless hidden by props).
const hidden: boolean =
col.switchable == false
? false
: (tableState.hiddenColumns?.includes(col.accessor) ?? false);
col.hidden === true
? true
: col.switchable == false
? false
: (tableState.hiddenColumns?.includes(col.accessor) ?? false);
// Wrap the render function with CopyableCell if copyable is enabled
const originalRender = col.render;
@ -382,11 +388,17 @@ export function InvenTreeTableInternal<T extends Record<string, any>>({
getInitialValueInEffect: false
});
// Reset column ordering and custom widths when the component is mounted
// Reset column ordering when the column set changes (columns added/removed).
// Ref: https://github.com/icflorescu/mantine-datatable/issues/759
useEffect(() => {
tableColumns.setColumnsOrder(dataColumns.map((col: any) => col.accessor));
}, [tableColumnNames]);
const savedOrder = tableColumns.columnsOrder.join(',');
if (savedOrder != tableColumnNames) {
// This is covering the edge case where the column order is not updated automatically
tableColumns.resetColumnsOrder();
tableColumns.setColumnsOrder(tableColumnNames.split(','));
}
}, [tableColumnNames, tableColumns.columnsOrder]);
// Reset the pagination state when the search term changes
useEffect(() => {

View File

@ -22,6 +22,7 @@ import {
ResponsibleFilter,
StartDateAfterFilter,
StartDateBeforeFilter,
TagsFilter,
TargetDateAfterFilter,
TargetDateBeforeFilter
} from '../Filter';
@ -43,18 +44,19 @@ export default function BuildOrderFilters({
OrderStatusFilter({ model: ModelType.build }),
OverdueFilter(),
AssignedToMeFilter(),
CompletedBeforeFilter(),
CompletedAfterFilter(),
ProjectCodeFilter(),
HasProjectCodeFilter(),
IssuedByFilter(),
ResponsibleFilter(),
PartCategoryFilter()
PartCategoryFilter(),
TagsFilter({ modelType: ModelType.build })
];
const dateFilters: TableFilter[] = [
MinDateFilter(),
MaxDateFilter(),
CompletedBeforeFilter(),
CompletedAfterFilter(),
CreatedBeforeFilter(),
CreatedAfterFilter(),
TargetDateBeforeFilter(),

View File

@ -170,7 +170,8 @@ export default function BuildOutputTable({
// Fetch the test templates associated with the partId
const { data: testTemplates, refetch: refetchTestTemplates } = useQuery({
queryKey: ['buildoutputtests', partId, build],
staleTime: 60 * 1000, // Cache for 1 minute
queryKey: ['buildoutputtests', partId, buildId],
queryFn: async () => {
if (!partId || partId < 0) {
return [];
@ -200,6 +201,7 @@ export default function BuildOutputTable({
// Fetch the "tracked" BOM items associated with the partId
const { data: trackedItems, refetch: refetchTrackedItems } = useQuery({
staleTime: 60 * 1000, // Cache for 1 minute
queryKey: ['trackeditems', buildId],
queryFn: async () => {
if (!buildId || buildId < 0) {

View File

@ -22,6 +22,7 @@ import {
CompanyColumn,
DescriptionColumn
} from '../ColumnRenderers';
import { TagsFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
/**
@ -115,7 +116,8 @@ export function CompanyTable({
name: 'is_customer',
label: t`Customer`,
description: t`Show companies which are customers`
}
},
TagsFilter({ modelType: ModelType.company })
];
}, []);

View File

@ -1,5 +1,7 @@
import { ModelType } from '@lib/enums/ModelType';
import type { TableFilter } from '@lib/types/Filters';
import { t } from '@lingui/core/macro';
import { TagsFilter } from '../Filter';
/**
* Construct a set of filters for the part table
@ -141,6 +143,9 @@ export function PartTableFilters(): TableFilter[] {
label: t`Subscribed`,
description: t`Filter by parts to which the user is subscribed`,
type: 'boolean'
}
},
TagsFilter({
modelType: ModelType.part
})
];
}

View File

@ -29,6 +29,7 @@ import {
LinkColumn,
PartColumn
} from '../ColumnRenderers';
import { TagsFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
/*
@ -161,7 +162,8 @@ export function ManufacturerPartTable({
active: !manufacturerId,
description: t`Show manufacturer parts for active manufacturers.`,
type: 'boolean'
}
},
TagsFilter({ modelType: ModelType.manufacturerpart })
];
}, [manufacturerId]);

View File

@ -19,6 +19,7 @@ import {
ResponsibleFilter,
StartDateAfterFilter,
StartDateBeforeFilter,
TagsFilter,
TargetDateAfterFilter,
TargetDateBeforeFilter,
UpdatedAfterFilter,
@ -38,7 +39,8 @@ export default function PurchaseOrderFilters({
ProjectCodeFilter(),
HasProjectCodeFilter(),
ResponsibleFilter(),
CreatedByFilter()
CreatedByFilter(),
TagsFilter({ modelType: ModelType.purchaseorder })
];
const dateFilters: TableFilter[] = [

View File

@ -37,6 +37,7 @@ import {
NoteColumn,
PartColumn
} from '../ColumnRenderers';
import { TagsFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
import { TableHoverCard } from '../TableHoverCard';
@ -60,7 +61,8 @@ export function SupplierPartTable({
{
name: 'active',
value: 'true'
}
},
TagsFilter({ modelType: ModelType.supplierpart })
];
if (!supplierId) {

View File

@ -20,6 +20,7 @@ import {
ResponsibleFilter,
StartDateAfterFilter,
StartDateBeforeFilter,
TagsFilter,
TargetDateAfterFilter,
TargetDateBeforeFilter,
UpdatedAfterFilter,
@ -41,7 +42,8 @@ export default function ReturnOrderFilters({
HasProjectCodeFilter(),
ProjectCodeFilter(),
ResponsibleFilter(),
CreatedByFilter()
CreatedByFilter(),
TagsFilter({ modelType: ModelType.returnorder })
];
const dateFilters: TableFilter[] = [

View File

@ -20,6 +20,7 @@ import {
ResponsibleFilter,
StartDateAfterFilter,
StartDateBeforeFilter,
TagsFilter,
TargetDateAfterFilter,
TargetDateBeforeFilter,
UpdatedAfterFilter,
@ -41,7 +42,8 @@ export default function SalesOrderFilters({
HasProjectCodeFilter(),
ProjectCodeFilter(),
ResponsibleFilter(),
CreatedByFilter()
CreatedByFilter(),
TagsFilter({ modelType: ModelType.salesorder })
];
const dateFilters: TableFilter[] = [

View File

@ -25,7 +25,6 @@ import type { TableColumn } from '@lib/types/Tables';
import {
useCheckShipmentForm,
useCompleteShipmentForm,
useSalesOrderShipmentCompleteFields,
useSalesOrderShipmentFields,
useUncheckShipmentForm
} from '../../forms/SalesOrderForms';
@ -41,6 +40,7 @@ import {
LinkColumn,
StatusColumn
} from '../ColumnRenderers';
import { TagsFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
export default function SalesOrderShipmentTable({
@ -71,8 +71,6 @@ export default function SalesOrderShipmentTable({
pending: !selectedShipment.shipment_date
});
const completeShipmentFields = useSalesOrderShipmentCompleteFields({});
const newShipment = useCreateApiFormModal({
url: ApiEndpoints.sales_order_shipment_list,
fields: newShipmentFields,
@ -303,7 +301,8 @@ export default function SalesOrderShipmentTable({
name: 'delivered',
label: t`Delivered`,
description: t`Show shipments which have been delivered`
}
},
TagsFilter({ modelType: ModelType.salesordershipment })
];
}, []);

View File

@ -44,6 +44,7 @@ import {
SerialLTEFilter,
StatusFilterOptions,
SupplierFilter,
TagsFilter,
UpdatedAfterFilter,
UpdatedBeforeFilter
} from '../Filter';
@ -306,7 +307,8 @@ function stockItemTableFilters({
name: 'external',
label: t`External Location`,
description: t`Show items in an external location`
}
},
TagsFilter({ modelType: ModelType.stockitem })
];
}

View File

@ -0,0 +1,79 @@
import { ModelType, type TableFilter } from '@lib/index';
import { t } from '@lingui/core/macro';
import {
AssignedToMeFilter,
CompletedAfterFilter,
CompletedBeforeFilter,
CreatedAfterFilter,
CreatedBeforeFilter,
CreatedByFilter,
HasProjectCodeFilter,
IncludeVariantsFilter,
MaxDateFilter,
MinDateFilter,
OrderStatusFilter,
OutstandingFilter,
OverdueFilter,
ProjectCodeFilter,
ResponsibleFilter,
StartDateAfterFilter,
StartDateBeforeFilter,
TagsFilter,
TargetDateAfterFilter,
TargetDateBeforeFilter
} from '../Filter';
export default function TransferOrderFilters({
partId,
includeDateFilters = true
}: {
partId?: number;
includeDateFilters?: boolean;
}): TableFilter[] {
const filters: TableFilter[] = [
OrderStatusFilter({ model: ModelType.transferorder }),
OutstandingFilter(),
OverdueFilter(),
AssignedToMeFilter(),
HasProjectCodeFilter(),
ProjectCodeFilter(),
ResponsibleFilter(),
CreatedByFilter(),
TagsFilter({ modelType: ModelType.transferorder })
];
const dateFilters: TableFilter[] = [
MinDateFilter(),
MaxDateFilter(),
CreatedBeforeFilter(),
CreatedAfterFilter(),
TargetDateBeforeFilter(),
TargetDateAfterFilter(),
StartDateBeforeFilter(),
StartDateAfterFilter(),
{
name: 'has_target_date',
type: 'boolean',
label: t`Has Target Date`,
description: t`Show orders with a target date`
},
{
name: 'has_start_date',
type: 'boolean',
label: t`Has Start Date`,
description: t`Show orders with a start date`
},
CompletedBeforeFilter(),
CompletedAfterFilter()
];
if (includeDateFilters) {
filters.push(...dateFilters);
}
if (!!partId) {
filters.push(IncludeVariantsFilter());
}
return filters;
}

View File

@ -22,28 +22,8 @@ import {
StatusColumn,
TargetDateColumn
} from '../ColumnRenderers';
import {
AssignedToMeFilter,
CompletedAfterFilter,
CompletedBeforeFilter,
CreatedAfterFilter,
CreatedBeforeFilter,
CreatedByFilter,
HasProjectCodeFilter,
IncludeVariantsFilter,
MaxDateFilter,
MinDateFilter,
OrderStatusFilter,
OutstandingFilter,
OverdueFilter,
ProjectCodeFilter,
ResponsibleFilter,
StartDateAfterFilter,
StartDateBeforeFilter,
TargetDateAfterFilter,
TargetDateBeforeFilter
} from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
import TransferOrderFilters from './TransferOrderFilters';
export function TransferOrderTable({
partId
@ -56,45 +36,8 @@ export function TransferOrderTable({
const user = useUserState();
const tableFilters: TableFilter[] = useMemo(() => {
const filters: TableFilter[] = [
OrderStatusFilter({ model: ModelType.transferorder }),
OutstandingFilter(),
OverdueFilter(),
AssignedToMeFilter(),
MinDateFilter(),
MaxDateFilter(),
CreatedBeforeFilter(),
CreatedAfterFilter(),
TargetDateBeforeFilter(),
TargetDateAfterFilter(),
StartDateBeforeFilter(),
StartDateAfterFilter(),
{
name: 'has_target_date',
type: 'boolean',
label: t`Has Target Date`,
description: t`Show orders with a target date`
},
{
name: 'has_start_date',
type: 'boolean',
label: t`Has Start Date`,
description: t`Show orders with a start date`
},
CompletedBeforeFilter(),
CompletedAfterFilter(),
HasProjectCodeFilter(),
ProjectCodeFilter(),
ResponsibleFilter(),
CreatedByFilter()
];
if (!!partId) {
filters.push(IncludeVariantsFilter());
}
return filters;
}, [partId]);
return TransferOrderFilters({ includeDateFilters: true });
}, []);
const tableColumns = useMemo(() => {
return [

View File

@ -120,6 +120,51 @@ test('Build Order - Basic Tests', async ({ browser }) => {
.waitFor();
});
// Test tags filtering against Build Orders
test('Build Order - Tags', async ({ browser }) => {
const page = await doCachedLogin(browser, {
url: 'manufacturing/index/buildorders'
});
// Filter by tag
await page
.getByRole('button', { name: 'segmented-icon-control-table' })
.click();
await clearTableFilters(page);
await page.getByRole('button', { name: 'table-select-filters' }).click();
await page.getByRole('button', { name: 'Add Filter' }).click();
await page.getByRole('combobox', { name: 'Filter' }).fill('tag');
await page.getByRole('option', { name: 'Tags' }).click();
await page.getByRole('combobox', { name: 'Value' }).click();
// Check for expected tags
await page.getByRole('option', { name: 'Furniture' }).waitFor();
await page.getByRole('option', { name: 'Electronics' }).click();
await page.getByRole('option', { name: 'PCB Assembly' }).click();
// Apply the "Furniture" tag filter
await page.getByRole('button', { name: 'apply-tags-filter' }).click();
await page.getByRole('button', { name: 'filter-drawer-close' }).click();
// Check for expected results
await page.getByRole('cell', { name: 'BO0026' }).click();
await page.getByText('100 x 002.01-PCBA | Widget').waitFor();
// Check for tags displayed on BuildOrder detail page
await page.getByText('Electronics', { exact: true }).first().waitFor();
await page.getByText('PCB Assembly', { exact: true }).first().waitFor();
// Edit the build order
await page.keyboard.press('Control+E');
const tagsField = await page.getByRole('combobox', {
name: 'tags-field-tags'
});
await expect(tagsField).toBeVisible();
await page.getByRole('button', { name: 'Cancel' }).click();
});
// Test that the build order reference field increments correctly
test('Build Order - Reference', async ({ browser }) => {
const page = await doCachedLogin(browser, {
@ -334,6 +379,24 @@ test('Build Order - Build Outputs', async ({ browser }) => {
)
.waitFor();
await page.getByRole('cell', { name: 'Quantity: 16' }).waitFor();
// Adjust the quantity field - we will only 'partially' scrap this output
await page.getByRole('textbox', { name: 'number-field-quantity' }).fill('10');
// Next, adjust the "location" field - and check that the "quantity" field does not change
// Ref: https://github.com/inventree/InvenTree/pull/12081
await page
.getByRole('combobox', { name: 'related-field-location' })
.fill('factory');
await page.getByTitle('Factory/Mechanical Lab').click();
await page.waitForTimeout(250);
// Check the 'quantity' value again - it should not have changed
const quantityValue = await page
.getByRole('textbox', { name: 'number-field-quantity' })
.inputValue();
expect(quantityValue).toBe('10');
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
});

View File

@ -276,6 +276,29 @@ test('Sales Orders - Shipments', async ({ browser }) => {
.click();
});
// Filter Shipments by tag
test('Sales Orders - Shipments - Tags', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: 'sales/index/shipments' });
// Filter by tag
await clearTableFilters(page);
await page.getByRole('button', { name: 'table-select-filters' }).click();
await page.getByRole('button', { name: 'Add Filter' }).click();
await page.getByRole('combobox', { name: 'Filter' }).fill('tag');
await page.getByRole('option', { name: 'Tags' }).click();
await page.getByRole('combobox', { name: 'Value' }).click();
// Apply the "Requires Payment" tag filter
await page.getByRole('option', { name: 'Requires Payment' }).click();
await page.getByRole('button', { name: 'apply-tags-filter' }).click();
await page.getByRole('button', { name: 'filter-drawer-close' }).click();
// Click through to one of the selected shipments
await page.getByRole('cell', { name: 'SO0007' }).click();
await page.getByText('Sales Order: SO0007').first().waitFor();
await page.getByText('Requires Payment', { exact: true }).first().waitFor();
});
// Complete a shipment against a sales order
test('Sales Orders - Complete Shipment', async ({ browser }) => {
const page = await doCachedLogin(browser, {

View File

@ -3,6 +3,7 @@ import { stevenuser } from './defaults.js';
import {
clearTableFilters,
navigate,
openFilterDrawer,
setTableChoiceFilter,
toggleColumnSorting
} from './helpers.js';
@ -39,6 +40,31 @@ test('Tables - Filters', async ({ browser }) => {
await setTableChoiceFilter(page, 'Has Start Date', 'Yes');
await clearTableFilters(page);
// Next, let's create a "custom filter group" and apply it
await openFilterDrawer(page);
await page.getByRole('button', { name: 'Add Filter' }).click();
await page.getByRole('combobox', { name: 'Filter' }).click();
await page.getByRole('option', { name: 'Outstanding' }).click();
await page.getByRole('combobox', { name: 'Value' }).click();
await page.getByRole('option', { name: 'Yes' }).click();
// Save the filter group
await page.getByRole('button', { name: 'Save Filters' }).click();
await page.getByRole('textbox', { name: 'filter-group-name' }).fill('custom');
await page
.getByRole('button', { name: 'save-filter-set', exact: true })
.click();
// Clear filters, and then restore from saved group
await page.getByRole('button', { name: 'Clear Filters' }).click();
await page.getByRole('button', { name: 'load-filter-group-custom' }).click();
await page.getByText('Show outstanding items').first().waitFor();
// Remove the filter group
await page
.getByRole('button', { name: 'delete-filter-group-custom' })
.click();
});
test('Tables - Pagination', async ({ browser }) => {