Adds "active" field for Company model (#7024)

* Add "active" field to Company model

* Expose 'active' parameter to API

* Fix default value

* Add 'active' column to PUI

* Update PUI table

* Update company detail pages

* Update API filters for SupplierPart and ManufacturerPart

* Bump API version

* Update order forms

* Add edit action to SalesOrderDetail page

* Enable editing of ReturnOrder

* Typo fix

* Adds explicit "active" field to SupplierPart model

* More updates

- Add "inactive" badge to SupplierPart page
- Update SupplierPartTable
- Update backend API fields

* Update ReturnOrderTable

- Also some refactoring

* Impove usePurchaseOrderLineItemFields hook

* Cleanup

* Implement duplicate action for SupplierPart

* Fix for ApiForm

- Only override initialValues for specified fields

* Allow edit and duplicate of StockItem

* Fix for ApiForm

- Default values were overriding initial data

* Add duplicate part option

* Cleanup ApiForm

- Cache props.fields

* Fix unused import

* More fixes

* Add unit tests

* Allow ordering company by 'active' status

* Update docs

* Merge migrations

* Fix for serializers.py

* Force new form value

* Remove debug call

* Further unit test fixes

* Update default CSRF_TRUSTED_ORIGINS values

* Reduce debug output
This commit is contained in:
Oliver 2024-04-20 23:18:25 +10:00 committed by GitHub
parent 2632bcfbbc
commit 2fe0eefa8f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 927 additions and 390 deletions

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@ -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 <span class='fas fa-edit'>Edit Company</span> 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 <span class='fas fa-trash-alt'></span> icon under the actions menu. Confirm the deletion using the checkbox then click on <span class="badge inventree confirm">Submit</span>
@ -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 <span class='fas fa-edit'></span> icon next to the supplier part image. Edit the supplier part information then click on <span class="badge inventree confirm">Submit</span>
#### 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.

View File

@ -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

View File

@ -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'

View File

@ -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',

View File

@ -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'),
),
]

View File

@ -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,

View File

@ -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)

View File

@ -10,6 +10,12 @@
{% block heading %}
{% trans "Company" %}: {{ company.name }}
{% if not company.active %}
&ensp;
<div class='badge rounded-pill bg-danger'>
{% trans 'Inactive' %}
</div>
{% endif %}
{% endblock heading %}
{% block actions %}

View File

@ -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."""

View File

@ -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" %}',

View File

@ -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" %}',

View File

@ -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<FieldValues> = 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({
<FormProvider {...form}>
<Stack spacing="xs">
{!optionsLoading &&
Object.entries(props.fields ?? {}).map(
([fieldName, field]) => (
<ApiFormField
key={fieldName}
fieldName={fieldName}
definition={field}
control={form.control}
/>
)
)}
Object.entries(fields).map(([fieldName, field]) => (
<ApiFormField
key={fieldName}
fieldName={fieldName}
definition={field}
control={form.control}
/>
))}
</Stack>
</FormProvider>
{props.postFormContent}

View File

@ -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)}
/>

View File

@ -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;
}

View File

@ -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: <IconList />
},
priority: {},
parent: {
icon: <IconSitemap />,
filters: {
part_detail: true
}
},
sales_order: {
icon: <IconTruckDelivery />
},
batch: {},
target_date: {
icon: <IconCalendar />
},
take_from: {},
destination: {
filters: {
structural: false
}
},
link: {
icon: <IconLink />
},
issued_by: {
icon: <IconUser />
},
responsible: {
icon: <IconUsersGroup />,
filters: {
is_active: true
}
}
},
title: {},
quantity: {},
project_code: {
icon: <IconList />
},
priority: {},
parent: {
icon: <IconSitemap />,
filters: {
part_detail: true
}
},
sales_order: {
icon: <IconTruckDelivery />
},
batch: {},
target_date: {
icon: <IconCalendar />
},
take_from: {},
destination: {
filters: {
structural: false
}
},
link: {
icon: <IconLink />
},
issued_by: {
icon: <IconUser />
},
responsible: {
icon: <IconUsersGroup />,
filters: {
is_active: true
}
}
};
};
}, [create]);
}

View File

@ -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<number | undefined>(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: <IconHash />
},
@ -67,15 +57,12 @@ export function useSupplierPartFields({
pack_quantity: {},
packaging: {
icon: <IconPackage />
}
},
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: {}
};
}

View File

@ -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<string>('');
@ -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: <IconHash />
},
description: {},
supplier: {
filters: {
is_supplier: true
export function usePurchaseOrderFields(): ApiFormFieldSet {
return useMemo(() => {
return {
reference: {
icon: <IconHash />
},
description: {},
supplier: {
filters: {
is_supplier: true,
active: true
}
},
supplier_reference: {},
project_code: {
icon: <IconList />
},
order_currency: {
icon: <IconCoins />
},
target_date: {
icon: <IconCalendar />
},
link: {},
contact: {
icon: <IconUser />,
adjustFilters: (value: ApiFormAdjustFilterType) => {
return {
...value.filters,
company: value.data.supplier
};
}
},
address: {
icon: <IconAddressBook />,
adjustFilters: (value: ApiFormAdjustFilterType) => {
return {
...value.filters,
company: value.data.supplier
};
}
},
responsible: {
icon: <IconUsers />
}
},
supplier_reference: {},
project_code: {
icon: <IconList />
},
order_currency: {
icon: <IconCoins />
},
target_date: {
icon: <IconCalendar />
},
link: {},
contact: {
icon: <IconUser />,
adjustFilters: (value: ApiFormAdjustFilterType) => {
return {
...value.filters,
company: value.data.supplier
};
}
},
address: {
icon: <IconAddressBook />,
adjustFilters: (value: ApiFormAdjustFilterType) => {
return {
...value.filters,
company: value.data.supplier
};
}
},
responsible: {
icon: <IconUsers />
}
};
};
}, []);
}
/**

View File

@ -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: <IconUser />,
adjustFilters: (value: ApiFormAdjustFilterType) => {
return {
...value.filters,
company: value.data.customer
};
}
},
address: {
icon: <IconAddressBook />,
adjustFilters: (value: ApiFormAdjustFilterType) => {
return {
...value.filters,
company: value.data.customer
};
}
},
responsible: {
icon: <IconUsers />
}
},
customer_reference: {},
project_code: {},
order_currency: {},
target_date: {},
link: {},
contact: {
icon: <IconUser />,
adjustFilters: (value: ApiFormAdjustFilterType) => {
return {
...value.filters,
company: value.data.customer
};
}
},
address: {
icon: <IconAddressBook />,
adjustFilters: (value: ApiFormAdjustFilterType) => {
return {
...value.filters,
company: value.data.customer
};
}
},
responsible: {
icon: <IconUsers />
}
};
};
}, []);
}
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: <IconUser />,
adjustFilters: (value: ApiFormAdjustFilterType) => {
return {
...value.filters,
company: value.data.customer
};
}
},
address: {
icon: <IconAddressBook />,
adjustFilters: (value: ApiFormAdjustFilterType) => {
return {
...value.filters,
company: value.data.customer
};
}
},
responsible: {
icon: <IconUsers />
}
};
}, []);
}

View File

@ -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

View File

@ -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();
}

View File

@ -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 [
<DetailsBadge label={t`Inactive`} color="red" visible={!company.active} />
];
}, [company]);
return (
<>
{editCompany.modal}
@ -304,6 +311,7 @@ export default function CompanyDetail(props: CompanyDetailProps) {
actions={companyActions}
imageUrl={company.image}
breadcrumbs={props.breadcrumbs}
badges={badges}
/>
<PanelGroup pageKey="company" panels={companyPanels} />
</Stack>

View File

@ -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={<IconDots />}
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 [
<DetailsBadge
label={t`Inactive`}
color="red"
visible={!supplierPart.active}
/>
];
}, [supplierPart]);
return (
<>
{editSuppliertPart.modal}
{duplicateSupplierPart.modal}
<Stack spacing="xs">
<LoadingOverlay visible={instanceQuery.isFetching} />
<PageDetail
title={t`Supplier Part`}
subtitle={`${supplierPart.SKU} - ${supplierPart?.part_detail?.name}`}
breadcrumbs={breadcrumbs}
badges={badges}
actions={supplierPartActions}
imageUrl={supplierPart?.part_detail?.thumbnail}
/>

View File

@ -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}
/>
/>,
<DetailsBadge label={t`Inactive`} color="red" visible={!part.active} />
];
}, [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={<IconDots />}
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}
<Stack spacing="xs">
<LoadingOverlay visible={instanceQuery.isFetching} />

View File

@ -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: <IconList />,
content: <PurchaseOrderLineItemTable orderId={Number(id)} />
content: (
<PurchaseOrderLineItemTable
orderId={Number(id)}
supplierId={Number(order.supplier)}
/>
)
},
{
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 [
<BarcodeActionDropdown
actions={[
@ -288,11 +294,14 @@ export default function PurchaseOrderDetail() {
icon={<IconDots />}
actions={[
EditItemAction({
hidden: !user.hasChangeRole(UserRoles.purchase_order),
onClick: () => {
editPurchaseOrder.open();
}
}),
DeleteItemAction({})
DeleteItemAction({
hidden: !user.hasDeleteRole(UserRoles.purchase_order)
})
]}
/>
];

View File

@ -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 [
<ActionDropdown
key="order-actions"
tooltip={t`Order Actions`}
icon={<IconDots />}
actions={[
EditItemAction({
hidden: !user.hasChangeRole(UserRoles.return_order),
onClick: () => {
editReturnOrder.open();
}
}),
DeleteItemAction({
hidden: !user.hasDeleteRole(UserRoles.return_order)
// TODO: Delete?
})
]}
/>
];
}, [user]);
return (
<>
{editReturnOrder.modal}
<Stack spacing="xs">
<LoadingOverlay visible={instanceQuery.isFetching} />
<PageDetail
@ -242,6 +292,7 @@ export default function ReturnOrderDetail() {
subtitle={order.description}
imageUrl={order.customer_detail?.image}
badges={orderBadges}
actions={orderActions}
breadcrumbs={[{ name: t`Sales`, url: '/sales/' }]}
/>
<PanelGroup pageKey="returnorder" panels={orderPanels} />

View File

@ -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 [
<ActionDropdown
key="order-actions"
tooltip={t`Order Actions`}
icon={<IconDots />}
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}
<Stack spacing="xs">
<LoadingOverlay visible={instanceQuery.isFetching} />
<PageDetail
@ -266,6 +316,7 @@ export default function SalesOrderDetail() {
subtitle={order.description}
imageUrl={order.customer_detail?.image}
badges={orderBadges}
actions={soActions}
breadcrumbs={[{ name: t`Sales`, url: '/sales/' }]}
/>
<PanelGroup pageKey="salesorder" panels={orderPanels} />

View File

@ -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*/ [
() => [
<BarcodeActionDropdown
actions={[
ViewBarcodeAction({}),
LinkBarcodeAction({
hidden: stockitem?.barcode_hash
hidden:
stockitem?.barcode_hash || !user.hasChangeRole(UserRoles.stock)
}),
UnlinkBarcodeAction({
hidden: !stockitem?.barcode_hash
hidden:
!stockitem?.barcode_hash || !user.hasChangeRole(UserRoles.stock)
})
]}
/>,
@ -425,16 +455,20 @@ export default function StockDetail() {
/>,
<ActionDropdown
key="stock"
// tooltip={t`Stock Actions`}
tooltip={t`Stock Item Actions`}
icon={<IconDots />}
actions={[
{
name: t`Duplicate`,
tooltip: t`Duplicate stock item`,
icon: <IconCopy />
},
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() {
/>
<PanelGroup pageKey="stockitem" panels={stockPanels} />
{editStockItem.modal}
{duplicateStockItem.modal}
{countStockItem.modal}
{addStockItem.modal}
{removeStockItem.modal}

View File

@ -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,

View File

@ -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) {

View File

@ -63,9 +63,7 @@ export function ContactTable({
};
}, []);
const [selectedContact, setSelectedContact] = useState<number | undefined>(
undefined
);
const [selectedContact, setSelectedContact] = useState<number>(0);
const editContact = useEditApiFormModal({
url: ApiEndpoints.contact_list,

View File

@ -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<number | undefined>(
undefined
);
const [selectedLine, setSelectedLine] = useState<number>(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 [
<AddItemButton
tooltip={t`Add line item`}
onClick={() => newLine.open()}
onClick={() => {
setInitialData({
order: orderId
});
newLine.open();
}}
hidden={!user?.hasAddRole(UserRoles.purchase_order)}
/>,
<ActionButton

View File

@ -8,7 +8,7 @@ import { formatCurrency } from '../../defaults/formatters';
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 { getDetailUrl } from '../../functions/urls';
import { useCreateApiFormModal } from '../../hooks/UseForm';
import { useTable } from '../../hooks/UseTable';
@ -106,10 +106,12 @@ export function PurchaseOrderTable({
];
}, []);
const purchaseOrderFields = usePurchaseOrderFields();
const newPurchaseOrder = useCreateApiFormModal({
url: ApiEndpoints.purchase_order_list,
title: t`Add Purchase Order`,
fields: purchaseOrderFields(),
fields: purchaseOrderFields,
initialData: {
supplier: supplierId
},

View File

@ -1,6 +1,6 @@
import { t } from '@lingui/macro';
import { Text } from '@mantine/core';
import { ReactNode, useCallback, useMemo } from 'react';
import { ReactNode, useCallback, useMemo, useState } from 'react';
import { AddItemButton } from '../../components/buttons/AddItemButton';
import { Thumbnail } from '../../components/images/Thumbnail';
@ -9,17 +9,23 @@ import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import { useSupplierPartFields } from '../../forms/CompanyForms';
import { openDeleteApiForm, openEditApiForm } from '../../functions/forms';
import { useCreateApiFormModal } from '../../hooks/UseForm';
import {
useCreateApiFormModal,
useDeleteApiFormModal,
useEditApiFormModal
} from '../../hooks/UseForm';
import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
import { TableColumn } from '../Column';
import {
BooleanColumn,
DescriptionColumn,
LinkColumn,
NoteColumn,
PartColumn
} from '../ColumnRenderers';
import { TableFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
import { RowDeleteAction, RowEditAction } from '../RowActions';
import { TableHoverCard } from '../TableHoverCard';
@ -88,6 +94,12 @@ export function SupplierPartTable({ params }: { params: any }): ReactNode {
title: t`MPN`,
render: (record: any) => 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 [
<AddItemButton
tooltip={t`Add supplier part`}
onClick={openAddSupplierPartForm}
onClick={() => 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<number>(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}
<InvenTreeTable
url={apiUrl(ApiEndpoints.supplier_part_list)}
tableState={table}
@ -229,6 +261,7 @@ export function SupplierPartTable({ params }: { params: any }): ReactNode {
},
rowActions: rowActions,
tableActions: tableActions,
tableFilters: tableFilters,
modelType: ModelType.supplierpart
}}
/>

View File

@ -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 [
<AddItemButton
tooltip={t`Add Return Order`}
onClick={addReturnOrder}
hidden={!user.hasAddRole(UserRoles.sales_order)}
onClick={() => newReturnOrder.open()}
hidden={!user.hasAddRole(UserRoles.return_order)}
/>
];
}, [user]);
return (
<InvenTreeTable
url={apiUrl(ApiEndpoints.return_order_list)}
tableState={table}
columns={tableColumns}
props={{
params: {
...params,
customer_detail: true
},
tableFilters: tableFilters,
tableActions: tableActions,
modelType: ModelType.returnorder
}}
/>
<>
{newReturnOrder.modal}
<InvenTreeTable
url={apiUrl(ApiEndpoints.return_order_list)}
tableState={table}
columns={tableColumns}
props={{
params: {
...params,
customer_detail: true
},
tableFilters: tableFilters,
tableActions: tableActions,
modelType: ModelType.returnorder
}}
/>
</>
);
}

View File

@ -8,7 +8,7 @@ import { formatCurrency } from '../../defaults/formatters';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import { salesOrderFields } from '../../forms/SalesOrderForms';
import { useSalesOrderFields } from '../../forms/SalesOrderForms';
import { getDetailUrl } from '../../functions/urls';
import { useCreateApiFormModal } from '../../hooks/UseForm';
import { useTable } from '../../hooks/UseTable';
@ -61,10 +61,12 @@ export function SalesOrderTable({
];
}, []);
const salesOrderFields = useSalesOrderFields();
const newSalesOrder = useCreateApiFormModal({
url: ApiEndpoints.sales_order_list,
title: t`Add Sales Order`,
fields: salesOrderFields(),
fields: salesOrderFields,
initialData: {
customer: customerId
},

View File

@ -279,9 +279,7 @@ export default function StockItemTestResultTable({
successMessage: t`Test result added`
});
const [selectedTest, setSelectedTest] = useState<number | undefined>(
undefined
);
const [selectedTest, setSelectedTest] = useState<number>(0);
const editTestModal = useEditApiFormModal({
url: ApiEndpoints.stock_test_result_list,

View File

@ -67,6 +67,11 @@ test('PUI - Purchasing', async ({ page }) => {
.click();
await page.getByRole('menuitem', { name: 'Edit' }).click();
await page.getByLabel('Address title *').waitFor();
// Read the current value of the cell, to ensure we always *change* it!
const value = await page.getByLabel('Line 2').inputValue();
await page.getByLabel('Line 2').fill(value == 'old' ? 'new' : 'old');
await page.getByRole('button', { name: 'Submit' }).isEnabled();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('tab', { name: 'Details' }).waitFor();