diff --git a/docs/docs/assets/images/part/part_create_revision.png b/docs/docs/assets/images/part/part_create_revision.png new file mode 100644 index 0000000000..a3a901d3c6 Binary files /dev/null and b/docs/docs/assets/images/part/part_create_revision.png differ diff --git a/docs/docs/assets/images/part/part_revision_b.png b/docs/docs/assets/images/part/part_revision_b.png new file mode 100644 index 0000000000..501d638a04 Binary files /dev/null and b/docs/docs/assets/images/part/part_revision_b.png differ diff --git a/docs/docs/assets/images/part/part_revision_select.png b/docs/docs/assets/images/part/part_revision_select.png new file mode 100644 index 0000000000..48da57a3b3 Binary files /dev/null and b/docs/docs/assets/images/part/part_revision_select.png differ diff --git a/docs/docs/assets/images/part/part_revision_settings.png b/docs/docs/assets/images/part/part_revision_settings.png new file mode 100644 index 0000000000..31940f53dd Binary files /dev/null and b/docs/docs/assets/images/part/part_revision_settings.png differ diff --git a/docs/docs/part/revision.md b/docs/docs/part/revision.md new file mode 100644 index 0000000000..4d3a9bd79d --- /dev/null +++ b/docs/docs/part/revision.md @@ -0,0 +1,78 @@ +--- +title: Part Revisions +--- + +## Part Revisions + +When creating a complex part (such as an assembly comprised of other parts), it is often necessary to track changes to the part over time. For example, throughout the lifetime of an assembly, it may be necessary to adjust the bill of materials, or update the design of the part. + +Rather than overwrite the existing part data, InvenTree allows you to create a new *revision* of the part. This allows you to track changes to the part over time, and maintain a history of the part design. + +Crucially, creating a new *revision* ensures that any related data entries which refer to the original part (such as stock items, build orders, purchase orders, etc) are not affected by the change. + +### Revisions are Parts + +A *revision* of a part is itself a part. This means that each revision of a part has its own part number, stock items, parameters, bill of materials, etc. The only thing that differentiates a *revision* from any other part is that the *revision* is linked to the original part. + +### Revision Fields + +Each part has two fields which are used to track the revision of the part: + +* **Revision**: The revision number of the part. This is a user-defined field, and can be any string value. +* **Revision Of**: A reference to the part of which *this* part is a revision. This field is used to keep track of the available revisions for any particular part. + +### Revision Restrictions + +When creating a new revision of a part, there are some restrictions which must be adhered to: + +* **Circular References**: A part cannot be a revision of itself. This would create a circular reference which is not allowed. +* **Unique Revisions**: A part cannot have two revisions with the same revision number. Each revision (of a given part) must have a unique revision code. +* **Revisions of Revisions**: A single part can have multiple revisions, but a revision cannot have its own revision. This restriction is in place to prevent overly complex part relationships. +* **Template Revisions**: A part which is a [template part](./template.md) cannot have revisions. This is because the template part is used to create variants, and allowing revisions of templates would create disallowed relationship states in the database. However, variant parts are allowed to have revisions. +* **Template References**: A part which is a revision of a variant part must point to the same template as the original part. This is to ensure that the revision is correctly linked to the original part. + +## Revision Settings + +The following options are available to control the behavior of part revisions. + +Note that these options can be changed in the InvenTree settings: + +{% with id="part_revision_settings", url="part/part_revision_settings.png", description="Part revision settings" %} +{% include 'img.html' %} +{% endwith %} + +* **Enable Revisions**: If this setting is enabled, parts can have revisions. If this setting is disabled, parts cannot have revisions. +* **Assembly Revisions Only**: If this setting is enabled, only assembly parts can have revisions. This is useful if you only want to track revisions of assemblies, and not individual parts. + +## Create a Revision + +To create a new revision for a given part, navigate to the part detail page, and click on the "Revisions" tab. + +Select the "Duplicate Part" action, to create a new copy of the selected part. This will open the "Duplicate Part" form: + +{% with id="part_create_revision", url="part/part_create_revision.png", description="Create part revision" %} +{% include 'img.html' %} +{% endwith %} + +In this form, make the following updates: + +1. Set the *Revision Of* field to the original part (the one that you are duplicating) +2. Set the *Revision* field to a unique revision number for the new part revision + +Once these changes (and any other required changes) are made, press *Submit* to create the new part. + +Once the form is submitted (without any errors), you will be redirected to the new part revision. Here you can see that it is linked to the original part: + +{% with id="part_revision_b", url="part/part_revision_b.png", description="Revision B" %} +{% include 'img.html' %} +{% endwith %} + +## Revision Navigation + +When multiple revisions exist for a particular part, you can navigate between revisions using the *Select Part Revision* drop-down which renders at the top of the part page: + +{% with id="part_revision_select", url="part/part_revision_select.png", description="Select part revision" %} +{% include 'img.html' %} +{% endwith %} + +Note that this revision selector is only visible when multiple revisions exist for the part. diff --git a/docs/docs/part/views.md b/docs/docs/part/views.md index f2301d9ea2..c574bccf7e 100644 --- a/docs/docs/part/views.md +++ b/docs/docs/part/views.md @@ -28,7 +28,6 @@ Details provides information about the particular part. Parts details can be dis {% with id="part_overview", url="part/part_overview.png", description="Part details" %} {% include 'img.html' %} {% endwith %} -

A Part is defined in the system by the following parameters: @@ -38,7 +37,7 @@ A Part is defined in the system by the following parameters: **Description** - Longer form text field describing the Part -**Revision** - An optional revision code denoting the particular version for the part. Used when there are multiple revisions of the same master part object. +**Revision** - An optional revision code denoting the particular version for the part. Used when there are multiple revisions of the same master part object. Read [more about part revisions here](./revision.md). **Keywords** - Optional few words to describe the part and make the part search more efficient. @@ -62,7 +61,7 @@ Parts can have multiple defined parameters. If a part is a *Template Part* then the *Variants* tab will be visible. -[Read about Part templates](./template.md) +[Read about Part templates and variants](./template.md) ### Stock diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 14c8ddd832..75974387d7 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -106,6 +106,7 @@ nav: - Part Views: part/views.md - Tracking: part/trackable.md - Parameters: part/parameter.md + - Revisions: part/revision.md - Templates: part/template.md - Tests: part/test.md - Pricing: part/pricing.md diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index bdb4330d90..b11316cb69 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,12 +1,16 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 219 +INVENTREE_API_VERSION = 220 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v220 - 2024-07-11 : https://github.com/inventree/InvenTree/pull/7585 + - Adds "revision_of" field to Part serializer + - Adds new API filters for "revision" status + v219 - 2024-07-11 : https://github.com/inventree/InvenTree/pull/7611 - Adds new fields to the BuildItem API endpoints - Adds new ordering / filtering options to the BuildItem API endpoints diff --git a/src/backend/InvenTree/build/serializers.py b/src/backend/InvenTree/build/serializers.py index ef84817123..7c8bb2a0aa 100644 --- a/src/backend/InvenTree/build/serializers.py +++ b/src/backend/InvenTree/build/serializers.py @@ -1,5 +1,7 @@ """JSON serializers for Build API.""" +from decimal import Decimal + from django.db import transaction from django.core.exceptions import ValidationError as DjangoValidationError from django.utils.translation import gettext_lazy as _ @@ -209,7 +211,7 @@ class BuildOutputQuantitySerializer(BuildOutputSerializer): quantity = serializers.DecimalField( max_digits=15, decimal_places=5, - min_value=0, + min_value=Decimal(0), required=True, label=_('Quantity'), help_text=_('Enter quantity for build output'), @@ -256,7 +258,7 @@ class BuildOutputCreateSerializer(serializers.Serializer): quantity = serializers.DecimalField( max_digits=15, decimal_places=5, - min_value=0, + min_value=Decimal(0), required=True, label=_('Quantity'), help_text=_('Enter quantity for build output'), @@ -864,7 +866,7 @@ class BuildAllocationItemSerializer(serializers.Serializer): quantity = serializers.DecimalField( max_digits=15, decimal_places=5, - min_value=0, + min_value=Decimal(0), required=True ) diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index 9d41d06f39..a55e829cc8 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -1408,6 +1408,12 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'validator': bool, 'default': True, }, + 'PART_REVISION_ASSEMBLY_ONLY': { + 'name': _('Assembly Revision Only'), + 'description': _('Only allow revisions for assembly parts'), + 'validator': bool, + 'default': False, + }, 'PART_ALLOW_DELETE_FROM_ASSEMBLY': { 'name': _('Allow Deletion from Assembly'), 'description': _('Allow deletion of parts which are used in an assembly'), diff --git a/src/backend/InvenTree/company/test_api.py b/src/backend/InvenTree/company/test_api.py index 65b55089a3..afe04e6653 100644 --- a/src/backend/InvenTree/company/test_api.py +++ b/src/backend/InvenTree/company/test_api.py @@ -57,22 +57,20 @@ class CompanyTest(InvenTreeAPITestCase): def test_company_detail(self): """Tests for the Company detail endpoint.""" url = reverse('api-company-detail', kwargs={'pk': self.acme.pk}) - response = self.get(url) + response = self.get(url, expected_code=200) + self.assertIn('name', response.data.keys()) self.assertEqual(response.data['name'], 'ACME') # Change the name of the company # Note we should not have the correct permissions (yet) data = response.data - response = self.client.patch(url, data, format='json', expected_code=400) - - self.assignRole('company.change') # Update the name and set the currency to a valid value data['name'] = 'ACMOO' data['currency'] = 'NZD' - response = self.client.patch(url, data, format='json', expected_code=200) + response = self.patch(url, data, expected_code=200) self.assertEqual(response.data['name'], 'ACMOO') self.assertEqual(response.data['currency'], 'NZD') diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py index 1226e753ce..be46b497c7 100644 --- a/src/backend/InvenTree/order/serializers.py +++ b/src/backend/InvenTree/order/serializers.py @@ -616,7 +616,7 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer): ) quantity = serializers.DecimalField( - max_digits=15, decimal_places=5, min_value=0, required=True + max_digits=15, decimal_places=5, min_value=Decimal(0), required=True ) def validate_quantity(self, quantity): @@ -1250,7 +1250,7 @@ class SalesOrderShipmentAllocationItemSerializer(serializers.Serializer): ) quantity = serializers.DecimalField( - max_digits=15, decimal_places=5, min_value=0, required=True + max_digits=15, decimal_places=5, min_value=Decimal(0), required=True ) def validate_quantity(self, quantity): diff --git a/src/backend/InvenTree/part/api.py b/src/backend/InvenTree/part/api.py index 07a8c56793..2d9be8b099 100644 --- a/src/backend/InvenTree/part/api.py +++ b/src/backend/InvenTree/part/api.py @@ -911,7 +911,27 @@ class PartFilter(rest_filters.FilterSet): """Metaclass options for this filter set.""" model = Part - fields = [] + fields = ['revision_of'] + + is_revision = rest_filters.BooleanFilter( + label=_('Is Revision'), method='filter_is_revision' + ) + + def filter_is_revision(self, queryset, name, value): + """Filter by whether the Part is a revision or not.""" + if str2bool(value): + return queryset.exclude(revision_of=None) + return queryset.filter(revision_of=None) + + has_revisions = rest_filters.BooleanFilter( + label=_('Has Revisions'), method='filter_has_revisions' + ) + + def filter_has_revisions(self, queryset, name, value): + """Filter by whether the Part has any revisions or not.""" + if str2bool(value): + return queryset.exclude(revision_count=0) + return queryset.filter(revision_count=0) has_units = rest_filters.BooleanFilter(label='Has units', method='filter_has_units') @@ -1361,6 +1381,8 @@ class PartList(PartMixin, DataExportViewMixin, ListCreateAPI): 'pricing_min', 'pricing_max', 'pricing_updated', + 'revision', + 'revision_count', ] ordering_field_aliases = { diff --git a/src/backend/InvenTree/part/migrations/0126_part_revision_of.py b/src/backend/InvenTree/part/migrations/0126_part_revision_of.py new file mode 100644 index 0000000000..e5324d60a9 --- /dev/null +++ b/src/backend/InvenTree/part/migrations/0126_part_revision_of.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.12 on 2024-07-07 04:42 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0125_part_locked'), + ] + + operations = [ + migrations.AddField( + model_name='part', + name='revision_of', + field=models.ForeignKey(help_text='Is this part a revision of another part?', null=True, blank=True, on_delete=django.db.models.deletion.SET_NULL, related_name='revisions', to='part.part', verbose_name='Revision Of'), + ), + ] diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py index af017e188b..730cf5a270 100644 --- a/src/backend/InvenTree/part/models.py +++ b/src/backend/InvenTree/part/models.py @@ -662,6 +662,49 @@ class Part( if match is None: raise ValidationError(_(f'IPN must match regex pattern {pattern}')) + def validate_revision(self): + """Check the 'revision' and 'revision_of' fields.""" + # Part cannot be a revision of itself + if self.revision_of: + if self.revision_of == self: + raise ValidationError({ + 'revision_of': _('Part cannot be a revision of itself') + }) + + # Part cannot be a revision of a part which is itself a revision + if self.revision_of.revision_of: + raise ValidationError({ + 'revision_of': _( + 'Cannot make a revision of a part which is already a revision' + ) + }) + + # If this part is a revision, it must have a revision code + if not self.revision: + raise ValidationError({ + 'revision': _('Revision code must be specified') + }) + + if get_global_setting('PART_REVISION_ASSEMBLY_ONLY'): + if not self.assembly or not self.revision_of.assembly: + raise ValidationError({ + 'revision_of': _( + 'Revisions are only allowed for assembly parts' + ) + }) + + # Cannot have a revision of a "template" part + if self.revision_of.is_template: + raise ValidationError({ + 'revision_of': _('Cannot make a revision of a template part') + }) + + # parent part must point to the same template (via variant_of) + if self.variant_of != self.revision_of.variant_of: + raise ValidationError({ + 'revision_of': _('Parent part must point to the same template') + }) + def validate_serial_number( self, serial: str, @@ -842,15 +885,24 @@ class Part( 'IPN': _('Duplicate IPN not allowed in part settings') }) + if self.revision_of and self.revision: + if ( + Part.objects.exclude(pk=self.pk) + .filter(revision_of=self.revision_of, revision=self.revision) + .exists() + ): + raise ValidationError(_('Duplicate part revision already exists.')) + # Ensure unique across (Name, revision, IPN) (as specified) - if ( - Part.objects.exclude(pk=self.pk) - .filter(name=self.name, revision=self.revision, IPN=self.IPN) - .exists() - ): - raise ValidationError( - _('Part with this Name, IPN and Revision already exists.') - ) + if self.revision or self.IPN: + if ( + Part.objects.exclude(pk=self.pk) + .filter(name=self.name, revision=self.revision, IPN=self.IPN) + .exists() + ): + raise ValidationError( + _('Part with this Name, IPN and Revision already exists.') + ) def clean(self): """Perform cleaning operations for the Part model. @@ -867,6 +919,9 @@ class Part( 'category': _('Parts cannot be assigned to structural part categories!') }) + # Check the 'revision' and 'revision_of' fields + self.validate_revision() + super().clean() # Strip IPN field @@ -954,6 +1009,16 @@ class Part( verbose_name=_('Revision'), ) + revision_of = models.ForeignKey( + 'part.Part', + related_name='revisions', + null=True, + blank=True, + on_delete=models.SET_NULL, + help_text=_('Is this part a revision of another part?'), + verbose_name=_('Revision Of'), + ) + link = InvenTreeURLField( blank=True, null=True, diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index c2aa330339..a5e4f783d6 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -658,6 +658,8 @@ class PartSerializer( 'pk', 'purchaseable', 'revision', + 'revision_of', + 'revision_count', 'salable', 'starred', 'thumbnail', @@ -762,6 +764,9 @@ class PartSerializer( """ queryset = queryset.prefetch_related('category', 'default_location') + # Annotate with the total number of revisions + queryset = queryset.annotate(revision_count=SubqueryCount('revisions')) + # Annotate with the total number of stock items queryset = queryset.annotate(stock_item_count=SubqueryCount('stock_items')) @@ -883,6 +888,7 @@ class PartSerializer( required_for_build_orders = serializers.IntegerField(read_only=True) required_for_sales_orders = serializers.IntegerField(read_only=True) stock_item_count = serializers.IntegerField(read_only=True, label=_('Stock Items')) + revision_count = serializers.IntegerField(read_only=True, label=_('Revisions')) suppliers = serializers.IntegerField(read_only=True, label=_('Suppliers')) total_in_stock = serializers.FloatField(read_only=True, label=_('Total Stock')) external_stock = serializers.FloatField(read_only=True, label=_('External Stock')) diff --git a/src/backend/InvenTree/part/templates/part/part_base.html b/src/backend/InvenTree/part/templates/part/part_base.html index 3b6b57542d..835258c8ea 100644 --- a/src/backend/InvenTree/part/templates/part/part_base.html +++ b/src/backend/InvenTree/part/templates/part/part_base.html @@ -271,6 +271,15 @@ {% endif %} {% settings_value "PART_ENABLE_REVISION" as show_revision %} + {% if show_revision and part.revision_of %} + + + {% trans "Revision Of" %} + + {{ part.revision_of.full_name }} + + + {% endif %} {% if show_revision and part.revision %} diff --git a/src/backend/InvenTree/part/test_part.py b/src/backend/InvenTree/part/test_part.py index 6e85c11cc0..6f0eb5beba 100644 --- a/src/backend/InvenTree/part/test_part.py +++ b/src/backend/InvenTree/part/test_part.py @@ -389,6 +389,83 @@ class PartTest(TestCase): part.delete() + def test_revisions(self): + """Test the 'revision' and 'revision_of' field.""" + template = Part.objects.create( + name='Template part', description='A template part', is_template=True + ) + + # Create a new part + part = Part.objects.create( + name='Master Part', + description='Master part (will have revisions)', + variant_of=template, + ) + + self.assertEqual(part.revisions.count(), 0) + + # Try to set as revision of itself + with self.assertRaises(ValidationError) as exc: + part.revision_of = part + part.save() + + self.assertIn('Part cannot be a revision of itself', str(exc.exception)) + + part.refresh_from_db() + + rev_a = Part.objects.create( + name='Master Part', description='Master part (revision A)' + ) + + with self.assertRaises(ValidationError) as exc: + print('rev a:', rev_a.revision_of, part.revision_of) + rev_a.revision_of = part + rev_a.save() + + self.assertIn('Revision code must be specified', str(exc.exception)) + + with self.assertRaises(ValidationError) as exc: + rev_a.revision_of = template + rev_a.revision = 'A' + rev_a.save() + + self.assertIn('Cannot make a revision of a template part', str(exc.exception)) + + with self.assertRaises(ValidationError) as exc: + rev_a.revision_of = part + rev_a.revision = 'A' + rev_a.save() + + self.assertIn('Parent part must point to the same template', str(exc.exception)) + + rev_a.variant_of = template + rev_a.revision_of = part + rev_a.revision = 'A' + rev_a.save() + + self.assertEqual(part.revisions.count(), 1) + + rev_b = Part.objects.create( + name='Master Part', description='Master part (revision B)' + ) + + with self.assertRaises(ValidationError) as exc: + rev_b.revision_of = rev_a + rev_b.revision = 'B' + rev_b.save() + + self.assertIn( + 'Cannot make a revision of a part which is already a revision', + str(exc.exception), + ) + + rev_b.variant_of = template + rev_b.revision_of = part + rev_b.revision = 'B' + rev_b.save() + + self.assertEqual(part.revisions.count(), 2) + class TestTemplateTest(TestCase): """Unit test for the TestTemplate class.""" diff --git a/src/backend/InvenTree/stock/serializers.py b/src/backend/InvenTree/stock/serializers.py index 7560bc2632..fa9e060e72 100644 --- a/src/backend/InvenTree/stock/serializers.py +++ b/src/backend/InvenTree/stock/serializers.py @@ -1518,7 +1518,7 @@ class StockAdjustmentItemSerializer(serializers.Serializer): ) quantity = serializers.DecimalField( - max_digits=15, decimal_places=5, min_value=0, required=True + max_digits=15, decimal_places=5, min_value=Decimal(0), required=True ) batch = serializers.CharField( diff --git a/src/backend/InvenTree/templates/InvenTree/settings/part.html b/src/backend/InvenTree/templates/InvenTree/settings/part.html index dae8368b7f..96aafc3f30 100644 --- a/src/backend/InvenTree/templates/InvenTree/settings/part.html +++ b/src/backend/InvenTree/templates/InvenTree/settings/part.html @@ -12,6 +12,7 @@ {% include "InvenTree/settings/setting.html" with key="PART_ENABLE_REVISION" %} + {% include "InvenTree/settings/setting.html" with key="PART_REVISION_ASSEMBLY_ONLY" %} {% include "InvenTree/settings/setting.html" with key="PART_IPN_REGEX" %} {% include "InvenTree/settings/setting.html" with key="PART_ALLOW_DUPLICATE_IPN" %} {% include "InvenTree/settings/setting.html" with key="PART_ALLOW_EDIT_IPN" %} diff --git a/src/backend/InvenTree/templates/js/translated/part.js b/src/backend/InvenTree/templates/js/translated/part.js index d369f26cbc..8606c3b84b 100644 --- a/src/backend/InvenTree/templates/js/translated/part.js +++ b/src/backend/InvenTree/templates/js/translated/part.js @@ -135,6 +135,7 @@ function partFields(options={}) { }, name: {}, IPN: {}, + revision_of: {}, revision: { icon: 'fa-code-branch', }, @@ -227,6 +228,7 @@ function partFields(options={}) { // Pop 'revision' field if (!global_settings.PART_ENABLE_REVISION) { delete fields['revision']; + delete fields['revision_of']; } if (options.create || options.duplicate) { diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 1adaf03d04..24818dccbe 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -29,4 +29,10 @@ export function setApiDefaults() { } } -export const queryClient = new QueryClient(); +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false + } + } +}); diff --git a/src/frontend/src/components/details/Details.tsx b/src/frontend/src/components/details/Details.tsx index c140c98dac..d3675bceaa 100644 --- a/src/frontend/src/components/details/Details.tsx +++ b/src/frontend/src/components/details/Details.tsx @@ -30,17 +30,6 @@ import { StylishText } from '../items/StylishText'; import { getModelInfo } from '../render/ModelType'; import { StatusRenderer } from '../render/StatusRenderer'; -export type PartIconsType = { - assembly: boolean; - template: boolean; - component: boolean; - trackable: boolean; - purchaseable: boolean; - saleable: boolean; - virtual: boolean; - active: boolean; -}; - export type DetailsField = | { hidden?: boolean; diff --git a/src/frontend/src/components/details/PartIcons.tsx b/src/frontend/src/components/details/PartIcons.tsx deleted file mode 100644 index 78086bc628..0000000000 --- a/src/frontend/src/components/details/PartIcons.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { Trans, t } from '@lingui/macro'; -import { Badge, Tooltip } from '@mantine/core'; - -import { InvenTreeIcon, InvenTreeIconType } from '../../functions/icons'; - -/** - * Fetches and wraps an InvenTreeIcon in a flex div - * @param icon name of icon - * - */ -function PartIcon(icon: InvenTreeIconType) { - return ( -
- -
- ); -} - -/** - * Generates a table cell with Part icons. - * Only used for Part Model Details - */ -export function PartIcons({ part }: { part: any }) { - return ( - - ); -} diff --git a/src/frontend/src/components/forms/ApiForm.tsx b/src/frontend/src/components/forms/ApiForm.tsx index f58ca69cda..3456b2e07b 100644 --- a/src/frontend/src/components/forms/ApiForm.tsx +++ b/src/frontend/src/components/forms/ApiForm.tsx @@ -125,7 +125,6 @@ export function OptionsApiForm({ const optionsQuery = useQuery({ enabled: true, refetchOnMount: false, - refetchOnWindowFocus: false, queryKey: [ 'form-options-data', id, diff --git a/src/frontend/src/components/items/ActionDropdown.tsx b/src/frontend/src/components/items/ActionDropdown.tsx index 5515858e0c..7d04fb0d56 100644 --- a/src/frontend/src/components/items/ActionDropdown.tsx +++ b/src/frontend/src/components/items/ActionDropdown.tsx @@ -39,12 +39,14 @@ export function ActionDropdown({ icon, tooltip, actions, - disabled = false + disabled = false, + hidden = false }: { icon: ReactNode; tooltip: string; actions: ActionDropdownItem[]; disabled?: boolean; + hidden?: boolean; }) { const hasActions = useMemo(() => { return actions.some((action) => !action.hidden); @@ -58,7 +60,7 @@ export function ActionDropdown({ return identifierString(`action-menu-${tooltip}`); }, [tooltip]); - return hasActions ? ( + return !hidden && hasActions ? ( diff --git a/src/frontend/src/components/nav/Header.tsx b/src/frontend/src/components/nav/Header.tsx index 4e3eece8bf..49426d599b 100644 --- a/src/frontend/src/components/nav/Header.tsx +++ b/src/frontend/src/components/nav/Header.tsx @@ -70,8 +70,7 @@ export function Header() { } }, refetchInterval: 30000, - refetchOnMount: true, - refetchOnWindowFocus: false + refetchOnMount: true }); // Sync Navigation Drawer state with zustand diff --git a/src/frontend/src/components/nav/NotificationDrawer.tsx b/src/frontend/src/components/nav/NotificationDrawer.tsx index 8d9a85483d..28464b4895 100644 --- a/src/frontend/src/components/nav/NotificationDrawer.tsx +++ b/src/frontend/src/components/nav/NotificationDrawer.tsx @@ -53,8 +53,7 @@ export function NotificationDrawer({ .catch((error) => { return error; }), - refetchOnMount: false, - refetchOnWindowFocus: false + refetchOnMount: false }); const hasNotifications: boolean = useMemo(() => { diff --git a/src/frontend/src/components/nav/SearchDrawer.tsx b/src/frontend/src/components/nav/SearchDrawer.tsx index 30287e9631..429af00f0c 100644 --- a/src/frontend/src/components/nav/SearchDrawer.tsx +++ b/src/frontend/src/components/nav/SearchDrawer.tsx @@ -275,8 +275,7 @@ export function SearchDrawer({ // Search query manager const searchQuery = useQuery({ queryKey: ['search', searchText, searchRegex, searchWhole], - queryFn: performSearch, - refetchOnWindowFocus: false + queryFn: performSearch }); // A list of queries which return valid results diff --git a/src/frontend/src/components/render/Instance.tsx b/src/frontend/src/components/render/Instance.tsx index 58a7261834..38ad0ac76d 100644 --- a/src/frontend/src/components/render/Instance.tsx +++ b/src/frontend/src/components/render/Instance.tsx @@ -47,6 +47,7 @@ export interface InstanceRenderInterface { instance: any; link?: boolean; navigate?: any; + showSecondary?: boolean; } /** @@ -149,10 +150,12 @@ export function RenderInlineModel({ image, labels, url, - navigate + navigate, + showSecondary = true }: { primary: string; secondary?: string; + showSecondary?: boolean; suffix?: ReactNode; image?: string; labels?: string[]; @@ -181,7 +184,7 @@ export function RenderInlineModel({ ) : ( {primary} )} - {secondary && {secondary}} + {showSecondary && secondary && {secondary}} {suffix && ( <> diff --git a/src/frontend/src/components/render/Part.tsx b/src/frontend/src/components/render/Part.tsx index fa35f697dd..f303b89acb 100644 --- a/src/frontend/src/components/render/Part.tsx +++ b/src/frontend/src/components/render/Part.tsx @@ -1,4 +1,5 @@ import { t } from '@lingui/macro'; +import { Badge } from '@mantine/core'; import { ReactNode } from 'react'; import { ModelType } from '../../enums/ModelType'; @@ -12,14 +13,35 @@ export function RenderPart( props: Readonly ): ReactNode { const { instance } = props; - const stock = t`Stock` + `: ${instance.in_stock}`; + + let badgeText = ''; + let badgeColor = 'green'; + + let stock = instance.total_in_stock; + + if (instance.active == false) { + badgeColor = 'red'; + badgeText = t`Inactive`; + } else if (stock <= 0) { + badgeColor = 'orange'; + badgeText = t`No stock`; + } else { + badgeText = t`Stock` + `: ${stock}`; + badgeColor = instance.minimum_stock > stock ? 'yellow' : 'green'; + } + + const badge = ( + + {badgeText} + + ); return ( diff --git a/src/frontend/src/enums/ApiEndpoints.tsx b/src/frontend/src/enums/ApiEndpoints.tsx index 5e8aeda199..a3b49824e0 100644 --- a/src/frontend/src/enums/ApiEndpoints.tsx +++ b/src/frontend/src/enums/ApiEndpoints.tsx @@ -69,6 +69,7 @@ export enum ApiEndpoints { bom_list = 'bom/', bom_item_validate = 'bom/:id/validate/', + bom_validate = 'part/:id/bom-validate/', // Part API endpoints part_list = 'part/', diff --git a/src/frontend/src/forms/PartForms.tsx b/src/frontend/src/forms/PartForms.tsx index 729b158c98..352dd3d53d 100644 --- a/src/frontend/src/forms/PartForms.tsx +++ b/src/frontend/src/forms/PartForms.tsx @@ -3,6 +3,7 @@ import { IconPackages } from '@tabler/icons-react'; import { useMemo, useState } from 'react'; import { ApiFormFieldSet } from '../components/forms/fields/ApiFormField'; +import { useGlobalSettingsState } from '../states/SettingsState'; /** * Construct a set of fields for creating / editing a Part instance @@ -21,9 +22,19 @@ export function usePartFields({ }, name: {}, IPN: {}, - revision: {}, description: {}, - variant_of: {}, + revision: {}, + revision_of: { + filters: { + is_revision: false, + is_template: false + } + }, + variant_of: { + filters: { + is_template: true + } + }, keywords: {}, units: {}, link: {}, @@ -82,13 +93,22 @@ export function usePartFields({ }; } - // TODO: pop 'expiry' field if expiry not enabled - delete fields['default_expiry']; + const settings = useGlobalSettingsState.getState(); - // TODO: pop 'revision' field if PART_ENABLE_REVISION is False - delete fields['revision']; + if (settings.isSet('PART_REVISION_ASSEMBLY_ONLY')) { + fields.revision_of.filters['assembly'] = true; + } - // TODO: handle part duplications + // Pop 'revision' field if PART_ENABLE_REVISION is False + if (!settings.isSet('PART_ENABLE_REVISION')) { + delete fields['revision']; + delete fields['revision_of']; + } + + // Pop 'expiry' field if expiry not enabled + if (!settings.isSet('STOCK_ENABLE_EXPIRY')) { + delete fields['default_expiry']; + } return fields; }, [create]); diff --git a/src/frontend/src/functions/icons.tsx b/src/frontend/src/functions/icons.tsx index bafde65c76..587a244eb8 100644 --- a/src/frontend/src/functions/icons.tsx +++ b/src/frontend/src/functions/icons.tsx @@ -33,6 +33,7 @@ import { IconGitBranch, IconGridDots, IconHash, + IconHierarchy, IconInfoCircle, IconLayersLinked, IconLink, @@ -89,7 +90,8 @@ import React from 'react'; const icons = { name: IconPoint, description: IconInfoCircle, - variant_of: IconStatusChange, + variant_of: IconHierarchy, + revision_of: IconStatusChange, unallocated_stock: IconPackage, total_in_stock: IconPackages, minimum_stock: IconFlag, diff --git a/src/frontend/src/hooks/UseInstance.tsx b/src/frontend/src/hooks/UseInstance.tsx index e7c0c573b1..7a6ae1ebf8 100644 --- a/src/frontend/src/hooks/UseInstance.tsx +++ b/src/frontend/src/hooks/UseInstance.tsx @@ -85,7 +85,7 @@ export function useInstance({ }); }, refetchOnMount: refetchOnMount, - refetchOnWindowFocus: refetchOnWindowFocus, + refetchOnWindowFocus: refetchOnWindowFocus ?? false, refetchInterval: updateInterval }); diff --git a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx index a9efa01aad..59bec1ab13 100644 --- a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx +++ b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx @@ -180,11 +180,12 @@ export default function SystemSettings() { content: ( @@ -467,22 +478,15 @@ export default function PartDetail() { /> - -
-
- {part.locked && ( - - - Locked - - - )} - {!part.active && ( - - - Inactive - - - )} - {part.template && ( - - )} - {part.assembly && ( - - )} - {part.component && ( - - )} - {part.trackable && ( - - )} - {part.purchaseable && ( - - )} - {part.saleable && ( - - )} - {part.virtual && ( - - -
- {' '} - Virtual -
-
-
- )} -
-
- - - - - -
- - + + ) : ( + ); }, [part, instanceQuery]); @@ -655,6 +659,89 @@ export default function PartDetail() { ]; }, [id, part, user]); + // Fetch information on part revision + const partRevisionQuery = useQuery({ + refetchOnMount: true, + queryKey: [ + 'part_revisions', + part.pk, + part.revision_of, + part.revision_count + ], + queryFn: async () => { + if (!part.revision_of && !part.revision_count) { + return []; + } + + let revisions = []; + + // First, fetch information for the top-level part + if (part.revision_of) { + await api + .get(apiUrl(ApiEndpoints.part_list, part.revision_of)) + .then((response) => { + revisions.push(response.data); + }); + } else { + revisions.push(part); + } + + const url = apiUrl(ApiEndpoints.part_list); + + await api + .get(url, { + params: { + revision_of: part.revision_of || part.pk + } + }) + .then((response) => { + switch (response.status) { + case 200: + response.data.forEach((r: any) => { + revisions.push(r); + }); + break; + default: + break; + } + }) + .catch(() => {}); + + return revisions; + } + }); + + const partRevisionOptions: any[] = useMemo(() => { + if (partRevisionQuery.isFetching || !partRevisionQuery.data) { + return []; + } + + if (!part.revision_of && !part.revision_count) { + return []; + } + + let options: any[] = partRevisionQuery.data.map((revision: any) => { + return { + value: revision.pk, + label: revision.full_name, + part: revision + }; + }); + + // Add this part if not already available + if (!options.find((o) => o.value == part.pk)) { + options.push({ + value: part.pk, + label: part.full_name, + part: part + }); + } + + return options.sort((a, b) => { + return ('' + a.part.revision).localeCompare(b.part.revision); + }); + }, [part, partRevisionQuery.isFetching, partRevisionQuery.data]); + const breadcrumbs = useMemo( () => [ { name: t`Parts`, url: '/part' }, @@ -686,7 +773,7 @@ export default function PartDetail() { />, , @@ -705,7 +792,7 @@ export default function PartDetail() { , { + return ( + partRevisionOptions.length > 0 && + globalSettings.isSet('PART_ENABLE_REVISION') + ); + }, [partRevisionOptions, globalSettings]); + return ( <> {duplicatePart.modal} @@ -886,6 +988,40 @@ export default function PartDetail() { setTreeOpen(true); }} actions={partActions} + detail={ + enableRevisionSelection ? ( + + {t`Select Part Revision`} +