2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-06-06 00:44:25 +00:00

[feature] tags support (#12077)

* Add Tag API endpoints

* Enable filtering by model type

* Remove old tags filters against Part endpoint

* Add generic tags filter for filtering against tagged items

* Add API unit tests for the tags API endpoints

* Create generic mixin class for adding tags support

* Update existing tagged models

* Add tags to more model types

* Enable new tags API filtering for multiple models

* Add support for tag filtering in part table

* Update transfer table filters

* Add tags filter to more places

* Allow multiple values to be selected as filters

* Add a new 'tags' type form field

* Display tags on part page

* tags support for orders

* Add support for SalesOrderShipment

* build order

* Company support

* SupplierPart and ManufacturerPart

* support StockItem

* Enable tag filtering for attachments

* Make tagslist readonly

* docs

* Mark props as read only

* Update API version

* Update CHANGELOG

* force tags to be case insensitive

* Add playwright test for build order tags

* more playwright testing

* Fix docs link
This commit is contained in:
Oliver
2026-06-04 19:38:22 +10:00
committed by GitHub
parent a0b2452ba5
commit 75a08a1e06
78 changed files with 1294 additions and 263 deletions
+1
View File
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- [#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.
+82
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`.
+1
View File
@@ -102,6 +102,7 @@ nav:
- Project Codes: concepts/project_codes.md
- Attachments: concepts/attachments.md
- Parameters: concepts/parameters.md
- Tags: concepts/tags.md
- Barcodes:
- Barcode Support: barcodes/index.md
- Internal Barcodes: barcodes/internal.md
@@ -1,11 +1,16 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 499
INVENTREE_API_VERSION = 500
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
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
+20
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
@@ -1250,6 +1251,25 @@ class InvenTreeNotesMixin(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.
@@ -699,10 +699,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.
@@ -1169,3 +1169,6 @@ if 'dbbackup' not in STORAGES:
if _media:
MEDIA_URL = _media
PRESIGNED_URL_EXPIRATION = 600
# Taggit settings
TAGGIT_CASE_INSENSITIVE = True
+3
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."""
@@ -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",
),
),
]
+1
View File
@@ -79,6 +79,7 @@ class Build(
InvenTree.models.InvenTreeParameterMixin,
InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeTagsMixin,
InvenTree.models.InvenTreeNotesMixin,
InvenTree.models.ReferenceIndexingMixin,
StateTransitionMixin,
@@ -36,6 +36,7 @@ from InvenTree.serializers import (
FilterableSerializerMixin,
InvenTreeDecimalField,
InvenTreeModelSerializer,
InvenTreeTaggitSerializer,
NotesFieldMixin,
OptionalField,
)
@@ -57,6 +58,7 @@ class BuildSerializer(
CustomStatusSerializerMixin,
FilterableSerializerMixin,
NotesFieldMixin,
InvenTreeTaggitSerializer,
DataImportExportSerializerMixin,
InvenTreeCustomStatusSerializerMixin,
InvenTreeModelSerializer,
@@ -103,6 +105,7 @@ class BuildSerializer(
'parameters',
'priority',
'level',
'tags',
]
read_only_fields = [
'completed',
@@ -127,6 +130,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')
)
+53
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
@@ -504,6 +505,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."""
@@ -745,6 +788,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."""
@@ -1554,6 +1599,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/',
+31
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']
+5 -4
View File
@@ -50,7 +50,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
@@ -1923,7 +1922,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.
@@ -2156,8 +2159,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."""
+28 -1
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:
+168
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
@@ -999,3 +1000,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)
+18 -7
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."""
@@ -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",
),
),
]
+3 -5
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.InvenTreeNotesMixin,
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.InvenTreeNotesMixin,
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."""
@@ -603,6 +602,7 @@ class SupplierPart(
InvenTree.models.InvenTreeParameterMixin,
InvenTree.models.MetadataMixin,
InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeTagsMixin,
InvenTree.models.InvenTreeNotesMixin,
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."""
+9 -3
View File
@@ -20,7 +20,7 @@ from InvenTree.serializers import (
InvenTreeImageSerializerField,
InvenTreeModelSerializer,
InvenTreeMoneySerializer,
InvenTreeTagModelSerializer,
InvenTreeTaggitSerializer,
NotesFieldMixin,
OptionalField,
)
@@ -108,6 +108,7 @@ class AddressBriefSerializer(InvenTreeModelSerializer):
class CompanySerializer(
FilterableSerializerMixin,
DataImportExportSerializerMixin,
InvenTreeTaggitSerializer,
NotesFieldMixin,
InvenTreeModelSerializer,
):
@@ -143,6 +144,7 @@ class CompanySerializer(
'primary_address',
'tax_id',
'parameters',
'tags',
]
@staticmethod
@@ -185,6 +187,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()
@@ -207,8 +211,9 @@ class ContactSerializer(DataImportExportSerializerMixin, InvenTreeModelSerialize
class ManufacturerPartSerializer(
FilterableSerializerMixin,
DataImportExportSerializerMixin,
InvenTreeTagModelSerializer,
InvenTreeTaggitSerializer,
NotesFieldMixin,
InvenTreeModelSerializer,
):
"""Serializer for ManufacturerPart object."""
@@ -308,8 +313,9 @@ class SupplierPriceBreakBriefSerializer(
class SupplierPartSerializer(
FilterableSerializerMixin,
DataImportExportSerializerMixin,
InvenTreeTagModelSerializer,
InvenTreeTaggitSerializer,
NotesFieldMixin,
InvenTreeModelSerializer,
):
"""Serializer for SupplierPart object."""
+5
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."""
@@ -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",
),
),
]
+2
View File
@@ -300,6 +300,7 @@ class Order(
InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeNotesMixin,
InvenTree.models.InvenTreeTagsMixin,
report.mixins.InvenTreeReportMixin,
InvenTree.models.MetadataMixin,
InvenTree.models.ReferenceIndexingMixin,
@@ -2465,6 +2466,7 @@ class SalesOrderShipment(
InvenTree.models.InvenTreeParameterMixin,
InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeTagsMixin,
InvenTree.models.InvenTreeNotesMixin,
report.mixins.InvenTreeReportMixin,
InvenTree.models.MetadataMixin,
@@ -37,6 +37,7 @@ from InvenTree.serializers import (
InvenTreeDecimalField,
InvenTreeModelSerializer,
InvenTreeMoneySerializer,
InvenTreeTaggitSerializer,
NotesFieldMixin,
OptionalField,
)
@@ -103,6 +104,7 @@ class DuplicateOrderSerializer(serializers.Serializer):
class AbstractOrderSerializer(
CustomStatusSerializerMixin,
DataImportExportSerializerMixin,
InvenTreeTaggitSerializer,
FilterableSerializerMixin,
serializers.Serializer,
):
@@ -172,6 +174,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)
@@ -236,6 +240,7 @@ class AbstractOrderSerializer(
'project_code_label',
'responsible_detail',
'parameters',
'tags',
*extra_fields,
]
@@ -1065,6 +1070,7 @@ class SalesOrderSerializer(
TotalPriceMixin,
InvenTreeCustomStatusSerializerMixin,
AbstractOrderSerializer,
InvenTreeTaggitSerializer,
InvenTreeModelSerializer,
):
"""Serializer for the SalesOrder model class."""
@@ -1084,6 +1090,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}}
@@ -1165,6 +1172,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."""
@@ -1363,6 +1372,7 @@ class SalesOrderLineItemSerializer(
class SalesOrderShipmentSerializer(
DataImportExportSerializerMixin,
FilterableSerializerMixin,
InvenTreeTaggitSerializer,
NotesFieldMixin,
InvenTreeModelSerializer,
):
@@ -1392,6 +1402,7 @@ class SalesOrderShipmentSerializer(
'customer_detail',
'order_detail',
'shipment_address_detail',
'tags',
]
@staticmethod
@@ -1461,6 +1472,8 @@ class SalesOrderShipmentSerializer(
parameters = common.filters.enable_parameters_filter()
tags = common.filters.enable_tags_filter()
class SalesOrderAllocationSerializer(
FilterableSerializerMixin, InvenTreeModelSerializer
+2 -3
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(
+1 -3
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
@@ -465,6 +464,7 @@ class Part(
InvenTree.models.InvenTreeParameterMixin,
InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeTagsMixin,
InvenTree.models.InvenTreeNotesMixin,
report.mixins.InvenTreeReportMixin,
InvenTree.models.InvenTreeImageMixin,
@@ -520,8 +520,6 @@ class Part(
objects = TreeManager()
tags = TaggableManager(blank=True)
class Meta:
"""Metaclass defines extra model properties."""
+4
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."""
+2 -5
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.InvenTreeNotesMixin,
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(
+4 -2
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.
@@ -1194,7 +1195,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."""
+27
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>
);
}
+1
View File
@@ -259,6 +259,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'
@@ -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'
}
};
+2 -1
View File
@@ -38,7 +38,8 @@ export enum ModelType {
contenttype = 'contenttype',
selectionlist = 'selectionlist',
selectionentry = 'selectionentry',
error = 'error'
error = 'error',
tag = 'tag'
}
export enum PluginPanelKey {
+1
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,
+3
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,8 @@ export type TableFilter = {
apiFilter?: Record<string, any>;
model?: ModelType;
modelRenderer?: (instance: any) => string;
transform?: (item: any) => TableFilterChoice;
multi?: boolean;
};
/*
+2 -1
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;
@@ -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`}>
@@ -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}`}
@@ -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', ';', ':', '.', '-']}
/>
);
}
@@ -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
}: {
@@ -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
};
/**
+2
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 />
},
+19
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`
};
}
+4
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: {},
+2
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: {
@@ -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 />,
@@ -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) => {
@@ -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]);
+2
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 />
},
@@ -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: {
+18 -12
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
});
@@ -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
});
@@ -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
});
@@ -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
});
+5 -1
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
});
@@ -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} />
@@ -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();
}
@@ -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();
}
@@ -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
});
@@ -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',
+21 -14
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
});
@@ -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();
}
+23
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,
+88 -1
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';
@@ -27,6 +29,7 @@ import type {
TableFilterType
} from '@lib/types/Filters';
import { IconCheck } from '@tabler/icons-react';
import { api } from '../App';
import { StandaloneField } from '../components/forms/StandaloneField';
import {
filterDisplayLabel,
@@ -106,6 +109,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 +204,14 @@ function FilterElement({
switch (filterProps.type) {
case 'api':
if (filterProps.multi) {
return (
<MultiApiFilterElement
filterProps={filterProps}
onValueChange={onValueChange}
/>
);
}
return (
<StandaloneField
fieldName={`filter-${filterName}`}
@@ -144,7 +223,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
);
}
}
}}
/>
@@ -22,6 +22,7 @@ import {
ResponsibleFilter,
StartDateAfterFilter,
StartDateBeforeFilter,
TagsFilter,
TargetDateAfterFilter,
TargetDateBeforeFilter
} from '../Filter';
@@ -49,7 +50,8 @@ export default function BuildOrderFilters({
HasProjectCodeFilter(),
IssuedByFilter(),
ResponsibleFilter(),
PartCategoryFilter()
PartCategoryFilter(),
TagsFilter({ modelType: ModelType.build })
];
const dateFilters: TableFilter[] = [
@@ -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 })
];
}, []);
@@ -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
})
];
}
@@ -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]);
@@ -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[] = [
@@ -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) {
@@ -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[] = [
@@ -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[] = [
@@ -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 })
];
}, []);
@@ -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 })
];
}
@@ -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;
}
@@ -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 [
@@ -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, {
@@ -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, {