diff --git a/docs/README.md b/docs/README.md index 989183c8324..578afc13e83 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,8 +12,7 @@ Run the following commands from the top-level project directory: ``` $ git clone https://github.com/inventree/inventree -$ cd inventree/docs -$ pip install -r requirements.txt +$ pip install -r docs/requirements.txt ``` ## Serve Locally diff --git a/docs/docs/assets/images/order/company_disable.png b/docs/docs/assets/images/order/company_disable.png new file mode 100644 index 00000000000..2c3ab4f49f0 Binary files /dev/null and b/docs/docs/assets/images/order/company_disable.png differ diff --git a/docs/docs/assets/images/order/disable_supplier_part.png b/docs/docs/assets/images/order/disable_supplier_part.png new file mode 100644 index 00000000000..a0e5f9bde82 Binary files /dev/null and b/docs/docs/assets/images/order/disable_supplier_part.png differ diff --git a/docs/docs/assets/images/order/disable_supplier_part_edit.png b/docs/docs/assets/images/order/disable_supplier_part_edit.png new file mode 100644 index 00000000000..2592f5ed91c Binary files /dev/null and b/docs/docs/assets/images/order/disable_supplier_part_edit.png differ diff --git a/docs/docs/order/company.md b/docs/docs/order/company.md index c99994b867b..3e4d821046a 100644 --- a/docs/docs/order/company.md +++ b/docs/docs/order/company.md @@ -11,7 +11,7 @@ External companies are represented by the *Company* database model. Each company - [Manufacturer](#manufacturers) !!! tip Multi Purpose - A company may be allocated to multiple categories + A company may be allocated to multiple categories, for example, a company may be both a supplier and a customer. ### Edit Company @@ -20,6 +20,20 @@ To edit a company, click on the Edit Company ic !!! warning "Permission Required" The edit button will not be available to users who do not have the required permissions to edit the company +### Disable Company + +Rather than deleting a company, it is possible to disable it. This will prevent the company from being used in new orders, but will not remove it from the database. Additionally, any existing orders associated with the company (and other linked items such as supplier parts, for a supplier) will remain intact. Unless the company is re-enabled, it will not be available for selection in new orders. + +It is recommended to disable a company rather than deleting it, as this will preserve the integrity of historical data. + +To disable a company, simply edit the company details and set the `active` attribute to `False`: + +{% with id="company_disable", url="order/company_disable.png", description="Disable Company" %} +{% include "img.html" %} +{% endwith %} + +To re-enable a company, simply follow the same process and set the `active` attribute to `True`. + ### Delete Company To delete a company, click on the icon under the actions menu. Confirm the deletion using the checkbox then click on Submit @@ -193,6 +207,24 @@ To edit a supplier part, first access the supplier part detail page with one of After the supplier part details are loaded, click on the icon next to the supplier part image. Edit the supplier part information then click on Submit +#### Disable Supplier Part + +Supplier parts can be individually disabled - for example, if a supplier part is no longer available for purchase. By disabling the part in the InvenTree system, it will no longer be available for selection in new purchase orders. However, any existing purchase orders which reference the supplier part will remain intact. + +The "active" status of a supplier part is clearly visible within the user interface: + +{% with id="supplier_part_disable", url="order/disable_supplier_part.png", description="Disable Supplier Part" %} +{% include "img.html" %} +{% endwith %} + +To change the "active" status of a supplier part, simply edit the supplier part details and set the `active` attribute: + +{% with id="supplier_part_disable_edit", url="order/disable_supplier_part_edit.png", description="Disable Supplier Part" %} +{% include "img.html" %} +{% endwith %} + +It is recommended to disable a supplier part rather than deleting it, as this will preserve the integrity of historical data. + #### Delete Supplier Part To delete a supplier part, first access the supplier part detail page like in the [Edit Supplier Part](#edit-supplier-part) section. diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 7db9dc01fb3..94aa80c38e9 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,15 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 189 +INVENTREE_API_VERSION = 190 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v190 - 2024-04-19 : https://github.com/inventree/InvenTree/pull/7024 + - Adds "active" field to the Company API endpoints + - Allow company list to be filtered by "active" status + v189 - 2024-04-19 : https://github.com/inventree/InvenTree/pull/7066 - Adds "currency" field to CompanyBriefSerializer class diff --git a/src/backend/InvenTree/InvenTree/settings.py b/src/backend/InvenTree/InvenTree/settings.py index a32a2fbd774..7bda362b49b 100644 --- a/src/backend/InvenTree/InvenTree/settings.py +++ b/src/backend/InvenTree/InvenTree/settings.py @@ -1087,14 +1087,17 @@ if SITE_URL and SITE_URL not in CSRF_TRUSTED_ORIGINS: CSRF_TRUSTED_ORIGINS.append(SITE_URL) -if not TESTING and len(CSRF_TRUSTED_ORIGINS) == 0: - if DEBUG: - logger.warning( - 'No CSRF_TRUSTED_ORIGINS specified. Defaulting to http://* for debug mode. This is not recommended for production use' - ) - CSRF_TRUSTED_ORIGINS = ['http://*'] +if DEBUG: + for origin in [ + 'http://localhost', + 'http://*.localhost' 'http://*localhost:8000', + 'http://*localhost:5173', + ]: + if origin not in CSRF_TRUSTED_ORIGINS: + CSRF_TRUSTED_ORIGINS.append(origin) - elif isInMainThread(): +if not TESTING and len(CSRF_TRUSTED_ORIGINS) == 0: + if isInMainThread(): # Server thread cannot run without CSRF_TRUSTED_ORIGINS logger.error( 'No CSRF_TRUSTED_ORIGINS specified. Please provide a list of trusted origins, or specify INVENTREE_SITE_URL' diff --git a/src/backend/InvenTree/company/api.py b/src/backend/InvenTree/company/api.py index 88b2c640bdd..aea8d5dc5ec 100644 --- a/src/backend/InvenTree/company/api.py +++ b/src/backend/InvenTree/company/api.py @@ -2,6 +2,7 @@ from django.db.models import Q from django.urls import include, path, re_path +from django.utils.translation import gettext_lazy as _ from django_filters import rest_framework as rest_filters @@ -58,11 +59,17 @@ def get_queryset(self): filter_backends = SEARCH_ORDER_FILTER - filterset_fields = ['is_customer', 'is_manufacturer', 'is_supplier', 'name'] + filterset_fields = [ + 'is_customer', + 'is_manufacturer', + 'is_supplier', + 'name', + 'active', + ] search_fields = ['name', 'description', 'website'] - ordering_fields = ['name', 'parts_supplied', 'parts_manufactured'] + ordering_fields = ['active', 'name', 'parts_supplied', 'parts_manufactured'] ordering = 'name' @@ -153,7 +160,13 @@ class Meta: fields = ['manufacturer', 'MPN', 'part', 'tags__name', 'tags__slug'] # Filter by 'active' status of linked part - active = rest_filters.BooleanFilter(field_name='part__active') + part_active = rest_filters.BooleanFilter( + field_name='part__active', label=_('Part is Active') + ) + + manufacturer_active = rest_filters.BooleanFilter( + field_name='manufacturer__active', label=_('Manufacturer is Active') + ) class ManufacturerPartList(ListCreateDestroyAPIView): @@ -301,8 +314,16 @@ class Meta: 'tags__slug', ] + active = rest_filters.BooleanFilter(label=_('Supplier Part is Active')) + # Filter by 'active' status of linked part - active = rest_filters.BooleanFilter(field_name='part__active') + part_active = rest_filters.BooleanFilter( + field_name='part__active', label=_('Internal Part is Active') + ) + + supplier_active = rest_filters.BooleanFilter( + field_name='supplier__active', label=_('Supplier is Active') + ) # Filter by the 'MPN' of linked manufacturer part MPN = rest_filters.CharFilter( @@ -378,6 +399,7 @@ def get_serializer(self, *args, **kwargs): 'part', 'supplier', 'manufacturer', + 'active', 'MPN', 'packaging', 'pack_quantity', diff --git a/src/backend/InvenTree/company/migrations/0069_company_active.py b/src/backend/InvenTree/company/migrations/0069_company_active.py new file mode 100644 index 00000000000..120046f6fcf --- /dev/null +++ b/src/backend/InvenTree/company/migrations/0069_company_active.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.11 on 2024-04-15 14:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('company', '0068_auto_20231120_1108'), + ] + + operations = [ + migrations.AddField( + model_name='company', + name='active', + field=models.BooleanField(default=True, help_text='Is this company active?', verbose_name='Active'), + ), + migrations.AddField( + model_name='supplierpart', + name='active', + field=models.BooleanField(default=True, help_text='Is this supplier part active?', verbose_name='Active'), + ), + ] diff --git a/src/backend/InvenTree/company/models.py b/src/backend/InvenTree/company/models.py index dc21dc62b1b..b891b5b0654 100644 --- a/src/backend/InvenTree/company/models.py +++ b/src/backend/InvenTree/company/models.py @@ -81,6 +81,7 @@ class Company( link: Secondary URL e.g. for link to internal Wiki page image: Company image / logo notes: Extra notes about the company + active: boolean value, is this company active is_customer: boolean value, is this company a customer is_supplier: boolean value, is this company a supplier is_manufacturer: boolean value, is this company a manufacturer @@ -155,6 +156,10 @@ def get_api_url(): verbose_name=_('Image'), ) + active = models.BooleanField( + default=True, verbose_name=_('Active'), help_text=_('Is this company active?') + ) + is_customer = models.BooleanField( default=False, verbose_name=_('is customer'), @@ -654,6 +659,7 @@ class SupplierPart( part: Link to the master Part (Obsolete) source_item: The sourcing item linked to this SupplierPart instance supplier: Company that supplies this SupplierPart object + active: Boolean value, is this supplier part active SKU: Stock keeping unit (supplier part number) link: Link to external website for this supplier part description: Descriptive notes field @@ -802,6 +808,12 @@ def save(self, *args, **kwargs): help_text=_('Supplier stock keeping unit'), ) + active = models.BooleanField( + default=True, + verbose_name=_('Active'), + help_text=_('Is this supplier part active?'), + ) + manufacturer_part = models.ForeignKey( ManufacturerPart, on_delete=models.CASCADE, diff --git a/src/backend/InvenTree/company/serializers.py b/src/backend/InvenTree/company/serializers.py index 7e3f86da78a..e329ea44e63 100644 --- a/src/backend/InvenTree/company/serializers.py +++ b/src/backend/InvenTree/company/serializers.py @@ -42,12 +42,17 @@ class Meta: """Metaclass options.""" model = Company - fields = ['pk', 'url', 'name', 'description', 'image', 'thumbnail', 'currency'] - + fields = [ + 'pk', + 'active', + 'name', + 'description', + 'image', + 'thumbnail', + 'currency', + ] read_only_fields = ['currency'] - url = serializers.CharField(source='get_absolute_url', read_only=True) - image = InvenTreeImageSerializerField(read_only=True) thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True) @@ -118,6 +123,7 @@ class Meta: 'contact', 'link', 'image', + 'active', 'is_customer', 'is_manufacturer', 'is_supplier', @@ -308,6 +314,7 @@ class Meta: 'description', 'in_stock', 'link', + 'active', 'manufacturer', 'manufacturer_detail', 'manufacturer_part', @@ -371,8 +378,9 @@ def __init__(self, *args, **kwargs): self.fields.pop('pretty_name') # Annotated field showing total in-stock quantity - in_stock = serializers.FloatField(read_only=True) - available = serializers.FloatField(required=False) + in_stock = serializers.FloatField(read_only=True, label=_('In Stock')) + + available = serializers.FloatField(required=False, label=_('Available')) pack_quantity_native = serializers.FloatField(read_only=True) diff --git a/src/backend/InvenTree/company/templates/company/company_base.html b/src/backend/InvenTree/company/templates/company/company_base.html index 87f8cf10c98..c7c7efde121 100644 --- a/src/backend/InvenTree/company/templates/company/company_base.html +++ b/src/backend/InvenTree/company/templates/company/company_base.html @@ -10,6 +10,12 @@ {% block heading %} {% trans "Company" %}: {{ company.name }} +{% if not company.active %} +  +
+ {% trans 'Inactive' %} +
+{% endif %} {% endblock heading %} {% block actions %} diff --git a/src/backend/InvenTree/company/test_api.py b/src/backend/InvenTree/company/test_api.py index dcb8dc81dc7..332292ca3c8 100644 --- a/src/backend/InvenTree/company/test_api.py +++ b/src/backend/InvenTree/company/test_api.py @@ -5,6 +5,7 @@ from rest_framework import status from InvenTree.unit_test import InvenTreeAPITestCase +from part.models import Part from .models import Address, Company, Contact, ManufacturerPart, SupplierPart @@ -131,6 +132,32 @@ def test_company_create(self): self.assertTrue('currency' in response.data) + def test_company_active(self): + """Test that the 'active' value and filter works.""" + Company.objects.filter(active=False).update(active=True) + n = Company.objects.count() + + url = reverse('api-company-list') + + self.assertEqual( + len(self.get(url, data={'active': True}, expected_code=200).data), n + ) + self.assertEqual( + len(self.get(url, data={'active': False}, expected_code=200).data), 0 + ) + + # Set one company to inactive + c = Company.objects.first() + c.active = False + c.save() + + self.assertEqual( + len(self.get(url, data={'active': True}, expected_code=200).data), n - 1 + ) + self.assertEqual( + len(self.get(url, data={'active': False}, expected_code=200).data), 1 + ) + class ContactTest(InvenTreeAPITestCase): """Tests for the Contact models.""" @@ -528,6 +555,50 @@ def test_available(self): self.assertEqual(sp.available, 999) self.assertIsNotNone(sp.availability_updated) + def test_active(self): + """Test that 'active' status filtering works correctly.""" + url = reverse('api-supplier-part-list') + + # Create a new company, which is inactive + company = Company.objects.create( + name='Inactive Company', is_supplier=True, active=False + ) + + part = Part.objects.filter(purchaseable=True).first() + + # Create some new supplier part objects, *some* of which are inactive + for idx in range(10): + SupplierPart.objects.create( + part=part, + supplier=company, + SKU=f'CMP-{company.pk}-SKU-{idx}', + active=(idx % 2 == 0), + ) + + n = SupplierPart.objects.count() + + # List *all* supplier parts + self.assertEqual(len(self.get(url, data={}, expected_code=200).data), n) + + # List only active supplier parts (all except 5 from the new supplier) + self.assertEqual( + len(self.get(url, data={'active': True}, expected_code=200).data), n - 5 + ) + + # List only from 'active' suppliers (all except this new supplier) + self.assertEqual( + len(self.get(url, data={'supplier_active': True}, expected_code=200).data), + n - 10, + ) + + # List active parts from inactive suppliers (only 5 from the new supplier) + response = self.get( + url, data={'supplier_active': False, 'active': True}, expected_code=200 + ) + self.assertEqual(len(response.data), 5) + for result in response.data: + self.assertEqual(result['supplier'], company.pk) + class CompanyMetadataAPITest(InvenTreeAPITestCase): """Unit tests for the various metadata endpoints of API.""" diff --git a/src/backend/InvenTree/templates/js/translated/company.js b/src/backend/InvenTree/templates/js/translated/company.js index 424f5a27fa7..a008da20412 100644 --- a/src/backend/InvenTree/templates/js/translated/company.js +++ b/src/backend/InvenTree/templates/js/translated/company.js @@ -426,7 +426,8 @@ function companyFormFields() { }, is_supplier: {}, is_manufacturer: {}, - is_customer: {} + is_customer: {}, + active: {}, }; } @@ -517,6 +518,15 @@ function loadCompanyTable(table, url, options={}) { field: 'description', title: '{% trans "Description" %}', }, + { + field: 'active', + title: '{% trans "Active" %}', + sortable: true, + switchable: true, + formatter: function(value) { + return yesNoLabel(value); + } + }, { field: 'website', title: '{% trans "Website" %}', diff --git a/src/backend/InvenTree/templates/js/translated/table_filters.js b/src/backend/InvenTree/templates/js/translated/table_filters.js index 91786c3e595..0c10b06f332 100644 --- a/src/backend/InvenTree/templates/js/translated/table_filters.js +++ b/src/backend/InvenTree/templates/js/translated/table_filters.js @@ -791,6 +791,10 @@ function getContactFilters() { // Return a dictionary of filters for the "company" table function getCompanyFilters() { return { + active: { + type: 'bool', + title: '{% trans "Active" %}' + }, is_manufacturer: { type: 'bool', title: '{% trans "Manufacturer" %}', diff --git a/src/frontend/src/components/forms/ApiForm.tsx b/src/frontend/src/components/forms/ApiForm.tsx index 4d02009d298..315121efef8 100644 --- a/src/frontend/src/components/forms/ApiForm.tsx +++ b/src/frontend/src/components/forms/ApiForm.tsx @@ -107,6 +107,8 @@ export function OptionsApiForm({ const optionsQuery = useQuery({ enabled: true, + refetchOnMount: false, + refetchOnWindowFocus: false, queryKey: [ 'form-options-data', id, @@ -181,21 +183,26 @@ export function ApiForm({ props: ApiFormProps; optionsLoading: boolean; }) { + const fields: ApiFormFieldSet = useMemo(() => { + return props.fields ?? {}; + }, [props.fields]); + const defaultValues: FieldValues = useMemo(() => { - let defaultValuesMap = mapFields(props.fields ?? {}, (_path, field) => { + let defaultValuesMap = mapFields(fields ?? {}, (_path, field) => { return field.value ?? field.default ?? undefined; }); - // If the user has specified initial data, use that instead + // If the user has specified initial data, that overrides default values + // But, *only* for the fields we have specified if (props.initialData) { - defaultValuesMap = { - ...defaultValuesMap, - ...props.initialData - }; + Object.keys(props.initialData).map((key) => { + if (key in defaultValuesMap) { + defaultValuesMap[key] = + props?.initialData?.[key] ?? defaultValuesMap[key]; + } + }); } - // Update the form values, but only for the fields specified for this form - return defaultValuesMap; }, [props.fields, props.initialData]); @@ -260,14 +267,22 @@ export function ApiForm({ }; // Process API response - const initialData: any = processFields( - props.fields ?? {}, - response.data - ); + const initialData: any = processFields(fields, response.data); // Update form values, but only for the fields specified for this form form.reset(initialData); + // Update the field references, too + Object.keys(fields).forEach((fieldName) => { + if (fieldName in initialData) { + let field = fields[fieldName] ?? {}; + fields[fieldName] = { + ...field, + value: initialData[fieldName] + }; + } + }); + return response; } catch (error) { console.error('Error fetching initial data:', error); @@ -301,12 +316,12 @@ export function ApiForm({ initialDataQuery.isFetching || optionsLoading || isSubmitting || - !props.fields, + !fields, [ isFormLoading, initialDataQuery.isFetching, isSubmitting, - props.fields, + fields, optionsLoading ] ); @@ -319,7 +334,7 @@ export function ApiForm({ if (!focusField) { // If a focus field is not specified, then focus on the first available field - Object.entries(props.fields ?? {}).forEach(([fieldName, field]) => { + Object.entries(fields).forEach(([fieldName, field]) => { if (focusField || field.read_only || field.disabled || field.hidden) { return; } @@ -334,7 +349,7 @@ export function ApiForm({ form.setFocus(focusField); setInitialFocus(focusField); - }, [props.focus, props.fields, form.setFocus, isLoading, initialFocus]); + }, [props.focus, fields, form.setFocus, isLoading, initialFocus]); const submitForm: SubmitHandler = async (data) => { setNonFieldErrors([]); @@ -342,7 +357,7 @@ export function ApiForm({ let method = props.method?.toLowerCase() ?? 'get'; let hasFiles = false; - mapFields(props.fields ?? {}, (_path, field) => { + mapFields(fields, (_path, field) => { if (field.field_type === 'file upload') { hasFiles = true; } @@ -474,16 +489,14 @@ export function ApiForm({ {!optionsLoading && - Object.entries(props.fields ?? {}).map( - ([fieldName, field]) => ( - - ) - )} + Object.entries(fields).map(([fieldName, field]) => ( + + ))} {props.postFormContent} diff --git a/src/frontend/src/components/forms/fields/ApiFormField.tsx b/src/frontend/src/components/forms/fields/ApiFormField.tsx index 70e73ca5d64..9d098f0abbb 100644 --- a/src/frontend/src/components/forms/fields/ApiFormField.tsx +++ b/src/frontend/src/components/forms/fields/ApiFormField.tsx @@ -15,6 +15,7 @@ import { useMemo } from 'react'; import { Control, FieldValues, useController } from 'react-hook-form'; import { ModelType } from '../../../enums/ModelType'; +import { isTrue } from '../../../functions/conversion'; import { ChoiceField } from './ChoiceField'; import DateField from './DateField'; import { NestedObjectField } from './NestedObjectField'; @@ -210,7 +211,7 @@ export function ApiFormField({ id={fieldId} radius="lg" size="sm" - checked={value ?? false} + checked={isTrue(value)} error={error?.message} onChange={(event) => onChange(event.currentTarget.checked)} /> diff --git a/src/frontend/src/components/render/StatusRenderer.tsx b/src/frontend/src/components/render/StatusRenderer.tsx index c737c7e9052..78fb3f94149 100644 --- a/src/frontend/src/components/render/StatusRenderer.tsx +++ b/src/frontend/src/components/render/StatusRenderer.tsx @@ -74,13 +74,7 @@ export const StatusRenderer = ({ }) => { const statusCodeList = useGlobalStatusState.getState().status; - if (status === undefined) { - console.log('StatusRenderer: status is undefined'); - return null; - } - - if (statusCodeList === undefined) { - console.log('StatusRenderer: statusCodeList is undefined'); + if (status === undefined || statusCodeList === undefined) { return null; } diff --git a/src/frontend/src/forms/BuildForms.tsx b/src/frontend/src/forms/BuildForms.tsx index b89bed64fd8..dd4523a6bce 100644 --- a/src/frontend/src/forms/BuildForms.tsx +++ b/src/frontend/src/forms/BuildForms.tsx @@ -7,57 +7,64 @@ import { IconUser, IconUsersGroup } from '@tabler/icons-react'; +import { useMemo } from 'react'; import { ApiFormFieldSet } from '../components/forms/fields/ApiFormField'; /** * Field set for BuildOrder forms */ -export function buildOrderFields(): ApiFormFieldSet { - return { - reference: {}, - part: { - filters: { - assembly: true, - virtual: false +export function useBuildOrderFields({ + create +}: { + create: boolean; +}): ApiFormFieldSet { + return useMemo(() => { + return { + reference: {}, + part: { + filters: { + assembly: true, + virtual: false + } + }, + title: {}, + quantity: {}, + project_code: { + icon: + }, + priority: {}, + parent: { + icon: , + filters: { + part_detail: true + } + }, + sales_order: { + icon: + }, + batch: {}, + target_date: { + icon: + }, + take_from: {}, + destination: { + filters: { + structural: false + } + }, + link: { + icon: + }, + issued_by: { + icon: + }, + responsible: { + icon: , + filters: { + is_active: true + } } - }, - title: {}, - quantity: {}, - project_code: { - icon: - }, - priority: {}, - parent: { - icon: , - filters: { - part_detail: true - } - }, - sales_order: { - icon: - }, - batch: {}, - target_date: { - icon: - }, - take_from: {}, - destination: { - filters: { - structural: false - } - }, - link: { - icon: - }, - issued_by: { - icon: - }, - responsible: { - icon: , - filters: { - is_active: true - } - } - }; + }; + }, [create]); } diff --git a/src/frontend/src/forms/CompanyForms.tsx b/src/frontend/src/forms/CompanyForms.tsx index 08212b37737..50b5d77190b 100644 --- a/src/frontend/src/forms/CompanyForms.tsx +++ b/src/frontend/src/forms/CompanyForms.tsx @@ -10,34 +10,21 @@ import { } from '@tabler/icons-react'; import { useEffect, useMemo, useState } from 'react'; -import { ApiFormFieldSet } from '../components/forms/fields/ApiFormField'; +import { + ApiFormAdjustFilterType, + ApiFormFieldSet +} from '../components/forms/fields/ApiFormField'; /** * Field set for SupplierPart instance */ -export function useSupplierPartFields({ - partPk, - supplierPk, - hidePart -}: { - partPk?: number; - supplierPk?: number; - hidePart?: boolean; -}) { - const [part, setPart] = useState(partPk); - - useEffect(() => { - setPart(partPk); - }, [partPk]); - +export function useSupplierPartFields() { return useMemo(() => { const fields: ApiFormFieldSet = { part: { - hidden: hidePart, - value: part, - onValueChange: setPart, filters: { - purchaseable: true + purchaseable: true, + active: true } }, manufacturer_part: { @@ -45,15 +32,18 @@ export function useSupplierPartFields({ part_detail: true, manufacturer_detail: true }, - adjustFilters: (filters: any) => { - if (part) { - filters.part = part; - } - - return filters; + adjustFilters: (adjust: ApiFormAdjustFilterType) => { + return { + ...adjust.filters, + part: adjust.data.part + }; + } + }, + supplier: { + filters: { + active: true } }, - supplier: {}, SKU: { icon: }, @@ -67,15 +57,12 @@ export function useSupplierPartFields({ pack_quantity: {}, packaging: { icon: - } + }, + active: {} }; - if (supplierPk !== undefined) { - fields.supplier.value = supplierPk; - } - return fields; - }, [part]); + }, []); } export function useManufacturerPartFields() { @@ -125,6 +112,7 @@ export function companyFields(): ApiFormFieldSet { }, is_supplier: {}, is_manufacturer: {}, - is_customer: {} + is_customer: {}, + active: {} }; } diff --git a/src/frontend/src/forms/PurchaseOrderForms.tsx b/src/frontend/src/forms/PurchaseOrderForms.tsx index b89fe2af23a..930e7295713 100644 --- a/src/frontend/src/forms/PurchaseOrderForms.tsx +++ b/src/frontend/src/forms/PurchaseOrderForms.tsx @@ -37,8 +37,12 @@ import { apiUrl } from '../states/ApiState'; * Construct a set of fields for creating / editing a PurchaseOrderLineItem instance */ export function usePurchaseOrderLineItemFields({ + supplierId, + orderId, create }: { + supplierId?: number; + orderId?: number; create?: boolean; }) { const [purchasePrice, setPurchasePrice] = useState(''); @@ -60,16 +64,20 @@ export function usePurchaseOrderLineItemFields({ filters: { supplier_detail: true }, - hidden: true + disabled: true }, part: { filters: { part_detail: true, - supplier_detail: true + supplier_detail: true, + active: true, + part_active: true }, - adjustFilters: (value: ApiFormAdjustFilterType) => { - // TODO: Adjust part based on the supplier associated with the supplier - return value.filters; + adjustFilters: (adjust: ApiFormAdjustFilterType) => { + return { + ...adjust.filters, + supplier: supplierId + }; } }, quantity: {}, @@ -105,7 +113,7 @@ export function usePurchaseOrderLineItemFields({ } return fields; - }, [create, autoPricing, purchasePrice]); + }, [create, orderId, supplierId, autoPricing, purchasePrice]); return fields; } @@ -113,50 +121,53 @@ export function usePurchaseOrderLineItemFields({ /** * Construct a set of fields for creating / editing a PurchaseOrder instance */ -export function purchaseOrderFields(): ApiFormFieldSet { - return { - reference: { - icon: - }, - description: {}, - supplier: { - filters: { - is_supplier: true - } - }, - supplier_reference: {}, - project_code: { - icon: - }, - order_currency: { - icon: - }, - target_date: { - icon: - }, - link: {}, - contact: { - icon: , - adjustFilters: (value: ApiFormAdjustFilterType) => { - return { - ...value.filters, - company: value.data.supplier - }; - } - }, - address: { - icon: , - adjustFilters: (value: ApiFormAdjustFilterType) => { - return { - ...value.filters, - company: value.data.supplier - }; +export function usePurchaseOrderFields(): ApiFormFieldSet { + return useMemo(() => { + return { + reference: { + icon: + }, + description: {}, + supplier: { + filters: { + is_supplier: true, + active: true + } + }, + supplier_reference: {}, + project_code: { + icon: + }, + order_currency: { + icon: + }, + target_date: { + icon: + }, + link: {}, + contact: { + icon: , + adjustFilters: (value: ApiFormAdjustFilterType) => { + return { + ...value.filters, + company: value.data.supplier + }; + } + }, + address: { + icon: , + adjustFilters: (value: ApiFormAdjustFilterType) => { + return { + ...value.filters, + company: value.data.supplier + }; + } + }, + responsible: { + icon: } - }, - responsible: { - icon: - } - }; + }; + }, []); } /** diff --git a/src/frontend/src/forms/SalesOrderForms.tsx b/src/frontend/src/forms/SalesOrderForms.tsx index fcaa25f94fe..9c97f13201d 100644 --- a/src/frontend/src/forms/SalesOrderForms.tsx +++ b/src/frontend/src/forms/SalesOrderForms.tsx @@ -1,44 +1,89 @@ import { IconAddressBook, IconUser, IconUsers } from '@tabler/icons-react'; +import { useMemo } from 'react'; import { ApiFormAdjustFilterType, ApiFormFieldSet } from '../components/forms/fields/ApiFormField'; -export function salesOrderFields(): ApiFormFieldSet { - return { - reference: {}, - description: {}, - customer: { - filters: { - is_customer: true +export function useSalesOrderFields(): ApiFormFieldSet { + return useMemo(() => { + return { + reference: {}, + description: {}, + customer: { + filters: { + is_customer: true, + active: true + } + }, + customer_reference: {}, + project_code: {}, + order_currency: {}, + target_date: {}, + link: {}, + contact: { + icon: , + adjustFilters: (value: ApiFormAdjustFilterType) => { + return { + ...value.filters, + company: value.data.customer + }; + } + }, + address: { + icon: , + adjustFilters: (value: ApiFormAdjustFilterType) => { + return { + ...value.filters, + company: value.data.customer + }; + } + }, + responsible: { + icon: } - }, - customer_reference: {}, - project_code: {}, - order_currency: {}, - target_date: {}, - link: {}, - contact: { - icon: , - adjustFilters: (value: ApiFormAdjustFilterType) => { - return { - ...value.filters, - company: value.data.customer - }; - } - }, - address: { - icon: , - adjustFilters: (value: ApiFormAdjustFilterType) => { - return { - ...value.filters, - company: value.data.customer - }; + }; + }, []); +} + +export function useReturnOrderFields(): ApiFormFieldSet { + return useMemo(() => { + return { + reference: {}, + description: {}, + customer: { + filters: { + is_customer: true, + active: true + } + }, + customer_reference: {}, + project_code: {}, + order_currency: {}, + target_date: {}, + link: {}, + contact: { + icon: , + adjustFilters: (value: ApiFormAdjustFilterType) => { + return { + ...value.filters, + company: value.data.customer + }; + } + }, + address: { + icon: , + adjustFilters: (value: ApiFormAdjustFilterType) => { + return { + ...value.filters, + company: value.data.customer + }; + } + }, + responsible: { + icon: } - }, - responsible: { - icon: - } - }; + }; + }, []); } diff --git a/src/frontend/src/forms/StockForms.tsx b/src/frontend/src/forms/StockForms.tsx index 9325d97b0c8..fc023688af3 100644 --- a/src/frontend/src/forms/StockForms.tsx +++ b/src/frontend/src/forms/StockForms.tsx @@ -39,7 +39,7 @@ export function useStockFields({ const fields: ApiFormFieldSet = { part: { value: part, - hidden: !create, + disabled: !create, onValueChange: (change) => { setPart(change); // TODO: implement remaining functionality from old stock.py @@ -57,12 +57,12 @@ export function useStockFields({ supplier_detail: true, ...(part ? { part } : {}) }, - adjustFilters: (value: ApiFormAdjustFilterType) => { - if (value.data.part) { - value.filters['part'] = value.data.part; + adjustFilters: (adjust: ApiFormAdjustFilterType) => { + if (adjust.data.part) { + adjust.filters['part'] = adjust.data.part; } - return value.filters; + return adjust.filters; } }, use_pack_size: { @@ -137,29 +137,6 @@ export function useCreateStockItem() { }); } -/** - * Launch a form to edit an existing StockItem instance - * @param item : primary key of the StockItem to edit - */ -export function useEditStockItem({ - item_id, - callback -}: { - item_id: number; - callback?: () => void; -}) { - const fields = useStockFields({ create: false }); - - return useEditApiFormModal({ - url: ApiEndpoints.stock_item_list, - pk: item_id, - fields: fields, - title: t`Edit Stock Item`, - successMessage: t`Stock item updated`, - onFormSuccess: callback - }); -} - function StockItemDefaultMove({ stockItem, value diff --git a/src/frontend/src/pages/build/BuildDetail.tsx b/src/frontend/src/pages/build/BuildDetail.tsx index 52695ff463c..8ec7b034449 100644 --- a/src/frontend/src/pages/build/BuildDetail.tsx +++ b/src/frontend/src/pages/build/BuildDetail.tsx @@ -35,8 +35,7 @@ import { NotesEditor } from '../../components/widgets/MarkdownEditor'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ModelType } from '../../enums/ModelType'; import { UserRoles } from '../../enums/Roles'; -import { buildOrderFields } from '../../forms/BuildForms'; -import { partCategoryFields } from '../../forms/PartForms'; +import { useBuildOrderFields } from '../../forms/BuildForms'; import { useEditApiFormModal } from '../../hooks/UseForm'; import { useInstance } from '../../hooks/UseInstance'; import { apiUrl } from '../../states/ApiState'; @@ -280,11 +279,13 @@ export default function BuildDetail() { ]; }, [build, id]); + const buildOrderFields = useBuildOrderFields({ create: false }); + const editBuild = useEditApiFormModal({ url: ApiEndpoints.build_order_list, pk: build.pk, title: t`Edit Build Order`, - fields: buildOrderFields(), + fields: buildOrderFields, onFormSuccess: () => { refreshInstance(); } diff --git a/src/frontend/src/pages/company/CompanyDetail.tsx b/src/frontend/src/pages/company/CompanyDetail.tsx index eed845e9ca0..5244a3f6484 100644 --- a/src/frontend/src/pages/company/CompanyDetail.tsx +++ b/src/frontend/src/pages/company/CompanyDetail.tsx @@ -15,10 +15,11 @@ import { IconTruckReturn, IconUsersGroup } from '@tabler/icons-react'; -import { useMemo } from 'react'; +import { ReactNode, useMemo } from 'react'; import { useParams } from 'react-router-dom'; import { DetailsField, DetailsTable } from '../../components/details/Details'; +import DetailsBadge from '../../components/details/DetailsBadge'; import { DetailsImage } from '../../components/details/DetailsImage'; import { ItemDetailsGrid } from '../../components/details/ItemDetails'; import { @@ -293,6 +294,12 @@ export default function CompanyDetail(props: CompanyDetailProps) { ]; }, [id, company, user]); + const badges: ReactNode[] = useMemo(() => { + return [ + + ]; + }, [company]); + return ( <> {editCompany.modal} @@ -304,6 +311,7 @@ export default function CompanyDetail(props: CompanyDetailProps) { actions={companyActions} imageUrl={company.image} breadcrumbs={props.breadcrumbs} + badges={badges} /> diff --git a/src/frontend/src/pages/company/SupplierPartDetail.tsx b/src/frontend/src/pages/company/SupplierPartDetail.tsx index 64731aefc11..49edc7f66f5 100644 --- a/src/frontend/src/pages/company/SupplierPartDetail.tsx +++ b/src/frontend/src/pages/company/SupplierPartDetail.tsx @@ -7,10 +7,11 @@ import { IconPackages, IconShoppingCart } from '@tabler/icons-react'; -import { useMemo } from 'react'; -import { useParams } from 'react-router-dom'; +import { ReactNode, useMemo } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; import { DetailsField, DetailsTable } from '../../components/details/Details'; +import DetailsBadge from '../../components/details/DetailsBadge'; import { DetailsImage } from '../../components/details/DetailsImage'; import { ItemDetailsGrid } from '../../components/details/ItemDetails'; import { @@ -25,7 +26,11 @@ import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ModelType } from '../../enums/ModelType'; import { UserRoles } from '../../enums/Roles'; import { useSupplierPartFields } from '../../forms/CompanyForms'; -import { useEditApiFormModal } from '../../hooks/UseForm'; +import { getDetailUrl } from '../../functions/urls'; +import { + useCreateApiFormModal, + useEditApiFormModal +} from '../../hooks/UseForm'; import { useInstance } from '../../hooks/UseInstance'; import { apiUrl } from '../../states/ApiState'; import { useUserState } from '../../states/UserState'; @@ -38,6 +43,8 @@ export default function SupplierPartDetail() { const user = useUserState(); + const navigate = useNavigate(); + const { instance: supplierPart, instanceQuery, @@ -245,7 +252,8 @@ export default function SupplierPartDetail() { icon={} actions={[ DuplicateItemAction({ - hidden: !user.hasAddRole(UserRoles.purchase_order) + hidden: !user.hasAddRole(UserRoles.purchase_order), + onClick: () => duplicateSupplierPart.open() }), EditItemAction({ hidden: !user.hasChangeRole(UserRoles.purchase_order), @@ -259,19 +267,30 @@ export default function SupplierPartDetail() { ]; }, [user]); - const editSupplierPartFields = useSupplierPartFields({ - hidePart: true, - partPk: supplierPart?.pk - }); + const supplierPartFields = useSupplierPartFields(); const editSuppliertPart = useEditApiFormModal({ url: ApiEndpoints.supplier_part_list, pk: supplierPart?.pk, title: t`Edit Supplier Part`, - fields: editSupplierPartFields, + fields: supplierPartFields, onFormSuccess: refreshInstance }); + const duplicateSupplierPart = useCreateApiFormModal({ + url: ApiEndpoints.supplier_part_list, + title: t`Add Supplier Part`, + fields: supplierPartFields, + initialData: { + ...supplierPart + }, + onFormSuccess: (response: any) => { + if (response.pk) { + navigate(getDetailUrl(ModelType.supplierpart, response.pk)); + } + } + }); + const breadcrumbs = useMemo(() => { return [ { @@ -285,15 +304,27 @@ export default function SupplierPartDetail() { ]; }, [supplierPart]); + const badges: ReactNode[] = useMemo(() => { + return [ + + ]; + }, [supplierPart]); + return ( <> {editSuppliertPart.modal} + {duplicateSupplierPart.modal} diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index 1e76fb46e03..09a1dfa36bb 100644 --- a/src/frontend/src/pages/part/PartDetail.tsx +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -1,13 +1,5 @@ import { t } from '@lingui/macro'; -import { - Badge, - Grid, - Group, - LoadingOverlay, - Skeleton, - Stack, - Text -} from '@mantine/core'; +import { Grid, LoadingOverlay, Skeleton, Stack } from '@mantine/core'; import { IconBookmarks, IconBuilding, @@ -32,13 +24,11 @@ import { } from '@tabler/icons-react'; import { useSuspenseQuery } from '@tanstack/react-query'; import { ReactNode, useMemo, useState } from 'react'; -import { useParams } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; import { api } from '../../App'; import { DetailsField, DetailsTable } from '../../components/details/Details'; -import DetailsBadge, { - DetailsBadgeProps -} from '../../components/details/DetailsBadge'; +import DetailsBadge from '../../components/details/DetailsBadge'; import { DetailsImage } from '../../components/details/DetailsImage'; import { ItemDetailsGrid } from '../../components/details/ItemDetails'; import { PartIcons } from '../../components/details/PartIcons'; @@ -68,7 +58,10 @@ import { } from '../../forms/StockForms'; import { InvenTreeIcon } from '../../functions/icons'; import { getDetailUrl } from '../../functions/urls'; -import { useEditApiFormModal } from '../../hooks/UseForm'; +import { + useCreateApiFormModal, + useEditApiFormModal +} from '../../hooks/UseForm'; import { useInstance } from '../../hooks/UseInstance'; import { apiUrl } from '../../states/ApiState'; import { useUserState } from '../../states/UserState'; @@ -93,6 +86,7 @@ export default function PartDetail() { const { id } = useParams(); const user = useUserState(); + const navigate = useNavigate(); const [treeOpen, setTreeOpen] = useState(false); @@ -664,7 +658,8 @@ export default function PartDetail() { label={t`In Production` + `: ${part.building}`} color="blue" visible={part.building > 0} - /> + />, + ]; }, [part, instanceQuery]); @@ -678,6 +673,20 @@ export default function PartDetail() { onFormSuccess: refreshInstance }); + const duplicatePart = useCreateApiFormModal({ + url: ApiEndpoints.part_list, + title: t`Add Part`, + fields: partFields, + initialData: { + ...part + }, + onFormSuccess: (response: any) => { + if (response.pk) { + navigate(getDetailUrl(ModelType.part, response.pk)); + } + } + }); + const stockActionProps: StockOperationProps = useMemo(() => { return { pk: part.pk, @@ -695,10 +704,10 @@ export default function PartDetail() { actions={[ ViewBarcodeAction({}), LinkBarcodeAction({ - hidden: part?.barcode_hash + hidden: part?.barcode_hash || !user.hasChangeRole(UserRoles.part) }), UnlinkBarcodeAction({ - hidden: !part?.barcode_hash + hidden: !part?.barcode_hash || !user.hasChangeRole(UserRoles.part) }) ]} />, @@ -737,7 +746,8 @@ export default function PartDetail() { icon={} actions={[ DuplicateItemAction({ - hidden: !user.hasAddRole(UserRoles.part) + hidden: !user.hasAddRole(UserRoles.part), + onClick: () => duplicatePart.open() }), EditItemAction({ hidden: !user.hasChangeRole(UserRoles.part), @@ -753,6 +763,7 @@ export default function PartDetail() { return ( <> + {duplicatePart.modal} {editPart.modal} diff --git a/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx b/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx index ecf43cd7bb5..a982cf3b233 100644 --- a/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx +++ b/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx @@ -30,7 +30,7 @@ import { NotesEditor } from '../../components/widgets/MarkdownEditor'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ModelType } from '../../enums/ModelType'; import { UserRoles } from '../../enums/Roles'; -import { purchaseOrderFields } from '../../forms/PurchaseOrderForms'; +import { usePurchaseOrderFields } from '../../forms/PurchaseOrderForms'; import { useEditApiFormModal } from '../../hooks/UseForm'; import { useInstance } from '../../hooks/UseInstance'; import { apiUrl } from '../../states/ApiState'; @@ -60,11 +60,13 @@ export default function PurchaseOrderDetail() { refetchOnMount: true }); + const purchaseOrderFields = usePurchaseOrderFields(); + const editPurchaseOrder = useEditApiFormModal({ url: ApiEndpoints.purchase_order_list, pk: id, title: t`Edit Purchase Order`, - fields: purchaseOrderFields(), + fields: purchaseOrderFields, onFormSuccess: () => { refreshInstance(); } @@ -227,7 +229,12 @@ export default function PurchaseOrderDetail() { name: 'line-items', label: t`Line Items`, icon: , - content: + content: ( + + ) }, { name: 'received-stock', @@ -269,7 +276,6 @@ export default function PurchaseOrderDetail() { }, [order, id]); const poActions = useMemo(() => { - // TODO: Disable certain actions based on user permissions return [ } actions={[ EditItemAction({ + hidden: !user.hasChangeRole(UserRoles.purchase_order), onClick: () => { editPurchaseOrder.open(); } }), - DeleteItemAction({}) + DeleteItemAction({ + hidden: !user.hasDeleteRole(UserRoles.purchase_order) + }) ]} /> ]; diff --git a/src/frontend/src/pages/sales/ReturnOrderDetail.tsx b/src/frontend/src/pages/sales/ReturnOrderDetail.tsx index 24554886733..c54b7d085c3 100644 --- a/src/frontend/src/pages/sales/ReturnOrderDetail.tsx +++ b/src/frontend/src/pages/sales/ReturnOrderDetail.tsx @@ -1,6 +1,7 @@ import { t } from '@lingui/macro'; import { Grid, LoadingOverlay, Skeleton, Stack } from '@mantine/core'; import { + IconDots, IconInfoCircle, IconList, IconNotes, @@ -12,6 +13,11 @@ import { useParams } from 'react-router-dom'; import { DetailsField, DetailsTable } from '../../components/details/Details'; import { DetailsImage } from '../../components/details/DetailsImage'; import { ItemDetailsGrid } from '../../components/details/ItemDetails'; +import { + ActionDropdown, + DeleteItemAction, + EditItemAction +} from '../../components/items/ActionDropdown'; import { PageDetail } from '../../components/nav/PageDetail'; import { PanelGroup, PanelType } from '../../components/nav/PanelGroup'; import { StatusRenderer } from '../../components/render/StatusRenderer'; @@ -19,8 +25,11 @@ import { NotesEditor } from '../../components/widgets/MarkdownEditor'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ModelType } from '../../enums/ModelType'; import { UserRoles } from '../../enums/Roles'; +import { useReturnOrderFields } from '../../forms/SalesOrderForms'; +import { useEditApiFormModal } from '../../hooks/UseForm'; import { useInstance } from '../../hooks/UseInstance'; import { apiUrl } from '../../states/ApiState'; +import { useUserState } from '../../states/UserState'; import { AttachmentTable } from '../../tables/general/AttachmentTable'; /** @@ -29,7 +38,13 @@ import { AttachmentTable } from '../../tables/general/AttachmentTable'; export default function ReturnOrderDetail() { const { id } = useParams(); - const { instance: order, instanceQuery } = useInstance({ + const user = useUserState(); + + const { + instance: order, + instanceQuery, + refreshInstance + } = useInstance({ endpoint: ApiEndpoints.return_order_list, pk: id, params: { @@ -233,8 +248,43 @@ export default function ReturnOrderDetail() { ]; }, [order, instanceQuery]); + const returnOrderFields = useReturnOrderFields(); + + const editReturnOrder = useEditApiFormModal({ + url: ApiEndpoints.return_order_list, + pk: order.pk, + title: t`Edit Return Order`, + fields: returnOrderFields, + onFormSuccess: () => { + refreshInstance(); + } + }); + + const orderActions = useMemo(() => { + return [ + } + actions={[ + EditItemAction({ + hidden: !user.hasChangeRole(UserRoles.return_order), + onClick: () => { + editReturnOrder.open(); + } + }), + DeleteItemAction({ + hidden: !user.hasDeleteRole(UserRoles.return_order) + // TODO: Delete? + }) + ]} + /> + ]; + }, [user]); + return ( <> + {editReturnOrder.modal} diff --git a/src/frontend/src/pages/sales/SalesOrderDetail.tsx b/src/frontend/src/pages/sales/SalesOrderDetail.tsx index 182c0fbfde3..783551d8607 100644 --- a/src/frontend/src/pages/sales/SalesOrderDetail.tsx +++ b/src/frontend/src/pages/sales/SalesOrderDetail.tsx @@ -1,6 +1,7 @@ import { t } from '@lingui/macro'; import { Grid, LoadingOverlay, Skeleton, Stack } from '@mantine/core'; import { + IconDots, IconInfoCircle, IconList, IconNotes, @@ -15,6 +16,11 @@ import { useParams } from 'react-router-dom'; import { DetailsField, DetailsTable } from '../../components/details/Details'; import { DetailsImage } from '../../components/details/DetailsImage'; import { ItemDetailsGrid } from '../../components/details/ItemDetails'; +import { + ActionDropdown, + DeleteItemAction, + EditItemAction +} from '../../components/items/ActionDropdown'; import { PageDetail } from '../../components/nav/PageDetail'; import { PanelGroup, PanelType } from '../../components/nav/PanelGroup'; import { StatusRenderer } from '../../components/render/StatusRenderer'; @@ -22,8 +28,11 @@ import { NotesEditor } from '../../components/widgets/MarkdownEditor'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ModelType } from '../../enums/ModelType'; import { UserRoles } from '../../enums/Roles'; +import { useSalesOrderFields } from '../../forms/SalesOrderForms'; +import { useEditApiFormModal } from '../../hooks/UseForm'; import { useInstance } from '../../hooks/UseInstance'; import { apiUrl } from '../../states/ApiState'; +import { useUserState } from '../../states/UserState'; import { BuildOrderTable } from '../../tables/build/BuildOrderTable'; import { AttachmentTable } from '../../tables/general/AttachmentTable'; @@ -33,7 +42,13 @@ import { AttachmentTable } from '../../tables/general/AttachmentTable'; export default function SalesOrderDetail() { const { id } = useParams(); - const { instance: order, instanceQuery } = useInstance({ + const user = useUserState(); + + const { + instance: order, + instanceQuery, + refreshInstance + } = useInstance({ endpoint: ApiEndpoints.sales_order_list, pk: id, params: { @@ -185,6 +200,18 @@ export default function SalesOrderDetail() { ); }, [order, instanceQuery]); + const salesOrderFields = useSalesOrderFields(); + + const editSalesOrder = useEditApiFormModal({ + url: ApiEndpoints.sales_order_list, + pk: order.pk, + title: t`Edit Sales Order`, + fields: salesOrderFields, + onFormSuccess: () => { + refreshInstance(); + } + }); + const orderPanels: PanelType[] = useMemo(() => { return [ { @@ -245,6 +272,28 @@ export default function SalesOrderDetail() { ]; }, [order, id]); + const soActions = useMemo(() => { + return [ + } + actions={[ + EditItemAction({ + hidden: !user.hasChangeRole(UserRoles.sales_order), + onClick: () => { + editSalesOrder.open(); + } + }), + DeleteItemAction({ + hidden: !user.hasDeleteRole(UserRoles.sales_order) + // TODO: Delete? + }) + ]} + /> + ]; + }, [user]); + const orderBadges: ReactNode[] = useMemo(() => { return instanceQuery.isLoading ? [] @@ -259,6 +308,7 @@ export default function SalesOrderDetail() { return ( <> + {editSalesOrder.modal} diff --git a/src/frontend/src/pages/stock/StockDetail.tsx b/src/frontend/src/pages/stock/StockDetail.tsx index 072b881823f..7a48713bfef 100644 --- a/src/frontend/src/pages/stock/StockDetail.tsx +++ b/src/frontend/src/pages/stock/StockDetail.tsx @@ -23,7 +23,7 @@ import { IconSitemap } from '@tabler/icons-react'; import { ReactNode, useMemo, useState } from 'react'; -import { useParams } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; import { DetailsField, DetailsTable } from '../../components/details/Details'; import DetailsBadge from '../../components/details/DetailsBadge'; @@ -33,6 +33,7 @@ import { ActionDropdown, BarcodeActionDropdown, DeleteItemAction, + DuplicateItemAction, EditItemAction, LinkBarcodeAction, UnlinkBarcodeAction, @@ -50,12 +51,16 @@ import { StockOperationProps, useAddStockItem, useCountStockItem, - useEditStockItem, useRemoveStockItem, + useStockFields, useTransferStockItem } from '../../forms/StockForms'; import { InvenTreeIcon } from '../../functions/icons'; import { getDetailUrl } from '../../functions/urls'; +import { + useCreateApiFormModal, + useEditApiFormModal +} from '../../hooks/UseForm'; import { useInstance } from '../../hooks/UseInstance'; import { apiUrl } from '../../states/ApiState'; import { useUserState } from '../../states/UserState'; @@ -69,6 +74,8 @@ export default function StockDetail() { const user = useUserState(); + const navigate = useNavigate(); + const [treeOpen, setTreeOpen] = useState(false); const { @@ -349,9 +356,30 @@ export default function StockDetail() { [stockitem] ); - const editStockItem = useEditStockItem({ - item_id: stockitem.pk, - callback: () => refreshInstance() + const editStockItemFields = useStockFields({ create: false }); + + const editStockItem = useEditApiFormModal({ + url: ApiEndpoints.stock_item_list, + pk: stockitem.pk, + title: t`Edit Stock Item`, + fields: editStockItemFields, + onFormSuccess: refreshInstance + }); + + const duplicateStockItemFields = useStockFields({ create: true }); + + const duplicateStockItem = useCreateApiFormModal({ + url: ApiEndpoints.stock_item_list, + title: t`Add Stock Item`, + fields: duplicateStockItemFields, + initialData: { + ...stockitem + }, + onFormSuccess: (response: any) => { + if (response.pk) { + navigate(getDetailUrl(ModelType.stockitem, response.pk)); + } + } }); const stockActionProps: StockOperationProps = useMemo(() => { @@ -368,15 +396,17 @@ export default function StockDetail() { const transferStockItem = useTransferStockItem(stockActionProps); const stockActions = useMemo( - () => /* TODO: Disable actions based on user permissions*/ [ + () => [ , @@ -425,16 +455,20 @@ export default function StockDetail() { />, } actions={[ - { - name: t`Duplicate`, - tooltip: t`Duplicate stock item`, - icon: - }, - EditItemAction({}), - DeleteItemAction({}) + DuplicateItemAction({ + hidden: !user.hasAddRole(UserRoles.stock), + onClick: () => duplicateStockItem.open() + }), + EditItemAction({ + hidden: !user.hasChangeRole(UserRoles.stock), + onClick: () => editStockItem.open() + }), + DeleteItemAction({ + hidden: !user.hasDeleteRole(UserRoles.stock) + }) ]} /> ], @@ -489,6 +523,7 @@ export default function StockDetail() { /> {editStockItem.modal} + {duplicateStockItem.modal} {countStockItem.modal} {addStockItem.modal} {removeStockItem.modal} diff --git a/src/frontend/src/tables/build/BuildOrderTable.tsx b/src/frontend/src/tables/build/BuildOrderTable.tsx index 66a15af959a..19f94383805 100644 --- a/src/frontend/src/tables/build/BuildOrderTable.tsx +++ b/src/frontend/src/tables/build/BuildOrderTable.tsx @@ -10,7 +10,7 @@ import { renderDate } from '../../defaults/formatters'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ModelType } from '../../enums/ModelType'; import { UserRoles } from '../../enums/Roles'; -import { buildOrderFields } from '../../forms/BuildForms'; +import { useBuildOrderFields } from '../../forms/BuildForms'; import { getDetailUrl } from '../../functions/urls'; import { useCreateApiFormModal } from '../../hooks/UseForm'; import { useTable } from '../../hooks/UseTable'; @@ -135,10 +135,12 @@ export function BuildOrderTable({ const table = useTable('buildorder'); + const buildOrderFields = useBuildOrderFields({ create: true }); + const newBuild = useCreateApiFormModal({ url: ApiEndpoints.build_order_list, title: t`Add Build Order`, - fields: buildOrderFields(), + fields: buildOrderFields, initialData: { part: partId, sales_order: salesOrderId, diff --git a/src/frontend/src/tables/company/CompanyTable.tsx b/src/frontend/src/tables/company/CompanyTable.tsx index 82cef9f7f54..9199a2248ce 100644 --- a/src/frontend/src/tables/company/CompanyTable.tsx +++ b/src/frontend/src/tables/company/CompanyTable.tsx @@ -1,5 +1,6 @@ import { t } from '@lingui/macro'; import { Group, Text } from '@mantine/core'; +import { access } from 'fs'; import { useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; @@ -13,7 +14,8 @@ import { useCreateApiFormModal } from '../../hooks/UseForm'; import { useTable } from '../../hooks/UseTable'; import { apiUrl } from '../../states/ApiState'; import { useUserState } from '../../states/UserState'; -import { DescriptionColumn } from '../ColumnRenderers'; +import { BooleanColumn, DescriptionColumn } from '../ColumnRenderers'; +import { TableFilter } from '../Filter'; import { InvenTreeTable } from '../InvenTreeTable'; /** @@ -51,6 +53,12 @@ export function CompanyTable({ } }, DescriptionColumn({}), + BooleanColumn({ + accessor: 'active', + title: t`Active`, + sortable: true, + switchable: true + }), { accessor: 'website', sortable: false @@ -73,6 +81,31 @@ export function CompanyTable({ } }); + const tableFilters: TableFilter[] = useMemo(() => { + return [ + { + name: 'active', + label: t`Active`, + description: t`Show active companies` + }, + { + name: 'is_supplier', + label: t`Supplier`, + description: t`Show companies which are suppliers` + }, + { + name: 'is_manufacturer', + label: t`Manufacturer`, + description: t`Show companies which are manufacturers` + }, + { + name: 'is_customer', + label: t`Customer`, + description: t`Show companies which are customers` + } + ]; + }, []); + const tableActions = useMemo(() => { const can_add = user.hasAddRole(UserRoles.purchase_order) || @@ -98,6 +131,7 @@ export function CompanyTable({ params: { ...params }, + tableFilters: tableFilters, tableActions: tableActions, onRowClick: (row: any) => { if (row.pk) { diff --git a/src/frontend/src/tables/company/ContactTable.tsx b/src/frontend/src/tables/company/ContactTable.tsx index 6641131e39d..83e4e6ebf87 100644 --- a/src/frontend/src/tables/company/ContactTable.tsx +++ b/src/frontend/src/tables/company/ContactTable.tsx @@ -63,9 +63,7 @@ export function ContactTable({ }; }, []); - const [selectedContact, setSelectedContact] = useState( - undefined - ); + const [selectedContact, setSelectedContact] = useState(0); const editContact = useEditApiFormModal({ url: ApiEndpoints.contact_list, diff --git a/src/frontend/src/tables/purchasing/PurchaseOrderLineItemTable.tsx b/src/frontend/src/tables/purchasing/PurchaseOrderLineItemTable.tsx index ecac9aef13b..9f85529353b 100644 --- a/src/frontend/src/tables/purchasing/PurchaseOrderLineItemTable.tsx +++ b/src/frontend/src/tables/purchasing/PurchaseOrderLineItemTable.tsx @@ -44,9 +44,11 @@ import { TableHoverCard } from '../TableHoverCard'; */ export function PurchaseOrderLineItemTable({ orderId, + supplierId, params }: { orderId: number; + supplierId?: number; params?: any; }) { const table = useTable('purchase-order-line-item'); @@ -67,7 +69,7 @@ export function PurchaseOrderLineItemTable({ return [ { accessor: 'part', - title: t`Part`, + title: t`Internal Part`, sortable: true, switchable: false, render: (record: any) => { @@ -183,25 +185,35 @@ export function PurchaseOrderLineItemTable({ ]; }, [orderId, user]); + const addPurchaseOrderFields = usePurchaseOrderLineItemFields({ + create: true, + orderId: orderId, + supplierId: supplierId + }); + + const [initialData, setInitialData] = useState({}); + const newLine = useCreateApiFormModal({ url: ApiEndpoints.purchase_order_line_list, title: t`Add Line Item`, - fields: usePurchaseOrderLineItemFields({ create: true }), - initialData: { - order: orderId - }, + fields: addPurchaseOrderFields, + initialData: initialData, onFormSuccess: table.refreshTable }); - const [selectedLine, setSelectedLine] = useState( - undefined - ); + const [selectedLine, setSelectedLine] = useState(0); + + const editPurchaseOrderFields = usePurchaseOrderLineItemFields({ + create: false, + orderId: orderId, + supplierId: supplierId + }); const editLine = useEditApiFormModal({ url: ApiEndpoints.purchase_order_line_list, pk: selectedLine, title: t`Edit Line Item`, - fields: usePurchaseOrderLineItemFields({}), + fields: editPurchaseOrderFields, onFormSuccess: table.refreshTable }); @@ -235,7 +247,11 @@ export function PurchaseOrderLineItemTable({ } }), RowDuplicateAction({ - hidden: !user.hasAddRole(UserRoles.purchase_order) + hidden: !user.hasAddRole(UserRoles.purchase_order), + onClick: () => { + setInitialData({ ...record }); + newLine.open(); + } }), RowDeleteAction({ hidden: !user.hasDeleteRole(UserRoles.purchase_order), @@ -254,7 +270,12 @@ export function PurchaseOrderLineItemTable({ return [ newLine.open()} + onClick={() => { + setInitialData({ + order: orderId + }); + newLine.open(); + }} hidden={!user?.hasAddRole(UserRoles.purchase_order)} />, record?.manufacturer_part_detail?.MPN }, + BooleanColumn({ + accessor: 'active', + title: t`Active`, + sortable: true, + switchable: true + }), { accessor: 'in_stock', sortable: true @@ -145,35 +157,67 @@ export function SupplierPartTable({ params }: { params: any }): ReactNode { ]; }, [params]); - const addSupplierPartFields = useSupplierPartFields({ - partPk: params?.part, - supplierPk: params?.supplier, - hidePart: true + const supplierPartFields = useSupplierPartFields(); + + const addSupplierPart = useCreateApiFormModal({ + url: ApiEndpoints.supplier_part_list, + title: t`Add Supplier Part`, + fields: supplierPartFields, + initialData: { + part: params?.part, + supplier: params?.supplier + }, + onFormSuccess: table.refreshTable, + successMessage: t`Supplier part created` }); - const { modal: addSupplierPartModal, open: openAddSupplierPartForm } = - useCreateApiFormModal({ - url: ApiEndpoints.supplier_part_list, - title: t`Add Supplier Part`, - fields: addSupplierPartFields, - onFormSuccess: table.refreshTable, - successMessage: t`Supplier part created` - }); - - // Table actions - const tableActions = useMemo(() => { - // TODO: Hide actions based on user permissions + const tableActions = useMemo(() => { return [ addSupplierPart.open()} + hidden={!user.hasAddRole(UserRoles.purchase_order)} /> ]; }, [user]); - const editSupplierPartFields = useSupplierPartFields({ - hidePart: true, - partPk: params?.part + const tableFilters: TableFilter[] = useMemo(() => { + return [ + { + name: 'active', + label: t`Active`, + description: t`Show active supplier parts` + }, + { + name: 'part_active', + label: t`Active Part`, + description: t`Show active internal parts` + }, + { + name: 'supplier_active', + label: t`Active Supplier`, + description: t`Show active suppliers` + } + ]; + }, []); + + const editSupplierPartFields = useSupplierPartFields(); + + const [selectedSupplierPart, setSelectedSupplierPart] = useState(0); + + const editSupplierPart = useEditApiFormModal({ + url: ApiEndpoints.supplier_part_list, + pk: selectedSupplierPart, + title: t`Edit Supplier Part`, + fields: editSupplierPartFields, + onFormSuccess: () => table.refreshTable() + }); + + const deleteSupplierPart = useDeleteApiFormModal({ + url: ApiEndpoints.supplier_part_list, + pk: selectedSupplierPart, + title: t`Delete Supplier Part`, + onFormSuccess: () => table.refreshTable() }); // Row action callback @@ -183,29 +227,15 @@ export function SupplierPartTable({ params }: { params: any }): ReactNode { RowEditAction({ hidden: !user.hasChangeRole(UserRoles.purchase_order), onClick: () => { - record.pk && - openEditApiForm({ - url: ApiEndpoints.supplier_part_list, - pk: record.pk, - title: t`Edit Supplier Part`, - fields: editSupplierPartFields, - onFormSuccess: table.refreshTable, - successMessage: t`Supplier part updated` - }); + setSelectedSupplierPart(record.pk); + editSupplierPart.open(); } }), RowDeleteAction({ hidden: !user.hasDeleteRole(UserRoles.purchase_order), onClick: () => { - record.pk && - openDeleteApiForm({ - url: ApiEndpoints.supplier_part_list, - pk: record.pk, - title: t`Delete Supplier Part`, - successMessage: t`Supplier part deleted`, - onFormSuccess: table.refreshTable, - preFormWarning: t`Are you sure you want to remove this supplier part?` - }); + setSelectedSupplierPart(record.pk); + deleteSupplierPart.open(); } }) ]; @@ -215,7 +245,9 @@ export function SupplierPartTable({ params }: { params: any }): ReactNode { return ( <> - {addSupplierPartModal} + {addSupplierPart.modal} + {editSupplierPart.modal} + {deleteSupplierPart.modal} diff --git a/src/frontend/src/tables/sales/ReturnOrderTable.tsx b/src/frontend/src/tables/sales/ReturnOrderTable.tsx index 0da8649b53a..c9c98564d5b 100644 --- a/src/frontend/src/tables/sales/ReturnOrderTable.tsx +++ b/src/frontend/src/tables/sales/ReturnOrderTable.tsx @@ -1,5 +1,6 @@ import { t } from '@lingui/macro'; import { useCallback, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; import { AddItemButton } from '../../components/buttons/AddItemButton'; import { Thumbnail } from '../../components/images/Thumbnail'; @@ -7,7 +8,10 @@ import { formatCurrency } from '../../defaults/formatters'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ModelType } from '../../enums/ModelType'; import { UserRoles } from '../../enums/Roles'; +import { useReturnOrderFields } from '../../forms/SalesOrderForms'; import { notYetImplemented } from '../../functions/notifications'; +import { getDetailUrl } from '../../functions/urls'; +import { useCreateApiFormModal } from '../../hooks/UseForm'; import { useTable } from '../../hooks/UseTable'; import { apiUrl } from '../../states/ApiState'; import { useUserState } from '../../states/UserState'; @@ -33,6 +37,7 @@ import { InvenTreeTable } from '../InvenTreeTable'; export function ReturnOrderTable({ params }: { params?: any }) { const table = useTable('return-orders'); const user = useUserState(); + const navigate = useNavigate(); const tableFilters: TableFilter[] = useMemo(() => { return [ @@ -48,10 +53,6 @@ export function ReturnOrderTable({ params }: { params?: any }) { ]; }, []); - // TODO: Row actions - - // TODO: Table actions (e.g. create new return order) - const tableColumns = useMemo(() => { return [ ReferenceColumn(), @@ -94,34 +95,48 @@ export function ReturnOrderTable({ params }: { params?: any }) { ]; }, []); - const addReturnOrder = useCallback(() => { - notYetImplemented(); - }, []); + const returnOrderFields = useReturnOrderFields(); + + const newReturnOrder = useCreateApiFormModal({ + url: ApiEndpoints.return_order_list, + title: t`Add Return Order`, + fields: returnOrderFields, + onFormSuccess: (response) => { + if (response.pk) { + navigate(getDetailUrl(ModelType.returnorder, response.pk)); + } else { + table.refreshTable(); + } + } + }); const tableActions = useMemo(() => { return [