mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge branch 'inventree:master' into ci-only-in-inventree
This commit is contained in:
commit
c48e333c63
@ -31,6 +31,8 @@ services:
|
||||
INVENTREE_DB_USER: inventree_user
|
||||
INVENTREE_DB_PASSWORD: inventree_password
|
||||
INVENTREE_PLUGINS_ENABLED: True
|
||||
INVENTREE_SITE_URL: http://localhost:8000
|
||||
INVENTREE_CORS_ORIGIN_ALLOW_ALL: True
|
||||
INVENTREE_PY_ENV: /home/inventree/dev/venv
|
||||
|
||||
depends_on:
|
||||
|
1
.github/workflows/docker.yaml
vendored
1
.github/workflows/docker.yaml
vendored
@ -44,6 +44,7 @@ jobs:
|
||||
- docker-compose.yml
|
||||
- docker.dev.env
|
||||
- Dockerfile
|
||||
- InvenTree/settings.py
|
||||
- requirements.txt
|
||||
- tasks.py
|
||||
|
||||
|
6
.github/workflows/qc_checks.yaml
vendored
6
.github/workflows/qc_checks.yaml
vendored
@ -20,6 +20,7 @@ env:
|
||||
INVENTREE_MEDIA_ROOT: ../test_inventree_media
|
||||
INVENTREE_STATIC_ROOT: ../test_inventree_static
|
||||
INVENTREE_BACKUP_DIR: ../test_inventree_backup
|
||||
INVENTREE_SITE_URL: http://localhost:8000
|
||||
|
||||
jobs:
|
||||
paths-filter:
|
||||
@ -216,9 +217,10 @@ jobs:
|
||||
INVENTREE_ADMIN_USER: testuser
|
||||
INVENTREE_ADMIN_PASSWORD: testpassword
|
||||
INVENTREE_ADMIN_EMAIL: test@test.com
|
||||
INVENTREE_PYTHON_TEST_SERVER: http://localhost:12345
|
||||
INVENTREE_PYTHON_TEST_SERVER: http://127.0.0.1:12345
|
||||
INVENTREE_PYTHON_TEST_USERNAME: testuser
|
||||
INVENTREE_PYTHON_TEST_PASSWORD: testpassword
|
||||
INVENTREE_SITE_URL: http://127.0.0.1:12345
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
|
||||
@ -505,7 +507,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: cd src/frontend && yarn install
|
||||
- name: Build frontend
|
||||
run: cd src/frontend && npm run build
|
||||
run: cd src/frontend && npm run compile && npm run build
|
||||
- name: Zip frontend
|
||||
run: |
|
||||
cd InvenTree/web/static
|
||||
|
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@ -37,7 +37,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: cd src/frontend && yarn install
|
||||
- name: Build frontend
|
||||
run: cd src/frontend && npm run build
|
||||
run: cd src/frontend && npm run compile && npm run build
|
||||
- name: Zip frontend
|
||||
run: |
|
||||
cd InvenTree/web/static/web
|
||||
|
1
.github/workflows/translations.yml
vendored
1
.github/workflows/translations.yml
vendored
@ -22,6 +22,7 @@ jobs:
|
||||
INVENTREE_MEDIA_ROOT: ./media
|
||||
INVENTREE_STATIC_ROOT: ./static
|
||||
INVENTREE_BACKUP_DIR: ./backup
|
||||
INVENTREE_SITE_URL: http://localhost:8000
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
|
@ -16,7 +16,7 @@ repos:
|
||||
- id: check-yaml
|
||||
- id: mixed-line-ending
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.3.0
|
||||
rev: v0.3.3
|
||||
hooks:
|
||||
- id: ruff-format
|
||||
args: [--preview]
|
||||
@ -60,7 +60,7 @@ repos:
|
||||
- "prettier@^2.4.1"
|
||||
- "@trivago/prettier-plugin-sort-imports"
|
||||
- repo: https://github.com/pre-commit/mirrors-eslint
|
||||
rev: "v9.0.0-beta.1"
|
||||
rev: "v9.0.0-beta.2"
|
||||
hooks:
|
||||
- id: eslint
|
||||
additional_dependencies:
|
||||
|
@ -23,7 +23,6 @@ ENV PYTHONUNBUFFERED 1
|
||||
ENV PIP_DISABLE_PIP_VERSION_CHECK 1
|
||||
ENV INVOKE_RUN_SHELL="/bin/ash"
|
||||
|
||||
ENV INVENTREE_LOG_LEVEL="WARNING"
|
||||
ENV INVENTREE_DOCKER="true"
|
||||
|
||||
# InvenTree paths
|
||||
|
@ -1,11 +1,14 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 183
|
||||
INVENTREE_API_VERSION = 184
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v184 - 2024-03-17 : https://github.com/inventree/InvenTree/pull/10464
|
||||
- Add additional fields for tests (start/end datetime, test station)
|
||||
|
||||
v183 - 2024-03-14 : https://github.com/inventree/InvenTree/pull/5972
|
||||
- Adds "category_default_location" annotated field to part serializer
|
||||
- Adds "part_detail.category_default_location" annotated field to stock item serializer
|
||||
|
@ -2,12 +2,14 @@
|
||||
|
||||
If a new language translation is supported, it must be added here
|
||||
After adding a new language, run the following command:
|
||||
|
||||
python manage.py makemessages -l <language_code> -e html,js,py --no-wrap
|
||||
where <language_code> is the code for the new language
|
||||
- where <language_code> is the code for the new language
|
||||
|
||||
Additionally, update the following files with the new locale code:
|
||||
|
||||
- /src/frontend/.linguirc file
|
||||
- /src/frontend/src/context/LanguageContext.tsx
|
||||
- /src/frontend/src/contexts/LanguageContext.tsx
|
||||
"""
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@ -30,6 +32,7 @@ LOCALES = [
|
||||
('it', _('Italian')),
|
||||
('ja', _('Japanese')),
|
||||
('ko', _('Korean')),
|
||||
('lv', _('Latvian')),
|
||||
('nl', _('Dutch')),
|
||||
('no', _('Norwegian')),
|
||||
('pl', _('Polish')),
|
||||
|
@ -352,7 +352,12 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
|
||||
try:
|
||||
instance.full_clean()
|
||||
except (ValidationError, DjangoValidationError) as exc:
|
||||
data = exc.message_dict
|
||||
if hasattr(exc, 'message_dict'):
|
||||
data = exc.message_dict
|
||||
elif hasattr(exc, 'message'):
|
||||
data = {'non_field_errors': [str(exc.message)]}
|
||||
else:
|
||||
data = {'non_field_errors': [str(exc)]}
|
||||
|
||||
# Change '__all__' key (django style) to 'non_field_errors' (DRF style)
|
||||
if '__all__' in data:
|
||||
|
@ -26,6 +26,7 @@ import pytz
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from InvenTree.config import get_boolean_setting, get_custom_file, get_setting
|
||||
from InvenTree.ready import isInMainThread
|
||||
from InvenTree.sentry import default_sentry_dsn, init_sentry
|
||||
from InvenTree.version import checkMinPythonVersion, inventreeApiVersion
|
||||
|
||||
@ -1002,11 +1003,15 @@ if not ALLOWED_HOSTS:
|
||||
'No ALLOWED_HOSTS specified. Defaulting to ["*"] for debug mode. This is not recommended for production use'
|
||||
)
|
||||
ALLOWED_HOSTS = ['*']
|
||||
else:
|
||||
elif not TESTING:
|
||||
logger.error(
|
||||
'No ALLOWED_HOSTS specified. Please provide a list of allowed hosts, or specify INVENTREE_SITE_URL'
|
||||
)
|
||||
|
||||
# Server cannot run without ALLOWED_HOSTS
|
||||
if isInMainThread():
|
||||
sys.exit(-1)
|
||||
|
||||
# Ensure that the ALLOWED_HOSTS do not contain any scheme info
|
||||
for i, host in enumerate(ALLOWED_HOSTS):
|
||||
if '://' in host:
|
||||
@ -1025,6 +1030,20 @@ 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://*']
|
||||
|
||||
elif 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'
|
||||
)
|
||||
sys.exit(-1)
|
||||
|
||||
USE_X_FORWARDED_HOST = get_boolean_setting(
|
||||
'INVENTREE_USE_X_FORWARDED_HOST',
|
||||
config_key='use_x_forwarded_host',
|
||||
@ -1202,6 +1221,9 @@ PLUGIN_RETRY = get_setting(
|
||||
) # How often should plugin loading be tried?
|
||||
PLUGIN_FILE_CHECKED = False # Was the plugin file checked?
|
||||
|
||||
# Flag to allow table events during testing
|
||||
TESTING_TABLE_EVENTS = False
|
||||
|
||||
# User interface customization values
|
||||
CUSTOM_LOGO = get_custom_file(
|
||||
'INVENTREE_CUSTOM_LOGO', 'customize.logo', 'custom logo', lookup_media=True
|
||||
@ -1265,5 +1287,5 @@ SPECTACULAR_SETTINGS = {
|
||||
'SCHEMA_PATH_PREFIX': '/api/',
|
||||
}
|
||||
|
||||
if SITE_URL:
|
||||
if SITE_URL and not TESTING:
|
||||
SPECTACULAR_SETTINGS['SERVERS'] = [{'url': SITE_URL}]
|
||||
|
@ -574,6 +574,7 @@ class FormatTest(TestCase):
|
||||
class TestHelpers(TestCase):
|
||||
"""Tests for InvenTree helper functions."""
|
||||
|
||||
@override_settings(SITE_URL=None)
|
||||
def test_absolute_url(self):
|
||||
"""Test helper function for generating an absolute URL."""
|
||||
base = 'https://demo.inventree.org:12345'
|
||||
@ -1347,6 +1348,7 @@ class TestInstanceName(InvenTreeTestCase):
|
||||
site_obj = Site.objects.all().order_by('id').first()
|
||||
self.assertEqual(site_obj.name, 'Testing title')
|
||||
|
||||
@override_settings(SITE_URL=None)
|
||||
def test_instance_url(self):
|
||||
"""Test instance url settings."""
|
||||
# Set up required setting
|
||||
|
@ -1750,6 +1750,14 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
'STOCK_ENFORCE_BOM_INSTALLATION': {
|
||||
'name': _('Check BOM when installing items'),
|
||||
'description': _(
|
||||
'Installed stock items must exist in the BOM for the parent part'
|
||||
),
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
'BUILDORDER_REFERENCE_PATTERN': {
|
||||
'name': _('Build Order Reference Pattern'),
|
||||
'description': _(
|
||||
@ -2004,6 +2012,12 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
'TEST_STATION_DATA': {
|
||||
'name': _('Enable Test Station Data'),
|
||||
'description': _('Enable test station data collection for test results'),
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
}
|
||||
|
||||
typ = 'inventree'
|
||||
|
@ -12,6 +12,7 @@ from django.core.cache import cache
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import Client, TestCase
|
||||
from django.test.utils import override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
import PIL
|
||||
@ -271,6 +272,7 @@ class SettingsTest(InvenTreeTestCase):
|
||||
print(f"run_settings_check failed for user setting '{key}'")
|
||||
raise exc
|
||||
|
||||
@override_settings(SITE_URL=None)
|
||||
def test_defaults(self):
|
||||
"""Populate the settings with default values."""
|
||||
for key in InvenTreeSetting.SETTINGS.keys():
|
||||
|
14163
InvenTree/locale/lv/LC_MESSAGES/django.po
Normal file
14163
InvenTree/locale/lv/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
@ -6,6 +6,7 @@ from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase
|
||||
from django.test.utils import override_settings
|
||||
|
||||
from allauth.account.models import EmailAddress
|
||||
|
||||
@ -63,6 +64,7 @@ class TemplateTagTest(InvenTreeTestCase):
|
||||
"""Test the 'instance name' setting."""
|
||||
self.assertEqual(inventree_extras.inventree_instance_name(), 'InvenTree')
|
||||
|
||||
@override_settings(SITE_URL=None)
|
||||
def test_inventree_base_url(self):
|
||||
"""Test that the base URL tag returns correctly."""
|
||||
self.assertEqual(inventree_extras.inventree_base_url(), '')
|
||||
|
@ -10,7 +10,7 @@ from django.apps import AppConfig
|
||||
|
||||
from maintenance_mode.core import set_maintenance_mode
|
||||
|
||||
from InvenTree.ready import canAppAccessDatabase, isInMainThread
|
||||
from InvenTree.ready import canAppAccessDatabase, isInMainThread, isInWorkerThread
|
||||
from plugin import registry
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
@ -24,7 +24,8 @@ class PluginAppConfig(AppConfig):
|
||||
def ready(self):
|
||||
"""The ready method is extended to initialize plugins."""
|
||||
# skip loading if we run in a background thread
|
||||
if not isInMainThread():
|
||||
|
||||
if not isInMainThread() and not isInWorkerThread():
|
||||
return
|
||||
|
||||
if not canAppAccessDatabase(
|
||||
|
@ -117,7 +117,7 @@ def allow_table_event(table_name):
|
||||
return False # pragma: no cover
|
||||
|
||||
# Prevent table events when in testing mode (saves a lot of time)
|
||||
if settings.TESTING:
|
||||
if settings.TESTING and not settings.TESTING_TABLE_EVENTS:
|
||||
return False
|
||||
|
||||
table_name = table_name.lower().strip()
|
||||
|
@ -0,0 +1,29 @@
|
||||
# Generated by Django 3.2.23 on 2023-12-18 18:52
|
||||
|
||||
import datetime
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stock', '0108_auto_20240219_0252'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='stockitemtestresult',
|
||||
name='finished_datetime',
|
||||
field=models.DateTimeField(blank=True, default=datetime.datetime.now, help_text='The timestamp of the test finish', verbose_name='Finished'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='stockitemtestresult',
|
||||
name='started_datetime',
|
||||
field=models.DateTimeField(blank=True, default=datetime.datetime.now, help_text='The timestamp of the test start', verbose_name='Started'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='stockitemtestresult',
|
||||
name='test_station',
|
||||
field=models.CharField(blank=True, help_text='The identifier of the test station where the test was performed', max_length=500, verbose_name='Test station'),
|
||||
),
|
||||
]
|
@ -2363,6 +2363,9 @@ class StockItemTestResult(InvenTree.models.InvenTreeMetadataModel):
|
||||
value: Recorded test output value (optional)
|
||||
attachment: Link to StockItem attachment (optional)
|
||||
notes: Extra user notes related to the test (optional)
|
||||
test_station: the name of the test station where the test was performed
|
||||
started_datetime: Date when the test was started
|
||||
finished_datetime: Date when the test was finished
|
||||
user: User who uploaded the test result
|
||||
date: Date the test result was recorded
|
||||
"""
|
||||
@ -2453,4 +2456,27 @@ class StockItemTestResult(InvenTree.models.InvenTreeMetadataModel):
|
||||
|
||||
user = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True)
|
||||
|
||||
test_station = models.CharField(
|
||||
blank=True,
|
||||
max_length=500,
|
||||
verbose_name=_('Test station'),
|
||||
help_text=_('The identifier of the test station where the test was performed'),
|
||||
)
|
||||
|
||||
started_datetime = models.DateTimeField(
|
||||
default=datetime.now,
|
||||
blank=True,
|
||||
verbose_name=_('Started'),
|
||||
help_text=_('The timestamp of the test start'),
|
||||
)
|
||||
|
||||
finished_datetime = models.DateTimeField(
|
||||
default=datetime.now,
|
||||
blank=True,
|
||||
verbose_name=_('Finished'),
|
||||
help_text=_('The timestamp of the test finish'),
|
||||
)
|
||||
|
||||
user = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True)
|
||||
|
||||
date = models.DateTimeField(auto_now_add=True, editable=False)
|
||||
|
@ -64,6 +64,9 @@ class StockItemTestResultSerializer(InvenTree.serializers.InvenTreeModelSerializ
|
||||
'value',
|
||||
'attachment',
|
||||
'notes',
|
||||
'test_station',
|
||||
'started_datetime',
|
||||
'finished_datetime',
|
||||
'user',
|
||||
'user_detail',
|
||||
'date',
|
||||
@ -137,7 +140,18 @@ class StockItemTestResultSerializer(InvenTree.serializers.InvenTreeModelSerializ
|
||||
part=stock_item.part, test_name=test_name
|
||||
)
|
||||
|
||||
return super().validate(data)
|
||||
data = super().validate(data)
|
||||
|
||||
started = data.get('started_datetime')
|
||||
finished = data.get('finished_datetime')
|
||||
|
||||
if started is not None and finished is not None and started > finished:
|
||||
raise ValidationError({
|
||||
'finished_datetime': _(
|
||||
'The test finished time cannot be earlier than the test started time'
|
||||
)
|
||||
})
|
||||
return data
|
||||
|
||||
|
||||
class StockItemSerializerBrief(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
@ -584,9 +598,14 @@ class InstallStockItemSerializer(serializers.Serializer):
|
||||
parent_item = self.context['item']
|
||||
parent_part = parent_item.part
|
||||
|
||||
# Check if the selected part is in the Bill of Materials of the parent item
|
||||
if not parent_part.check_if_part_in_bom(stock_item.part):
|
||||
raise ValidationError(_('Selected part is not in the Bill of Materials'))
|
||||
if common.models.InvenTreeSetting.get_setting(
|
||||
'STOCK_ENFORCE_BOM_INSTALLATION', backup_value=True, cache=False
|
||||
):
|
||||
# Check if the selected part is in the Bill of Materials of the parent item
|
||||
if not parent_part.check_if_part_in_bom(stock_item.part):
|
||||
raise ValidationError(
|
||||
_('Selected part is not in the Bill of Materials')
|
||||
)
|
||||
|
||||
return stock_item
|
||||
|
||||
|
@ -183,7 +183,10 @@
|
||||
|
||||
$('#stock-item-install').click(function() {
|
||||
|
||||
{% settings_value "STOCK_ENFORCE_BOM_INSTALLATION" as enforce_bom %}
|
||||
|
||||
installStockItem({{ item.pk }}, {{ item.part.pk }}, {
|
||||
enforce_bom: {% js_bool enforce_bom %},
|
||||
onSuccess: function(response) {
|
||||
$("#installed-table").bootstrapTable('refresh');
|
||||
}
|
||||
@ -228,10 +231,13 @@
|
||||
});
|
||||
});
|
||||
|
||||
{% settings_value "TEST_STATION_DATA" as test_station_fields %}
|
||||
|
||||
loadStockTestResultsTable(
|
||||
$("#test-result-table"), {
|
||||
part: {{ item.part.id }},
|
||||
stock_item: {{ item.id }},
|
||||
test_station_fields: {% js_bool test_station_fields %}
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -22,6 +22,8 @@
|
||||
{% include "InvenTree/settings/setting.html" with key="STOCK_OWNERSHIP_CONTROL" icon="fa-users" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="STOCK_LOCATION_DEFAULT_ICON" icon="fa-icons" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="STOCK_SHOW_INSTALLED_ITEMS" icon="fa-sitemap" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="STOCK_ENFORCE_BOM_INSTALLATION" icon="fa-check-circle" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="TEST_STATION_DATA" icon="fa-network-wired" %}
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
@ -2590,6 +2590,9 @@ function constructInput(name, parameters, options={}) {
|
||||
case 'date':
|
||||
func = constructDateInput;
|
||||
break;
|
||||
case 'datetime':
|
||||
func = constructDateTimeInput;
|
||||
break;
|
||||
case 'candy':
|
||||
func = constructCandyInput;
|
||||
break;
|
||||
@ -2860,6 +2863,19 @@ function constructDateInput(name, parameters) {
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Construct a field for a datetime input
|
||||
*/
|
||||
function constructDateTimeInput(name, parameters) {
|
||||
|
||||
return constructInputOptions(
|
||||
name,
|
||||
'datetimeinput form-control',
|
||||
'datetime',
|
||||
parameters
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* Construct a "candy" field input
|
||||
* No actual field data!
|
||||
|
@ -1367,11 +1367,11 @@ function noResultBadge() {
|
||||
return `<span class='badge badge-right rounded-pill bg-info'>{% trans "NO RESULT" %}</span>`;
|
||||
}
|
||||
|
||||
function formatDate(row) {
|
||||
function formatDate(row, date, options={}) {
|
||||
// Function for formatting date field
|
||||
var html = renderDate(row.date);
|
||||
var html = renderDate(date, options);
|
||||
|
||||
if (row.user_detail) {
|
||||
if (row.user_detail && !options.no_user_detail) {
|
||||
html += `<span class='badge badge-right rounded-pill bg-secondary'>${row.user_detail.username}</span>`;
|
||||
}
|
||||
|
||||
@ -1392,6 +1392,13 @@ function stockItemTestResultFields(options={}) {
|
||||
notes: {
|
||||
icon: 'fa-sticky-note',
|
||||
},
|
||||
test_station: {},
|
||||
started_datetime: {
|
||||
icon: 'fa-calendar-alt',
|
||||
},
|
||||
finished_datetime: {
|
||||
icon: 'fa-calendar-alt',
|
||||
},
|
||||
stock_item: {
|
||||
hidden: true,
|
||||
},
|
||||
@ -1530,7 +1537,30 @@ function loadStockTestResultsTable(table, options) {
|
||||
title: '{% trans "Test Date" %}',
|
||||
sortable: true,
|
||||
formatter: function(value, row) {
|
||||
return formatDate(row);
|
||||
return formatDate(row, row.date);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'test_station',
|
||||
title: '{% trans "Test station" %}',
|
||||
visible: false,
|
||||
},
|
||||
{
|
||||
field: 'started_timestamp',
|
||||
title: '{% trans "Test started" %}',
|
||||
sortable: true,
|
||||
visible: false,
|
||||
formatter: function(value, row) {
|
||||
return formatDate(row, row.started_datetime, {showTime: true, no_user_detail: true});
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'finished_timestamp',
|
||||
title: '{% trans "Test finished" %}',
|
||||
sortable: true,
|
||||
visible: false,
|
||||
formatter: function(value, row) {
|
||||
return formatDate(row, row.finished_datetime, {showTime: true, no_user_detail: true});
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -1656,6 +1686,12 @@ function loadStockTestResultsTable(table, options) {
|
||||
fields['template']['value'] = templateId;
|
||||
fields['template']['filters']['part'] = options.part;
|
||||
|
||||
if (!options.test_station_fields) {
|
||||
delete fields['test_station'];
|
||||
delete fields['started_datetime'];
|
||||
delete fields['finished_datetime'];
|
||||
}
|
||||
|
||||
constructForm('{% url "api-stock-test-result-list" %}', {
|
||||
method: 'POST',
|
||||
fields: fields,
|
||||
@ -3204,7 +3240,7 @@ function installStockItem(stock_item_id, part_id, options={}) {
|
||||
auto_fill: true,
|
||||
filters: {
|
||||
trackable: true,
|
||||
in_bom_for: part_id,
|
||||
in_bom_for: options.enforce_bom ? part_id : undefined,
|
||||
}
|
||||
},
|
||||
stock_item: {
|
||||
|
@ -56,12 +56,14 @@ The following basic options are available:
|
||||
| INVENTREE_ADMIN_ENABLED | admin_enabled | Enable the [django administrator interface]({% include "django.html" %}/ref/contrib/admin/) | True |
|
||||
| INVENTREE_ADMIN_URL | admin_url | URL for accessing [admin interface](../settings/admin.md) | admin |
|
||||
| INVENTREE_LANGUAGE | language | Default language | en-us |
|
||||
| INVENTREE_BASE_URL | base_url | Server base URL | *Not specified* |
|
||||
| INVENTREE_AUTO_UPDATE | auto_update | Database migrations will be run automatically | False |
|
||||
|
||||
!!! tip "INVENTREE_SITE_URL"
|
||||
The *INVENTREE_SITE_URL* option defines the base URL for the InvenTree server. This is a critical setting, and it is required for correct operation of the server. If not specified, the server will attempt to determine the site URL automatically - but this may not always be correct!
|
||||
|
||||
## Server Access
|
||||
|
||||
Depending on how your InvenTree installation is configured, you will need to pay careful attention to the following settings. If you are running your server behind a proxy, or want to adjust support for CORS requests, one or more of the following settings may need to be adjusted.
|
||||
Depending on how your InvenTree installation is configured, you will need to pay careful attention to the following settings. If you are running your server behind a proxy, or want to adjust support for [CORS requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS), one or more of the following settings may need to be adjusted.
|
||||
|
||||
!!! warning "Advanced Users"
|
||||
The following settings require a certain assumed level of knowledge. You should also refer to the [django documentation]({% include "django.html" %}/ref/settings/) for more information.
|
||||
@ -86,6 +88,10 @@ Depending on how your InvenTree installation is configured, you will need to pay
|
||||
| INVENTREE_USE_X_FORWARDED_PORT | use_x_forwarded_port | Use forwarded port header | False |
|
||||
| INVENTREE_CORS_ALLOW_CREDENTIALS | cors.allow_credentials | Allow cookies in cross-site requests | True |
|
||||
|
||||
### Proxy Settings
|
||||
|
||||
If you are running InvenTree behind another proxy, you will need to ensure that the InvenTree server is configured to listen on the correct host and port. You will likely have to adjust the `INVENTREE_ALLOWED_HOSTS` setting to ensure that the server will accept requests from the proxy.
|
||||
|
||||
## Admin Site
|
||||
|
||||
Django provides a powerful [administrator interface]({% include "django.html" %}/ref/contrib/admin/) which can be used to manage the InvenTree database. This interface is enabled by default, and available at the `/admin/` URL.
|
||||
|
@ -27,7 +27,7 @@ The following guide provides a streamlined production InvenTree installation, wi
|
||||
|
||||
### Required Files
|
||||
|
||||
The following files required for this setup are provided with the InvenTree source, located in the `./docker/` directory of the [InvenTree source code](https://github.com/inventree/InvenTree/tree/master/docker/production):
|
||||
The following files required for this setup are provided with the InvenTree source, located in the `./docker/` directory of the [InvenTree source code](https://github.com/inventree/InvenTree/tree/master/docker/):
|
||||
|
||||
| Filename | Description |
|
||||
| --- | --- |
|
||||
@ -109,6 +109,9 @@ This command launches the following containers:
|
||||
!!! success "Up and Running!"
|
||||
You should now be able to view the InvenTree login screen at [http://inventree.localhost](http://inventree.localhost) (or whatever custom domain you have configured in the `.env` file).
|
||||
|
||||
!!! tip "External Access"
|
||||
Note that `http://inventree.localhost` will only be available from the machine you are running the code on. To make it accessible externally, change `INVENTREE_SITE_URL` to a host address which can be accessed by other computers on your network.
|
||||
|
||||
## Updating InvenTree
|
||||
|
||||
To update your InvenTree installation to the latest version, follow these steps:
|
||||
|
@ -23,7 +23,7 @@ cryptography==42.0.4
|
||||
# via pdfminer-six
|
||||
distlib==0.3.8
|
||||
# via virtualenv
|
||||
django==4.2.10
|
||||
django==4.2.11
|
||||
# via django-slowtests
|
||||
django-slowtests==1.1.1
|
||||
django-test-migrations==1.3.0
|
||||
@ -57,7 +57,7 @@ pyyaml==6.0.1
|
||||
# via pre-commit
|
||||
requests==2.31.0
|
||||
# via coveralls
|
||||
setuptools==69.1.0
|
||||
setuptools==69.2.0
|
||||
# via
|
||||
# nodeenv
|
||||
# pip-tools
|
||||
|
@ -52,7 +52,7 @@ deprecated==1.2.14
|
||||
diff-match-patch==20230430
|
||||
# via django-import-export
|
||||
dj-rest-auth==5.0.2
|
||||
django==4.2.10
|
||||
django==4.2.11
|
||||
# via
|
||||
# dj-rest-auth
|
||||
# django-allauth
|
||||
@ -296,7 +296,7 @@ rpds-py==0.17.1
|
||||
# referencing
|
||||
sentry-sdk==1.40.0
|
||||
# via django-q-sentry
|
||||
setuptools==69.1.0
|
||||
setuptools==69.2.0
|
||||
# via
|
||||
# django-money
|
||||
# opentelemetry-instrumentation
|
||||
|
@ -17,6 +17,7 @@
|
||||
"it",
|
||||
"ja",
|
||||
"ko",
|
||||
"lv",
|
||||
"nl",
|
||||
"no",
|
||||
"pl",
|
||||
|
@ -22,12 +22,9 @@ export function setApiDefaults() {
|
||||
|
||||
api.defaults.baseURL = host;
|
||||
api.defaults.timeout = 2500;
|
||||
|
||||
if (!!token) {
|
||||
api.defaults.headers.common['Authorization'] = `Token ${token}`;
|
||||
} else {
|
||||
api.defaults.headers.common['Authorization'] = undefined;
|
||||
}
|
||||
api.defaults.headers.common['Authorization'] = token
|
||||
? `Token ${token}`
|
||||
: undefined;
|
||||
|
||||
if (!!getCsrfCookie()) {
|
||||
api.defaults.withCredentials = true;
|
||||
|
@ -20,6 +20,7 @@ import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { InvenTreeIcon, InvenTreeIconType } from '../../functions/icons';
|
||||
import { getDetailUrl } from '../../functions/urls';
|
||||
import { base_url } from '../../main';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import { useGlobalSettingsState } from '../../states/SettingsState';
|
||||
import { ProgressBar } from '../items/ProgressBar';
|
||||
@ -198,7 +199,14 @@ function TableStringValue(props: FieldProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
wordBreak: 'break-word',
|
||||
alignItems: 'flex-start'
|
||||
}}
|
||||
>
|
||||
<Suspense fallback={<Skeleton width={200} height={20} radius="xl" />}>
|
||||
<span>
|
||||
{value ? value : props.field_data?.unit && '0'}{' '}
|
||||
@ -285,7 +293,7 @@ function TableAnchorValue(props: FieldProps) {
|
||||
<Suspense fallback={<Skeleton width={200} height={20} radius="xl" />}>
|
||||
{make_link ? (
|
||||
<Anchor
|
||||
href={`/platform${detailUrl}`}
|
||||
href={`/${base_url}${detailUrl}`}
|
||||
target={data?.external ? '_blank' : undefined}
|
||||
rel={data?.external ? 'noreferrer noopener' : undefined}
|
||||
>
|
||||
@ -360,22 +368,19 @@ export function DetailsTableField({
|
||||
const FieldType: any = getFieldType(field.type);
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<tr style={{ verticalAlign: 'top' }}>
|
||||
<td
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '20px',
|
||||
width: '50',
|
||||
justifyContent: 'flex-start'
|
||||
width: '50'
|
||||
}}
|
||||
>
|
||||
<InvenTreeIcon icon={(field.icon ?? field.name) as InvenTreeIconType} />
|
||||
</td>
|
||||
<td>
|
||||
<td style={{ minWidth: '25%', maxWidth: '65%' }}>
|
||||
<Text>{field.label}</Text>
|
||||
</td>
|
||||
<td style={{ minWidth: '40%' }}>
|
||||
<td style={{ width: '100%' }}>
|
||||
<FieldType field_data={field} field_value={item[field.name]} />
|
||||
</td>
|
||||
<td style={{ width: '50' }}>
|
||||
|
@ -17,6 +17,7 @@ import { useMemo, useState } from 'react';
|
||||
|
||||
import { api } from '../../App';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
import { cancelEvent } from '../../functions/events';
|
||||
import { InvenTreeIcon } from '../../functions/icons';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import { PartThumbTable } from '../../tables/part/PartThumbTable';
|
||||
@ -267,9 +268,8 @@ function ImageActionButtons({
|
||||
size="lg"
|
||||
tooltipAlignment="top"
|
||||
onClick={(event: any) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
event?.nativeEvent?.stopImmediatePropagation();
|
||||
cancelEvent(event);
|
||||
|
||||
modals.open({
|
||||
title: <StylishText size="xl">{t`Select Image`}</StylishText>,
|
||||
size: 'xxl',
|
||||
@ -288,9 +288,7 @@ function ImageActionButtons({
|
||||
size="lg"
|
||||
tooltipAlignment="top"
|
||||
onClick={(event: any) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
event?.nativeEvent?.stopImmediatePropagation();
|
||||
cancelEvent(event);
|
||||
modals.open({
|
||||
title: <StylishText size="xl">{t`Upload Image`}</StylishText>,
|
||||
children: (
|
||||
@ -310,9 +308,7 @@ function ImageActionButtons({
|
||||
size="lg"
|
||||
tooltipAlignment="top"
|
||||
onClick={(event: any) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
event?.nativeEvent?.stopImmediatePropagation();
|
||||
cancelEvent(event);
|
||||
removeModal(apiPath, setImage);
|
||||
}}
|
||||
/>
|
||||
@ -349,9 +345,7 @@ export function DetailsImage(props: DetailImageProps) {
|
||||
}, [props.imageActions]);
|
||||
|
||||
const expandImage = (event: any) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
event?.nativeEvent?.stopImmediatePropagation();
|
||||
cancelEvent(event);
|
||||
modals.open({
|
||||
children: <ApiImage src={img} />,
|
||||
withCloseButton: false
|
||||
|
@ -2,15 +2,15 @@ import { t } from '@lingui/macro';
|
||||
import {
|
||||
Alert,
|
||||
DefaultMantineColor,
|
||||
Divider,
|
||||
LoadingOverlay,
|
||||
Paper,
|
||||
Text
|
||||
} from '@mantine/core';
|
||||
import { Button, Group, Stack } from '@mantine/core';
|
||||
import { useId } from '@mantine/hooks';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
FieldValues,
|
||||
@ -114,16 +114,14 @@ export function OptionsApiForm({
|
||||
props.pk,
|
||||
props.pathParams
|
||||
],
|
||||
queryFn: () =>
|
||||
api.options(url).then((res) => {
|
||||
let fields: Record<string, ApiFormFieldType> | null = {};
|
||||
|
||||
if (!props.ignorePermissionCheck) {
|
||||
fields = extractAvailableFields(res, props.method);
|
||||
}
|
||||
|
||||
return fields;
|
||||
}),
|
||||
queryFn: async () => {
|
||||
let response = await api.options(url);
|
||||
let fields: Record<string, ApiFormFieldType> | null = {};
|
||||
if (!props.ignorePermissionCheck) {
|
||||
fields = extractAvailableFields(response, props.method);
|
||||
}
|
||||
return fields;
|
||||
},
|
||||
throwOnError: (error: any) => {
|
||||
if (error.response) {
|
||||
invalidResponse(error.response.status);
|
||||
@ -134,7 +132,6 @@ export function OptionsApiForm({
|
||||
color: 'red'
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
});
|
||||
@ -142,6 +139,12 @@ export function OptionsApiForm({
|
||||
const formProps: ApiFormProps = useMemo(() => {
|
||||
const _props = { ...props };
|
||||
|
||||
// This forcefully overrides initial data
|
||||
// Currently, most modals do not get pre-loaded correctly
|
||||
if (!data) {
|
||||
_props.fields = undefined;
|
||||
}
|
||||
|
||||
if (!_props.fields) return _props;
|
||||
|
||||
for (const [k, v] of Object.entries(_props.fields)) {
|
||||
@ -161,10 +164,6 @@ export function OptionsApiForm({
|
||||
return _props;
|
||||
}, [data, props]);
|
||||
|
||||
if (!data) {
|
||||
return <LoadingOverlay visible={true} />;
|
||||
}
|
||||
|
||||
return <ApiForm id={id} props={formProps} />;
|
||||
}
|
||||
|
||||
@ -225,41 +224,46 @@ export function ApiForm({ id, props }: { id: string; props: ApiFormProps }) {
|
||||
props.pathParams
|
||||
],
|
||||
queryFn: async () => {
|
||||
return api
|
||||
.get(url)
|
||||
.then((response) => {
|
||||
const processFields = (fields: ApiFormFieldSet, data: NestedDict) => {
|
||||
const res: NestedDict = {};
|
||||
try {
|
||||
// Await API call
|
||||
let response = await api.get(url);
|
||||
// Define function to process API response
|
||||
const processFields = (fields: ApiFormFieldSet, data: NestedDict) => {
|
||||
const res: NestedDict = {};
|
||||
|
||||
for (const [k, field] of Object.entries(fields)) {
|
||||
const dataValue = data[k];
|
||||
// TODO: replace with .map()
|
||||
for (const [k, field] of Object.entries(fields)) {
|
||||
const dataValue = data[k];
|
||||
|
||||
if (
|
||||
field.field_type === 'nested object' &&
|
||||
field.children &&
|
||||
typeof dataValue === 'object'
|
||||
) {
|
||||
res[k] = processFields(field.children, dataValue);
|
||||
} else {
|
||||
res[k] = dataValue;
|
||||
}
|
||||
if (
|
||||
field.field_type === 'nested object' &&
|
||||
field.children &&
|
||||
typeof dataValue === 'object'
|
||||
) {
|
||||
res[k] = processFields(field.children, dataValue);
|
||||
} else {
|
||||
res[k] = dataValue;
|
||||
}
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
const initialData: any = processFields(
|
||||
props.fields ?? {},
|
||||
response.data
|
||||
);
|
||||
return res;
|
||||
};
|
||||
|
||||
// Update form values, but only for the fields specified for this form
|
||||
form.reset(initialData);
|
||||
// Process API response
|
||||
const initialData: any = processFields(
|
||||
props.fields ?? {},
|
||||
response.data
|
||||
);
|
||||
|
||||
return response;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error fetching initial data:', error);
|
||||
});
|
||||
// Update form values, but only for the fields specified for this form
|
||||
form.reset(initialData);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Error fetching initial data:', error);
|
||||
// Re-throw error to allow react-query to handle error
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -380,8 +384,12 @@ export function ApiForm({ id, props }: { id: string; props: ApiFormProps }) {
|
||||
};
|
||||
|
||||
const isLoading = useMemo(
|
||||
() => isFormLoading || initialDataQuery.isFetching || isSubmitting,
|
||||
[isFormLoading, initialDataQuery.isFetching, isSubmitting]
|
||||
() =>
|
||||
isFormLoading ||
|
||||
initialDataQuery.isFetching ||
|
||||
isSubmitting ||
|
||||
!props.fields,
|
||||
[isFormLoading, initialDataQuery.isFetching, isSubmitting, props.fields]
|
||||
);
|
||||
|
||||
const onFormError = useCallback<SubmitErrorHandler<FieldValues>>(() => {
|
||||
@ -390,67 +398,81 @@ export function ApiForm({ id, props }: { id: string; props: ApiFormProps }) {
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Stack spacing="sm">
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
{(!isValid || nonFieldErrors.length > 0) && (
|
||||
<Alert radius="sm" color="red" title={t`Form Errors Exist`}>
|
||||
{nonFieldErrors.length > 0 && (
|
||||
<Stack spacing="xs">
|
||||
{nonFieldErrors.map((message) => (
|
||||
<Text key={message}>{message}</Text>
|
||||
))}
|
||||
</Stack>
|
||||
{/* Show loading overlay while fetching fields */}
|
||||
{/* zIndex used to force overlay on top of modal header bar */}
|
||||
<LoadingOverlay visible={isLoading} zIndex={1010} />
|
||||
|
||||
{/* Attempt at making fixed footer with scroll area */}
|
||||
<Paper mah={'65vh'} style={{ overflowY: 'auto' }}>
|
||||
<div>
|
||||
{/* Form Fields */}
|
||||
<Stack spacing="sm">
|
||||
{(!isValid || nonFieldErrors.length > 0) && (
|
||||
<Alert radius="sm" color="red" title={t`Form Errors Exist`}>
|
||||
{nonFieldErrors.length > 0 && (
|
||||
<Stack spacing="xs">
|
||||
{nonFieldErrors.map((message) => (
|
||||
<Text key={message}>{message}</Text>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
{props.preFormContent}
|
||||
{props.preFormSuccess && (
|
||||
<Alert color="green" radius="sm">
|
||||
{props.preFormSuccess}
|
||||
</Alert>
|
||||
)}
|
||||
{props.preFormWarning && (
|
||||
<Alert color="orange" radius="sm">
|
||||
{props.preFormWarning}
|
||||
</Alert>
|
||||
)}
|
||||
<FormProvider {...form}>
|
||||
<Stack spacing="xs">
|
||||
{Object.entries(props.fields ?? {}).map(([fieldName, field]) => (
|
||||
<ApiFormField
|
||||
key={fieldName}
|
||||
fieldName={fieldName}
|
||||
definition={field}
|
||||
control={form.control}
|
||||
/>
|
||||
))}
|
||||
{props.preFormContent}
|
||||
{props.preFormSuccess && (
|
||||
<Alert color="green" radius="sm">
|
||||
{props.preFormSuccess}
|
||||
</Alert>
|
||||
)}
|
||||
{props.preFormWarning && (
|
||||
<Alert color="orange" radius="sm">
|
||||
{props.preFormWarning}
|
||||
</Alert>
|
||||
)}
|
||||
<FormProvider {...form}>
|
||||
<Stack spacing="xs">
|
||||
{Object.entries(props.fields ?? {}).map(
|
||||
([fieldName, field]) => (
|
||||
<ApiFormField
|
||||
key={fieldName}
|
||||
fieldName={fieldName}
|
||||
definition={field}
|
||||
control={form.control}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Stack>
|
||||
</FormProvider>
|
||||
{props.postFormContent}
|
||||
</Stack>
|
||||
</FormProvider>
|
||||
{props.postFormContent}
|
||||
</Stack>
|
||||
<Divider />
|
||||
<Group position="right">
|
||||
{props.actions?.map((action, i) => (
|
||||
</div>
|
||||
</Paper>
|
||||
|
||||
{/* Footer with Action Buttons */}
|
||||
<div>
|
||||
<Group position="right">
|
||||
{props.actions?.map((action, i) => (
|
||||
<Button
|
||||
key={i}
|
||||
onClick={action.onClick}
|
||||
variant={action.variant ?? 'outline'}
|
||||
radius="sm"
|
||||
color={action.color}
|
||||
>
|
||||
{action.text}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
key={i}
|
||||
onClick={action.onClick}
|
||||
variant={action.variant ?? 'outline'}
|
||||
onClick={form.handleSubmit(submitForm, onFormError)}
|
||||
variant="filled"
|
||||
radius="sm"
|
||||
color={action.color}
|
||||
color={props.submitColor ?? 'green'}
|
||||
disabled={isLoading || (props.fetchInitialData && !isDirty)}
|
||||
>
|
||||
{action.text}
|
||||
{props.submitText ?? t`Submit`}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
onClick={form.handleSubmit(submitForm, onFormError)}
|
||||
variant="filled"
|
||||
radius="sm"
|
||||
color={props.submitColor ?? 'green'}
|
||||
disabled={isLoading || (props.fetchInitialData && !isDirty)}
|
||||
>
|
||||
{props.submitText ?? t`Submit`}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { Group, Text } from '@mantine/core';
|
||||
import { IconPhoto } from '@tabler/icons-react';
|
||||
import { Anchor, Group, Text } from '@mantine/core';
|
||||
import { IconLink, IconPhoto } from '@tabler/icons-react';
|
||||
import {
|
||||
IconFile,
|
||||
IconFileTypeCsv,
|
||||
@ -50,14 +50,20 @@ export function attachmentIcon(attachment: string): ReactNode {
|
||||
* @param attachment : string - The attachment filename
|
||||
*/
|
||||
export function AttachmentLink({
|
||||
attachment
|
||||
attachment,
|
||||
external
|
||||
}: {
|
||||
attachment: string;
|
||||
external?: boolean;
|
||||
}): ReactNode {
|
||||
let text = external ? attachment : attachment.split('/').pop();
|
||||
|
||||
return (
|
||||
<Group position="left" spacing="sm">
|
||||
{attachmentIcon(attachment)}
|
||||
<Text>{attachment.split('/').pop()}</Text>
|
||||
{external ? <IconLink /> : attachmentIcon(attachment)}
|
||||
<Anchor href={attachment} target="_blank" rel="noopener noreferrer">
|
||||
{text}
|
||||
</Anchor>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
@ -37,20 +37,22 @@ export function Header() {
|
||||
const notifications = useQuery({
|
||||
queryKey: ['notification-count'],
|
||||
queryFn: async () => {
|
||||
return api
|
||||
.get(apiUrl(ApiEndpoints.notifications_list), {
|
||||
try {
|
||||
const params = {
|
||||
params: {
|
||||
read: false,
|
||||
limit: 1
|
||||
}
|
||||
})
|
||||
.then((response) => {
|
||||
setNotificationCount(response.data.count);
|
||||
return response.data;
|
||||
})
|
||||
.catch((error) => {
|
||||
return error;
|
||||
});
|
||||
};
|
||||
let response = await api.get(
|
||||
apiUrl(ApiEndpoints.notifications_list),
|
||||
params
|
||||
);
|
||||
setNotificationCount(response.data.count);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return error;
|
||||
}
|
||||
},
|
||||
refetchInterval: 30000,
|
||||
refetchOnMount: true,
|
||||
|
@ -34,6 +34,7 @@ export const getSupportedLanguages = (): Record<string, string> => {
|
||||
it: t`Italian`,
|
||||
ja: t`Japanese`,
|
||||
ko: t`Korean`,
|
||||
lv: t`Latvian`,
|
||||
nl: t`Dutch`,
|
||||
no: t`Norwegian`,
|
||||
pl: t`Polish`,
|
||||
|
@ -92,6 +92,7 @@ export enum ApiEndpoints {
|
||||
stock_merge = 'stock/merge/',
|
||||
stock_assign = 'stock/assign/',
|
||||
stock_status = 'stock/status/',
|
||||
stock_install = 'stock/:id/install',
|
||||
|
||||
// Order API endpoints
|
||||
purchase_order_list = 'order/po/',
|
||||
|
@ -80,7 +80,7 @@ export function editAttachment({
|
||||
model: string;
|
||||
pk: number;
|
||||
attachmentType: 'file' | 'link';
|
||||
callback?: () => void;
|
||||
callback?: (record: any) => void;
|
||||
}) {
|
||||
let formFields: ApiFormFieldSet = {
|
||||
link: {},
|
||||
|
6
src/frontend/src/functions/events.tsx
Normal file
6
src/frontend/src/functions/events.tsx
Normal file
@ -0,0 +1,6 @@
|
||||
// Helper function to cancel event propagation
|
||||
export function cancelEvent(event: any) {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
event?.nativeEvent?.stopImmediatePropagation();
|
||||
}
|
@ -28,6 +28,13 @@ export type TableState = {
|
||||
setHiddenColumns: (columns: string[]) => void;
|
||||
searchTerm: string;
|
||||
setSearchTerm: (term: string) => void;
|
||||
recordCount: number;
|
||||
setRecordCount: (count: number) => void;
|
||||
page: number;
|
||||
setPage: (page: number) => void;
|
||||
records: any[];
|
||||
setRecords: (records: any[]) => void;
|
||||
updateRecord: (record: any) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -71,6 +78,12 @@ export function useTable(tableName: string): TableState {
|
||||
setSelectedRecords([]);
|
||||
}, []);
|
||||
|
||||
// Total record count
|
||||
const [recordCount, setRecordCount] = useState<number>(0);
|
||||
|
||||
// Pagination data
|
||||
const [page, setPage] = useState<number>(1);
|
||||
|
||||
// A list of hidden columns, saved to local storage
|
||||
const [hiddenColumns, setHiddenColumns] = useLocalStorage<string[]>({
|
||||
key: `inventree-hidden-table-columns-${tableName}`,
|
||||
@ -80,6 +93,28 @@ export function useTable(tableName: string): TableState {
|
||||
// Search term
|
||||
const [searchTerm, setSearchTerm] = useState<string>('');
|
||||
|
||||
// Table records
|
||||
const [records, setRecords] = useState<any[]>([]);
|
||||
|
||||
// Update a single record in the table, by primary key value
|
||||
const updateRecord = useCallback(
|
||||
(record: any) => {
|
||||
let _records = [...records];
|
||||
|
||||
// Find the matching record in the table
|
||||
const index = _records.findIndex((r) => r.pk === record.pk);
|
||||
|
||||
if (index >= 0) {
|
||||
_records[index] = record;
|
||||
} else {
|
||||
_records.push(record);
|
||||
}
|
||||
|
||||
setRecords(_records);
|
||||
},
|
||||
[records]
|
||||
);
|
||||
|
||||
return {
|
||||
tableKey,
|
||||
refreshTable,
|
||||
@ -94,6 +129,13 @@ export function useTable(tableName: string): TableState {
|
||||
hiddenColumns,
|
||||
setHiddenColumns,
|
||||
searchTerm,
|
||||
setSearchTerm
|
||||
setSearchTerm,
|
||||
recordCount,
|
||||
setRecordCount,
|
||||
page,
|
||||
setPage,
|
||||
records,
|
||||
setRecords,
|
||||
updateRecord
|
||||
};
|
||||
}
|
||||
|
4
src/frontend/src/locales/lv/messages.d.ts
vendored
Normal file
4
src/frontend/src/locales/lv/messages.d.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
import { Messages } from '@lingui/core';
|
||||
declare const messages: Messages;
|
||||
export { messages };
|
||||
|
4964
src/frontend/src/locales/lv/messages.po
Normal file
4964
src/frontend/src/locales/lv/messages.po
Normal file
File diff suppressed because it is too large
Load Diff
@ -132,7 +132,6 @@ export default function SystemSettings() {
|
||||
<GlobalSettingList
|
||||
keys={['CURRENCY_UPDATE_PLUGIN', 'CURRENCY_UPDATE_INTERVAL']}
|
||||
/>
|
||||
<StylishText size="xl">{t`Exchange Rates`}</StylishText>
|
||||
</>
|
||||
)
|
||||
},
|
||||
@ -212,7 +211,9 @@ export default function SystemSettings() {
|
||||
'STOCK_ALLOW_EXPIRED_BUILD',
|
||||
'STOCK_OWNERSHIP_CONTROL',
|
||||
'STOCK_LOCATION_DEFAULT_ICON',
|
||||
'STOCK_SHOW_INSTALLED_ITEMS'
|
||||
'STOCK_SHOW_INSTALLED_ITEMS',
|
||||
'STOCK_ENFORCE_BOM_INSTALLATION',
|
||||
'TEST_STATION_DATA'
|
||||
]}
|
||||
/>
|
||||
)
|
||||
|
@ -176,7 +176,6 @@ export default function CategoryDetail({}: {}) {
|
||||
label: t`Category Details`,
|
||||
icon: <IconInfoCircle />,
|
||||
content: detailsPanel
|
||||
// hidden: !category?.pk,
|
||||
},
|
||||
{
|
||||
name: 'parts',
|
||||
@ -200,7 +199,7 @@ export default function CategoryDetail({}: {}) {
|
||||
},
|
||||
{
|
||||
name: 'parameters',
|
||||
label: t`Parameters`,
|
||||
label: t`Part Parameters`,
|
||||
icon: <IconListDetails />,
|
||||
content: <ParametricPartTable categoryId={id} />
|
||||
}
|
||||
|
@ -111,7 +111,14 @@ export default function PartDetail() {
|
||||
// Construct the details tables
|
||||
let tl: DetailsField[] = [
|
||||
{
|
||||
type: 'text',
|
||||
type: 'string',
|
||||
name: 'name',
|
||||
label: t`Name`,
|
||||
icon: 'part',
|
||||
copy: true
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'description',
|
||||
label: t`Description`,
|
||||
copy: true
|
||||
|
@ -55,6 +55,7 @@ import { useInstance } from '../../hooks/UseInstance';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import { AttachmentTable } from '../../tables/general/AttachmentTable';
|
||||
import InstalledItemsTable from '../../tables/stock/InstalledItemsTable';
|
||||
import { StockItemTable } from '../../tables/stock/StockItemTable';
|
||||
import StockItemTestResultTable from '../../tables/stock/StockItemTestResultTable';
|
||||
|
||||
@ -164,6 +165,14 @@ export default function StockDetail() {
|
||||
type: 'link',
|
||||
name: 'belongs_to',
|
||||
label: t`Installed In`,
|
||||
model_formatter: (model: any) => {
|
||||
let text = model?.part_detail?.full_name ?? model?.name;
|
||||
if (model.serial && model.quantity == 1) {
|
||||
text += `# ${model.serial}`;
|
||||
}
|
||||
|
||||
return text;
|
||||
},
|
||||
model: ModelType.stockitem,
|
||||
hidden: !stockitem.belongs_to
|
||||
},
|
||||
@ -259,7 +268,8 @@ export default function StockDetail() {
|
||||
name: 'installed_items',
|
||||
label: t`Installed Items`,
|
||||
icon: <IconBoxPadding />,
|
||||
hidden: !stockitem?.part_detail?.assembly
|
||||
hidden: !stockitem?.part_detail?.assembly,
|
||||
content: <InstalledItemsTable parentId={stockitem.pk} />
|
||||
},
|
||||
{
|
||||
name: 'child_items',
|
||||
|
@ -16,4 +16,5 @@ export type TableColumn<T = any> = {
|
||||
ellipsis?: boolean; // Whether the column should be ellipsized
|
||||
textAlignment?: 'left' | 'center' | 'right'; // The text alignment of the column
|
||||
cellsStyle?: any; // The style of the cells in the column
|
||||
extra?: any; // Extra data to pass to the render function
|
||||
};
|
||||
|
@ -2,6 +2,7 @@
|
||||
* Common rendering functions for table column data.
|
||||
*/
|
||||
import { t } from '@lingui/macro';
|
||||
import { Anchor } from '@mantine/core';
|
||||
|
||||
import { Thumbnail } from '../components/images/Thumbnail';
|
||||
import { ProgressBar } from '../components/items/ProgressBar';
|
||||
@ -10,6 +11,7 @@ import { TableStatusRenderer } from '../components/render/StatusRenderer';
|
||||
import { RenderOwner } from '../components/render/User';
|
||||
import { formatCurrency, renderDate } from '../defaults/formatters';
|
||||
import { ModelType } from '../enums/ModelType';
|
||||
import { cancelEvent } from '../functions/events';
|
||||
import { TableColumn } from './Column';
|
||||
import { ProjectCodeHoverCard } from './TableHoverCard';
|
||||
|
||||
@ -55,11 +57,36 @@ export function DescriptionColumn({
|
||||
};
|
||||
}
|
||||
|
||||
export function LinkColumn(): TableColumn {
|
||||
export function LinkColumn({
|
||||
accessor = 'link'
|
||||
}: {
|
||||
accessor?: string;
|
||||
}): TableColumn {
|
||||
return {
|
||||
accessor: 'link',
|
||||
sortable: false
|
||||
// TODO: Custom URL hyperlink renderer?
|
||||
accessor: accessor,
|
||||
sortable: false,
|
||||
render: (record: any) => {
|
||||
let url = record[accessor];
|
||||
|
||||
if (!url) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return (
|
||||
<Anchor
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
onClick={(event: any) => {
|
||||
cancelEvent(event);
|
||||
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}}
|
||||
>
|
||||
{url}
|
||||
</Anchor>
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -191,7 +191,7 @@ export function FilterSelectDrawer({
|
||||
>
|
||||
<Stack spacing="xs">
|
||||
{tableState.activeFilters &&
|
||||
tableState.activeFilters.map((f) => (
|
||||
tableState.activeFilters?.map((f) => (
|
||||
<FilterItem key={f.name} flt={f} tableState={tableState} />
|
||||
))}
|
||||
{tableState.activeFilters && tableState.activeFilters.length > 0 && (
|
||||
|
@ -14,16 +14,24 @@ import { modals } from '@mantine/modals';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { IconFilter, IconRefresh, IconTrash } from '@tabler/icons-react';
|
||||
import { IconBarcode, IconPrinter } from '@tabler/icons-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
|
||||
import { dataTagSymbol, useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
DataTable,
|
||||
DataTableCellClickHandler,
|
||||
DataTableSortStatus
|
||||
} from 'mantine-datatable';
|
||||
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { api } from '../App';
|
||||
import { ActionButton } from '../components/buttons/ActionButton';
|
||||
import { ButtonMenu } from '../components/buttons/ButtonMenu';
|
||||
import { ApiFormFieldSet } from '../components/forms/fields/ApiFormField';
|
||||
import { ModelType } from '../enums/ModelType';
|
||||
import { extractAvailableFields, mapFields } from '../functions/forms';
|
||||
import { getDetailUrl } from '../functions/urls';
|
||||
import { TableState } from '../hooks/UseTable';
|
||||
import { base_url } from '../main';
|
||||
import { useLocalState } from '../states/LocalState';
|
||||
import { TableColumn } from './Column';
|
||||
import { TableColumnSelect } from './ColumnSelect';
|
||||
@ -57,6 +65,8 @@ const defaultPageSize: number = 25;
|
||||
* @param dataFormatter : (data: any) => any - Callback function to reformat data returned by server (if not in default format)
|
||||
* @param rowActions : (record: any) => RowAction[] - Callback function to generate row actions
|
||||
* @param onRowClick : (record: any, index: number, event: any) => void - Callback function when a row is clicked
|
||||
* @param onCellClick : (event: any, record: any, recordIndex: number, column: any, columnIndex: number) => void - Callback function when a cell is clicked
|
||||
* @param modelType: ModelType - The model type for the table
|
||||
*/
|
||||
export type InvenTreeTableProps<T = any> = {
|
||||
params?: any;
|
||||
@ -79,6 +89,8 @@ export type InvenTreeTableProps<T = any> = {
|
||||
dataFormatter?: (data: any) => any;
|
||||
rowActions?: (record: T) => RowAction[];
|
||||
onRowClick?: (record: T, index: number, event: any) => void;
|
||||
onCellClick?: DataTableCellClickHandler<T>;
|
||||
modelType?: ModelType;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -119,6 +131,8 @@ export function InvenTreeTable<T = any>({
|
||||
const { getTableColumnNames, setTableColumnNames } = useLocalState();
|
||||
const [fieldNames, setFieldNames] = useState<Record<string, string>>({});
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Construct table filters - note that we can introspect filter labels from column names
|
||||
const filters: TableFilter[] = useMemo(() => {
|
||||
return (
|
||||
@ -133,9 +147,10 @@ export function InvenTreeTable<T = any>({
|
||||
|
||||
// Request OPTIONS data from the API, before we load the table
|
||||
const tableOptionQuery = useQuery({
|
||||
enabled: false,
|
||||
enabled: true,
|
||||
queryKey: ['options', url, tableState.tableKey],
|
||||
retry: 3,
|
||||
refetchOnMount: true,
|
||||
queryFn: async () => {
|
||||
return api
|
||||
.options(url, {
|
||||
@ -259,15 +274,12 @@ export function InvenTreeTable<T = any>({
|
||||
);
|
||||
}
|
||||
|
||||
// Pagination
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
// Filter list visibility
|
||||
const [filtersVisible, setFiltersVisible] = useState<boolean>(false);
|
||||
|
||||
// Reset the pagination state when the search term changes
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
tableState.setPage(1);
|
||||
}, [tableState.searchTerm]);
|
||||
|
||||
/*
|
||||
@ -294,7 +306,7 @@ export function InvenTreeTable<T = any>({
|
||||
if (tableProps.enablePagination && paginate) {
|
||||
let pageSize = tableProps.pageSize ?? defaultPageSize;
|
||||
queryParams.limit = pageSize;
|
||||
queryParams.offset = (page - 1) * pageSize;
|
||||
queryParams.offset = (tableState.page - 1) * pageSize;
|
||||
}
|
||||
|
||||
// Ordering
|
||||
@ -355,7 +367,7 @@ export function InvenTreeTable<T = any>({
|
||||
);
|
||||
|
||||
const handleSortStatusChange = (status: DataTableSortStatus) => {
|
||||
setPage(1);
|
||||
tableState.setPage(1);
|
||||
setSortStatus(status);
|
||||
};
|
||||
|
||||
@ -387,7 +399,7 @@ export function InvenTreeTable<T = any>({
|
||||
results = [];
|
||||
}
|
||||
|
||||
setRecordCount(response.data?.count ?? results.length);
|
||||
tableState.setRecordCount(response.data?.count ?? results.length);
|
||||
|
||||
return results;
|
||||
case 400:
|
||||
@ -419,7 +431,7 @@ export function InvenTreeTable<T = any>({
|
||||
|
||||
const { data, isFetching, refetch } = useQuery({
|
||||
queryKey: [
|
||||
page,
|
||||
tableState.page,
|
||||
props.params,
|
||||
sortStatus.columnAccessor,
|
||||
sortStatus.direction,
|
||||
@ -432,7 +444,10 @@ export function InvenTreeTable<T = any>({
|
||||
refetchOnMount: true
|
||||
});
|
||||
|
||||
const [recordCount, setRecordCount] = useState<number>(0);
|
||||
// Update tableState.records when new data received
|
||||
useEffect(() => {
|
||||
tableState.setRecords(data ?? []);
|
||||
}, [data]);
|
||||
|
||||
// Callback function to delete the selected records in the table
|
||||
const deleteSelectedRecords = useCallback(() => {
|
||||
@ -494,6 +509,30 @@ export function InvenTreeTable<T = any>({
|
||||
});
|
||||
}, [tableState.selectedRecords]);
|
||||
|
||||
// Callback when a row is clicked
|
||||
const handleRowClick = useCallback(
|
||||
(record: any, index: number, event: any) => {
|
||||
if (props.onRowClick) {
|
||||
// If a custom row click handler is provided, use that
|
||||
props.onRowClick(record, index, event);
|
||||
} else if (tableProps.modelType && record?.pk) {
|
||||
// If a model type is provided, navigate to the detail view for that model
|
||||
let url = getDetailUrl(tableProps.modelType, record.pk);
|
||||
|
||||
// Should it be opened in a new tab?
|
||||
if (event?.ctrlKey || event?.shiftKey) {
|
||||
// Open in a new tab
|
||||
url = `/${base_url}${url}`;
|
||||
window.open(url, '_blank');
|
||||
} else {
|
||||
// Navigate internally
|
||||
navigate(url);
|
||||
}
|
||||
}
|
||||
},
|
||||
[props.onRowClick]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{tableProps.enableFilters && (filters.length ?? 0) > 0 && (
|
||||
@ -596,10 +635,10 @@ export function InvenTreeTable<T = any>({
|
||||
pinLastColumn={tableProps.rowActions != undefined}
|
||||
idAccessor={tableProps.idAccessor}
|
||||
minHeight={300}
|
||||
totalRecords={recordCount}
|
||||
totalRecords={tableState.recordCount}
|
||||
recordsPerPage={tableProps.pageSize ?? defaultPageSize}
|
||||
page={page}
|
||||
onPageChange={setPage}
|
||||
page={tableState.page}
|
||||
onPageChange={tableState.setPage}
|
||||
sortStatus={sortStatus}
|
||||
onSortStatusChange={handleSortStatusChange}
|
||||
selectedRecords={
|
||||
@ -613,9 +652,10 @@ export function InvenTreeTable<T = any>({
|
||||
rowExpansion={tableProps.rowExpansion}
|
||||
fetching={isFetching}
|
||||
noRecordsText={missingRecordsText}
|
||||
records={data}
|
||||
records={tableState.records}
|
||||
columns={dataColumns}
|
||||
onRowClick={tableProps.onRowClick}
|
||||
onRowClick={handleRowClick}
|
||||
onCellClick={tableProps.onCellClick}
|
||||
defaultColumnProps={{
|
||||
noWrap: true,
|
||||
textAlignment: 'left',
|
||||
|
@ -4,6 +4,7 @@ import { Menu } from '@mantine/core';
|
||||
import { IconCopy, IconDots, IconEdit, IconTrash } from '@tabler/icons-react';
|
||||
import { ReactNode, useMemo, useState } from 'react';
|
||||
|
||||
import { cancelEvent } from '../functions/events';
|
||||
import { notYetImplemented } from '../functions/notifications';
|
||||
|
||||
// Type definition for a table row action
|
||||
@ -93,9 +94,7 @@ export function RowActions({
|
||||
// Prevent default event handling
|
||||
// Ref: https://icflorescu.github.io/mantine-datatable/examples/links-or-buttons-inside-clickable-rows-or-cells
|
||||
function openMenu(event: any) {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
event?.nativeEvent?.stopImmediatePropagation();
|
||||
cancelEvent(event);
|
||||
setOpened(!opened);
|
||||
}
|
||||
|
||||
@ -118,9 +117,7 @@ export function RowActions({
|
||||
icon={action.icon}
|
||||
onClick={(event) => {
|
||||
// Prevent clicking on the action from selecting the row itself
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
event?.nativeEvent?.stopImmediatePropagation();
|
||||
cancelEvent(event);
|
||||
|
||||
if (action.onClick) {
|
||||
action.onClick();
|
||||
|
@ -16,7 +16,6 @@ import { ModelType } from '../../enums/ModelType';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
import { bomItemFields } from '../../forms/BomForms';
|
||||
import { openDeleteApiForm, openEditApiForm } from '../../functions/forms';
|
||||
import { getDetailUrl } from '../../functions/urls';
|
||||
import { useTable } from '../../hooks/UseTable';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
@ -55,11 +54,9 @@ export function BomTable({
|
||||
partId: number;
|
||||
params?: any;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const user = useUserState();
|
||||
|
||||
const table = useTable('bom');
|
||||
const navigate = useNavigate();
|
||||
|
||||
const tableColumns: TableColumn[] = useMemo(() => {
|
||||
return [
|
||||
@ -355,8 +352,7 @@ export function BomTable({
|
||||
sub_part_detail: true
|
||||
},
|
||||
tableFilters: tableFilters,
|
||||
onRowClick: (row) =>
|
||||
navigate(getDetailUrl(ModelType.part, row.sub_part)),
|
||||
modelType: ModelType.part,
|
||||
rowActions: rowActions
|
||||
}}
|
||||
/>
|
||||
|
@ -1,11 +1,9 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { PartHoverCard } from '../../components/images/Thumbnail';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { getDetailUrl } from '../../functions/urls';
|
||||
import { useTable } from '../../hooks/UseTable';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import { TableColumn } from '../Column';
|
||||
@ -23,8 +21,6 @@ export function UsedInTable({
|
||||
partId: number;
|
||||
params?: any;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const table = useTable('usedin');
|
||||
|
||||
const tableColumns: TableColumn[] = useMemo(() => {
|
||||
@ -87,7 +83,7 @@ export function UsedInTable({
|
||||
sub_part_detail: true
|
||||
},
|
||||
tableFilters: tableFilters,
|
||||
onRowClick: (row) => navigate(getDetailUrl(ModelType.part, row.part))
|
||||
modelType: ModelType.part
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
@ -178,7 +178,7 @@ export function BuildOrderTable({
|
||||
},
|
||||
tableActions: tableActions,
|
||||
tableFilters: tableFilters,
|
||||
onRowClick: (row) => navigate(getDetailUrl(ModelType.build, row.pk))
|
||||
modelType: ModelType.build
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
@ -134,7 +134,7 @@ export function AddressTable({
|
||||
pk: selectedAddress,
|
||||
title: t`Edit Address`,
|
||||
fields: addressFields,
|
||||
onFormSuccess: table.refreshTable
|
||||
onFormSuccess: (record: any) => table.updateRecord(record)
|
||||
});
|
||||
|
||||
const deleteAddress = useDeleteApiFormModal({
|
||||
|
@ -6,6 +6,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { AddItemButton } from '../../components/buttons/AddItemButton';
|
||||
import { Thumbnail } from '../../components/images/Thumbnail';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
import { companyFields } from '../../forms/CompanyForms';
|
||||
import { useCreateApiFormModal } from '../../hooks/UseForm';
|
||||
|
@ -72,7 +72,7 @@ export function ContactTable({
|
||||
pk: selectedContact,
|
||||
title: t`Edit Contact`,
|
||||
fields: contactFields,
|
||||
onFormSuccess: table.refreshTable
|
||||
onFormSuccess: (record: any) => table.updateRecord(record)
|
||||
});
|
||||
|
||||
const newContact = useCreateApiFormModal({
|
||||
|
@ -34,8 +34,7 @@ function attachmentTableColumns(): TableColumn[] {
|
||||
if (record.attachment) {
|
||||
return <AttachmentLink attachment={record.attachment} />;
|
||||
} else if (record.link) {
|
||||
// TODO: Custom renderer for links
|
||||
return record.link;
|
||||
return <AttachmentLink attachment={record.link} external />;
|
||||
} else {
|
||||
return '-';
|
||||
}
|
||||
@ -121,7 +120,9 @@ export function AttachmentTable({
|
||||
model: model,
|
||||
pk: record.pk,
|
||||
attachmentType: record.attachment ? 'file' : 'link',
|
||||
callback: table.refreshTable
|
||||
callback: (record: any) => {
|
||||
table.updateRecord(record);
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
|
@ -1,12 +1,21 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { ActionIcon, Group, Text, Tooltip } from '@mantine/core';
|
||||
import { useHover } from '@mantine/hooks';
|
||||
import { IconEdit } from '@tabler/icons-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { api } from '../../App';
|
||||
import { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField';
|
||||
import { YesNoButton } from '../../components/items/YesNoButton';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { getDetailUrl } from '../../functions/urls';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
import { cancelEvent } from '../../functions/events';
|
||||
import {
|
||||
useCreateApiFormModal,
|
||||
useEditApiFormModal
|
||||
} from '../../hooks/UseForm';
|
||||
import { useTable } from '../../hooks/UseTable';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
@ -15,6 +24,71 @@ import { DescriptionColumn, PartColumn } from '../ColumnRenderers';
|
||||
import { InvenTreeTable } from '../InvenTreeTable';
|
||||
import { TableHoverCard } from '../TableHoverCard';
|
||||
|
||||
// Render an individual parameter cell
|
||||
function ParameterCell({
|
||||
record,
|
||||
template,
|
||||
canEdit,
|
||||
onEdit
|
||||
}: {
|
||||
record: any;
|
||||
template: any;
|
||||
canEdit: boolean;
|
||||
onEdit: () => void;
|
||||
}) {
|
||||
const { hovered, ref } = useHover();
|
||||
|
||||
// Find matching template parameter
|
||||
let parameter = record.parameters?.find(
|
||||
(p: any) => p.template == template.pk
|
||||
);
|
||||
|
||||
let extra: any[] = [];
|
||||
|
||||
let value: any = parameter?.data;
|
||||
|
||||
if (template?.checkbox && value != undefined) {
|
||||
value = <YesNoButton value={parameter.data} />;
|
||||
}
|
||||
|
||||
if (
|
||||
template.units &&
|
||||
parameter &&
|
||||
parameter.data_numeric &&
|
||||
parameter.data_numeric != parameter.data
|
||||
) {
|
||||
extra.push(`${parameter.data_numeric} [${template.units}]`);
|
||||
}
|
||||
|
||||
const handleClick = useCallback((event: any) => {
|
||||
cancelEvent(event);
|
||||
onEdit();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Group grow ref={ref} position="apart">
|
||||
<Group grow style={{ flex: 1 }}>
|
||||
<TableHoverCard
|
||||
value={value ?? '-'}
|
||||
extra={extra}
|
||||
title={t`Internal Units`}
|
||||
/>
|
||||
</Group>
|
||||
{hovered && canEdit && (
|
||||
<div style={{ flex: 0 }}>
|
||||
<Tooltip label={t`Edit parameter`}>
|
||||
<ActionIcon size="xs" onClick={handleClick}>
|
||||
<IconEdit />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ParametricPartTable({
|
||||
categoryId
|
||||
}: {
|
||||
@ -22,7 +96,6 @@ export default function ParametricPartTable({
|
||||
}) {
|
||||
const table = useTable('parametric-parts');
|
||||
const user = useUserState();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const categoryParmeters = useQuery({
|
||||
queryKey: ['category-parameters', categoryId],
|
||||
@ -39,6 +112,73 @@ export default function ParametricPartTable({
|
||||
refetchOnMount: true
|
||||
});
|
||||
|
||||
const [selectedPart, setSelectedPart] = useState<number>(0);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<number>(0);
|
||||
const [selectedParameter, setSelectedParameter] = useState<number>(0);
|
||||
|
||||
const partParameterFields: ApiFormFieldSet = useMemo(() => {
|
||||
return {
|
||||
part: {
|
||||
disabled: true
|
||||
},
|
||||
template: {
|
||||
disabled: true
|
||||
},
|
||||
data: {}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const addParameter = useCreateApiFormModal({
|
||||
url: ApiEndpoints.part_parameter_list,
|
||||
title: t`Add Part Parameter`,
|
||||
fields: partParameterFields,
|
||||
onFormSuccess: (parameter: any) => {
|
||||
updateParameterRecord(selectedPart, parameter);
|
||||
},
|
||||
initialData: {
|
||||
part: selectedPart,
|
||||
template: selectedTemplate
|
||||
}
|
||||
});
|
||||
|
||||
const editParameter = useEditApiFormModal({
|
||||
url: ApiEndpoints.part_parameter_list,
|
||||
title: t`Edit Part Parameter`,
|
||||
pk: selectedParameter,
|
||||
fields: partParameterFields,
|
||||
onFormSuccess: (parameter: any) => {
|
||||
updateParameterRecord(selectedPart, parameter);
|
||||
}
|
||||
});
|
||||
|
||||
// Update a single parameter record in the table
|
||||
const updateParameterRecord = useCallback(
|
||||
(part: number, parameter: any) => {
|
||||
let records = table.records;
|
||||
let partIndex = records.findIndex((record: any) => record.pk == part);
|
||||
|
||||
if (partIndex < 0) {
|
||||
// No matching part: reload the entire table
|
||||
table.refreshTable();
|
||||
return;
|
||||
}
|
||||
|
||||
let parameterIndex = records[partIndex].parameters.findIndex(
|
||||
(p: any) => p.pk == parameter.pk
|
||||
);
|
||||
|
||||
if (parameterIndex < 0) {
|
||||
// No matching parameter - append new parameter
|
||||
records[partIndex].parameters.push(parameter);
|
||||
} else {
|
||||
records[partIndex].parameters[parameterIndex] = parameter;
|
||||
}
|
||||
|
||||
table.setRecords(records);
|
||||
},
|
||||
[table.records]
|
||||
);
|
||||
|
||||
const parameterColumns: TableColumn[] = useMemo(() => {
|
||||
let data = categoryParmeters.data ?? [];
|
||||
|
||||
@ -53,37 +193,33 @@ export default function ParametricPartTable({
|
||||
accessor: `parameter_${template.pk}`,
|
||||
title: title,
|
||||
sortable: true,
|
||||
render: (record: any) => {
|
||||
// Find matching template parameter
|
||||
let parameter = record.parameters?.find(
|
||||
(p: any) => p.template == template.pk
|
||||
);
|
||||
extra: {
|
||||
template: template.pk
|
||||
},
|
||||
render: (record: any) => (
|
||||
<ParameterCell
|
||||
record={record}
|
||||
template={template}
|
||||
canEdit={user.hasChangeRole(UserRoles.part)}
|
||||
onEdit={() => {
|
||||
setSelectedTemplate(template.pk);
|
||||
setSelectedPart(record.pk);
|
||||
let parameter = record.parameters?.find(
|
||||
(p: any) => p.template == template.pk
|
||||
);
|
||||
|
||||
if (!parameter) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
let extra: any[] = [];
|
||||
|
||||
if (
|
||||
template.units &&
|
||||
parameter.data_numeric &&
|
||||
parameter.data_numeric != parameter.data
|
||||
) {
|
||||
extra.push(`${parameter.data_numeric} [${template.units}]`);
|
||||
}
|
||||
|
||||
return (
|
||||
<TableHoverCard
|
||||
value={parameter.data}
|
||||
extra={extra}
|
||||
title={t`Internal Units`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (parameter) {
|
||||
setSelectedParameter(parameter.pk);
|
||||
editParameter.open();
|
||||
} else {
|
||||
addParameter.open();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
};
|
||||
});
|
||||
}, [categoryParmeters.data]);
|
||||
}, [user, categoryParmeters.data]);
|
||||
|
||||
const tableColumns: TableColumn[] = useMemo(() => {
|
||||
const partColumns: TableColumn[] = [
|
||||
@ -104,25 +240,54 @@ export default function ParametricPartTable({
|
||||
return [...partColumns, ...parameterColumns];
|
||||
}, [parameterColumns]);
|
||||
|
||||
// Callback when a parameter cell is clicked - either edit or add a new parameter
|
||||
const handleCellClick = useCallback(
|
||||
(record: any, column: any) => {
|
||||
let template_id = column?.extra?.template;
|
||||
|
||||
if (!template_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedPart(record.pk);
|
||||
setSelectedTemplate(template_id);
|
||||
|
||||
// Find the associated parameter
|
||||
let parameter = record?.parameters?.find(
|
||||
(p: any) => p.template == template_id
|
||||
);
|
||||
|
||||
if (parameter) {
|
||||
// Parameter exists - open edit dialog
|
||||
setSelectedParameter(parameter.pk);
|
||||
editParameter.open();
|
||||
} else {
|
||||
// Parameter does not exist - create it!
|
||||
addParameter.open();
|
||||
}
|
||||
},
|
||||
[user]
|
||||
);
|
||||
|
||||
return (
|
||||
<InvenTreeTable
|
||||
url={apiUrl(ApiEndpoints.part_list)}
|
||||
tableState={table}
|
||||
columns={tableColumns}
|
||||
props={{
|
||||
enableDownload: false,
|
||||
params: {
|
||||
category: categoryId,
|
||||
cascade: true,
|
||||
category_detail: true,
|
||||
parameters: true
|
||||
},
|
||||
onRowClick: (record) => {
|
||||
if (record.pk) {
|
||||
navigate(getDetailUrl(ModelType.part, record.pk));
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
{addParameter.modal}
|
||||
{editParameter.modal}
|
||||
<InvenTreeTable
|
||||
url={apiUrl(ApiEndpoints.part_list)}
|
||||
tableState={table}
|
||||
columns={tableColumns}
|
||||
props={{
|
||||
enableDownload: false,
|
||||
params: {
|
||||
category: categoryId,
|
||||
cascade: true,
|
||||
category_detail: true,
|
||||
parameters: true
|
||||
},
|
||||
modelType: ModelType.part
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -95,7 +95,7 @@ export function PartCategoryTable({ parentId }: { parentId?: any }) {
|
||||
pk: selectedCategory,
|
||||
title: t`Edit Part Category`,
|
||||
fields: partCategoryFields({}),
|
||||
onFormSuccess: table.refreshTable
|
||||
onFormSuccess: (record: any) => table.updateRecord(record)
|
||||
});
|
||||
|
||||
const tableActions = useMemo(() => {
|
||||
@ -143,8 +143,7 @@ export function PartCategoryTable({ parentId }: { parentId?: any }) {
|
||||
tableFilters: tableFilters,
|
||||
tableActions: tableActions,
|
||||
rowActions: rowActions,
|
||||
onRowClick: (record) =>
|
||||
navigate(getDetailUrl(ModelType.partcategory, record.pk))
|
||||
modelType: ModelType.partcategory
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
@ -14,7 +14,7 @@ import { useTable } from '../../hooks/UseTable';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import { TableColumn } from '../Column';
|
||||
import { DescriptionColumn } from '../ColumnRenderers';
|
||||
import { BooleanColumn, DescriptionColumn } from '../ColumnRenderers';
|
||||
import { TableFilter } from '../Filter';
|
||||
import { InvenTreeTable } from '../InvenTreeTable';
|
||||
import { RowDeleteAction, RowEditAction } from '../RowActions';
|
||||
@ -61,9 +61,9 @@ export default function PartParameterTemplateTable() {
|
||||
sortable: true
|
||||
},
|
||||
DescriptionColumn({}),
|
||||
{
|
||||
BooleanColumn({
|
||||
accessor: 'checkbox'
|
||||
},
|
||||
}),
|
||||
{
|
||||
accessor: 'choices'
|
||||
}
|
||||
@ -96,7 +96,7 @@ export default function PartParameterTemplateTable() {
|
||||
pk: selectedTemplate,
|
||||
title: t`Edit Parameter Template`,
|
||||
fields: partParameterTemplateFields,
|
||||
onFormSuccess: table.refreshTable
|
||||
onFormSuccess: (record: any) => table.updateRecord(record)
|
||||
});
|
||||
|
||||
const deleteTemplate = useDeleteApiFormModal({
|
||||
|
@ -4,7 +4,6 @@ import { ReactNode, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { AddItemButton } from '../../components/buttons/AddItemButton';
|
||||
import { Thumbnail } from '../../components/images/Thumbnail';
|
||||
import { formatPriceRange } from '../../defaults/formatters';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
@ -163,7 +162,7 @@ function partTableColumns(): TableColumn[] {
|
||||
render: (record: any) =>
|
||||
formatPriceRange(record.pricing_min, record.pricing_max)
|
||||
},
|
||||
LinkColumn()
|
||||
LinkColumn({})
|
||||
];
|
||||
}
|
||||
|
||||
@ -268,8 +267,8 @@ export function PartListTable({ props }: { props: InvenTreeTableProps }) {
|
||||
const tableFilters = useMemo(() => partTableFilters(), []);
|
||||
|
||||
const table = useTable('part-list');
|
||||
const navigate = useNavigate();
|
||||
const user = useUserState();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const newPart = useCreateApiFormModal({
|
||||
url: ApiEndpoints.part_list,
|
||||
@ -305,14 +304,13 @@ export function PartListTable({ props }: { props: InvenTreeTableProps }) {
|
||||
props={{
|
||||
...props,
|
||||
enableDownload: true,
|
||||
modelType: ModelType.part,
|
||||
tableFilters: tableFilters,
|
||||
tableActions: tableActions,
|
||||
params: {
|
||||
...props.params,
|
||||
category_detail: true
|
||||
},
|
||||
onRowClick: (record) =>
|
||||
navigate(getDetailUrl(ModelType.part, record.pk))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
@ -134,7 +134,7 @@ export default function PartTestTemplateTable({ partId }: { partId: number }) {
|
||||
pk: selectedTest,
|
||||
title: t`Edit Test Template`,
|
||||
fields: partTestTemplateFields,
|
||||
onFormSuccess: table.refreshTable
|
||||
onFormSuccess: (record: any) => table.updateRecord(record)
|
||||
});
|
||||
|
||||
const deleteTestTemplate = useDeleteApiFormModal({
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { ReactNode, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { AddItemButton } from '../../components/buttons/AddItemButton';
|
||||
import { Thumbnail } from '../../components/images/Thumbnail';
|
||||
@ -9,8 +8,6 @@ import { ModelType } from '../../enums/ModelType';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
import { useManufacturerPartFields } from '../../forms/CompanyForms';
|
||||
import { openDeleteApiForm, openEditApiForm } from '../../functions/forms';
|
||||
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';
|
||||
@ -27,7 +24,6 @@ export function ManufacturerPartTable({ params }: { params: any }): ReactNode {
|
||||
const table = useTable('manufacturerparts');
|
||||
|
||||
const user = useUserState();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Construct table columns for this table
|
||||
const tableColumns: TableColumn[] = useMemo(() => {
|
||||
@ -58,7 +54,7 @@ export function ManufacturerPartTable({ params }: { params: any }): ReactNode {
|
||||
sortable: true
|
||||
},
|
||||
DescriptionColumn({}),
|
||||
LinkColumn()
|
||||
LinkColumn({})
|
||||
];
|
||||
}, [params]);
|
||||
|
||||
@ -139,11 +135,7 @@ export function ManufacturerPartTable({ params }: { params: any }): ReactNode {
|
||||
},
|
||||
rowActions: rowActions,
|
||||
tableActions: tableActions,
|
||||
onRowClick: (record: any) => {
|
||||
if (record?.pk) {
|
||||
navigate(getDetailUrl(ModelType.manufacturerpart, record.pk));
|
||||
}
|
||||
}
|
||||
modelType: ModelType.manufacturerpart
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
@ -2,7 +2,6 @@ import { t } from '@lingui/macro';
|
||||
import { Text } from '@mantine/core';
|
||||
import { IconSquareArrowRight } from '@tabler/icons-react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { ActionButton } from '../../components/buttons/ActionButton';
|
||||
import { AddItemButton } from '../../components/buttons/AddItemButton';
|
||||
@ -16,7 +15,6 @@ import {
|
||||
usePurchaseOrderLineItemFields,
|
||||
useReceiveLineItems
|
||||
} from '../../forms/PurchaseOrderForms';
|
||||
import { getDetailUrl } from '../../functions/urls';
|
||||
import {
|
||||
useCreateApiFormModal,
|
||||
useDeleteApiFormModal,
|
||||
@ -28,6 +26,7 @@ import { useUserState } from '../../states/UserState';
|
||||
import {
|
||||
CurrencyColumn,
|
||||
LinkColumn,
|
||||
NoteColumn,
|
||||
ReferenceColumn,
|
||||
TargetDateColumn,
|
||||
TotalPriceColumn
|
||||
@ -52,7 +51,6 @@ export function PurchaseOrderLineItemTable({
|
||||
}) {
|
||||
const table = useTable('purchase-order-line-item');
|
||||
|
||||
const navigate = useNavigate();
|
||||
const user = useUserState();
|
||||
|
||||
const [singleRecord, setSingeRecord] = useState(null);
|
||||
@ -180,11 +178,8 @@ export function PurchaseOrderLineItemTable({
|
||||
? RenderStockLocation({ instance: record.destination_detail })
|
||||
: '-'
|
||||
},
|
||||
{
|
||||
accessor: 'notes',
|
||||
title: t`Notes`
|
||||
},
|
||||
LinkColumn()
|
||||
NoteColumn(),
|
||||
LinkColumn({})
|
||||
];
|
||||
}, [orderId, user]);
|
||||
|
||||
@ -291,11 +286,7 @@ export function PurchaseOrderLineItemTable({
|
||||
},
|
||||
rowActions: rowActions,
|
||||
tableActions: tableActions,
|
||||
onRowClick: (row: any) => {
|
||||
if (row.part) {
|
||||
navigate(getDetailUrl(ModelType.supplierpart, row.part));
|
||||
}
|
||||
}
|
||||
modelType: ModelType.supplierpart
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
@ -138,11 +138,7 @@ export function PurchaseOrderTable({
|
||||
},
|
||||
tableFilters: tableFilters,
|
||||
tableActions: tableActions,
|
||||
onRowClick: (row: any) => {
|
||||
if (row.pk) {
|
||||
navigate(getDetailUrl(ModelType.purchaseorder, row.pk));
|
||||
}
|
||||
}
|
||||
modelType: ModelType.purchaseorder
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Text } from '@mantine/core';
|
||||
import { ReactNode, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { AddItemButton } from '../../components/buttons/AddItemButton';
|
||||
import { Thumbnail } from '../../components/images/Thumbnail';
|
||||
@ -10,7 +9,6 @@ import { ModelType } from '../../enums/ModelType';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
import { useSupplierPartFields } from '../../forms/CompanyForms';
|
||||
import { openDeleteApiForm, openEditApiForm } from '../../functions/forms';
|
||||
import { getDetailUrl } from '../../functions/urls';
|
||||
import { useCreateApiFormModal } from '../../hooks/UseForm';
|
||||
import { useTable } from '../../hooks/UseTable';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
@ -34,7 +32,6 @@ export function SupplierPartTable({ params }: { params: any }): ReactNode {
|
||||
const table = useTable('supplierparts');
|
||||
|
||||
const user = useUserState();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Construct table columns for this table
|
||||
const tableColumns: TableColumn[] = useMemo(() => {
|
||||
@ -125,7 +122,7 @@ export function SupplierPartTable({ params }: { params: any }): ReactNode {
|
||||
);
|
||||
}
|
||||
},
|
||||
LinkColumn(),
|
||||
LinkColumn({}),
|
||||
NoteColumn(),
|
||||
{
|
||||
accessor: 'available',
|
||||
@ -232,11 +229,7 @@ export function SupplierPartTable({ params }: { params: any }): ReactNode {
|
||||
},
|
||||
rowActions: rowActions,
|
||||
tableActions: tableActions,
|
||||
onRowClick: (record: any) => {
|
||||
if (record?.pk) {
|
||||
navigate(getDetailUrl(ModelType.supplierpart, record.pk));
|
||||
}
|
||||
}
|
||||
modelType: ModelType.supplierpart
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
@ -1,6 +1,5 @@
|
||||
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';
|
||||
@ -8,7 +7,6 @@ import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
import { notYetImplemented } from '../../functions/notifications';
|
||||
import { getDetailUrl } from '../../functions/urls';
|
||||
import { useTable } from '../../hooks/UseTable';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
@ -36,8 +34,6 @@ export function ReturnOrderTable({ params }: { params?: any }) {
|
||||
const table = useTable('return-orders');
|
||||
const user = useUserState();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const tableFilters: TableFilter[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
@ -115,11 +111,7 @@ export function ReturnOrderTable({ params }: { params?: any }) {
|
||||
},
|
||||
tableFilters: tableFilters,
|
||||
tableActions: tableActions,
|
||||
onRowClick: (row: any) => {
|
||||
if (row.pk) {
|
||||
navigate(getDetailUrl(ModelType.returnorder, row.pk));
|
||||
}
|
||||
}
|
||||
modelType: ModelType.returnorder
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
@ -136,11 +136,7 @@ export function SalesOrderTable({
|
||||
},
|
||||
tableFilters: tableFilters,
|
||||
tableActions: tableActions,
|
||||
onRowClick: (row: any) => {
|
||||
if (row.pk) {
|
||||
navigate(getDetailUrl(ModelType.salesorder, row.pk));
|
||||
}
|
||||
}
|
||||
modelType: ModelType.salesorder
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
@ -59,7 +59,7 @@ export default function CustomUnitsTable() {
|
||||
pk: selectedUnit,
|
||||
title: t`Edit Custom Unit`,
|
||||
fields: customUnitsFields(),
|
||||
onFormSuccess: table.refreshTable
|
||||
onFormSuccess: (record: any) => table.updateRecord(record)
|
||||
});
|
||||
|
||||
const deleteUnit = useDeleteApiFormModal({
|
||||
|
@ -53,7 +53,7 @@ export default function ProjectCodeTable() {
|
||||
pk: selectedProjectCode,
|
||||
title: t`Edit Project Code`,
|
||||
fields: projectCodeFields(),
|
||||
onFormSuccess: table.refreshTable
|
||||
onFormSuccess: (record: any) => table.updateRecord(record)
|
||||
});
|
||||
|
||||
const deleteProjectCode = useDeleteApiFormModal({
|
||||
|
@ -1,9 +1,16 @@
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import { Alert, List, LoadingOverlay, Stack, Text, Title } from '@mantine/core';
|
||||
import {
|
||||
Alert,
|
||||
List,
|
||||
LoadingOverlay,
|
||||
Spoiler,
|
||||
Stack,
|
||||
Text,
|
||||
Title
|
||||
} from '@mantine/core';
|
||||
import { IconInfoCircle } from '@tabler/icons-react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { AddItemButton } from '../../components/buttons/AddItemButton';
|
||||
import { EditApiForm } from '../../components/forms/ApiForm';
|
||||
@ -12,7 +19,10 @@ import {
|
||||
DetailDrawerLink
|
||||
} from '../../components/nav/DetailDrawer';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { openCreateApiForm, openDeleteApiForm } from '../../functions/forms';
|
||||
import {
|
||||
useCreateApiFormModal,
|
||||
useDeleteApiFormModal
|
||||
} from '../../hooks/UseForm';
|
||||
import { useInstance } from '../../hooks/UseInstance';
|
||||
import { useTable } from '../../hooks/UseTable';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
@ -120,25 +130,29 @@ export function UserDrawer({
|
||||
id={`user-detail-drawer-${id}`}
|
||||
/>
|
||||
|
||||
<Title order={5}>
|
||||
<Trans>Groups</Trans>
|
||||
</Title>
|
||||
<Text ml={'md'}>
|
||||
{userDetail?.groups && userDetail?.groups?.length > 0 ? (
|
||||
<List>
|
||||
{userDetail?.groups?.map((group) => (
|
||||
<List.Item key={group.pk}>
|
||||
<DetailDrawerLink
|
||||
to={`../group-${group.pk}`}
|
||||
text={group.name}
|
||||
/>
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
) : (
|
||||
<Trans>No groups</Trans>
|
||||
)}
|
||||
</Text>
|
||||
<Stack>
|
||||
<Title order={5}>
|
||||
<Trans>Groups</Trans>
|
||||
</Title>
|
||||
<Spoiler maxHeight={125} showLabel="Show More" hideLabel="Show Less">
|
||||
<Text ml={'md'}>
|
||||
{userDetail?.groups && userDetail?.groups?.length > 0 ? (
|
||||
<List>
|
||||
{userDetail?.groups?.map((group) => (
|
||||
<List.Item key={group.pk}>
|
||||
<DetailDrawerLink
|
||||
to={`../group-${group.pk}`}
|
||||
text={group.name}
|
||||
/>
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
) : (
|
||||
<Trans>No groups</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</Spoiler>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@ -194,6 +208,9 @@ export function UserTable() {
|
||||
];
|
||||
}, []);
|
||||
|
||||
// Row Actions
|
||||
const [selectedUser, setSelectedUser] = useState<number>(-1);
|
||||
|
||||
const rowActions = useCallback((record: UserDetailI): RowAction[] => {
|
||||
return [
|
||||
RowEditAction({
|
||||
@ -201,39 +218,45 @@ export function UserTable() {
|
||||
}),
|
||||
RowDeleteAction({
|
||||
onClick: () => {
|
||||
openDeleteApiForm({
|
||||
url: ApiEndpoints.user_list,
|
||||
pk: record.pk,
|
||||
title: t`Delete user`,
|
||||
successMessage: t`User deleted`,
|
||||
onFormSuccess: table.refreshTable,
|
||||
preFormWarning: t`Are you sure you want to delete this user?`
|
||||
});
|
||||
setSelectedUser(record.pk);
|
||||
deleteUser.open();
|
||||
}
|
||||
})
|
||||
];
|
||||
}, []);
|
||||
|
||||
const addUser = useCallback(() => {
|
||||
openCreateApiForm({
|
||||
url: ApiEndpoints.user_list,
|
||||
title: t`Add user`,
|
||||
fields: {
|
||||
username: {},
|
||||
email: {},
|
||||
first_name: {},
|
||||
last_name: {}
|
||||
},
|
||||
onFormSuccess: table.refreshTable,
|
||||
successMessage: t`Added user`
|
||||
});
|
||||
}, []);
|
||||
const deleteUser = useDeleteApiFormModal({
|
||||
url: ApiEndpoints.user_list,
|
||||
pk: selectedUser,
|
||||
title: t`Delete user`,
|
||||
successMessage: t`User deleted`,
|
||||
onFormSuccess: table.refreshTable,
|
||||
preFormWarning: t`Are you sure you want to delete this user?`
|
||||
});
|
||||
|
||||
// Table Actions - Add New User
|
||||
const newUser = useCreateApiFormModal({
|
||||
url: ApiEndpoints.user_list,
|
||||
title: t`Add user`,
|
||||
fields: {
|
||||
username: {},
|
||||
email: {},
|
||||
first_name: {},
|
||||
last_name: {}
|
||||
},
|
||||
onFormSuccess: table.refreshTable,
|
||||
successMessage: t`Added user`
|
||||
});
|
||||
|
||||
const tableActions = useMemo(() => {
|
||||
let actions = [];
|
||||
|
||||
actions.push(
|
||||
<AddItemButton key="add-user" onClick={addUser} tooltip={t`Add user`} />
|
||||
<AddItemButton
|
||||
key="add-user"
|
||||
onClick={newUser.open}
|
||||
tooltip={t`Add user`}
|
||||
/>
|
||||
);
|
||||
|
||||
return actions;
|
||||
@ -241,6 +264,8 @@ export function UserTable() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{newUser.modal}
|
||||
{deleteUser.modal}
|
||||
<DetailDrawer
|
||||
title={t`Edit user`}
|
||||
renderContent={(id) => {
|
||||
|
69
src/frontend/src/tables/stock/InstalledItemsTable.tsx
Normal file
69
src/frontend/src/tables/stock/InstalledItemsTable.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { useTable } from '../../hooks/UseTable';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import { TableColumn } from '../Column';
|
||||
import { PartColumn, StatusColumn } from '../ColumnRenderers';
|
||||
import { InvenTreeTable } from '../InvenTreeTable';
|
||||
|
||||
export default function InstalledItemsTable({
|
||||
parentId
|
||||
}: {
|
||||
parentId?: number | string;
|
||||
}) {
|
||||
const table = useTable('stock_item_install');
|
||||
const user = useUserState();
|
||||
|
||||
const tableColumns: TableColumn[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
accessor: 'part',
|
||||
switchable: false,
|
||||
render: (record: any) => PartColumn(record?.part_detail)
|
||||
},
|
||||
{
|
||||
accessor: 'quantity',
|
||||
switchable: false,
|
||||
render: (record: any) => {
|
||||
let text = record.quantity;
|
||||
|
||||
if (record.serial && record.quantity == 1) {
|
||||
text = `# ${record.serial}`;
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
},
|
||||
{
|
||||
accessor: 'batch',
|
||||
switchable: false
|
||||
},
|
||||
StatusColumn(ModelType.stockitem)
|
||||
];
|
||||
}, []);
|
||||
|
||||
const tableActions = useMemo(() => {
|
||||
return [];
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<InvenTreeTable
|
||||
url={apiUrl(ApiEndpoints.stock_item_list)}
|
||||
tableState={table}
|
||||
columns={tableColumns}
|
||||
props={{
|
||||
tableActions: tableActions,
|
||||
modelType: ModelType.stockitem,
|
||||
params: {
|
||||
belongs_to: parentId,
|
||||
part_detail: true
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
@ -509,8 +509,7 @@ export function StockItemTable({ params = {} }: { params?: any }) {
|
||||
enableSelection: true,
|
||||
tableFilters: tableFilters,
|
||||
tableActions: tableActions,
|
||||
onRowClick: (record) =>
|
||||
navigate(getDetailUrl(ModelType.stockitem, record.pk)),
|
||||
modelType: ModelType.stockitem,
|
||||
params: {
|
||||
...params,
|
||||
part_detail: true,
|
||||
|
@ -277,6 +277,13 @@ export default function StockItemTestResultTable({
|
||||
message: t`Test result has been recorded`,
|
||||
color: 'green'
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
showNotification({
|
||||
title: t`Error`,
|
||||
message: t`Failed to record test result`,
|
||||
color: 'red'
|
||||
});
|
||||
});
|
||||
},
|
||||
[itemId]
|
||||
|
@ -105,7 +105,7 @@ export function StockLocationTable({ parentId }: { parentId?: any }) {
|
||||
pk: selectedLocation,
|
||||
title: t`Edit Stock Location`,
|
||||
fields: stockLocationFields({}),
|
||||
onFormSuccess: table.refreshTable
|
||||
onFormSuccess: (record: any) => table.updateRecord(record)
|
||||
});
|
||||
|
||||
const tableActions = useMemo(() => {
|
||||
@ -153,9 +153,7 @@ export function StockLocationTable({ parentId }: { parentId?: any }) {
|
||||
tableFilters: tableFilters,
|
||||
tableActions: tableActions,
|
||||
rowActions: rowActions,
|
||||
onRowClick: (record) => {
|
||||
navigate(getDetailUrl(ModelType.stocklocation, record.pk));
|
||||
}
|
||||
modelType: ModelType.stocklocation
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
@ -2155,9 +2155,9 @@ find-up@^3.0.0:
|
||||
locate-path "^3.0.0"
|
||||
|
||||
follow-redirects@^1.15.0:
|
||||
version "1.15.4"
|
||||
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.4.tgz#cdc7d308bf6493126b17ea2191ea0ccf3e535adf"
|
||||
integrity sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==
|
||||
version "1.15.6"
|
||||
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b"
|
||||
integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==
|
||||
|
||||
form-data@^4.0.0:
|
||||
version "4.0.0"
|
||||
|
Loading…
Reference in New Issue
Block a user