diff --git a/docs/README.md b/docs/README.md index 989183c832..578afc13e8 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 0000000000..2c3ab4f49f 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 0000000000..a0e5f9bde8 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 0000000000..2592f5ed91 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 c99994b867..3e4d821046 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 7db9dc01fb..94aa80c38e 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 a32a2fbd77..7bda362b49 100644 --- a/src/backend/InvenTree/InvenTree/settings.py +++ b/src/backend/InvenTree/InvenTree/settings.py @@ -1087,14 +1087,17 @@ CSRF_TRUSTED_ORIGINS = get_setting( 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 88b2c640bd..aea8d5dc5e 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 @@ class CompanyList(ListCreateAPI): 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 ManufacturerPartFilter(rest_filters.FilterSet): 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 SupplierPartFilter(rest_filters.FilterSet): '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 @@ class SupplierPartList(ListCreateDestroyAPIView): '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 0000000000..120046f6fc --- /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 dc21dc62b1..b891b5b065 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 @@ class Company( 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 @@ class SupplierPart( 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 7e3f86da78..e329ea44e6 100644 --- a/src/backend/InvenTree/company/serializers.py +++ b/src/backend/InvenTree/company/serializers.py @@ -42,12 +42,17 @@ class CompanyBriefSerializer(InvenTreeModelSerializer): """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 CompanySerializer(RemoteImageMixin, InvenTreeModelSerializer): 'contact', 'link', 'image', + 'active', 'is_customer', 'is_manufacturer', 'is_supplier', @@ -308,6 +314,7 @@ class SupplierPartSerializer(InvenTreeTagModelSerializer): 'description', 'in_stock', 'link', + 'active', 'manufacturer', 'manufacturer_detail', 'manufacturer_part', @@ -371,8 +378,9 @@ class SupplierPartSerializer(InvenTreeTagModelSerializer): 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 87f8cf10c9..c7c7efde12 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 dcb8dc81dc..332292ca3c 100644 --- a/src/backend/InvenTree/company/test_api.py +++ b/src/backend/InvenTree/company/test_api.py @@ -5,6 +5,7 @@ from django.urls import reverse 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 @@ class CompanyTest(InvenTreeAPITestCase): 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 @@ class SupplierPartTest(InvenTreeAPITestCase): 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 424f5a27fa..a008da2041 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 91786c3e59..0c10b06f33 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 4d02009d29..315121efef 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 70e73ca5d6..9d098f0abb 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 c737c7e905..78fb3f9414 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 b89bed64fd..dd4523a6bc 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 08212b3773..50b5d77190 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 b89fe2af23..930e729571 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 +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: } - }, - 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: - } - }; + }; + }, []); } /** diff --git a/src/frontend/src/forms/SalesOrderForms.tsx b/src/frontend/src/forms/SalesOrderForms.tsx index fcaa25f94f..9c97f13201 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 - }; - } - }, - responsible: { - icon: - } - }; + }; + }, []); +} + +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: + } + }; + }, []); } diff --git a/src/frontend/src/forms/StockForms.tsx b/src/frontend/src/forms/StockForms.tsx index 9325d97b0c..fc023688af 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 52695ff463..8ec7b03444 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 eed845e9ca..5244a3f648 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 64731aefc1..49edc7f66f 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 1e76fb46e0..09a1dfa36b 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 ecf43cd7bb..a982cf3b23 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 2455488673..c54b7d085c 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 182c0fbfde..783551d860 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 072b881823..7a48713bfe 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 66a15af959..19f9438380 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 82cef9f7f5..9199a2248c 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 6641131e39..83e4e6ebf8 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 ecac9aef13..9f85529353 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 - 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 0da8649b53..c9c98564d5 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 [