mirror of
https://github.com/inventree/InvenTree.git
synced 2026-07-09 16:41:25 +00:00
Merge branch 'master' of https://github.com/inventree/InvenTree
This commit is contained in:
@@ -20,15 +20,38 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
# pull_request:
|
||||
# branches:
|
||||
# - 'master'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'master'
|
||||
|
||||
jobs:
|
||||
|
||||
paths-filter:
|
||||
name: Filter
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
outputs:
|
||||
docker: ${{ steps.filter.outputs.docker }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
|
||||
- uses: dorny/paths-filter@4512585405083f25c027a35db413c2b3b9006d50 # pin@v2.11.1
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
docker:
|
||||
- .github/workflows/docker.yaml
|
||||
- docker/**
|
||||
- docker-compose.yml
|
||||
- docker.dev.env
|
||||
- Dockerfile
|
||||
|
||||
|
||||
# Build the docker image
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
needs: paths-filter
|
||||
if: needs.paths-filter.outputs.docker == 'true' || github.event_name == 'release' || github.event_name == 'push'
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@@ -59,7 +82,6 @@ jobs:
|
||||
docker-compose run inventree-dev-server invoke update
|
||||
docker-compose run inventree-dev-server invoke setup-dev
|
||||
docker-compose up -d
|
||||
docker-compose run inventree-dev-server pip install setuptools==68.1.2
|
||||
docker-compose run inventree-dev-server invoke wait
|
||||
- name: Check Data Directory
|
||||
# The following file structure should have been created by the docker image
|
||||
|
||||
@@ -2,11 +2,21 @@
|
||||
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 151
|
||||
INVENTREE_API_VERSION = 154
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v154 -> 2023-11-21 : https://github.com/inventree/InvenTree/pull/5944
|
||||
- Adds "responsible" field to the ProjectCode table
|
||||
|
||||
v153 -> 2023-11-21 : https://github.com/inventree/InvenTree/pull/5956
|
||||
- Adds override_min and override_max fields to part pricing API
|
||||
|
||||
v152 -> 2023-11-20 : https://github.com/inventree/InvenTree/pull/5949
|
||||
- Adds barcode support for manufacturerpart model
|
||||
- Adds API endpoint for adding parts to purchase order using barcode scan
|
||||
|
||||
v151 -> 2023-11-13 : https://github.com/inventree/InvenTree/pull/5906
|
||||
- Allow user list API to be filtered by user active status
|
||||
- Allow owner list API to be filtered by user active status
|
||||
|
||||
@@ -349,7 +349,7 @@ def MakeBarcode(cls_name, object_pk: int, object_data=None, **kwargs):
|
||||
object_data['id'] = object_pk
|
||||
data[cls_name] = object_data
|
||||
|
||||
return json.dumps(data, sort_keys=True)
|
||||
return str(json.dumps(data, sort_keys=True))
|
||||
|
||||
|
||||
def GetExportFormats():
|
||||
|
||||
@@ -228,7 +228,11 @@ def getModelsWithMixin(mixin_class) -> list:
|
||||
"""
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
db_models = [x.model_class() for x in ContentType.objects.all() if x is not None]
|
||||
try:
|
||||
db_models = [x.model_class() for x in ContentType.objects.all() if x is not None]
|
||||
except (OperationalError, ProgrammingError):
|
||||
# Database is likely not yet ready
|
||||
db_models = []
|
||||
|
||||
return [x for x in db_models if x is not None and issubclass(x, mixin_class)]
|
||||
|
||||
|
||||
@@ -969,6 +969,22 @@ class InvenTreeBarcodeMixin(models.Model):
|
||||
**kwargs
|
||||
)
|
||||
|
||||
def format_matched_response(self):
|
||||
"""Format a standard response for a matched barcode."""
|
||||
|
||||
data = {
|
||||
'pk': self.pk,
|
||||
}
|
||||
|
||||
if hasattr(self, 'get_api_url'):
|
||||
api_url = self.get_api_url()
|
||||
data['api_url'] = f"{api_url}{self.pk}/"
|
||||
|
||||
if hasattr(self, 'get_absolute_url'):
|
||||
data['web_url'] = self.get_absolute_url()
|
||||
|
||||
return data
|
||||
|
||||
@property
|
||||
def barcode(self):
|
||||
"""Format a minimal barcode string (e.g. for label printing)"""
|
||||
|
||||
@@ -553,7 +553,7 @@ def update_exchange_rates(force: bool = False):
|
||||
# Record successful task execution
|
||||
record_task_success('update_exchange_rates')
|
||||
|
||||
except OperationalError:
|
||||
except (AppRegistryNotReady, OperationalError, ProgrammingError):
|
||||
logger.warning("Could not update exchange rates - database not ready")
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.exception("Error updating exchange rates: %s", str(type(e)))
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 3.2.23 on 2023-11-20 08:04
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0010_alter_apitoken_key'),
|
||||
('common', '0021_auto_20230805_1748'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='projectcode',
|
||||
name='responsible',
|
||||
field=models.ForeignKey(blank=True, help_text='User or group responsible for this project', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='project_codes', to='users.owner', verbose_name='Responsible'),
|
||||
),
|
||||
]
|
||||
@@ -51,6 +51,7 @@ import InvenTree.tasks
|
||||
import InvenTree.validators
|
||||
import order.validators
|
||||
import report.helpers
|
||||
import users.models
|
||||
from plugin import registry
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
@@ -126,6 +127,15 @@ class ProjectCode(InvenTree.models.MetadataMixin, models.Model):
|
||||
help_text=_('Project description'),
|
||||
)
|
||||
|
||||
responsible = models.ForeignKey(
|
||||
users.models.Owner,
|
||||
on_delete=models.SET_NULL,
|
||||
blank=True, null=True,
|
||||
verbose_name=_('Responsible'),
|
||||
help_text=_('User or group responsible for this project'),
|
||||
related_name='project_codes',
|
||||
)
|
||||
|
||||
|
||||
class SettingsKeyType(TypedDict, total=False):
|
||||
"""Type definitions for a SettingsKeyType
|
||||
|
||||
@@ -11,6 +11,7 @@ from InvenTree.helpers import get_objectreference
|
||||
from InvenTree.helpers_model import construct_absolute_url
|
||||
from InvenTree.serializers import (InvenTreeImageSerializerField,
|
||||
InvenTreeModelSerializer)
|
||||
from users.serializers import OwnerSerializer
|
||||
|
||||
|
||||
class SettingsValueField(serializers.Field):
|
||||
@@ -281,9 +282,13 @@ class ProjectCodeSerializer(InvenTreeModelSerializer):
|
||||
fields = [
|
||||
'pk',
|
||||
'code',
|
||||
'description'
|
||||
'description',
|
||||
'responsible',
|
||||
'responsible_detail',
|
||||
]
|
||||
|
||||
responsible_detail = OwnerSerializer(source='responsible', read_only=True)
|
||||
|
||||
|
||||
class FlagSerializer(serializers.Serializer):
|
||||
"""Serializer for feature flags."""
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 3.2.23 on 2023-11-20 11:08
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('company', '0067_alter_supplierpricebreak_price_currency'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='manufacturerpart',
|
||||
name='barcode_data',
|
||||
field=models.CharField(blank=True, help_text='Third party barcode data', max_length=500, verbose_name='Barcode Data'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='manufacturerpart',
|
||||
name='barcode_hash',
|
||||
field=models.CharField(blank=True, help_text='Unique hash of barcode data', max_length=128, verbose_name='Barcode Hash'),
|
||||
),
|
||||
]
|
||||
@@ -387,7 +387,7 @@ class Address(models.Model):
|
||||
help_text=_('Link to address information (external)'))
|
||||
|
||||
|
||||
class ManufacturerPart(MetadataMixin, models.Model):
|
||||
class ManufacturerPart(MetadataMixin, InvenTreeBarcodeMixin, models.Model):
|
||||
"""Represents a unique part as provided by a Manufacturer Each ManufacturerPart is identified by a MPN (Manufacturer Part Number) Each ManufacturerPart is also linked to a Part object. A Part may be available from multiple manufacturers.
|
||||
|
||||
Attributes:
|
||||
|
||||
@@ -223,6 +223,7 @@ class ManufacturerPartSerializer(InvenTreeTagModelSerializer):
|
||||
'description',
|
||||
'MPN',
|
||||
'link',
|
||||
'barcode_hash',
|
||||
|
||||
'tags',
|
||||
]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -66,17 +66,16 @@ class GeneralExtraLineList(APIDownloadMixin):
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
|
||||
ordering_fields = [
|
||||
'title',
|
||||
'quantity',
|
||||
'note',
|
||||
'reference',
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
'title',
|
||||
'quantity',
|
||||
'note',
|
||||
'reference'
|
||||
'reference',
|
||||
'description',
|
||||
]
|
||||
|
||||
filterset_fields = [
|
||||
|
||||
@@ -522,7 +522,7 @@ class PurchaseOrder(TotalPriceMixin, Order):
|
||||
@property
|
||||
def is_pending(self):
|
||||
"""Return True if the PurchaseOrder is 'pending'"""
|
||||
return self.status == PurchaseOrderStatus.PENDING
|
||||
return self.status == PurchaseOrderStatus.PENDING.value
|
||||
|
||||
@property
|
||||
def is_open(self):
|
||||
@@ -536,8 +536,8 @@ class PurchaseOrder(TotalPriceMixin, Order):
|
||||
- Status is PENDING
|
||||
"""
|
||||
return self.status in [
|
||||
PurchaseOrderStatus.PLACED,
|
||||
PurchaseOrderStatus.PENDING
|
||||
PurchaseOrderStatus.PLACED.value,
|
||||
PurchaseOrderStatus.PENDING.value
|
||||
]
|
||||
|
||||
@transaction.atomic
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Various helper functions for the part app"""
|
||||
|
||||
import logging
|
||||
|
||||
from jinja2 import Environment
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
# Compiled template for rendering the 'full_name' attribute of a Part
|
||||
_part_full_name_template = None
|
||||
_part_full_name_template_string = ''
|
||||
|
||||
|
||||
def compile_full_name_template(*args, **kwargs):
|
||||
"""Recompile the template for rendering the 'full_name' attribute of a Part.
|
||||
|
||||
This function is called whenever the 'PART_NAME_FORMAT' setting is changed.
|
||||
"""
|
||||
|
||||
from common.models import InvenTreeSetting
|
||||
|
||||
global _part_full_name_template
|
||||
global _part_full_name_template_string
|
||||
|
||||
template_string = InvenTreeSetting.get_setting('PART_NAME_FORMAT', '')
|
||||
|
||||
# Skip if the template string has not changed
|
||||
if template_string == _part_full_name_template_string and _part_full_name_template is not None:
|
||||
return _part_full_name_template
|
||||
|
||||
_part_full_name_template_string = template_string
|
||||
|
||||
env = Environment(
|
||||
autoescape=True,
|
||||
variable_start_string='{{',
|
||||
variable_end_string='}}'
|
||||
)
|
||||
|
||||
# Compile the template
|
||||
try:
|
||||
_part_full_name_template = env.from_string(template_string)
|
||||
except Exception:
|
||||
_part_full_name_template = None
|
||||
|
||||
return _part_full_name_template
|
||||
|
||||
|
||||
def render_part_full_name(part) -> str:
|
||||
"""Render the 'full_name' attribute of a Part.
|
||||
|
||||
To improve render efficiency, we re-compile the template whenever the setting is changed
|
||||
|
||||
Args:
|
||||
part: The Part object to render
|
||||
"""
|
||||
|
||||
template = compile_full_name_template()
|
||||
|
||||
if template:
|
||||
try:
|
||||
return template.render(part=part)
|
||||
except Exception as e:
|
||||
logger.warning("exception while trying to create full name for part %s: %s", part.name, e)
|
||||
|
||||
# Fallback to the default format
|
||||
elements = [el for el in [part.IPN, part.name, part.revision] if el]
|
||||
return ' | '.join(elements)
|
||||
@@ -0,0 +1,36 @@
|
||||
# Generated by Django 3.2.23 on 2023-11-20 04:57
|
||||
|
||||
import InvenTree.fields
|
||||
from django.db import migrations
|
||||
import djmoney.models.fields
|
||||
import djmoney.models.validators
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('part', '0118_auto_20231024_1844'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='partpricing',
|
||||
name='override_max',
|
||||
field=InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Override maximum cost', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Maximum Cost'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='partpricing',
|
||||
name='override_max_currency',
|
||||
field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='partpricing',
|
||||
name='override_min',
|
||||
field=InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Override minimum cost', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Minimum Cost'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='partpricing',
|
||||
name='override_min_currency',
|
||||
field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3, null=True),
|
||||
),
|
||||
]
|
||||
+24
-35
@@ -27,7 +27,6 @@ from django_cleanup import cleanup
|
||||
from djmoney.contrib.exchange.exceptions import MissingRate
|
||||
from djmoney.contrib.exchange.models import convert_money
|
||||
from djmoney.money import Money
|
||||
from jinja2 import Template
|
||||
from mptt.exceptions import InvalidMove
|
||||
from mptt.managers import TreeManager
|
||||
from mptt.models import MPTTModel, TreeForeignKey
|
||||
@@ -40,6 +39,7 @@ import InvenTree.conversion
|
||||
import InvenTree.fields
|
||||
import InvenTree.ready
|
||||
import InvenTree.tasks
|
||||
import part.helpers as part_helpers
|
||||
import part.settings as part_settings
|
||||
import users.models
|
||||
from build import models as BuildModels
|
||||
@@ -692,41 +692,9 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel)
|
||||
|
||||
@property
|
||||
def full_name(self):
|
||||
"""Format a 'full name' for this Part based on the format PART_NAME_FORMAT defined in InvenTree settings.
|
||||
"""Format a 'full name' for this Part based on the format PART_NAME_FORMAT defined in InvenTree settings"""
|
||||
|
||||
As a failsafe option, the following is done:
|
||||
|
||||
- IPN (if not null)
|
||||
- Part name
|
||||
- Part variant (if not null)
|
||||
|
||||
Elements are joined by the | character
|
||||
"""
|
||||
full_name_pattern = InvenTreeSetting.get_setting('PART_NAME_FORMAT')
|
||||
|
||||
try:
|
||||
context = {'part': self}
|
||||
template_string = Template(full_name_pattern)
|
||||
full_name = template_string.render(context)
|
||||
|
||||
return full_name
|
||||
|
||||
except Exception as attr_err:
|
||||
|
||||
logger.warning("exception while trying to create full name for part %s: %s", self.name, attr_err)
|
||||
|
||||
# Fallback to default format
|
||||
elements = []
|
||||
|
||||
if self.IPN:
|
||||
elements.append(self.IPN)
|
||||
|
||||
elements.append(self.name)
|
||||
|
||||
if self.revision:
|
||||
elements.append(self.revision)
|
||||
|
||||
return ' | '.join(elements)
|
||||
return part_helpers.render_part_full_name(self)
|
||||
|
||||
def get_absolute_url(self):
|
||||
"""Return the web URL for viewing this part."""
|
||||
@@ -2401,6 +2369,7 @@ class PartPricing(common.models.MetaMixin):
|
||||
def update_pricing(self, counter: int = 0, cascade: bool = True):
|
||||
"""Recalculate all cost data for the referenced Part instance"""
|
||||
# If importing data, skip pricing update
|
||||
|
||||
if InvenTree.ready.isImportingData():
|
||||
return
|
||||
|
||||
@@ -2730,6 +2699,7 @@ class PartPricing(common.models.MetaMixin):
|
||||
|
||||
Here we simply take the minimum / maximum values of the other calculated fields.
|
||||
"""
|
||||
|
||||
overall_min = None
|
||||
overall_max = None
|
||||
|
||||
@@ -2790,7 +2760,14 @@ class PartPricing(common.models.MetaMixin):
|
||||
if self.internal_cost_max is not None:
|
||||
overall_max = self.internal_cost_max
|
||||
|
||||
if self.override_min is not None:
|
||||
overall_min = self.convert(self.override_min)
|
||||
|
||||
self.overall_min = overall_min
|
||||
|
||||
if self.override_max is not None:
|
||||
overall_max = self.convert(self.override_max)
|
||||
|
||||
self.overall_max = overall_max
|
||||
|
||||
def update_sale_cost(self, save=True):
|
||||
@@ -2929,6 +2906,18 @@ class PartPricing(common.models.MetaMixin):
|
||||
help_text=_('Calculated maximum cost of variant parts'),
|
||||
)
|
||||
|
||||
override_min = InvenTree.fields.InvenTreeModelMoneyField(
|
||||
null=True, blank=True,
|
||||
verbose_name=_('Minimum Cost'),
|
||||
help_text=_('Override minimum cost'),
|
||||
)
|
||||
|
||||
override_max = InvenTree.fields.InvenTreeModelMoneyField(
|
||||
null=True, blank=True,
|
||||
verbose_name=_('Maximum Cost'),
|
||||
help_text=_('Override maximum cost'),
|
||||
)
|
||||
|
||||
overall_min = InvenTree.fields.InvenTreeModelMoneyField(
|
||||
null=True, blank=True,
|
||||
verbose_name=_('Minimum Cost'),
|
||||
|
||||
@@ -5,6 +5,7 @@ import io
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import IntegrityError, models, transaction
|
||||
@@ -13,11 +14,14 @@ from django.db.models.functions import Coalesce
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from djmoney.contrib.exchange.exceptions import MissingRate
|
||||
from djmoney.contrib.exchange.models import convert_money
|
||||
from rest_framework import serializers
|
||||
from sql_util.utils import SubqueryCount, SubquerySum
|
||||
from taggit.serializers import TagListSerializerField
|
||||
|
||||
import common.models
|
||||
import common.settings
|
||||
import company.models
|
||||
import InvenTree.helpers
|
||||
import InvenTree.serializers
|
||||
@@ -819,7 +823,7 @@ class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serialize
|
||||
# Create initial stock entry
|
||||
if initial_stock:
|
||||
quantity = initial_stock['quantity']
|
||||
location = initial_stock['location'] or instance.default_location
|
||||
location = initial_stock.get('location', None) or instance.default_location
|
||||
|
||||
if quantity > 0:
|
||||
stockitem = stock.models.StockItem(
|
||||
@@ -1042,6 +1046,10 @@ class PartPricingSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
'supplier_price_max',
|
||||
'variant_cost_min',
|
||||
'variant_cost_max',
|
||||
'override_min',
|
||||
'override_min_currency',
|
||||
'override_max',
|
||||
'override_max_currency',
|
||||
'overall_min',
|
||||
'overall_max',
|
||||
'sale_price_min',
|
||||
@@ -1073,6 +1081,30 @@ class PartPricingSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
variant_cost_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True)
|
||||
variant_cost_max = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True)
|
||||
|
||||
override_min = InvenTree.serializers.InvenTreeMoneySerializer(
|
||||
label=_('Minimum Price'),
|
||||
help_text=_('Override calculated value for minimum price'),
|
||||
allow_null=True, read_only=False, required=False,
|
||||
)
|
||||
|
||||
override_min_currency = serializers.ChoiceField(
|
||||
label=_('Minimum price currency'),
|
||||
read_only=False, required=False,
|
||||
choices=common.settings.currency_code_mappings(),
|
||||
)
|
||||
|
||||
override_max = InvenTree.serializers.InvenTreeMoneySerializer(
|
||||
label=_('Maximum Price'),
|
||||
help_text=_('Override calculated value for maximum price'),
|
||||
allow_null=True, read_only=False, required=False,
|
||||
)
|
||||
|
||||
override_max_currency = serializers.ChoiceField(
|
||||
label=_('Maximum price currency'),
|
||||
read_only=False, required=False,
|
||||
choices=common.settings.currency_code_mappings(),
|
||||
)
|
||||
|
||||
overall_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True)
|
||||
overall_max = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True)
|
||||
|
||||
@@ -1086,18 +1118,44 @@ class PartPricingSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
write_only=True,
|
||||
label=_('Update'),
|
||||
help_text=_('Update pricing for this part'),
|
||||
default=False,
|
||||
required=False,
|
||||
default=False, required=False, allow_null=True,
|
||||
)
|
||||
|
||||
def validate(self, data):
|
||||
"""Validate supplied pricing data"""
|
||||
|
||||
super().validate(data)
|
||||
|
||||
# Check that override_min is not greater than override_max
|
||||
override_min = data.get('override_min', None)
|
||||
override_max = data.get('override_max', None)
|
||||
|
||||
default_currency = common.settings.currency_code_default()
|
||||
|
||||
if override_min is not None and override_max is not None:
|
||||
|
||||
try:
|
||||
override_min = convert_money(override_min, default_currency)
|
||||
override_max = convert_money(override_max, default_currency)
|
||||
except MissingRate:
|
||||
raise ValidationError(_(f'Could not convert from provided currencies to {default_currency}'))
|
||||
|
||||
if override_min > override_max:
|
||||
raise ValidationError({
|
||||
'override_min': _('Minimum price must not be greater than maximum price'),
|
||||
'override_max': _('Maximum price must not be less than minimum price')
|
||||
})
|
||||
|
||||
return data
|
||||
|
||||
def save(self):
|
||||
"""Called when the serializer is saved"""
|
||||
data = self.validated_data
|
||||
|
||||
if InvenTree.helpers.str2bool(data.get('update', False)):
|
||||
# Update part pricing
|
||||
pricing = self.instance
|
||||
pricing.update_pricing()
|
||||
super().save()
|
||||
|
||||
# Update part pricing
|
||||
pricing = self.instance
|
||||
pricing.update_pricing()
|
||||
|
||||
|
||||
class PartRelationSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
<button type='button' class='btn btn-success' id='part-pricing-refresh' title='{% trans "Refresh Part Pricing" %}'>
|
||||
<span class='fas fa-redo-alt'></span> {% trans "Refresh" %}
|
||||
</button>
|
||||
<button type='button' class='btn btn-success' id='part-pricing-edit' title='{% trans "Override Part Pricing" %}'>
|
||||
<span class='fas fa-edit'></span> {% trans "Edit" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -97,6 +100,14 @@
|
||||
<td>{% render_currency pricing.variant_cost_max %}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if pricing.override_min or pricing.override_max %}
|
||||
<tr>
|
||||
<td><a href='#pricing-overrides'><span class='fas fa-exclamation-circle'></span></a></td>
|
||||
<th>{% trans "Pricing Overrides" %}</th>
|
||||
<td>{% render_currency pricing.override_min currency=pricing.override_min_currency %}</td>
|
||||
<td>{% render_currency pricing.override_max currency=pricing.override_max_currency %}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td></td>
|
||||
<th>{% trans "Overall Pricing" %}</th>
|
||||
|
||||
@@ -19,6 +19,25 @@ $('#part-pricing-refresh').click(function() {
|
||||
);
|
||||
});
|
||||
|
||||
$('#part-pricing-edit').click(function() {
|
||||
constructForm('{% url "api-part-pricing" part.pk %}', {
|
||||
title: '{% trans "Update Pricing" %}',
|
||||
fields: {
|
||||
override_min: {},
|
||||
override_min_currency: {},
|
||||
override_max: {},
|
||||
override_max_currency: {},
|
||||
update: {
|
||||
hidden: true,
|
||||
value: true,
|
||||
}
|
||||
},
|
||||
onSuccess: function(response) {
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Internal Pricebreaks
|
||||
{% if show_internal_price and roles.sales_order.view %}
|
||||
initPriceBreakSet($('#internal-price-break-table'), {
|
||||
|
||||
@@ -7,69 +7,73 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from rest_framework import permissions
|
||||
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||
from rest_framework.generics import CreateAPIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from InvenTree.helpers import hash_barcode
|
||||
from order.models import PurchaseOrder
|
||||
from plugin import registry
|
||||
from plugin.builtin.barcodes.inventree_barcode import \
|
||||
InvenTreeInternalBarcodePlugin
|
||||
from stock.models import StockLocation
|
||||
from users.models import RuleSet
|
||||
|
||||
from . import serializers as barcode_serializers
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
class BarcodeScan(APIView):
|
||||
"""Endpoint for handling generic barcode scan requests.
|
||||
class BarcodeView(CreateAPIView):
|
||||
"""Custom view class for handling a barcode scan"""
|
||||
|
||||
Barcode data are decoded by the client application,
|
||||
and sent to this endpoint (as a JSON object) for validation.
|
||||
# Default serializer class (can be overridden)
|
||||
serializer_class = barcode_serializers.BarcodeSerializer
|
||||
|
||||
A barcode could follow the internal InvenTree barcode format,
|
||||
or it could match to a third-party barcode format (e.g. Digikey).
|
||||
|
||||
When a barcode is sent to the server, the following parameters must be provided:
|
||||
|
||||
- barcode: The raw barcode data
|
||||
|
||||
plugins:
|
||||
Third-party barcode formats may be supported using 'plugins'
|
||||
(more information to follow)
|
||||
|
||||
hashing:
|
||||
Barcode hashes are calculated using MD5
|
||||
"""
|
||||
def queryset(self):
|
||||
"""This API view does not have a queryset"""
|
||||
return None
|
||||
|
||||
# Default permission classes (can be overridden)
|
||||
permission_classes = [
|
||||
permissions.IsAuthenticated,
|
||||
]
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
"""Respond to a barcode POST request.
|
||||
def create(self, request, *args, **kwargs):
|
||||
"""Handle create method - override default create"""
|
||||
|
||||
Check if required info was provided and then run though the plugin steps or try to match up-
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
data = serializer.validated_data
|
||||
|
||||
barcode = str(data.pop('barcode')).strip()
|
||||
|
||||
return self.handle_barcode(barcode, request, **data)
|
||||
|
||||
def handle_barcode(self, barcode: str, request, **kwargs):
|
||||
"""Handle barcode scan.
|
||||
|
||||
Arguments:
|
||||
barcode: Raw barcode value
|
||||
request: HTTP request object
|
||||
|
||||
kwargs:
|
||||
Any custom fields passed by the specific serializer
|
||||
"""
|
||||
data = request.data
|
||||
raise NotImplementedError(f"handle_barcode not implemented for {self.__class__}")
|
||||
|
||||
barcode_data = data.get('barcode', None)
|
||||
def scan_barcode(self, barcode: str, request, **kwargs):
|
||||
"""Perform a generic 'scan' of the provided barcode data.
|
||||
|
||||
if not barcode_data:
|
||||
raise ValidationError({'barcode': _('Missing barcode data')})
|
||||
Check each loaded plugin, and return the first valid match
|
||||
"""
|
||||
|
||||
# Note: the default barcode handlers are loaded (and thus run) first
|
||||
plugins = registry.with_mixin('barcode')
|
||||
|
||||
barcode_hash = hash_barcode(barcode_data)
|
||||
|
||||
# Look for a barcode plugin which knows how to deal with this barcode
|
||||
plugin = None
|
||||
response = {}
|
||||
|
||||
for current_plugin in plugins:
|
||||
|
||||
result = current_plugin.scan(barcode_data)
|
||||
result = current_plugin.scan(barcode)
|
||||
|
||||
if result is None:
|
||||
continue
|
||||
@@ -86,57 +90,74 @@ class BarcodeScan(APIView):
|
||||
break
|
||||
|
||||
response['plugin'] = plugin.name if plugin else None
|
||||
response['barcode_data'] = barcode_data
|
||||
response['barcode_hash'] = barcode_hash
|
||||
response['barcode_data'] = barcode
|
||||
response['barcode_hash'] = hash_barcode(barcode)
|
||||
|
||||
# A plugin has not been found!
|
||||
if plugin is None:
|
||||
response['error'] = _('No match found for barcode data')
|
||||
|
||||
raise ValidationError(response)
|
||||
else:
|
||||
response['success'] = _('Match found for barcode data')
|
||||
return Response(response)
|
||||
return response
|
||||
|
||||
|
||||
class BarcodeAssign(APIView):
|
||||
class BarcodeScan(BarcodeView):
|
||||
"""Endpoint for handling generic barcode scan requests.
|
||||
|
||||
Barcode data are decoded by the client application,
|
||||
and sent to this endpoint (as a JSON object) for validation.
|
||||
|
||||
A barcode could follow the internal InvenTree barcode format,
|
||||
or it could match to a third-party barcode format (e.g. Digikey).
|
||||
"""
|
||||
|
||||
def handle_barcode(self, barcode: str, request, **kwargs):
|
||||
"""Perform barcode scan action
|
||||
|
||||
Arguments:
|
||||
barcode: Raw barcode value
|
||||
request: HTTP request object
|
||||
|
||||
kwargs:
|
||||
Any custom fields passed by the specific serializer
|
||||
"""
|
||||
|
||||
result = self.scan_barcode(barcode, request, **kwargs)
|
||||
|
||||
if result['plugin'] is None:
|
||||
result['error'] = _('No match found for barcode data')
|
||||
|
||||
raise ValidationError(result)
|
||||
|
||||
result['success'] = _('Match found for barcode data')
|
||||
return Response(result)
|
||||
|
||||
|
||||
class BarcodeAssign(BarcodeView):
|
||||
"""Endpoint for assigning a barcode to a stock item.
|
||||
|
||||
- This only works if the barcode is not already associated with an object in the database
|
||||
- If the barcode does not match an object, then the barcode hash is assigned to the StockItem
|
||||
"""
|
||||
|
||||
permission_classes = [
|
||||
permissions.IsAuthenticated
|
||||
]
|
||||
serializer_class = barcode_serializers.BarcodeAssignSerializer
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
"""Respond to a barcode assign POST request.
|
||||
def handle_barcode(self, barcode: str, request, **kwargs):
|
||||
"""Respond to a barcode assign request.
|
||||
|
||||
Checks inputs and assign barcode (hash) to StockItem.
|
||||
"""
|
||||
data = request.data
|
||||
|
||||
barcode_data = data.get('barcode', None)
|
||||
|
||||
if not barcode_data:
|
||||
raise ValidationError({'barcode': _('Missing barcode data')})
|
||||
|
||||
# Here we only check against 'InvenTree' plugins
|
||||
plugins = registry.with_mixin('barcode', builtin=True)
|
||||
|
||||
# First check if the provided barcode matches an existing database entry
|
||||
for plugin in plugins:
|
||||
result = plugin.scan(barcode_data)
|
||||
result = plugin.scan(barcode)
|
||||
|
||||
if result is not None:
|
||||
result["error"] = _("Barcode matches existing item")
|
||||
result["plugin"] = plugin.name
|
||||
result["barcode_data"] = barcode_data
|
||||
result["barcode_data"] = barcode
|
||||
|
||||
raise ValidationError(result)
|
||||
|
||||
barcode_hash = hash_barcode(barcode_data)
|
||||
barcode_hash = hash_barcode(barcode)
|
||||
|
||||
valid_labels = []
|
||||
|
||||
@@ -144,39 +165,32 @@ class BarcodeAssign(APIView):
|
||||
label = model.barcode_model_type()
|
||||
valid_labels.append(label)
|
||||
|
||||
if label in data:
|
||||
try:
|
||||
instance = model.objects.get(pk=data[label])
|
||||
if instance := kwargs.get(label, None):
|
||||
|
||||
# Check that the user has the required permission
|
||||
app_label = model._meta.app_label
|
||||
model_name = model._meta.model_name
|
||||
# Check that the user has the required permission
|
||||
app_label = model._meta.app_label
|
||||
model_name = model._meta.model_name
|
||||
|
||||
table = f"{app_label}_{model_name}"
|
||||
table = f"{app_label}_{model_name}"
|
||||
|
||||
if not RuleSet.check_table_permission(request.user, table, "change"):
|
||||
raise PermissionDenied({
|
||||
"error": f"You do not have the required permissions for {table}"
|
||||
})
|
||||
|
||||
instance.assign_barcode(
|
||||
barcode_data=barcode_data,
|
||||
barcode_hash=barcode_hash,
|
||||
)
|
||||
|
||||
return Response({
|
||||
'success': f"Assigned barcode to {label} instance",
|
||||
label: {
|
||||
'pk': instance.pk,
|
||||
},
|
||||
"barcode_data": barcode_data,
|
||||
"barcode_hash": barcode_hash,
|
||||
if not RuleSet.check_table_permission(request.user, table, "change"):
|
||||
raise PermissionDenied({
|
||||
"error": f"You do not have the required permissions for {table}"
|
||||
})
|
||||
|
||||
except (ValueError, model.DoesNotExist):
|
||||
raise ValidationError({
|
||||
'error': f"No matching {label} instance found in database",
|
||||
})
|
||||
instance.assign_barcode(
|
||||
barcode_data=barcode,
|
||||
barcode_hash=barcode_hash,
|
||||
)
|
||||
|
||||
return Response({
|
||||
'success': f"Assigned barcode to {label} instance",
|
||||
label: {
|
||||
'pk': instance.pk,
|
||||
},
|
||||
"barcode_data": barcode,
|
||||
"barcode_hash": barcode_hash,
|
||||
})
|
||||
|
||||
# If we got here, it means that no valid model types were provided
|
||||
raise ValidationError({
|
||||
@@ -184,23 +198,23 @@ class BarcodeAssign(APIView):
|
||||
})
|
||||
|
||||
|
||||
class BarcodeUnassign(APIView):
|
||||
class BarcodeUnassign(BarcodeView):
|
||||
"""Endpoint for unlinking / unassigning a custom barcode from a database object"""
|
||||
|
||||
permission_classes = [
|
||||
permissions.IsAuthenticated,
|
||||
]
|
||||
serializer_class = barcode_serializers.BarcodeUnassignSerializer
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
"""Respond to a barcode unassign request."""
|
||||
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
data = serializer.validated_data
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
"""Respond to a barcode unassign POST request"""
|
||||
# The following database models support assignment of third-party barcodes
|
||||
supported_models = InvenTreeInternalBarcodePlugin.get_supported_barcode_models()
|
||||
|
||||
supported_labels = [model.barcode_model_type() for model in supported_models]
|
||||
model_names = ', '.join(supported_labels)
|
||||
|
||||
data = request.data
|
||||
|
||||
matched_labels = []
|
||||
|
||||
for label in supported_labels:
|
||||
@@ -219,15 +233,10 @@ class BarcodeUnassign(APIView):
|
||||
|
||||
# At this stage, we know that we have received a single valid field
|
||||
for model in supported_models:
|
||||
|
||||
label = model.barcode_model_type()
|
||||
|
||||
if label in data:
|
||||
try:
|
||||
instance = model.objects.get(pk=data[label])
|
||||
except (ValueError, model.DoesNotExist):
|
||||
raise ValidationError({
|
||||
label: _('No match found for provided value')
|
||||
})
|
||||
if instance := data.get(label, None):
|
||||
|
||||
# Check that the user has the required permission
|
||||
app_label = model._meta.app_label
|
||||
@@ -253,7 +262,99 @@ class BarcodeUnassign(APIView):
|
||||
})
|
||||
|
||||
|
||||
class BarcodePOReceive(APIView):
|
||||
class BarcodePOAllocate(BarcodeView):
|
||||
"""Endpoint for allocating parts to a purchase order by scanning their barcode
|
||||
|
||||
Note that the scanned barcode may point to:
|
||||
|
||||
- A Part object
|
||||
- A ManufacturerPart object
|
||||
- A SupplierPart object
|
||||
"""
|
||||
|
||||
serializer_class = barcode_serializers.BarcodePOAllocateSerializer
|
||||
|
||||
def get_supplier_part(self, purchase_order, part=None, supplier_part=None, manufacturer_part=None):
|
||||
"""Return a single matching SupplierPart (or else raise an exception)
|
||||
|
||||
Arguments:
|
||||
purchase_order: PurchaseOrder object
|
||||
part: Part object (optional)
|
||||
supplier_part: SupplierPart object (optional)
|
||||
manufacturer_part: ManufacturerPart object (optional)
|
||||
|
||||
Returns:
|
||||
SupplierPart object
|
||||
|
||||
Raises:
|
||||
ValidationError if no matching SupplierPart is found
|
||||
|
||||
"""
|
||||
|
||||
import company.models
|
||||
|
||||
supplier = purchase_order.supplier
|
||||
|
||||
supplier_parts = company.models.SupplierPart.objects.filter(supplier=supplier)
|
||||
|
||||
if not part and not supplier_part and not manufacturer_part:
|
||||
raise ValidationError({
|
||||
'error': _('No matching part data found'),
|
||||
})
|
||||
|
||||
if part:
|
||||
if part_id := part.get('pk', None):
|
||||
supplier_parts = supplier_parts.filter(part__pk=part_id)
|
||||
|
||||
if supplier_part:
|
||||
if supplier_part_id := supplier_part.get('pk', None):
|
||||
supplier_parts = supplier_parts.filter(pk=supplier_part_id)
|
||||
|
||||
if manufacturer_part:
|
||||
if manufacturer_part_id := manufacturer_part.get('pk', None):
|
||||
supplier_parts = supplier_parts.filter(manufacturer_part__pk=manufacturer_part_id)
|
||||
|
||||
if supplier_parts.count() == 0:
|
||||
raise ValidationError({
|
||||
"error": _("No matching supplier parts found")
|
||||
})
|
||||
|
||||
if supplier_parts.count() > 1:
|
||||
raise ValidationError({
|
||||
"error": _("Multiple matching supplier parts found")
|
||||
})
|
||||
|
||||
# At this stage, we have a single matching supplier part
|
||||
return supplier_parts.first()
|
||||
|
||||
def handle_barcode(self, barcode: str, request, **kwargs):
|
||||
"""Scan the provided barcode data"""
|
||||
|
||||
# The purchase order is provided as part of the request
|
||||
purchase_order = kwargs.get('purchase_order')
|
||||
|
||||
result = self.scan_barcode(barcode, request, **kwargs)
|
||||
|
||||
if result['plugin'] is None:
|
||||
result['error'] = _('No match found for barcode data')
|
||||
raise ValidationError(result)
|
||||
|
||||
supplier_part = self.get_supplier_part(
|
||||
purchase_order,
|
||||
part=result.get('part', None),
|
||||
supplier_part=result.get('supplierpart', None),
|
||||
manufacturer_part=result.get('manufacturerpart', None),
|
||||
)
|
||||
|
||||
result['success'] = _("Matched supplier part")
|
||||
result['supplierpart'] = supplier_part.format_matched_response()
|
||||
|
||||
# TODO: Determine the 'quantity to order' for the supplier part
|
||||
|
||||
return Response(result)
|
||||
|
||||
|
||||
class BarcodePOReceive(BarcodeView):
|
||||
"""Endpoint for handling receiving parts by scanning their barcode.
|
||||
|
||||
Barcode data are decoded by the client application,
|
||||
@@ -269,32 +370,16 @@ class BarcodePOReceive(APIView):
|
||||
- location: The destination location for the received item (optional)
|
||||
"""
|
||||
|
||||
permission_classes = [
|
||||
permissions.IsAuthenticated,
|
||||
]
|
||||
serializer_class = barcode_serializers.BarcodePOReceiveSerializer
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
"""Respond to a barcode POST request."""
|
||||
def handle_barcode(self, barcode: str, request, **kwargs):
|
||||
"""Handle a barcode scan for a purchase order item."""
|
||||
|
||||
data = request.data
|
||||
logger.debug("BarcodePOReceive: scanned barcode - '%s'", barcode)
|
||||
|
||||
if not (barcode_data := data.get("barcode")):
|
||||
raise ValidationError({"barcode": _("Missing barcode data")})
|
||||
|
||||
logger.debug("BarcodePOReceive: scanned barcode - '%s'", barcode_data)
|
||||
|
||||
purchase_order = None
|
||||
|
||||
if purchase_order_pk := data.get("purchase_order"):
|
||||
purchase_order = PurchaseOrder.objects.filter(pk=purchase_order_pk).first()
|
||||
if not purchase_order:
|
||||
raise ValidationError({"purchase_order": _("Invalid purchase order")})
|
||||
|
||||
location = None
|
||||
if (location_pk := data.get("location")):
|
||||
location = StockLocation.objects.get(pk=location_pk)
|
||||
if not location:
|
||||
raise ValidationError({"location": _("Invalid stock location")})
|
||||
# Extract optional fields from the dataset
|
||||
purchase_order = kwargs.get('purchase_order', None)
|
||||
location = kwargs.get('location', None)
|
||||
|
||||
plugins = registry.with_mixin("barcode")
|
||||
|
||||
@@ -303,8 +388,10 @@ class BarcodePOReceive(APIView):
|
||||
response = {}
|
||||
|
||||
internal_barcode_plugin = next(filter(
|
||||
lambda plugin: plugin.name == "InvenTreeBarcode", plugins))
|
||||
if internal_barcode_plugin.scan(barcode_data):
|
||||
lambda plugin: plugin.name == "InvenTreeBarcode", plugins
|
||||
))
|
||||
|
||||
if internal_barcode_plugin.scan(barcode):
|
||||
response["error"] = _("Item has already been received")
|
||||
raise ValidationError(response)
|
||||
|
||||
@@ -314,7 +401,7 @@ class BarcodePOReceive(APIView):
|
||||
for current_plugin in plugins:
|
||||
|
||||
result = current_plugin.scan_receive_item(
|
||||
barcode_data,
|
||||
barcode,
|
||||
request.user,
|
||||
purchase_order=purchase_order,
|
||||
location=location,
|
||||
@@ -335,8 +422,8 @@ class BarcodePOReceive(APIView):
|
||||
break
|
||||
|
||||
response["plugin"] = plugin.name if plugin else None
|
||||
response["barcode_data"] = barcode_data
|
||||
response["barcode_hash"] = hash_barcode(barcode_data)
|
||||
response["barcode_data"] = barcode
|
||||
response["barcode_hash"] = hash_barcode(barcode)
|
||||
|
||||
# A plugin has not been found!
|
||||
if plugin is None:
|
||||
@@ -358,6 +445,9 @@ barcode_api_urls = [
|
||||
# Receive a purchase order item by scanning its barcode
|
||||
path("po-receive/", BarcodePOReceive.as_view(), name="api-barcode-po-receive"),
|
||||
|
||||
# Allocate parts to a purchase order by scanning their barcode
|
||||
path("po-allocate/", BarcodePOAllocate.as_view(), name="api-barcode-po-allocate"),
|
||||
|
||||
# Catch-all performs barcode 'scan'
|
||||
re_path(r'^.*$', BarcodeScan.as_view(), name='api-barcode-scan'),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""DRF serializers for barcode scanning API"""
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
import order.models
|
||||
import stock.models
|
||||
from InvenTree.status_codes import PurchaseOrderStatus
|
||||
from plugin.builtin.barcodes.inventree_barcode import \
|
||||
InvenTreeInternalBarcodePlugin
|
||||
|
||||
|
||||
class BarcodeSerializer(serializers.Serializer):
|
||||
"""Generic serializer for receiving barcode data"""
|
||||
|
||||
MAX_BARCODE_LENGTH = 4095
|
||||
|
||||
barcode = serializers.CharField(
|
||||
required=True, help_text=_('Scanned barcode data'),
|
||||
max_length=MAX_BARCODE_LENGTH,
|
||||
)
|
||||
|
||||
|
||||
class BarcodeAssignMixin(serializers.Serializer):
|
||||
"""Serializer for linking and unlinking barcode to an internal class"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Generate serializer fields for each supported model type"""
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
for model in InvenTreeInternalBarcodePlugin.get_supported_barcode_models():
|
||||
self.fields[model.barcode_model_type()] = serializers.PrimaryKeyRelatedField(
|
||||
queryset=model.objects.all(),
|
||||
required=False, allow_null=True,
|
||||
label=model._meta.verbose_name,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_model_fields():
|
||||
"""Return a list of model fields"""
|
||||
fields = [
|
||||
model.barcode_model_type() for model in InvenTreeInternalBarcodePlugin.get_supported_barcode_models()
|
||||
]
|
||||
|
||||
return fields
|
||||
|
||||
|
||||
class BarcodeAssignSerializer(BarcodeAssignMixin, BarcodeSerializer):
|
||||
"""Serializer class for linking a barcode to an internal model"""
|
||||
|
||||
class Meta:
|
||||
"""Meta class for BarcodeAssignSerializer"""
|
||||
|
||||
fields = [
|
||||
'barcode',
|
||||
*BarcodeAssignMixin.get_model_fields()
|
||||
]
|
||||
|
||||
|
||||
class BarcodeUnassignSerializer(BarcodeAssignMixin):
|
||||
"""Serializer class for unlinking a barcode from an internal model"""
|
||||
|
||||
class Meta:
|
||||
"""Meta class for BarcodeUnlinkSerializer"""
|
||||
|
||||
fields = BarcodeAssignMixin.get_model_fields()
|
||||
|
||||
|
||||
class BarcodePOAllocateSerializer(BarcodeSerializer):
|
||||
"""Serializer for allocating items against a purchase order.
|
||||
|
||||
The scanned barcode could be a Part, ManufacturerPart or SupplierPart object
|
||||
"""
|
||||
|
||||
purchase_order = serializers.PrimaryKeyRelatedField(
|
||||
queryset=order.models.PurchaseOrder.objects.all(),
|
||||
required=True,
|
||||
help_text=_('PurchaseOrder to allocate items against'),
|
||||
)
|
||||
|
||||
def validate_purchase_order(self, order: order.models.PurchaseOrder):
|
||||
"""Validate the provided order"""
|
||||
|
||||
if order.status != PurchaseOrderStatus.PENDING.value:
|
||||
raise ValidationError(_("Purchase order is not pending"))
|
||||
|
||||
return order
|
||||
|
||||
|
||||
class BarcodePOReceiveSerializer(BarcodeSerializer):
|
||||
"""Serializer for receiving items against a purchase order.
|
||||
|
||||
The following additional fields may be specified:
|
||||
|
||||
- purchase_order: PurchaseOrder object to receive items against
|
||||
- location: Location to receive items into
|
||||
"""
|
||||
|
||||
purchase_order = serializers.PrimaryKeyRelatedField(
|
||||
queryset=order.models.PurchaseOrder.objects.all(),
|
||||
required=False, allow_null=True,
|
||||
help_text=_('PurchaseOrder to receive items against'),
|
||||
)
|
||||
|
||||
def validate_purchase_order(self, order: order.models.PurchaseOrder):
|
||||
"""Validate the provided order"""
|
||||
|
||||
if order and order.status != PurchaseOrderStatus.PLACED.value:
|
||||
raise ValidationError(_("Purchase order has not been placed"))
|
||||
|
||||
return order
|
||||
|
||||
location = serializers.PrimaryKeyRelatedField(
|
||||
queryset=stock.models.StockLocation.objects.all(),
|
||||
required=False, allow_null=True,
|
||||
help_text=_('Location to receive items into'),
|
||||
)
|
||||
|
||||
def validate_location(self, location: stock.models.StockLocation):
|
||||
"""Validate the provided location"""
|
||||
|
||||
if location and location.structural:
|
||||
raise ValidationError(_("Cannot select a structural location"))
|
||||
|
||||
return location
|
||||
@@ -2,9 +2,8 @@
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
from rest_framework import status
|
||||
|
||||
from InvenTree.unit_test import InvenTreeAPITestCase
|
||||
from part.models import Part
|
||||
from stock.models import StockItem
|
||||
|
||||
|
||||
@@ -24,150 +23,128 @@ class BarcodeAPITest(InvenTreeAPITestCase):
|
||||
|
||||
self.scan_url = reverse('api-barcode-scan')
|
||||
self.assign_url = reverse('api-barcode-link')
|
||||
self.unassign_url = reverse('api-barcode-unlink')
|
||||
|
||||
def postBarcode(self, url, barcode):
|
||||
def postBarcode(self, url, barcode, expected_code=None):
|
||||
"""Post barcode and return results."""
|
||||
return self.client.post(url, format='json', data={'barcode': str(barcode)})
|
||||
return self.post(url, format='json', data={'barcode': str(barcode)}, expected_code=expected_code)
|
||||
|
||||
def test_invalid(self):
|
||||
"""Test that invalid requests fail."""
|
||||
# test scan url
|
||||
response = self.client.post(self.scan_url, format='json', data={})
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.post(self.scan_url, format='json', data={}, expected_code=400)
|
||||
|
||||
# test wrong assign urls
|
||||
response = self.client.post(self.assign_url, format='json', data={})
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
response = self.client.post(self.assign_url, format='json', data={'barcode': '123'})
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
response = self.client.post(self.assign_url, format='json', data={'barcode': '123', 'stockitem': '123'})
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.post(self.assign_url, format='json', data={}, expected_code=400)
|
||||
self.post(self.assign_url, format='json', data={'barcode': '123'}, expected_code=400)
|
||||
self.post(self.assign_url, format='json', data={'barcode': '123', 'stockitem': '123'}, expected_code=400)
|
||||
|
||||
def test_empty(self):
|
||||
"""Test an empty barcode scan.
|
||||
|
||||
Ensure that all required data is in the respomse.
|
||||
Ensure that all required data is in the response.
|
||||
"""
|
||||
response = self.postBarcode(self.scan_url, '')
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
response = self.postBarcode(self.scan_url, '', expected_code=400)
|
||||
|
||||
data = response.data
|
||||
self.assertIn('barcode', data)
|
||||
self.assertIn('Missing barcode data', str(response.data['barcode']))
|
||||
|
||||
self.assertIn('This field may not be blank', str(response.data['barcode']))
|
||||
|
||||
def test_find_part(self):
|
||||
"""Test that we can lookup a part based on ID."""
|
||||
response = self.client.post(
|
||||
|
||||
part = Part.objects.first()
|
||||
|
||||
response = self.post(
|
||||
self.scan_url,
|
||||
{
|
||||
'barcode': {
|
||||
'part': 1,
|
||||
},
|
||||
'barcode': f'{{"part": {part.pk}}}',
|
||||
},
|
||||
format='json',
|
||||
expected_code=200
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertIn('part', response.data)
|
||||
self.assertIn('barcode_data', response.data)
|
||||
self.assertEqual(response.data['part']['pk'], 1)
|
||||
self.assertEqual(response.data['part']['pk'], part.pk)
|
||||
|
||||
def test_invalid_part(self):
|
||||
"""Test response for invalid part."""
|
||||
response = self.client.post(
|
||||
response = self.post(
|
||||
self.scan_url,
|
||||
{
|
||||
'barcode': {
|
||||
'part': 999999999,
|
||||
}
|
||||
'barcode': '{"part": 999999999}'
|
||||
},
|
||||
format='json'
|
||||
expected_code=400
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn('error', response.data)
|
||||
|
||||
def test_find_stock_item(self):
|
||||
"""Test that we can lookup a stock item based on ID."""
|
||||
response = self.client.post(
|
||||
|
||||
item = StockItem.objects.first()
|
||||
|
||||
response = self.post(
|
||||
self.scan_url,
|
||||
{
|
||||
'barcode': {
|
||||
'stockitem': 1,
|
||||
}
|
||||
'barcode': item.format_barcode(),
|
||||
},
|
||||
format='json',
|
||||
expected_code=200
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertIn('stockitem', response.data)
|
||||
self.assertIn('barcode_data', response.data)
|
||||
self.assertEqual(response.data['stockitem']['pk'], 1)
|
||||
self.assertEqual(response.data['stockitem']['pk'], item.pk)
|
||||
|
||||
def test_invalid_item(self):
|
||||
"""Test response for invalid stock item."""
|
||||
response = self.client.post(
|
||||
response = self.post(
|
||||
self.scan_url,
|
||||
{
|
||||
'barcode': {
|
||||
'stockitem': 999999999,
|
||||
}
|
||||
'barcode': '{"stockitem": 999999999}'
|
||||
},
|
||||
format='json'
|
||||
expected_code=400
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn('error', response.data)
|
||||
|
||||
def test_find_location(self):
|
||||
"""Test that we can lookup a stock location based on ID."""
|
||||
response = self.client.post(
|
||||
response = self.post(
|
||||
self.scan_url,
|
||||
{
|
||||
'barcode': {
|
||||
'stocklocation': 1,
|
||||
},
|
||||
'barcode': '{"stocklocation": 1}',
|
||||
},
|
||||
format='json'
|
||||
expected_code=200
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertIn('stocklocation', response.data)
|
||||
self.assertIn('barcode_data', response.data)
|
||||
self.assertEqual(response.data['stocklocation']['pk'], 1)
|
||||
|
||||
def test_invalid_location(self):
|
||||
"""Test response for an invalid location."""
|
||||
response = self.client.post(
|
||||
response = self.post(
|
||||
self.scan_url,
|
||||
{
|
||||
'barcode': {
|
||||
'stocklocation': 999999999,
|
||||
}
|
||||
'barcode': '{"stocklocation": 999999999}'
|
||||
},
|
||||
format='json'
|
||||
expected_code=400
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn('error', response.data)
|
||||
|
||||
def test_integer_barcode(self):
|
||||
"""Test scan of an integer barcode."""
|
||||
response = self.postBarcode(self.scan_url, '123456789')
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
response = self.postBarcode(self.scan_url, '123456789', expected_code=400)
|
||||
|
||||
data = response.data
|
||||
self.assertIn('error', data)
|
||||
|
||||
def test_array_barcode(self):
|
||||
"""Test scan of barcode with string encoded array."""
|
||||
response = self.postBarcode(self.scan_url, "['foo', 'bar']")
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
response = self.postBarcode(self.scan_url, "['foo', 'bar']", expected_code=400)
|
||||
|
||||
data = response.data
|
||||
self.assertIn('error', data)
|
||||
@@ -176,11 +153,9 @@ class BarcodeAPITest(InvenTreeAPITestCase):
|
||||
"""Test that a barcode is generated with a scan."""
|
||||
item = StockItem.objects.get(pk=522)
|
||||
|
||||
response = self.postBarcode(self.scan_url, item.format_barcode())
|
||||
response = self.postBarcode(self.scan_url, item.format_barcode(), expected_code=200)
|
||||
data = response.data
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
self.assertIn('stockitem', data)
|
||||
|
||||
pk = data['stockitem']['pk']
|
||||
@@ -197,37 +172,88 @@ class BarcodeAPITest(InvenTreeAPITestCase):
|
||||
|
||||
barcode_data = 'A-TEST-BARCODE-STRING'
|
||||
|
||||
response = self.client.post(
|
||||
response = self.post(
|
||||
self.assign_url, format='json',
|
||||
data={
|
||||
'barcode': barcode_data,
|
||||
'stockitem': item.pk
|
||||
}
|
||||
},
|
||||
expected_code=200
|
||||
)
|
||||
|
||||
data = response.data
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
self.assertIn('success', data)
|
||||
|
||||
result_hash = data['barcode_hash']
|
||||
|
||||
# Read the item out from the database again
|
||||
item = StockItem.objects.get(pk=522)
|
||||
item.refresh_from_db()
|
||||
|
||||
self.assertEqual(item.barcode_data, barcode_data)
|
||||
self.assertEqual(result_hash, item.barcode_hash)
|
||||
|
||||
# Ensure that the same barcode hash cannot be assigned to a different stock item!
|
||||
response = self.client.post(
|
||||
response = self.post(
|
||||
self.assign_url, format='json',
|
||||
data={
|
||||
'barcode': barcode_data,
|
||||
'stockitem': 521
|
||||
}
|
||||
},
|
||||
expected_code=400
|
||||
)
|
||||
|
||||
data = response.data
|
||||
self.assertIn('error', response.data)
|
||||
self.assertNotIn('success', response.data)
|
||||
|
||||
self.assertIn('error', data)
|
||||
self.assertNotIn('success', data)
|
||||
# Check that we can now unassign a barcode
|
||||
response = self.post(
|
||||
self.unassign_url,
|
||||
{
|
||||
'stockitem': item.pk,
|
||||
},
|
||||
expected_code=200
|
||||
)
|
||||
|
||||
item.refresh_from_db()
|
||||
self.assertEqual(item.barcode_data, '')
|
||||
|
||||
# Check that the 'unassign' endpoint fails if the stockitem is invalid
|
||||
response = self.post(
|
||||
self.unassign_url,
|
||||
{
|
||||
'stockitem': 999999999,
|
||||
},
|
||||
expected_code=400
|
||||
)
|
||||
|
||||
def test_unassign_endpoint(self):
|
||||
"""Test that the unassign endpoint works as expected"""
|
||||
|
||||
invalid_keys = ['cat', 'dog', 'fish']
|
||||
|
||||
# Invalid key should fail
|
||||
for k in invalid_keys:
|
||||
response = self.post(
|
||||
self.unassign_url,
|
||||
{
|
||||
k: 123
|
||||
},
|
||||
expected_code=400
|
||||
)
|
||||
|
||||
self.assertIn("Missing data: Provide one of", str(response.data['error']))
|
||||
|
||||
valid_keys = ['build', 'salesorder', 'part']
|
||||
|
||||
# Valid key but invalid pk should fail
|
||||
for k in valid_keys:
|
||||
response = self.post(
|
||||
self.unassign_url,
|
||||
{
|
||||
k: 999999999
|
||||
},
|
||||
expected_code=400
|
||||
)
|
||||
|
||||
self.assertIn("object does not exist", str(response.data[k]))
|
||||
|
||||
@@ -7,6 +7,7 @@ from unittest import mock
|
||||
from django.apps import apps
|
||||
from django.urls import reverse
|
||||
|
||||
from pdfminer.high_level import extract_text
|
||||
from PIL import Image
|
||||
|
||||
from InvenTree.unit_test import InvenTreeAPITestCase
|
||||
@@ -138,6 +139,7 @@ class LabelMixinTests(InvenTreeAPITestCase):
|
||||
|
||||
# Lookup references
|
||||
part = Part.objects.first()
|
||||
parts = Part.objects.all()[:2]
|
||||
plugin_ref = 'samplelabelprinter'
|
||||
label = PartLabel.objects.first()
|
||||
|
||||
@@ -158,13 +160,13 @@ class LabelMixinTests(InvenTreeAPITestCase):
|
||||
self.get(url, expected_code=200)
|
||||
|
||||
# Print multiple parts
|
||||
self.get(self.do_url(Part.objects.all()[:2], plugin_ref, label), expected_code=200)
|
||||
self.get(self.do_url(parts, plugin_ref, label), expected_code=200)
|
||||
|
||||
# Print multiple parts without a plugin
|
||||
self.get(self.do_url(Part.objects.all()[:2], None, label), expected_code=200)
|
||||
self.get(self.do_url(parts, None, label), expected_code=200)
|
||||
|
||||
# Print multiple parts without a plugin in debug mode
|
||||
response = self.get(self.do_url(Part.objects.all()[:2], None, label), expected_code=200)
|
||||
response = self.get(self.do_url(parts, None, label), expected_code=200)
|
||||
|
||||
data = json.loads(response.content)
|
||||
self.assertIn('file', data)
|
||||
@@ -177,9 +179,9 @@ class LabelMixinTests(InvenTreeAPITestCase):
|
||||
self.assertTrue(os.path.exists('label.pdf'))
|
||||
|
||||
# Read the raw .pdf data - ensure it contains some sensible information
|
||||
with open('label.pdf', 'rb') as f:
|
||||
pdf_data = str(f.read())
|
||||
self.assertIn('WeasyPrint', pdf_data)
|
||||
filetext = extract_text('label.pdf')
|
||||
matched = [part.name in filetext for part in parts]
|
||||
self.assertIn(True, matched)
|
||||
|
||||
# Check that the .png file has already been created
|
||||
self.assertTrue(os.path.exists('label.png'))
|
||||
@@ -193,24 +195,25 @@ class LabelMixinTests(InvenTreeAPITestCase):
|
||||
apps.get_app_config('label').create_labels()
|
||||
|
||||
# Lookup references
|
||||
parts = Part.objects.all()[:2]
|
||||
plugin_ref = 'samplelabelprinter'
|
||||
label = PartLabel.objects.first()
|
||||
|
||||
self.do_activate_plugin()
|
||||
|
||||
# test options response
|
||||
options = self.options(self.do_url(Part.objects.all()[:2], plugin_ref, label), expected_code=200).json()
|
||||
options = self.options(self.do_url(parts, plugin_ref, label), expected_code=200).json()
|
||||
self.assertTrue("amount" in options["actions"]["POST"])
|
||||
|
||||
plg = registry.get_plugin(plugin_ref)
|
||||
with mock.patch.object(plg, "print_label") as print_label:
|
||||
# wrong value type
|
||||
res = self.post(self.do_url(Part.objects.all()[:2], plugin_ref, label), data={"amount": "-no-valid-int-"}, expected_code=400).json()
|
||||
res = self.post(self.do_url(parts, plugin_ref, label), data={"amount": "-no-valid-int-"}, expected_code=400).json()
|
||||
self.assertTrue("amount" in res)
|
||||
print_label.assert_not_called()
|
||||
|
||||
# correct value type
|
||||
self.post(self.do_url(Part.objects.all()[:2], plugin_ref, label), data={"amount": 13}, expected_code=200).json()
|
||||
self.post(self.do_url(parts, plugin_ref, label), data={"amount": 13}, expected_code=200).json()
|
||||
self.assertEqual(print_label.call_args.kwargs["printing_options"], {"amount": 13})
|
||||
|
||||
def test_printing_endpoints(self):
|
||||
|
||||
@@ -34,37 +34,14 @@ class InvenTreeInternalBarcodePlugin(BarcodeMixin, InvenTreePlugin):
|
||||
|
||||
def format_matched_response(self, label, model, instance):
|
||||
"""Format a response for the scanned data"""
|
||||
data = {
|
||||
'pk': instance.pk
|
||||
}
|
||||
|
||||
# Add in the API URL if available
|
||||
if hasattr(model, 'get_api_url'):
|
||||
data['api_url'] = f"{model.get_api_url()}{instance.pk}/"
|
||||
|
||||
# Add in the web URL if available
|
||||
if hasattr(instance, 'get_absolute_url'):
|
||||
url = instance.get_absolute_url()
|
||||
data['web_url'] = url
|
||||
else:
|
||||
url = None # pragma: no cover
|
||||
|
||||
response = {
|
||||
label: data
|
||||
}
|
||||
|
||||
if url is not None:
|
||||
response['url'] = url
|
||||
|
||||
return response
|
||||
return {label: instance.format_matched_response()}
|
||||
|
||||
def scan(self, barcode_data):
|
||||
"""Scan a barcode against this plugin.
|
||||
|
||||
Here we are looking for a dict object which contains a reference to a particular InvenTree database object
|
||||
"""
|
||||
# Create hash from raw barcode data
|
||||
barcode_hash = hash_barcode(barcode_data)
|
||||
|
||||
# Attempt to coerce the barcode data into a dict object
|
||||
# This is the internal barcode representation that InvenTree uses
|
||||
@@ -78,9 +55,11 @@ class InvenTreeInternalBarcodePlugin(BarcodeMixin, InvenTreePlugin):
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
supported_models = self.get_supported_barcode_models()
|
||||
|
||||
if barcode_dict is not None and type(barcode_dict) is dict:
|
||||
# Look for various matches. First good match will be returned
|
||||
for model in self.get_supported_barcode_models():
|
||||
for model in supported_models:
|
||||
label = model.barcode_model_type()
|
||||
|
||||
if label in barcode_dict:
|
||||
@@ -91,8 +70,11 @@ class InvenTreeInternalBarcodePlugin(BarcodeMixin, InvenTreePlugin):
|
||||
except (ValueError, model.DoesNotExist):
|
||||
pass
|
||||
|
||||
# Create hash from raw barcode data
|
||||
barcode_hash = hash_barcode(barcode_data)
|
||||
|
||||
# If no "direct" hits are found, look for assigned third-party barcodes
|
||||
for model in self.get_supported_barcode_models():
|
||||
for model in supported_models:
|
||||
label = model.barcode_model_type()
|
||||
|
||||
instance = model.lookup_barcode(barcode_hash)
|
||||
|
||||
@@ -80,8 +80,8 @@ class TestInvenTreeBarcode(InvenTreeAPITestCase):
|
||||
# Fail with too many fields provided
|
||||
response = self.unassign(
|
||||
{
|
||||
'stockitem': 'abcde',
|
||||
'part': 'abcde',
|
||||
'stockitem': stock.models.StockItem.objects.first().pk,
|
||||
'part': part.models.Part.objects.first().pk,
|
||||
},
|
||||
expected_code=400,
|
||||
)
|
||||
@@ -96,17 +96,17 @@ class TestInvenTreeBarcode(InvenTreeAPITestCase):
|
||||
expected_code=400,
|
||||
)
|
||||
|
||||
self.assertIn('No match found', str(response.data['stockitem']))
|
||||
self.assertIn('Incorrect type', str(response.data['stockitem']))
|
||||
|
||||
# Fail with an invalid Part instance
|
||||
response = self.unassign(
|
||||
{
|
||||
'part': 'invalid',
|
||||
'part': 99999999999,
|
||||
},
|
||||
expected_code=400,
|
||||
)
|
||||
|
||||
self.assertIn('No match found', str(response.data['part']))
|
||||
self.assertIn('object does not exist', str(response.data['part']))
|
||||
|
||||
def test_assign_to_stock_item(self):
|
||||
"""Test that we can assign a unique barcode to a StockItem object"""
|
||||
@@ -216,7 +216,7 @@ class TestInvenTreeBarcode(InvenTreeAPITestCase):
|
||||
expected_code=400,
|
||||
)
|
||||
|
||||
self.assertIn('No matching part instance found in database', str(response.data))
|
||||
self.assertIn('object does not exist', str(response.data['part']))
|
||||
|
||||
# Test assigning to a valid part (should pass)
|
||||
response = self.assign(
|
||||
|
||||
@@ -23,8 +23,8 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase):
|
||||
"""Setup for all tests."""
|
||||
self.MSG_NO_PKG = 'Either packagename of URL must be provided'
|
||||
|
||||
self.PKG_NAME = 'minimal'
|
||||
self.PKG_URL = 'git+https://github.com/geoffrey-a-reed/minimal'
|
||||
self.PKG_NAME = 'inventree-brother-plugin'
|
||||
self.PKG_URL = 'git+https://github.com/inventree/inventree-brother-plugin'
|
||||
super().setUp()
|
||||
|
||||
def test_plugin_install(self):
|
||||
@@ -71,7 +71,7 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase):
|
||||
{
|
||||
'confirm': True,
|
||||
'url': self.PKG_URL,
|
||||
'packagename': 'minimal',
|
||||
'packagename': self.PKG_NAME,
|
||||
},
|
||||
expected_code=201,
|
||||
).data
|
||||
|
||||
+82
-81
@@ -629,10 +629,92 @@ class StockFilter(rest_filters.FilterSet):
|
||||
parent__in=ancestor.get_descendants(include_self=True)
|
||||
)
|
||||
|
||||
category = rest_filters.ModelChoiceFilter(
|
||||
label=_('Category'),
|
||||
queryset=PartCategory.objects.all(),
|
||||
method='filter_category'
|
||||
)
|
||||
|
||||
def filter_category(self, queryset, name, category):
|
||||
"""Filter based on part category"""
|
||||
|
||||
child_categories = category.get_descendants(include_self=True)
|
||||
|
||||
return queryset.filter(
|
||||
part__category__in=child_categories,
|
||||
)
|
||||
|
||||
bom_item = rest_filters.ModelChoiceFilter(
|
||||
label=_('BOM Item'),
|
||||
queryset=BomItem.objects.all(),
|
||||
method='filter_bom_item'
|
||||
)
|
||||
|
||||
def filter_bom_item(self, queryset, name, bom_item):
|
||||
"""Filter based on BOM item"""
|
||||
|
||||
return queryset.filter(bom_item.get_stock_filter())
|
||||
|
||||
part_tree = rest_filters.ModelChoiceFilter(
|
||||
label=_('Part Tree'),
|
||||
queryset=Part.objects.all(),
|
||||
method='filter_part_tree'
|
||||
)
|
||||
|
||||
def filter_part_tree(self, queryset, name, part_tree):
|
||||
"""Filter based on part tree"""
|
||||
return queryset.filter(
|
||||
part__tree_id=part_tree.tree_id
|
||||
)
|
||||
|
||||
company = rest_filters.ModelChoiceFilter(
|
||||
label=_('Company'),
|
||||
queryset=Company.objects.all(),
|
||||
method='filter_company'
|
||||
)
|
||||
|
||||
def filter_company(self, queryset, name, company):
|
||||
"""Filter by company (either manufacturer or supplier)"""
|
||||
return queryset.filter(
|
||||
Q(supplier_part__supplier=company) | Q(supplier_part__manufacturer_part__manufacturer=company)
|
||||
).distinct()
|
||||
|
||||
# Update date filters
|
||||
updated_before = rest_filters.DateFilter(label='Updated before', field_name='updated', lookup_expr='lte')
|
||||
updated_after = rest_filters.DateFilter(label='Updated after', field_name='updated', lookup_expr='gte')
|
||||
|
||||
# Stock "expiry" filters
|
||||
expiry_date_lte = rest_filters.DateFilter(
|
||||
label=_("Expiry date before"),
|
||||
field_name='expiry_date',
|
||||
lookup_expr='lte',
|
||||
)
|
||||
|
||||
expiry_date_gte = rest_filters.DateFilter(
|
||||
label=_('Expiry date after'),
|
||||
field_name='expiry_date',
|
||||
lookup_expr='gte',
|
||||
)
|
||||
|
||||
stale = rest_filters.BooleanFilter(label=_('Stale'), method='filter_stale')
|
||||
|
||||
def filter_stale(self, queryset, name, value):
|
||||
"""Filter by stale stock items."""
|
||||
|
||||
stale_days = common.models.InvenTreeSetting.get_setting('STOCK_STALE_DAYS')
|
||||
|
||||
if stale_days <= 0:
|
||||
# No filtering, does not make sense
|
||||
return queryset
|
||||
|
||||
stale_date = datetime.now().date() + timedelta(days=stale_days)
|
||||
stale_filter = StockItem.IN_STOCK_FILTER & ~Q(expiry_date=None) & Q(expiry_date__lt=stale_date)
|
||||
|
||||
if str2bool(value):
|
||||
return queryset.filter(stale_filter)
|
||||
else:
|
||||
return queryset.exclude(stale_filter)
|
||||
|
||||
|
||||
class StockList(APIDownloadMixin, ListCreateDestroyAPIView):
|
||||
"""API endpoint for list view of Stock objects.
|
||||
@@ -898,44 +980,6 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView):
|
||||
|
||||
queryset = super().filter_queryset(queryset)
|
||||
|
||||
if common.settings.stock_expiry_enabled():
|
||||
|
||||
# Filter by 'expiry date'
|
||||
expired_date_lte = params.get('expiry_date_lte', None)
|
||||
if expired_date_lte is not None:
|
||||
try:
|
||||
date_lte = datetime.fromisoformat(expired_date_lte)
|
||||
queryset = queryset.filter(expiry_date__lte=date_lte)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
expiry_date_gte = params.get('expiry_date_gte', None)
|
||||
if expiry_date_gte is not None:
|
||||
try:
|
||||
date_gte = datetime.fromisoformat(expiry_date_gte)
|
||||
queryset = queryset.filter(expiry_date__gte=date_gte)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Filter by 'stale' status
|
||||
stale = params.get('stale', None)
|
||||
|
||||
if stale is not None:
|
||||
stale = str2bool(stale)
|
||||
|
||||
# How many days to account for "staleness"?
|
||||
stale_days = common.models.InvenTreeSetting.get_setting('STOCK_STALE_DAYS')
|
||||
|
||||
if stale_days > 0:
|
||||
stale_date = datetime.now().date() + timedelta(days=stale_days)
|
||||
|
||||
stale_filter = StockItem.IN_STOCK_FILTER & ~Q(expiry_date=None) & Q(expiry_date__lt=stale_date)
|
||||
|
||||
if stale:
|
||||
queryset = queryset.filter(stale_filter)
|
||||
else:
|
||||
queryset = queryset.exclude(stale_filter)
|
||||
|
||||
# Exclude stock item tree
|
||||
exclude_tree = params.get('exclude_tree', None)
|
||||
|
||||
@@ -950,18 +994,6 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView):
|
||||
except (ValueError, StockItem.DoesNotExist):
|
||||
pass
|
||||
|
||||
# Filter by "part tree" - only allow parts within a given variant tree
|
||||
part_tree = params.get('part_tree', None)
|
||||
|
||||
if part_tree is not None:
|
||||
try:
|
||||
part = Part.objects.get(pk=part_tree)
|
||||
|
||||
if part.tree_id is not None:
|
||||
queryset = queryset.filter(part__tree_id=part.tree_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Exclude StockItems which are already allocated to a particular SalesOrder
|
||||
exclude_so_allocation = params.get('exclude_so_allocation', None)
|
||||
|
||||
@@ -1032,37 +1064,6 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView):
|
||||
except (ValueError, StockLocation.DoesNotExist):
|
||||
pass
|
||||
|
||||
# Does the client wish to filter by part category?
|
||||
cat_id = params.get('category', None)
|
||||
|
||||
if cat_id:
|
||||
try:
|
||||
category = PartCategory.objects.get(pk=cat_id)
|
||||
queryset = queryset.filter(part__category__in=category.getUniqueChildren())
|
||||
|
||||
except (ValueError, PartCategory.DoesNotExist):
|
||||
raise ValidationError({"category": "Invalid category id specified"})
|
||||
|
||||
# Does the client wish to filter by BomItem
|
||||
bom_item_id = params.get('bom_item', None)
|
||||
|
||||
if bom_item_id is not None:
|
||||
try:
|
||||
bom_item = BomItem.objects.get(pk=bom_item_id)
|
||||
|
||||
queryset = queryset.filter(bom_item.get_stock_filter())
|
||||
|
||||
except (ValueError, BomItem.DoesNotExist):
|
||||
pass
|
||||
|
||||
# Filter by company (either manufacturer or supplier)
|
||||
company = params.get('company', None)
|
||||
|
||||
if company is not None:
|
||||
queryset = queryset.filter(
|
||||
Q(supplier_part__supplier=company) | Q(supplier_part__manufacturer_part__manufacturer=company).distinct()
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
||||
|
||||
@@ -486,6 +486,11 @@ class StockItemListTest(StockAPITestCase):
|
||||
response = self.get_stock(batch='B123')
|
||||
self.assertEqual(len(response), 1)
|
||||
|
||||
def test_filter_by_company(self):
|
||||
"""Test that we can filter stock items by company"""
|
||||
for cmp in company.models.Company.objects.all():
|
||||
self.get_stock(company=cmp.pk)
|
||||
|
||||
def test_filter_by_serialized(self):
|
||||
"""Filter StockItem by serialized status."""
|
||||
response = self.get_stock(serialized=1)
|
||||
@@ -740,10 +745,10 @@ class StockItemListTest(StockAPITestCase):
|
||||
def test_query_count(self):
|
||||
"""Test that the number of queries required to fetch stock items is reasonable."""
|
||||
|
||||
def get_stock(data):
|
||||
def get_stock(data, expected_status=200):
|
||||
"""Helper function to fetch stock items."""
|
||||
response = self.client.get(self.list_url, data=data)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.status_code, expected_status)
|
||||
return response.data
|
||||
|
||||
# Create a bunch of StockItem objects
|
||||
|
||||
@@ -145,6 +145,25 @@ onPanelLoad('project-codes', function() {
|
||||
sortable: true,
|
||||
title: '{% trans "Project Code" %}',
|
||||
},
|
||||
{
|
||||
field: 'responsible',
|
||||
title: '{% trans "Responsible" %}',
|
||||
formatter: function(value, row) {
|
||||
if (!row.responsible_detail) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
var html = row.responsible_detail.name;
|
||||
|
||||
if (row.responsible_detail.label == '{% trans "group" %}') {
|
||||
html += `<span class='float-right fas fa-users'></span>`;
|
||||
} else {
|
||||
html += `<span class='float-right fas fa-user'></span>`;
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'description',
|
||||
sortable: false,
|
||||
@@ -171,6 +190,7 @@ onPanelLoad('project-codes', function() {
|
||||
fields: {
|
||||
code: {},
|
||||
description: {},
|
||||
responsible: {},
|
||||
},
|
||||
refreshTable: '#project-code-table',
|
||||
});
|
||||
|
||||
@@ -486,7 +486,15 @@ function extractNestedField(field_name, fields) {
|
||||
*/
|
||||
function constructFormBody(fields, options) {
|
||||
|
||||
var html = '';
|
||||
let html = '';
|
||||
|
||||
// Client must provide set of fields to be displayed,
|
||||
// otherwise *all* fields will be displayed
|
||||
const displayed_fields = options.fields || fields || {};
|
||||
|
||||
if(!options.fields) {
|
||||
options.fields = displayed_fields;
|
||||
}
|
||||
|
||||
// add additional content as a header on top (provided as html by the caller)
|
||||
if (options.header_html) {
|
||||
@@ -516,13 +524,11 @@ function constructFormBody(fields, options) {
|
||||
}
|
||||
|
||||
for (const [k,v] of Object.entries(fields)) {
|
||||
processField(k, v, options.fields[k]);
|
||||
if (options.fields && k in options.fields) {
|
||||
processField(k, v, options.fields[k]);
|
||||
}
|
||||
}
|
||||
|
||||
// Client must provide set of fields to be displayed,
|
||||
// otherwise *all* fields will be displayed
|
||||
var displayed_fields = options.fields || fields;
|
||||
|
||||
// Override default option values if a 'DELETE' form is specified
|
||||
if (options.method == 'DELETE') {
|
||||
if (!('confirm' in options)) {
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
# Base python requirements for docker containers
|
||||
|
||||
# Basic package requirements
|
||||
invoke>=1.4.0 # Invoke build tool
|
||||
invoke>=2.2.0 # Invoke build tool
|
||||
pyyaml>=6.0.1
|
||||
setuptools==65.6.3
|
||||
wheel>=0.37.0
|
||||
setuptools>=69.0.0
|
||||
wheel>=0.41.0
|
||||
|
||||
# Database links
|
||||
psycopg2>=2.9.1
|
||||
mysqlclient>=2.0.3,<=2.1.1
|
||||
psycopg2>=2.9.9
|
||||
mysqlclient>=2.2.0
|
||||
pgcli>=3.1.0
|
||||
mariadb>=1.0.7,<1.1.0
|
||||
mariadb>=1.1.8
|
||||
|
||||
# gunicorn web server
|
||||
gunicorn>=20.1.0
|
||||
gunicorn>=21.2.0
|
||||
|
||||
# LDAP required packages
|
||||
django-auth-ldap # Django integration for ldap auth
|
||||
|
||||
@@ -28,6 +28,17 @@ Pricing information can be determined from multiple sources:
|
||||
| Supplier Price | The price to theoretically purchase a part from a given supplier (with price-breaks) | [Supplier](../order/company.md#suppliers) |
|
||||
| Purchase Cost | Historical cost information for parts purchased | [Purchase Order](../order/purchase_order.md) |
|
||||
| BOM Price | Total price for an assembly (total price of all component items) | [Part](../part/part.md) |
|
||||
|
||||
#### Override Pricing
|
||||
|
||||
In addition to caching pricing data as documented in the above table, manual pricing overrides can be specified for a particular part. Both the *minimum price* and *maximum price* can be specified manually, independent of the calculated values. If an manual price is specified for a part, it overrides any calculated value.
|
||||
|
||||
### Sale Pricing
|
||||
|
||||
Additionally, the following information is stored for each part, in relation to sale pricing:
|
||||
|
||||
| Pricing Source | Description | Linked to |
|
||||
| --- | --- | --- |
|
||||
| Sale Price | How much a salable item is sold for (with price-breaks) | [Part](../part/part.md) |
|
||||
| Sale Cost | How much an item was sold for | [Sales Order](../order/sales_order.md) |
|
||||
|
||||
|
||||
@@ -12,3 +12,4 @@ pep8-naming # PEP naming convention extension
|
||||
pip-tools # Compile pip requirements
|
||||
pre-commit # Git pre-commit
|
||||
setuptools # Standard dependency
|
||||
pdfminer.six # PDF validation
|
||||
|
||||
@@ -14,11 +14,16 @@ certifi==2023.7.22
|
||||
# via
|
||||
# -c requirements.txt
|
||||
# requests
|
||||
cffi==1.16.0
|
||||
# via
|
||||
# -c requirements.txt
|
||||
# cryptography
|
||||
cfgv==3.4.0
|
||||
# via pre-commit
|
||||
charset-normalizer==3.3.2
|
||||
# via
|
||||
# -c requirements.txt
|
||||
# pdfminer-six
|
||||
# requests
|
||||
click==8.1.7
|
||||
# via pip-tools
|
||||
@@ -28,6 +33,10 @@ coverage==5.5
|
||||
# coveralls
|
||||
coveralls==2.1.2
|
||||
# via -r requirements-dev.in
|
||||
cryptography==41.0.5
|
||||
# via
|
||||
# -c requirements.txt
|
||||
# pdfminer-six
|
||||
distlib==0.3.7
|
||||
# via virtualenv
|
||||
django==3.2.23
|
||||
@@ -72,6 +81,8 @@ packaging==23.2
|
||||
# via
|
||||
# -c requirements.txt
|
||||
# build
|
||||
pdfminer-six==20221105
|
||||
# via -r requirements-dev.in
|
||||
pep8-naming==0.13.3
|
||||
# via -r requirements-dev.in
|
||||
pip-tools==7.3.0
|
||||
@@ -82,6 +93,10 @@ pre-commit==3.5.0
|
||||
# via -r requirements-dev.in
|
||||
pycodestyle==2.11.1
|
||||
# via flake8
|
||||
pycparser==2.21
|
||||
# via
|
||||
# -c requirements.txt
|
||||
# cffi
|
||||
pydocstyle==6.3.0
|
||||
# via flake8-docstrings
|
||||
pyflakes==3.1.0
|
||||
|
||||
+1
-1
@@ -48,4 +48,4 @@ regex # Advanced regular expressions
|
||||
sentry-sdk # Error reporting (optional)
|
||||
setuptools # Standard dependency
|
||||
tablib[xls,xlsx,yaml] # Support for XLS and XLSX formats
|
||||
weasyprint==54.3 # PDF generation
|
||||
weasyprint # PDF generation
|
||||
|
||||
+2
-2
@@ -269,7 +269,7 @@ requests==2.31.0
|
||||
# requests-oauthlib
|
||||
requests-oauthlib==1.3.1
|
||||
# via django-allauth
|
||||
rpds-py==0.10.6
|
||||
rpds-py==0.12.0
|
||||
# via
|
||||
# jsonschema
|
||||
# referencing
|
||||
@@ -311,7 +311,7 @@ urllib3==2.0.7
|
||||
# dulwich
|
||||
# requests
|
||||
# sentry-sdk
|
||||
weasyprint==54.3
|
||||
weasyprint==60.1
|
||||
# via
|
||||
# -r requirements.in
|
||||
# django-weasyprint
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-grid-layout": "^1.4.2",
|
||||
"react-hook-form": "^7.48.2",
|
||||
"react-router-dom": "^6.17.0",
|
||||
"react-select": "^5.7.7",
|
||||
"react-simplemde-editor": "^5.2.0",
|
||||
|
||||
@@ -15,8 +15,6 @@ export function ButtonMenu({
|
||||
label?: string;
|
||||
tooltip?: string;
|
||||
}) {
|
||||
let idx = 0;
|
||||
|
||||
return (
|
||||
<Menu shadow="xs">
|
||||
<Menu.Target>
|
||||
@@ -26,8 +24,8 @@ export function ButtonMenu({
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{label && <Menu.Label>{label}</Menu.Label>}
|
||||
{actions.map((action) => (
|
||||
<Menu.Item key={idx++}>{action}</Menu.Item>
|
||||
{actions.map((action, i) => (
|
||||
<Menu.Item key={i}>{action}</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
@@ -1,120 +1,231 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Alert, Divider, LoadingOverlay, Text } from '@mantine/core';
|
||||
import {
|
||||
Alert,
|
||||
DefaultMantineColor,
|
||||
Divider,
|
||||
LoadingOverlay,
|
||||
Text
|
||||
} from '@mantine/core';
|
||||
import { Button, Group, Stack } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { modals } from '@mantine/modals';
|
||||
import { useId } from '@mantine/hooks';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
FieldValues,
|
||||
SubmitErrorHandler,
|
||||
SubmitHandler,
|
||||
useForm
|
||||
} from 'react-hook-form';
|
||||
|
||||
import { api, queryClient } from '../../App';
|
||||
import { ApiPaths } from '../../enums/ApiEndpoints';
|
||||
import { constructFormUrl } from '../../functions/forms';
|
||||
import {
|
||||
NestedDict,
|
||||
constructField,
|
||||
constructFormUrl,
|
||||
extractAvailableFields,
|
||||
mapFields
|
||||
} from '../../functions/forms';
|
||||
import { invalidResponse } from '../../functions/notifications';
|
||||
import { ApiFormField, ApiFormFieldSet } from './fields/ApiFormField';
|
||||
import { PathParams } from '../../states/ApiState';
|
||||
import {
|
||||
ApiFormField,
|
||||
ApiFormFieldSet,
|
||||
ApiFormFieldType
|
||||
} from './fields/ApiFormField';
|
||||
|
||||
export interface ApiFormAction {
|
||||
text: string;
|
||||
variant?: 'outline';
|
||||
color?: DefaultMantineColor;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Properties for the ApiForm component
|
||||
* @param url : The API endpoint to fetch the form data from
|
||||
* @param pk : Optional primary-key value when editing an existing object
|
||||
* @param title : The title to display in the form header
|
||||
* @param pathParams : Optional path params for the url
|
||||
* @param method : Optional HTTP method to use when submitting the form (default: GET)
|
||||
* @param fields : The fields to render in the form
|
||||
* @param submitText : Optional custom text to display on the submit button (default: Submit)4
|
||||
* @param submitColor : Optional custom color for the submit button (default: green)
|
||||
* @param cancelText : Optional custom text to display on the cancel button (default: Cancel)
|
||||
* @param cancelColor : Optional custom color for the cancel button (default: blue)
|
||||
* @param fetchInitialData : Optional flag to fetch initial data from the server (default: true)
|
||||
* @param method : Optional HTTP method to use when submitting the form (default: GET)
|
||||
* @param preFormContent : Optional content to render before the form fields
|
||||
* @param postFormContent : Optional content to render after the form fields
|
||||
* @param successMessage : Optional message to display on successful form submission
|
||||
* @param onClose : A callback function to call when the form is closed.
|
||||
* @param onFormSuccess : A callback function to call when the form is submitted successfully.
|
||||
* @param onFormError : A callback function to call when the form is submitted with errors.
|
||||
*/
|
||||
export interface ApiFormProps {
|
||||
url: ApiPaths;
|
||||
pk?: number | string | undefined;
|
||||
title: string;
|
||||
pathParams?: PathParams;
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||
fields?: ApiFormFieldSet;
|
||||
cancelText?: string;
|
||||
submitText?: string;
|
||||
submitColor?: string;
|
||||
cancelColor?: string;
|
||||
fetchInitialData?: boolean;
|
||||
ignorePermissionCheck?: boolean;
|
||||
method?: string;
|
||||
preFormContent?: JSX.Element | (() => JSX.Element);
|
||||
postFormContent?: JSX.Element | (() => JSX.Element);
|
||||
preFormContent?: JSX.Element;
|
||||
postFormContent?: JSX.Element;
|
||||
successMessage?: string;
|
||||
onClose?: () => void;
|
||||
onFormSuccess?: (data: any) => void;
|
||||
onFormError?: () => void;
|
||||
actions?: ApiFormAction[];
|
||||
}
|
||||
|
||||
export function OptionsApiForm({
|
||||
props: _props,
|
||||
id: pId
|
||||
}: {
|
||||
props: ApiFormProps;
|
||||
id?: string;
|
||||
}) {
|
||||
const props = useMemo(
|
||||
() => ({
|
||||
..._props,
|
||||
method: _props.method || 'GET'
|
||||
}),
|
||||
[_props]
|
||||
);
|
||||
|
||||
const id = useId(pId);
|
||||
|
||||
const url = useMemo(
|
||||
() => constructFormUrl(props.url, props.pk, props.pathParams),
|
||||
[props.url, props.pk, props.pathParams]
|
||||
);
|
||||
|
||||
const { data } = useQuery({
|
||||
enabled: true,
|
||||
queryKey: [
|
||||
'form-options-data',
|
||||
id,
|
||||
props.method,
|
||||
props.url,
|
||||
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;
|
||||
}),
|
||||
throwOnError: (error: any) => {
|
||||
if (error.response) {
|
||||
invalidResponse(error.response.status);
|
||||
} else {
|
||||
notifications.show({
|
||||
title: t`Form Error`,
|
||||
message: error.message,
|
||||
color: 'red'
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const formProps: ApiFormProps = useMemo(() => {
|
||||
const _props = { ...props };
|
||||
|
||||
if (!_props.fields) return _props;
|
||||
|
||||
for (const [k, v] of Object.entries(_props.fields)) {
|
||||
_props.fields[k] = constructField({
|
||||
field: v,
|
||||
definition: data?.[k]
|
||||
});
|
||||
}
|
||||
|
||||
return _props;
|
||||
}, [data, props]);
|
||||
|
||||
if (!data) {
|
||||
return <LoadingOverlay visible={true} />;
|
||||
}
|
||||
|
||||
return <ApiForm id={id} props={formProps} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* An ApiForm component is a modal form which is rendered dynamically,
|
||||
* based on an API endpoint.
|
||||
*/
|
||||
export function ApiForm({
|
||||
modalId,
|
||||
props,
|
||||
fieldDefinitions
|
||||
}: {
|
||||
modalId: string;
|
||||
props: ApiFormProps;
|
||||
fieldDefinitions: ApiFormFieldSet;
|
||||
}) {
|
||||
export function ApiForm({ id, props }: { id: string; props: ApiFormProps }) {
|
||||
const defaultValues: FieldValues = useMemo(
|
||||
() =>
|
||||
mapFields(props.fields ?? {}, (_path, field) => {
|
||||
return field.default ?? undefined;
|
||||
}),
|
||||
[props.fields]
|
||||
);
|
||||
|
||||
// Form errors which are not associated with a specific field
|
||||
const [nonFieldErrors, setNonFieldErrors] = useState<string[]>([]);
|
||||
|
||||
// Form state
|
||||
const form = useForm({});
|
||||
const form = useForm({
|
||||
criteriaMode: 'all',
|
||||
defaultValues
|
||||
});
|
||||
const { isValid, isDirty, isLoading: isFormLoading } = form.formState;
|
||||
|
||||
// Cache URL
|
||||
const url = useMemo(() => constructFormUrl(props), [props]);
|
||||
const url = useMemo(
|
||||
() => constructFormUrl(props.url, props.pk, props.pathParams),
|
||||
[props.url, props.pk, props.pathParams]
|
||||
);
|
||||
|
||||
// Render pre-form content
|
||||
// TODO: Future work will allow this content to be updated dynamically based on the form data
|
||||
const preFormElement: JSX.Element | null = useMemo(() => {
|
||||
if (props.preFormContent === undefined) {
|
||||
return null;
|
||||
} else if (props.preFormContent instanceof Function) {
|
||||
return props.preFormContent();
|
||||
} else {
|
||||
return props.preFormContent;
|
||||
}
|
||||
}, [props]);
|
||||
|
||||
// Render post-form content
|
||||
// TODO: Future work will allow this content to be updated dynamically based on the form data
|
||||
const postFormElement: JSX.Element | null = useMemo(() => {
|
||||
if (props.postFormContent === undefined) {
|
||||
return null;
|
||||
} else if (props.postFormContent instanceof Function) {
|
||||
return props.postFormContent();
|
||||
} else {
|
||||
return props.postFormContent;
|
||||
}
|
||||
}, [props]);
|
||||
|
||||
// Query manager for retrieiving initial data from the server
|
||||
// Query manager for retrieving initial data from the server
|
||||
const initialDataQuery = useQuery({
|
||||
enabled: false,
|
||||
queryKey: ['form-initial-data', modalId, props.method, props.url, props.pk],
|
||||
queryKey: [
|
||||
'form-initial-data',
|
||||
id,
|
||||
props.method,
|
||||
props.url,
|
||||
props.pk,
|
||||
props.pathParams
|
||||
],
|
||||
queryFn: async () => {
|
||||
return api
|
||||
.get(url)
|
||||
.then((response) => {
|
||||
// Update form values, but only for the fields specified for the form
|
||||
Object.keys(props.fields ?? {}).forEach((fieldName) => {
|
||||
if (fieldName in response.data) {
|
||||
form.setValues({
|
||||
[fieldName]: response.data[fieldName]
|
||||
});
|
||||
const processFields = (fields: ApiFormFieldSet, data: NestedDict) => {
|
||||
const res: NestedDict = {};
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return res;
|
||||
};
|
||||
const initialData: any = processFields(
|
||||
props.fields ?? {},
|
||||
response.data
|
||||
);
|
||||
|
||||
// Update form values, but only for the fields specified for this form
|
||||
form.reset(initialData);
|
||||
|
||||
return response;
|
||||
})
|
||||
@@ -126,144 +237,129 @@ export function ApiForm({
|
||||
|
||||
// Fetch initial data on form load
|
||||
useEffect(() => {
|
||||
// Provide initial form data
|
||||
Object.entries(props.fields ?? {}).forEach(([fieldName, field]) => {
|
||||
// fieldDefinition is supplied by the API, and can serve as a backup
|
||||
let fieldDefinition = fieldDefinitions[fieldName] ?? {};
|
||||
|
||||
let v =
|
||||
field.value ??
|
||||
field.default ??
|
||||
fieldDefinition.value ??
|
||||
fieldDefinition.default ??
|
||||
undefined;
|
||||
|
||||
if (v !== undefined) {
|
||||
form.setValues({
|
||||
[fieldName]: v
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch initial data if the fetchInitialData property is set
|
||||
if (props.fetchInitialData) {
|
||||
queryClient.removeQueries({
|
||||
queryKey: [
|
||||
'form-initial-data',
|
||||
modalId,
|
||||
id,
|
||||
props.method,
|
||||
props.url,
|
||||
props.pk
|
||||
props.pk,
|
||||
props.pathParams
|
||||
]
|
||||
});
|
||||
initialDataQuery.refetch();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Query manager for submitting data
|
||||
const submitQuery = useQuery({
|
||||
enabled: false,
|
||||
queryKey: ['form-submit', modalId, props.method, props.url, props.pk],
|
||||
queryFn: async () => {
|
||||
let method = props.method?.toLowerCase() ?? 'get';
|
||||
const submitForm: SubmitHandler<FieldValues> = async (data) => {
|
||||
setNonFieldErrors([]);
|
||||
|
||||
return api({
|
||||
method: method,
|
||||
url: url,
|
||||
data: form.values,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
let method = props.method?.toLowerCase() ?? 'get';
|
||||
|
||||
let hasFiles = false;
|
||||
mapFields(props.fields ?? {}, (_path, field) => {
|
||||
if (field.field_type === 'file upload') {
|
||||
hasFiles = true;
|
||||
}
|
||||
});
|
||||
|
||||
return api({
|
||||
method: method,
|
||||
url: url,
|
||||
data: data,
|
||||
headers: {
|
||||
'Content-Type': hasFiles ? 'multipart/form-data' : 'application/json'
|
||||
}
|
||||
})
|
||||
.then((response) => {
|
||||
switch (response.status) {
|
||||
case 200:
|
||||
case 201:
|
||||
case 204:
|
||||
// Form was submitted successfully
|
||||
|
||||
// Optionally call the onFormSuccess callback
|
||||
if (props.onFormSuccess) {
|
||||
props.onFormSuccess(response.data);
|
||||
}
|
||||
|
||||
// Optionally show a success message
|
||||
if (props.successMessage) {
|
||||
notifications.show({
|
||||
title: t`Success`,
|
||||
message: props.successMessage,
|
||||
color: 'green'
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
default:
|
||||
// Unexpected state on form success
|
||||
invalidResponse(response.status);
|
||||
props.onFormError?.();
|
||||
break;
|
||||
}
|
||||
|
||||
return response;
|
||||
})
|
||||
.then((response) => {
|
||||
switch (response.status) {
|
||||
case 200:
|
||||
case 201:
|
||||
case 204:
|
||||
// Form was submitted successfully
|
||||
.catch((error) => {
|
||||
if (error.response) {
|
||||
switch (error.response.status) {
|
||||
case 400:
|
||||
// Data validation errors
|
||||
const nonFieldErrors: string[] = [];
|
||||
const processErrors = (errors: any, _path?: string) => {
|
||||
for (const [k, v] of Object.entries(errors)) {
|
||||
const path = _path ? `${_path}.${k}` : k;
|
||||
|
||||
// Optionally call the onFormSuccess callback
|
||||
if (props.onFormSuccess) {
|
||||
props.onFormSuccess(response.data);
|
||||
}
|
||||
if (k === 'non_field_errors') {
|
||||
nonFieldErrors.push((v as string[]).join(', '));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Optionally show a success message
|
||||
if (props.successMessage) {
|
||||
notifications.show({
|
||||
title: t`Success`,
|
||||
message: props.successMessage,
|
||||
color: 'green'
|
||||
});
|
||||
}
|
||||
if (typeof v === 'object' && Array.isArray(v)) {
|
||||
form.setError(path, { message: v.join(', ') });
|
||||
} else {
|
||||
processErrors(v, path);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
closeForm();
|
||||
processErrors(error.response.data);
|
||||
setNonFieldErrors(nonFieldErrors);
|
||||
break;
|
||||
default:
|
||||
// Unexpected state on form success
|
||||
invalidResponse(response.status);
|
||||
closeForm();
|
||||
// Unexpected state on form error
|
||||
invalidResponse(error.response.status);
|
||||
props.onFormError?.();
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
invalidResponse(0);
|
||||
props.onFormError?.();
|
||||
}
|
||||
|
||||
return response;
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.response) {
|
||||
switch (error.response.status) {
|
||||
case 400:
|
||||
// Data validation error
|
||||
form.setErrors(error.response.data);
|
||||
setNonFieldErrors(error.response.data.non_field_errors ?? []);
|
||||
setIsLoading(false);
|
||||
break;
|
||||
default:
|
||||
// Unexpected state on form error
|
||||
invalidResponse(error.response.status);
|
||||
closeForm();
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
invalidResponse(0);
|
||||
closeForm();
|
||||
}
|
||||
return error;
|
||||
});
|
||||
};
|
||||
|
||||
return error;
|
||||
});
|
||||
},
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false
|
||||
});
|
||||
const isLoading = useMemo(
|
||||
() => isFormLoading || initialDataQuery.isFetching,
|
||||
[isFormLoading, initialDataQuery.isFetching]
|
||||
);
|
||||
|
||||
// Data loading state
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(submitQuery.isFetching || initialDataQuery.isFetching);
|
||||
}, [initialDataQuery.status, submitQuery.status]);
|
||||
|
||||
/**
|
||||
* Callback to perform form submission
|
||||
*/
|
||||
function submitForm() {
|
||||
setIsLoading(true);
|
||||
submitQuery.refetch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback to close the form
|
||||
* Note that the calling function might implement an onClose() callback,
|
||||
* which will be automatically called
|
||||
*/
|
||||
function closeForm() {
|
||||
modals.close(modalId);
|
||||
}
|
||||
const onFormError = useCallback<SubmitErrorHandler<FieldValues>>(() => {
|
||||
props.onFormError?.();
|
||||
}, [props.onFormError]);
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Divider />
|
||||
<Stack spacing="sm">
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
{(Object.keys(form.errors).length > 0 || nonFieldErrors.length > 0) && (
|
||||
{(!isValid || nonFieldErrors.length > 0) && (
|
||||
<Alert radius="sm" color="red" title={t`Form Errors Exist`}>
|
||||
{nonFieldErrors.length > 0 && (
|
||||
<Stack spacing="xs">
|
||||
@@ -274,41 +370,38 @@ export function ApiForm({
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
{preFormElement}
|
||||
{props.preFormContent}
|
||||
<Stack spacing="xs">
|
||||
{Object.entries(props.fields ?? {}).map(
|
||||
([fieldName, field]) =>
|
||||
!field.hidden && (
|
||||
<ApiFormField
|
||||
key={fieldName}
|
||||
field={field}
|
||||
fieldName={fieldName}
|
||||
formProps={props}
|
||||
form={form}
|
||||
error={form.errors[fieldName] ?? null}
|
||||
definitions={fieldDefinitions}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{Object.entries(props.fields ?? {}).map(([fieldName, field]) => (
|
||||
<ApiFormField
|
||||
key={fieldName}
|
||||
fieldName={fieldName}
|
||||
definition={field}
|
||||
control={form.control}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
{postFormElement}
|
||||
{props.postFormContent}
|
||||
</Stack>
|
||||
<Divider />
|
||||
<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
|
||||
onClick={closeForm}
|
||||
variant="outline"
|
||||
radius="sm"
|
||||
color={props.cancelColor ?? 'blue'}
|
||||
>
|
||||
{props.cancelText ?? t`Cancel`}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={submitForm}
|
||||
onClick={form.handleSubmit(submitForm, onFormError)}
|
||||
variant="outline"
|
||||
radius="sm"
|
||||
color={props.submitColor ?? 'green'}
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || (props.fetchInitialData && !isDirty)}
|
||||
>
|
||||
{props.submitText ?? t`Submit`}
|
||||
</Button>
|
||||
|
||||
@@ -11,27 +11,18 @@ import { DateInput } from '@mantine/dates';
|
||||
import { UseFormReturnType } from '@mantine/form';
|
||||
import { useId } from '@mantine/hooks';
|
||||
import { IconX } from '@tabler/icons-react';
|
||||
import { ReactNode } from 'react';
|
||||
import { ReactNode, useCallback, useEffect } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { Control, FieldValues, useController } from 'react-hook-form';
|
||||
|
||||
import { ModelType } from '../../../enums/ModelType';
|
||||
import { ApiFormProps } from '../ApiForm';
|
||||
import { ChoiceField } from './ChoiceField';
|
||||
import { NestedObjectField } from './NestedObjectField';
|
||||
import { RelatedModelField } from './RelatedModelField';
|
||||
|
||||
export type ApiFormData = UseFormReturnType<Record<string, unknown>>;
|
||||
|
||||
/**
|
||||
* Callback function type when a form field value changes
|
||||
*/
|
||||
export type ApiFormChangeCallback = {
|
||||
name: string;
|
||||
value: any;
|
||||
field: ApiFormFieldType;
|
||||
form: ApiFormData;
|
||||
};
|
||||
|
||||
/* Definition of the ApiForm field component.
|
||||
/** Definition of the ApiForm field component.
|
||||
* - The 'name' attribute *must* be provided
|
||||
* - All other attributes are optional, and may be provided by the API
|
||||
* - However, they can be overridden by the user
|
||||
@@ -60,10 +51,25 @@ export type ApiFormFieldType = {
|
||||
value?: any;
|
||||
default?: any;
|
||||
icon?: ReactNode;
|
||||
field_type?: string;
|
||||
field_type?:
|
||||
| 'related field'
|
||||
| 'email'
|
||||
| 'url'
|
||||
| 'string'
|
||||
| 'boolean'
|
||||
| 'date'
|
||||
| 'integer'
|
||||
| 'decimal'
|
||||
| 'float'
|
||||
| 'number'
|
||||
| 'choice'
|
||||
| 'file upload'
|
||||
| 'nested object';
|
||||
api_url?: string;
|
||||
model?: ModelType;
|
||||
modelRenderer?: (instance: any) => ReactNode;
|
||||
filters?: any;
|
||||
children?: { [key: string]: ApiFormFieldType };
|
||||
required?: boolean;
|
||||
choices?: any[];
|
||||
hidden?: boolean;
|
||||
@@ -71,127 +77,66 @@ export type ApiFormFieldType = {
|
||||
read_only?: boolean;
|
||||
placeholder?: string;
|
||||
description?: string;
|
||||
preFieldContent?: JSX.Element | (() => JSX.Element);
|
||||
postFieldContent?: JSX.Element | (() => JSX.Element);
|
||||
onValueChange?: (change: ApiFormChangeCallback) => void;
|
||||
adjustFilters?: (filters: any, form: ApiFormData) => any;
|
||||
preFieldContent?: JSX.Element;
|
||||
postFieldContent?: JSX.Element;
|
||||
onValueChange?: (value: any) => void;
|
||||
adjustFilters?: (filters: any) => any;
|
||||
};
|
||||
|
||||
/*
|
||||
* Build a complete field definition based on the provided data
|
||||
*/
|
||||
export function constructField({
|
||||
form,
|
||||
fieldName,
|
||||
field,
|
||||
definitions
|
||||
}: {
|
||||
form: UseFormReturnType<Record<string, unknown>>;
|
||||
fieldName: string;
|
||||
field: ApiFormFieldType;
|
||||
definitions: Record<string, ApiFormFieldType>;
|
||||
}) {
|
||||
let def = definitions[fieldName] || field;
|
||||
|
||||
def = {
|
||||
...def,
|
||||
...field
|
||||
};
|
||||
|
||||
// Retrieve the latest value from the form
|
||||
let value = form.values[fieldName];
|
||||
|
||||
if (value != undefined) {
|
||||
def.value = value;
|
||||
}
|
||||
|
||||
// Change value to a date object if required
|
||||
switch (def.field_type) {
|
||||
case 'date':
|
||||
if (def.value) {
|
||||
def.value = new Date(def.value);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// Clear out the 'read_only' attribute
|
||||
def.disabled = def.disabled ?? def.read_only ?? false;
|
||||
delete def['read_only'];
|
||||
|
||||
return def;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an individual form field
|
||||
*/
|
||||
export function ApiFormField({
|
||||
formProps,
|
||||
form,
|
||||
fieldName,
|
||||
field,
|
||||
error,
|
||||
definitions
|
||||
definition,
|
||||
control
|
||||
}: {
|
||||
formProps: ApiFormProps;
|
||||
form: UseFormReturnType<Record<string, unknown>>;
|
||||
fieldName: string;
|
||||
field: ApiFormFieldType;
|
||||
error: ReactNode;
|
||||
definitions: Record<string, ApiFormFieldType>;
|
||||
definition: ApiFormFieldType;
|
||||
control: Control<FieldValues, any>;
|
||||
}) {
|
||||
const fieldId = useId(fieldName);
|
||||
const fieldId = useId();
|
||||
const controller = useController({
|
||||
name: fieldName,
|
||||
control
|
||||
});
|
||||
const {
|
||||
field,
|
||||
fieldState: { error }
|
||||
} = controller;
|
||||
const { value, ref } = field;
|
||||
|
||||
// Extract field definition from provided data
|
||||
// Where user has provided specific data, override the API definition
|
||||
const definition: ApiFormFieldType = useMemo(
|
||||
() =>
|
||||
constructField({
|
||||
form: form,
|
||||
fieldName: fieldName,
|
||||
field: field,
|
||||
definitions: definitions
|
||||
}),
|
||||
[fieldName, field, definitions]
|
||||
);
|
||||
useEffect(() => {
|
||||
if (definition.field_type === 'nested object') return;
|
||||
|
||||
const preFieldElement: JSX.Element | null = useMemo(() => {
|
||||
if (field.preFieldContent === undefined) {
|
||||
return null;
|
||||
} else if (field.preFieldContent instanceof Function) {
|
||||
return field.preFieldContent();
|
||||
} else {
|
||||
return field.preFieldContent;
|
||||
// hook up the value state to the input field
|
||||
if (definition.value !== undefined) {
|
||||
field.onChange(definition.value);
|
||||
}
|
||||
}, [field]);
|
||||
}, [definition.value]);
|
||||
|
||||
const postFieldElement: JSX.Element | null = useMemo(() => {
|
||||
if (field.postFieldContent === undefined) {
|
||||
return null;
|
||||
} else if (field.postFieldContent instanceof Function) {
|
||||
return field.postFieldContent();
|
||||
} else {
|
||||
return field.postFieldContent;
|
||||
}
|
||||
}, [field]);
|
||||
// pull out onValueChange as this can cause strange errors when passing the
|
||||
// definition to the input components via spread syntax
|
||||
const reducedDefinition = useMemo(() => {
|
||||
return {
|
||||
...definition,
|
||||
onValueChange: undefined,
|
||||
adjustFilters: undefined,
|
||||
read_only: undefined,
|
||||
children: undefined
|
||||
};
|
||||
}, [definition]);
|
||||
|
||||
// Callback helper when form value changes
|
||||
function onChange(value: any) {
|
||||
form.setValues({ [fieldName]: value });
|
||||
const onChange = useCallback(
|
||||
(value: any) => {
|
||||
field.onChange(value);
|
||||
|
||||
// Run custom callback for this field
|
||||
if (definition.onValueChange) {
|
||||
definition.onValueChange({
|
||||
name: fieldName,
|
||||
value: value,
|
||||
field: definition,
|
||||
form: form
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const value: any = useMemo(() => form.values[fieldName], [form.values]);
|
||||
// Run custom callback for this field
|
||||
definition.onValueChange?.(value);
|
||||
},
|
||||
[fieldName, definition]
|
||||
);
|
||||
|
||||
// Coerce the value to a numerical value
|
||||
const numericalValue: number | undefined = useMemo(() => {
|
||||
@@ -223,12 +168,9 @@ export function ApiFormField({
|
||||
case 'related field':
|
||||
return (
|
||||
<RelatedModelField
|
||||
error={error}
|
||||
formProps={formProps}
|
||||
form={form}
|
||||
field={definition}
|
||||
controller={controller}
|
||||
definition={definition}
|
||||
fieldName={fieldName}
|
||||
definitions={definitions}
|
||||
/>
|
||||
);
|
||||
case 'email':
|
||||
@@ -236,11 +178,12 @@ export function ApiFormField({
|
||||
case 'string':
|
||||
return (
|
||||
<TextInput
|
||||
{...definition}
|
||||
{...reducedDefinition}
|
||||
ref={ref}
|
||||
id={fieldId}
|
||||
type={definition.field_type}
|
||||
value={value || ''}
|
||||
error={error}
|
||||
error={error?.message}
|
||||
radius="sm"
|
||||
onChange={(event) => onChange(event.currentTarget.value)}
|
||||
rightSection={
|
||||
@@ -253,23 +196,25 @@ export function ApiFormField({
|
||||
case 'boolean':
|
||||
return (
|
||||
<Switch
|
||||
{...definition}
|
||||
{...reducedDefinition}
|
||||
ref={ref}
|
||||
id={fieldId}
|
||||
radius="lg"
|
||||
size="sm"
|
||||
checked={value ?? false}
|
||||
error={error}
|
||||
error={error?.message}
|
||||
onChange={(event) => onChange(event.currentTarget.checked)}
|
||||
/>
|
||||
);
|
||||
case 'date':
|
||||
return (
|
||||
<DateInput
|
||||
{...definition}
|
||||
{...reducedDefinition}
|
||||
ref={ref}
|
||||
id={fieldId}
|
||||
radius="sm"
|
||||
type={undefined}
|
||||
error={error}
|
||||
error={error?.message}
|
||||
value={value}
|
||||
clearable={!definition.required}
|
||||
onChange={(value) => onChange(value)}
|
||||
@@ -282,11 +227,12 @@ export function ApiFormField({
|
||||
case 'number':
|
||||
return (
|
||||
<NumberInput
|
||||
{...definition}
|
||||
{...reducedDefinition}
|
||||
radius="sm"
|
||||
ref={ref}
|
||||
id={fieldId}
|
||||
value={numericalValue}
|
||||
error={error}
|
||||
error={error?.message}
|
||||
formatter={(value) => {
|
||||
let v: any = parseFloat(value);
|
||||
|
||||
@@ -303,24 +249,31 @@ export function ApiFormField({
|
||||
case 'choice':
|
||||
return (
|
||||
<ChoiceField
|
||||
error={error}
|
||||
form={form}
|
||||
controller={controller}
|
||||
fieldName={fieldName}
|
||||
field={definition}
|
||||
definitions={definitions}
|
||||
definition={definition}
|
||||
/>
|
||||
);
|
||||
case 'file upload':
|
||||
return (
|
||||
<FileInput
|
||||
{...definition}
|
||||
{...reducedDefinition}
|
||||
id={fieldId}
|
||||
ref={ref}
|
||||
radius="sm"
|
||||
value={value}
|
||||
error={error}
|
||||
error={error?.message}
|
||||
onChange={(payload: File | null) => onChange(payload)}
|
||||
/>
|
||||
);
|
||||
case 'nested object':
|
||||
return (
|
||||
<NestedObjectField
|
||||
definition={definition}
|
||||
fieldName={fieldName}
|
||||
control={control}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Alert color="red" title={t`Error`}>
|
||||
@@ -331,11 +284,15 @@ export function ApiFormField({
|
||||
}
|
||||
}
|
||||
|
||||
if (definition.hidden) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
{preFieldElement}
|
||||
{definition.preFieldContent}
|
||||
{buildField()}
|
||||
{postFieldElement}
|
||||
{definition.postFieldContent}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,47 +1,30 @@
|
||||
import { Select } from '@mantine/core';
|
||||
import { UseFormReturnType } from '@mantine/form';
|
||||
import { useId } from '@mantine/hooks';
|
||||
import { ReactNode } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { FieldValues, UseControllerReturn } from 'react-hook-form';
|
||||
|
||||
import { constructField } from './ApiFormField';
|
||||
import { ApiFormFieldSet, ApiFormFieldType } from './ApiFormField';
|
||||
import { ApiFormFieldType } from './ApiFormField';
|
||||
|
||||
/**
|
||||
* Render a 'select' field for selecting from a list of choices
|
||||
*/
|
||||
export function ChoiceField({
|
||||
error,
|
||||
form,
|
||||
fieldName,
|
||||
field,
|
||||
definitions
|
||||
controller,
|
||||
definition
|
||||
}: {
|
||||
error: ReactNode;
|
||||
form: UseFormReturnType<Record<string, unknown>>;
|
||||
field: ApiFormFieldType;
|
||||
controller: UseControllerReturn<FieldValues, any>;
|
||||
definition: ApiFormFieldType;
|
||||
fieldName: string;
|
||||
definitions: ApiFormFieldSet;
|
||||
}) {
|
||||
// Extract field definition from provided data
|
||||
// Where user has provided specific data, override the API definition
|
||||
const definition: ApiFormFieldType = useMemo(() => {
|
||||
let def = constructField({
|
||||
form: form,
|
||||
field: field,
|
||||
fieldName: fieldName,
|
||||
definitions: definitions
|
||||
});
|
||||
const fieldId = useId();
|
||||
|
||||
return def;
|
||||
}, [fieldName, field, definitions]);
|
||||
|
||||
const fieldId = useId(fieldName);
|
||||
|
||||
const value: any = useMemo(() => form.values[fieldName], [form.values]);
|
||||
const {
|
||||
field,
|
||||
fieldState: { error }
|
||||
} = controller;
|
||||
|
||||
// Build a set of choices for the field
|
||||
// TODO: In future, allow this to be created dynamically?
|
||||
const choices: any[] = useMemo(() => {
|
||||
let choices = definition.choices ?? [];
|
||||
|
||||
@@ -53,30 +36,28 @@ export function ChoiceField({
|
||||
label: choice.display_name
|
||||
};
|
||||
});
|
||||
}, [definition]);
|
||||
}, [definition.choices]);
|
||||
|
||||
// Callback when an option is selected
|
||||
function onChange(value: any) {
|
||||
form.setFieldValue(fieldName, value);
|
||||
// Update form values when the selected value changes
|
||||
const onChange = useCallback(
|
||||
(value: any) => {
|
||||
field.onChange(value);
|
||||
|
||||
if (definition.onValueChange) {
|
||||
definition.onValueChange({
|
||||
name: fieldName,
|
||||
value: value,
|
||||
field: definition,
|
||||
form: form
|
||||
});
|
||||
}
|
||||
}
|
||||
// Run custom callback for this field (if provided)
|
||||
definition.onValueChange?.(value);
|
||||
},
|
||||
[field.onChange, definition]
|
||||
);
|
||||
|
||||
return (
|
||||
<Select
|
||||
id={fieldId}
|
||||
error={error?.message}
|
||||
radius="sm"
|
||||
{...definition}
|
||||
{...field}
|
||||
onChange={onChange}
|
||||
data={choices}
|
||||
value={value}
|
||||
onChange={(value) => onChange(value)}
|
||||
value={field.value}
|
||||
withinPortal={true}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Accordion, Divider, Stack, Text } from '@mantine/core';
|
||||
import { Control, FieldValues } from 'react-hook-form';
|
||||
|
||||
import { ApiFormField, ApiFormFieldType } from './ApiFormField';
|
||||
|
||||
export function NestedObjectField({
|
||||
control,
|
||||
fieldName,
|
||||
definition
|
||||
}: {
|
||||
control: Control<FieldValues, any>;
|
||||
definition: ApiFormFieldType;
|
||||
fieldName: string;
|
||||
}) {
|
||||
return (
|
||||
<Accordion defaultValue={'OpenByDefault'} variant="contained">
|
||||
<Accordion.Item value={'OpenByDefault'}>
|
||||
<Accordion.Control icon={definition.icon}>
|
||||
<Text>{definition.label}</Text>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Divider sx={{ marginTop: '-10px', marginBottom: '10px' }} />
|
||||
<Stack spacing="xs">
|
||||
{Object.entries(definition.children ?? {}).map(
|
||||
([childFieldName, field]) => (
|
||||
<ApiFormField
|
||||
key={childFieldName}
|
||||
fieldName={`${fieldName}.${childFieldName}`}
|
||||
definition={field}
|
||||
control={control}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
@@ -1,90 +1,65 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Input } from '@mantine/core';
|
||||
import { UseFormReturnType } from '@mantine/form';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { useId } from '@mantine/hooks';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { FieldValues, UseControllerReturn } from 'react-hook-form';
|
||||
import Select from 'react-select';
|
||||
|
||||
import { api } from '../../../App';
|
||||
import { RenderInstance } from '../../render/Instance';
|
||||
import { ApiFormProps } from '../ApiForm';
|
||||
import { ApiFormFieldSet, ApiFormFieldType } from './ApiFormField';
|
||||
import { constructField } from './ApiFormField';
|
||||
import { ApiFormFieldType } from './ApiFormField';
|
||||
|
||||
/**
|
||||
* Render a 'select' field for searching the database against a particular model type
|
||||
*/
|
||||
export function RelatedModelField({
|
||||
error,
|
||||
formProps,
|
||||
form,
|
||||
controller,
|
||||
fieldName,
|
||||
field,
|
||||
definitions,
|
||||
definition,
|
||||
limit = 10
|
||||
}: {
|
||||
error: ReactNode;
|
||||
formProps: ApiFormProps;
|
||||
form: UseFormReturnType<Record<string, unknown>>;
|
||||
field: ApiFormFieldType;
|
||||
controller: UseControllerReturn<FieldValues, any>;
|
||||
definition: ApiFormFieldType;
|
||||
fieldName: string;
|
||||
definitions: ApiFormFieldSet;
|
||||
limit?: number;
|
||||
}) {
|
||||
const fieldId = useId(fieldName);
|
||||
const fieldId = useId();
|
||||
|
||||
// Extract field definition from provided data
|
||||
// Where user has provided specific data, override the API definition
|
||||
const definition: ApiFormFieldType = useMemo(() => {
|
||||
let def = constructField({
|
||||
form: form,
|
||||
field: field,
|
||||
fieldName: fieldName,
|
||||
definitions: definitions
|
||||
});
|
||||
|
||||
// Remove the 'read_only' attribute (causes issues with Mantine)
|
||||
delete def['read_only'];
|
||||
return def;
|
||||
}, [form.values, field, definitions]);
|
||||
const {
|
||||
field,
|
||||
fieldState: { error }
|
||||
} = controller;
|
||||
|
||||
// Keep track of the primary key value for this field
|
||||
const [pk, setPk] = useState<number | null>(null);
|
||||
|
||||
// If an initial value is provided, load from the API
|
||||
useEffect(() => {
|
||||
// If a value is provided, load the related object
|
||||
if (form.values) {
|
||||
let formPk = form.values[fieldName] ?? null;
|
||||
// If the value is unchanged, do nothing
|
||||
if (field.value === pk) return;
|
||||
|
||||
// If the value is unchanged, do nothing
|
||||
if (formPk == pk) {
|
||||
return;
|
||||
}
|
||||
if (field.value !== null) {
|
||||
const url = `${definition.api_url}${field.value}/`;
|
||||
|
||||
if (formPk != null) {
|
||||
let url = (definition.api_url || '') + formPk + '/';
|
||||
api.get(url).then((response) => {
|
||||
const data = response.data;
|
||||
|
||||
api.get(url).then((response) => {
|
||||
let data = response.data;
|
||||
if (data && data.pk) {
|
||||
const value = {
|
||||
value: data.pk,
|
||||
data: data
|
||||
};
|
||||
|
||||
if (data && data.pk) {
|
||||
let value = {
|
||||
value: data.pk,
|
||||
data: data
|
||||
};
|
||||
|
||||
setData([value]);
|
||||
setPk(data.pk);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setPk(null);
|
||||
}
|
||||
setData([value]);
|
||||
setPk(data.pk);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setPk(null);
|
||||
}
|
||||
}, [form.values[fieldName]]);
|
||||
}, [definition.api_url, field.value]);
|
||||
|
||||
const [offset, setOffset] = useState<number>(0);
|
||||
|
||||
@@ -96,7 +71,7 @@ export function RelatedModelField({
|
||||
|
||||
const selectQuery = useQuery({
|
||||
enabled: !definition.disabled && !!definition.api_url && !definition.hidden,
|
||||
queryKey: [`related-field-${fieldName}`, offset, searchText],
|
||||
queryKey: [`related-field-${fieldName}`, fieldId, offset, searchText],
|
||||
queryFn: async () => {
|
||||
if (!definition.api_url) {
|
||||
return null;
|
||||
@@ -105,7 +80,7 @@ export function RelatedModelField({
|
||||
let filters = definition.filters ?? {};
|
||||
|
||||
if (definition.adjustFilters) {
|
||||
filters = definition.adjustFilters(filters, form);
|
||||
filters = definition.adjustFilters(filters);
|
||||
}
|
||||
|
||||
let params = {
|
||||
@@ -120,11 +95,15 @@ export function RelatedModelField({
|
||||
params: params
|
||||
})
|
||||
.then((response) => {
|
||||
let values: any[] = [...data];
|
||||
const values: any[] = [...data];
|
||||
const alreadyPresentPks = values.map((x) => x.value);
|
||||
|
||||
let results = response.data?.results ?? response.data ?? [];
|
||||
const results = response.data?.results ?? response.data ?? [];
|
||||
|
||||
results.forEach((item: any) => {
|
||||
// do not push already existing items into the values array
|
||||
if (alreadyPresentPks.includes(item.pk)) return;
|
||||
|
||||
values.push({
|
||||
value: item.pk ?? -1,
|
||||
data: item
|
||||
@@ -144,33 +123,34 @@ export function RelatedModelField({
|
||||
/**
|
||||
* Format an option for display in the select field
|
||||
*/
|
||||
function formatOption(option: any) {
|
||||
let data = option.data ?? option;
|
||||
const formatOption = useCallback(
|
||||
(option: any) => {
|
||||
const data = option.data ?? option;
|
||||
|
||||
// TODO: If a custom render function is provided, use that
|
||||
if (definition.modelRenderer) {
|
||||
return <definition.modelRenderer instance={data} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<RenderInstance instance={data} model={definition.model ?? undefined} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<RenderInstance instance={data} model={definition.model ?? undefined} />
|
||||
);
|
||||
},
|
||||
[definition.model, definition.modelRenderer]
|
||||
);
|
||||
|
||||
// Update form values when the selected value changes
|
||||
function onChange(value: any) {
|
||||
let _pk = value?.value ?? null;
|
||||
form.setValues({ [fieldName]: _pk });
|
||||
const onChange = useCallback(
|
||||
(value: any) => {
|
||||
let _pk = value?.value ?? null;
|
||||
field.onChange(_pk);
|
||||
|
||||
setPk(_pk);
|
||||
setPk(_pk);
|
||||
|
||||
// Run custom callback for this field (if provided)
|
||||
if (definition.onValueChange) {
|
||||
definition.onValueChange({
|
||||
name: fieldName,
|
||||
value: _pk,
|
||||
field: definition,
|
||||
form: form
|
||||
});
|
||||
}
|
||||
}
|
||||
// Run custom callback for this field (if provided)
|
||||
definition.onValueChange?.(_pk);
|
||||
},
|
||||
[field.onChange, definition]
|
||||
);
|
||||
|
||||
/* Construct a "cut-down" version of the definition,
|
||||
* which does not include any attributes that the lower components do not recognize
|
||||
@@ -184,11 +164,16 @@ export function RelatedModelField({
|
||||
};
|
||||
}, [definition]);
|
||||
|
||||
const currentValue = useMemo(
|
||||
() => pk !== null && data.find((item) => item.value === pk),
|
||||
[pk, data]
|
||||
);
|
||||
|
||||
return (
|
||||
<Input.Wrapper {...fieldDefinition} error={error}>
|
||||
<Input.Wrapper {...fieldDefinition} error={error?.message}>
|
||||
<Select
|
||||
id={fieldId}
|
||||
value={pk != null && data.find((item) => item.value == pk)}
|
||||
value={currentValue}
|
||||
options={data}
|
||||
filterOption={null}
|
||||
onInputChange={(value: any) => {
|
||||
|
||||
@@ -50,10 +50,9 @@ export function ActionDropdown({
|
||||
<Menu.Dropdown>
|
||||
{actions.map((action) =>
|
||||
action.disabled ? null : (
|
||||
<Tooltip label={action.tooltip} key={`tooltip-${action.name}`}>
|
||||
<Tooltip label={action.tooltip} key={action.name}>
|
||||
<Menu.Item
|
||||
icon={action.icon}
|
||||
key={action.name}
|
||||
onClick={() => {
|
||||
if (action.onClick != undefined) {
|
||||
action.onClick();
|
||||
|
||||
@@ -38,7 +38,7 @@ export function BreadcrumbList({
|
||||
{breadcrumbs.map((breadcrumb, index) => {
|
||||
return (
|
||||
<Anchor
|
||||
key={`breadcrumb-${index}`}
|
||||
key={index}
|
||||
onClick={() => breadcrumb.url && navigate(breadcrumb.url)}
|
||||
>
|
||||
<Text size="sm">{breadcrumb.name}</Text>
|
||||
|
||||
@@ -88,7 +88,7 @@ export function NotificationDrawer({
|
||||
</Alert>
|
||||
)}
|
||||
{notificationQuery.data?.results?.map((notification: any) => (
|
||||
<Group position="apart">
|
||||
<Group position="apart" key={notification.pk}>
|
||||
<Stack spacing="3">
|
||||
<Text size="sm">{notification.target?.name ?? 'target'}</Text>
|
||||
<Text size="xs">{notification.age_human ?? 'name'}</Text>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Group, Paper, Space, Stack, Text } from '@mantine/core';
|
||||
import { ReactNode } from 'react';
|
||||
import { Fragment, ReactNode } from 'react';
|
||||
|
||||
import { ApiImage } from '../images/ApiImage';
|
||||
import { StylishText } from '../items/StylishText';
|
||||
@@ -58,8 +58,10 @@ export function PageDetail({
|
||||
{detail}
|
||||
<Space />
|
||||
{actions && (
|
||||
<Group key="page-actions" spacing={5} position="right">
|
||||
{actions}
|
||||
<Group spacing={5} position="right">
|
||||
{actions.map((action, idx) => (
|
||||
<Fragment key={idx}>{action}</Fragment>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
@@ -90,15 +90,14 @@ export function PanelGroup({
|
||||
>
|
||||
<Tabs.List position="left">
|
||||
{panels.map(
|
||||
(panel, idx) =>
|
||||
(panel) =>
|
||||
!panel.hidden && (
|
||||
<Tooltip
|
||||
label={panel.label}
|
||||
key={`panel-tab-tooltip-${panel.name}`}
|
||||
key={panel.name}
|
||||
disabled={expanded}
|
||||
>
|
||||
<Tabs.Tab
|
||||
key={`panel-tab-${panel.name}`}
|
||||
p="xs"
|
||||
value={panel.name}
|
||||
icon={panel.icon}
|
||||
@@ -125,10 +124,10 @@ export function PanelGroup({
|
||||
)}
|
||||
</Tabs.List>
|
||||
{panels.map(
|
||||
(panel, idx) =>
|
||||
(panel) =>
|
||||
!panel.hidden && (
|
||||
<Tabs.Panel
|
||||
key={idx}
|
||||
key={panel.name}
|
||||
value={panel.name}
|
||||
p="sm"
|
||||
style={{
|
||||
|
||||
@@ -90,12 +90,11 @@ function QueryResultGroup({
|
||||
<Divider />
|
||||
<Stack>
|
||||
{query.results.results.map((result: any) => (
|
||||
<Anchor onClick={() => onResultClick(query.model, result.pk)}>
|
||||
<RenderInstance
|
||||
key={`${query.model}-${result.pk}`}
|
||||
instance={result}
|
||||
model={query.model}
|
||||
/>
|
||||
<Anchor
|
||||
onClick={() => onResultClick(query.model, result.pk)}
|
||||
key={result.pk}
|
||||
>
|
||||
<RenderInstance instance={result} model={query.model} />
|
||||
</Anchor>
|
||||
))}
|
||||
</Stack>
|
||||
@@ -395,8 +394,9 @@ export function SearchDrawer({
|
||||
)}
|
||||
{!searchQuery.isFetching && !searchQuery.isError && (
|
||||
<Stack spacing="md">
|
||||
{queryResults.map((query) => (
|
||||
{queryResults.map((query, idx) => (
|
||||
<QueryResultGroup
|
||||
key={idx}
|
||||
query={query}
|
||||
onRemove={(query) => removeResults(query)}
|
||||
onResultClick={(query, pk) => onResultClick(query, pk)}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { api } from '../../App';
|
||||
import { openModalApiForm } from '../../functions/forms';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import { SettingsStateProps } from '../../states/SettingsState';
|
||||
import { Setting } from '../../states/states';
|
||||
import { Setting, SettingType } from '../../states/states';
|
||||
|
||||
/**
|
||||
* Render a single setting value
|
||||
@@ -23,7 +23,10 @@ function SettingValue({
|
||||
// Callback function when a boolean value is changed
|
||||
function onToggle(value: boolean) {
|
||||
api
|
||||
.patch(apiUrl(settingsState.endpoint, setting.key), { value: value })
|
||||
.patch(
|
||||
apiUrl(settingsState.endpoint, setting.key, settingsState.pathParams),
|
||||
{ value: value }
|
||||
)
|
||||
.then(() => {
|
||||
showNotification({
|
||||
title: t`Setting updated`,
|
||||
@@ -44,15 +47,16 @@ function SettingValue({
|
||||
|
||||
// Callback function to open the edit dialog (for non-boolean settings)
|
||||
function onEditButton() {
|
||||
let field_type: string = setting?.type ?? 'string';
|
||||
let field_type = setting?.type ?? 'string';
|
||||
|
||||
if (setting?.choices && setting?.choices?.length > 0) {
|
||||
field_type = 'choice';
|
||||
field_type = SettingType.Choice;
|
||||
}
|
||||
|
||||
openModalApiForm({
|
||||
url: settingsState.endpoint,
|
||||
pk: setting.key,
|
||||
pathParams: settingsState.pathParams,
|
||||
method: 'PATCH',
|
||||
title: t`Edit Setting`,
|
||||
ignorePermissionCheck: true,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Stack, Text } from '@mantine/core';
|
||||
import { useEffect } from 'react';
|
||||
import { Stack, Text, useMantineTheme } from '@mantine/core';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import {
|
||||
SettingsStateProps,
|
||||
@@ -16,21 +16,36 @@ export function SettingList({
|
||||
keys
|
||||
}: {
|
||||
settingsState: SettingsStateProps;
|
||||
keys: string[];
|
||||
keys?: string[];
|
||||
}) {
|
||||
useEffect(() => {
|
||||
settingsState.fetchSettings();
|
||||
}, []);
|
||||
|
||||
const allKeys = useMemo(
|
||||
() => settingsState?.settings?.map((s) => s.key),
|
||||
[settingsState?.settings]
|
||||
);
|
||||
|
||||
const theme = useMantineTheme();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack spacing="xs">
|
||||
{keys.map((key) => {
|
||||
{(keys || allKeys).map((key, i) => {
|
||||
const setting = settingsState?.settings?.find(
|
||||
(s: any) => s.key === key
|
||||
);
|
||||
|
||||
const style: Record<string, string> = { paddingLeft: '8px' };
|
||||
if (i % 2 === 0)
|
||||
style['backgroundColor'] =
|
||||
theme.colorScheme === 'light'
|
||||
? theme.colors.gray[1]
|
||||
: theme.colors.gray[9];
|
||||
|
||||
return (
|
||||
<div key={key}>
|
||||
<div key={key} style={style}>
|
||||
{setting ? (
|
||||
<SettingItem settingsState={settingsState} setting={setting} />
|
||||
) : (
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
/**
|
||||
* Interface for the table column definition
|
||||
*/
|
||||
export type TableColumn = {
|
||||
export type TableColumn<T = any> = {
|
||||
accessor: string; // The key in the record to access
|
||||
ordering?: string; // The key in the record to sort by (defaults to accessor)
|
||||
title: string; // The title of the column
|
||||
sortable?: boolean; // Whether the column is sortable
|
||||
switchable?: boolean; // Whether the column is switchable
|
||||
hidden?: boolean; // Whether the column is hidden
|
||||
render?: (record: any) => any; // A custom render function
|
||||
render?: (record: T) => any; // A custom render function
|
||||
filter?: any; // A custom filter function
|
||||
filtering?: boolean; // Whether the column is filterable
|
||||
width?: number; // The width of the column
|
||||
|
||||
@@ -6,7 +6,7 @@ import { IconFilter, IconRefresh } from '@tabler/icons-react';
|
||||
import { IconBarcode, IconPrinter } from '@tabler/icons-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Fragment, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { api } from '../../App';
|
||||
import { ButtonMenu } from '../buttons/ButtonMenu';
|
||||
@@ -44,7 +44,7 @@ const defaultPageSize: number = 25;
|
||||
* @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
|
||||
*/
|
||||
export type InvenTreeTableProps = {
|
||||
export type InvenTreeTableProps<T = any> = {
|
||||
params?: any;
|
||||
defaultSortColumn?: string;
|
||||
noRecordsText?: string;
|
||||
@@ -57,12 +57,12 @@ export type InvenTreeTableProps = {
|
||||
pageSize?: number;
|
||||
barcodeActions?: any[];
|
||||
customFilters?: TableFilter[];
|
||||
customActionGroups?: any[];
|
||||
customActionGroups?: React.ReactNode[];
|
||||
printingActions?: any[];
|
||||
idAccessor?: string;
|
||||
dataFormatter?: (data: any) => any;
|
||||
rowActions?: (record: any) => RowAction[];
|
||||
onRowClick?: (record: any, index: number, event: any) => void;
|
||||
dataFormatter?: (data: T) => any;
|
||||
rowActions?: (record: T) => RowAction[];
|
||||
onRowClick?: (record: T, index: number, event: any) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -90,7 +90,7 @@ const defaultInvenTreeTableProps: InvenTreeTableProps = {
|
||||
/**
|
||||
* Table Component which extends DataTable with custom InvenTree functionality
|
||||
*/
|
||||
export function InvenTreeTable({
|
||||
export function InvenTreeTable<T = any>({
|
||||
url,
|
||||
tableKey,
|
||||
columns,
|
||||
@@ -98,8 +98,8 @@ export function InvenTreeTable({
|
||||
}: {
|
||||
url: string;
|
||||
tableKey: string;
|
||||
columns: TableColumn[];
|
||||
props: InvenTreeTableProps;
|
||||
columns: TableColumn<T>[];
|
||||
props: InvenTreeTableProps<T>;
|
||||
}) {
|
||||
// Use the first part of the table key as the table name
|
||||
const tableName: string = useMemo(() => {
|
||||
@@ -107,7 +107,7 @@ export function InvenTreeTable({
|
||||
}, []);
|
||||
|
||||
// Build table properties based on provided props (and default props)
|
||||
const tableProps: InvenTreeTableProps = useMemo(() => {
|
||||
const tableProps: InvenTreeTableProps<T> = useMemo(() => {
|
||||
return {
|
||||
...defaultInvenTreeTableProps,
|
||||
...props
|
||||
@@ -432,9 +432,9 @@ export function InvenTreeTable({
|
||||
<Stack spacing="sm">
|
||||
<Group position="apart">
|
||||
<Group position="left" key="custom-actions" spacing={5}>
|
||||
{tableProps.customActionGroups?.map(
|
||||
(group: any, idx: number) => group
|
||||
)}
|
||||
{tableProps.customActionGroups?.map((group, idx) => (
|
||||
<Fragment key={idx}>{group}</Fragment>
|
||||
))}
|
||||
{(tableProps.barcodeActions?.length ?? 0 > 0) && (
|
||||
<ButtonMenu
|
||||
key="barcode-actions"
|
||||
|
||||
@@ -150,8 +150,8 @@ export function RowActions({
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Group position="right" spacing="xs" p={8}>
|
||||
{visibleActions.map((action, _idx) => (
|
||||
<RowActionIcon {...action} />
|
||||
{visibleActions.map((action) => (
|
||||
<RowActionIcon key={action.title} {...action} />
|
||||
))}
|
||||
</Group>
|
||||
</Menu.Dropdown>
|
||||
|
||||
@@ -4,13 +4,10 @@ import { ReactNode, useCallback, useMemo } from 'react';
|
||||
|
||||
import { ApiPaths } from '../../../enums/ApiEndpoints';
|
||||
import { UserRoles } from '../../../enums/Roles';
|
||||
import { supplierPartFields } from '../../../forms/CompanyForms';
|
||||
import {
|
||||
openCreateApiForm,
|
||||
openDeleteApiForm,
|
||||
openEditApiForm
|
||||
} from '../../../functions/forms';
|
||||
import { useSupplierPartFields } from '../../../forms/CompanyForms';
|
||||
import { openDeleteApiForm, openEditApiForm } from '../../../functions/forms';
|
||||
import { useTableRefresh } from '../../../hooks/TableRefresh';
|
||||
import { useCreateApiFormModal } from '../../../hooks/UseForm';
|
||||
import { apiUrl } from '../../../states/ApiState';
|
||||
import { useUserState } from '../../../states/UserState';
|
||||
import { AddItemButton } from '../../buttons/AddItemButton';
|
||||
@@ -155,30 +152,36 @@ export function SupplierPartTable({ params }: { params: any }): ReactNode {
|
||||
];
|
||||
}, [params]);
|
||||
|
||||
const addSupplierPart = useCallback(() => {
|
||||
let fields = supplierPartFields();
|
||||
|
||||
fields.part.value = params?.part;
|
||||
fields.supplier.value = params?.supplier;
|
||||
|
||||
openCreateApiForm({
|
||||
const addSupplierPartFields = useSupplierPartFields({
|
||||
partPk: params?.part,
|
||||
supplierPk: params?.supplier,
|
||||
hidePart: true
|
||||
});
|
||||
const { modal: addSupplierPartModal, open: openAddSupplierPartForm } =
|
||||
useCreateApiFormModal({
|
||||
url: ApiPaths.supplier_part_list,
|
||||
title: t`Add Supplier Part`,
|
||||
fields: fields,
|
||||
fields: addSupplierPartFields,
|
||||
onFormSuccess: refreshTable,
|
||||
successMessage: t`Supplier part created`
|
||||
});
|
||||
}, [params]);
|
||||
|
||||
// Table actions
|
||||
const tableActions = useMemo(() => {
|
||||
// TODO: Hide actions based on user permissions
|
||||
|
||||
return [
|
||||
<AddItemButton tooltip={t`Add supplier part`} onClick={addSupplierPart} />
|
||||
<AddItemButton
|
||||
tooltip={t`Add supplier part`}
|
||||
onClick={openAddSupplierPartForm}
|
||||
/>
|
||||
];
|
||||
}, [user]);
|
||||
|
||||
const editSupplierPartFields = useSupplierPartFields({
|
||||
hidePart: true
|
||||
});
|
||||
|
||||
// Row action callback
|
||||
const rowActions = useCallback(
|
||||
(record: any) => {
|
||||
@@ -191,7 +194,7 @@ export function SupplierPartTable({ params }: { params: any }): ReactNode {
|
||||
url: ApiPaths.supplier_part_list,
|
||||
pk: record.pk,
|
||||
title: t`Edit Supplier Part`,
|
||||
fields: supplierPartFields(),
|
||||
fields: editSupplierPartFields,
|
||||
onFormSuccess: refreshTable,
|
||||
successMessage: t`Supplier part updated`
|
||||
});
|
||||
@@ -215,24 +218,27 @@ export function SupplierPartTable({ params }: { params: any }): ReactNode {
|
||||
})
|
||||
];
|
||||
},
|
||||
[user]
|
||||
[user, editSupplierPartFields]
|
||||
);
|
||||
|
||||
return (
|
||||
<InvenTreeTable
|
||||
url={apiUrl(ApiPaths.supplier_part_list)}
|
||||
tableKey={tableKey}
|
||||
columns={tableColumns}
|
||||
props={{
|
||||
params: {
|
||||
...params,
|
||||
part_detail: true,
|
||||
supplier_detail: true,
|
||||
manufacturer_detail: true
|
||||
},
|
||||
rowActions: rowActions,
|
||||
customActionGroups: tableActions
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
{addSupplierPartModal}
|
||||
<InvenTreeTable
|
||||
url={apiUrl(ApiPaths.supplier_part_list)}
|
||||
tableKey={tableKey}
|
||||
columns={tableColumns}
|
||||
props={{
|
||||
params: {
|
||||
...params,
|
||||
part_detail: true,
|
||||
supplier_detail: true,
|
||||
manufacturer_detail: true
|
||||
},
|
||||
rowActions: rowActions,
|
||||
customActionGroups: tableActions
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { apiUrl } from '../../../states/ApiState';
|
||||
import { useUserState } from '../../../states/UserState';
|
||||
import { AddItemButton } from '../../buttons/AddItemButton';
|
||||
import { TableColumn } from '../Column';
|
||||
import { DescriptionColumn } from '../ColumnRenderers';
|
||||
import { DescriptionColumn, ResponsibleColumn } from '../ColumnRenderers';
|
||||
import { InvenTreeTable } from '../InvenTreeTable';
|
||||
import { RowAction, RowDeleteAction, RowEditAction } from '../RowActions';
|
||||
|
||||
@@ -33,7 +33,8 @@ export function ProjectCodeTable() {
|
||||
sortable: true,
|
||||
title: t`Project Code`
|
||||
},
|
||||
DescriptionColumn()
|
||||
DescriptionColumn(),
|
||||
ResponsibleColumn()
|
||||
];
|
||||
}, []);
|
||||
|
||||
@@ -49,7 +50,8 @@ export function ProjectCodeTable() {
|
||||
title: t`Edit project code`,
|
||||
fields: {
|
||||
code: {},
|
||||
description: {}
|
||||
description: {},
|
||||
responsible: {}
|
||||
},
|
||||
onFormSuccess: refreshTable,
|
||||
successMessage: t`Project code updated`
|
||||
@@ -82,7 +84,8 @@ export function ProjectCodeTable() {
|
||||
title: t`Add project code`,
|
||||
fields: {
|
||||
code: {},
|
||||
description: {}
|
||||
description: {},
|
||||
responsible: {}
|
||||
},
|
||||
onFormSuccess: refreshTable,
|
||||
successMessage: t`Added project code`
|
||||
|
||||
@@ -86,7 +86,7 @@ export function UserDrawer({
|
||||
function setPermission(pk: number, data: any) {
|
||||
setLocked(true);
|
||||
api
|
||||
.patch(`${apiUrl(ApiPaths.user_list)}${pk}/`, data)
|
||||
.patch(apiUrl(ApiPaths.user_list, pk), data)
|
||||
.then(() => {
|
||||
notifications.show({
|
||||
title: t`User permission changed successfully`,
|
||||
@@ -110,7 +110,7 @@ export function UserDrawer({
|
||||
function setActive(pk: number, active: boolean) {
|
||||
setLocked(true);
|
||||
api
|
||||
.patch(`${apiUrl(ApiPaths.user_list)}${pk}/`, {
|
||||
.patch(apiUrl(ApiPaths.user_list, pk), {
|
||||
is_active: active
|
||||
})
|
||||
.then(() => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { i18n } from '@lingui/core';
|
||||
import { t } from '@lingui/macro';
|
||||
import { I18nProvider } from '@lingui/react';
|
||||
import { useEffect } from 'react';
|
||||
import { LoadingOverlay, Text } from '@mantine/core';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { api } from '../App';
|
||||
import { useLocalState } from '../states/LocalState';
|
||||
@@ -45,10 +46,43 @@ export const languages: Record<string, string> = {
|
||||
export function LanguageContext({ children }: { children: JSX.Element }) {
|
||||
const [language] = useLocalState((state) => [state.language]);
|
||||
|
||||
const [loadedState, setLoadedState] = useState<
|
||||
'loading' | 'loaded' | 'error'
|
||||
>('loading');
|
||||
const isMounted = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
activateLocale(language);
|
||||
isMounted.current = true;
|
||||
|
||||
activateLocale(language)
|
||||
.then(() => {
|
||||
if (isMounted.current) setLoadedState('loaded');
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed loading translations', err);
|
||||
if (isMounted.current) setLoadedState('error');
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
};
|
||||
}, [language]);
|
||||
|
||||
if (loadedState === 'loading') {
|
||||
return <LoadingOverlay visible={true} />;
|
||||
}
|
||||
|
||||
if (loadedState === 'error') {
|
||||
return (
|
||||
<Text>
|
||||
An error occurred while loading translations, see browser console for
|
||||
details.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
// only render the i18n Provider if the locales are fully activated, otherwise we end
|
||||
// up with an error in the browser console
|
||||
return <I18nProvider i18n={i18n}>{children}</I18nProvider>;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,55 +9,76 @@ import {
|
||||
IconPackage,
|
||||
IconPhone
|
||||
} from '@tabler/icons-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
ApiFormData,
|
||||
ApiFormFieldSet
|
||||
} from '../components/forms/fields/ApiFormField';
|
||||
import { ApiFormFieldSet } from '../components/forms/fields/ApiFormField';
|
||||
import { ApiPaths } from '../enums/ApiEndpoints';
|
||||
import { openEditApiForm } from '../functions/forms';
|
||||
|
||||
/**
|
||||
* Field set for SupplierPart instance
|
||||
*/
|
||||
export function supplierPartFields(): ApiFormFieldSet {
|
||||
return {
|
||||
part: {
|
||||
filters: {
|
||||
purchaseable: true
|
||||
}
|
||||
},
|
||||
manufacturer_part: {
|
||||
filters: {
|
||||
part_detail: true,
|
||||
manufacturer_detail: true
|
||||
},
|
||||
adjustFilters: (filters: any, form: ApiFormData) => {
|
||||
let part = form.values.part;
|
||||
export function useSupplierPartFields({
|
||||
partPk,
|
||||
supplierPk,
|
||||
hidePart
|
||||
}: {
|
||||
partPk?: number;
|
||||
supplierPk?: number;
|
||||
hidePart?: boolean;
|
||||
}) {
|
||||
const [part, setPart] = useState<number | undefined>(partPk);
|
||||
|
||||
if (part) {
|
||||
filters.part = part;
|
||||
useEffect(() => {
|
||||
setPart(partPk);
|
||||
}, [partPk]);
|
||||
|
||||
return useMemo(() => {
|
||||
const fields: ApiFormFieldSet = {
|
||||
part: {
|
||||
hidden: hidePart,
|
||||
value: part,
|
||||
onValueChange: setPart,
|
||||
filters: {
|
||||
purchaseable: true
|
||||
}
|
||||
},
|
||||
manufacturer_part: {
|
||||
filters: {
|
||||
part_detail: true,
|
||||
manufacturer_detail: true
|
||||
},
|
||||
adjustFilters: (filters: any) => {
|
||||
if (part) {
|
||||
filters.part = part;
|
||||
}
|
||||
|
||||
return filters;
|
||||
return filters;
|
||||
}
|
||||
},
|
||||
supplier: {},
|
||||
SKU: {
|
||||
icon: <IconHash />
|
||||
},
|
||||
description: {},
|
||||
link: {
|
||||
icon: <IconLink />
|
||||
},
|
||||
note: {
|
||||
icon: <IconNote />
|
||||
},
|
||||
pack_quantity: {},
|
||||
packaging: {
|
||||
icon: <IconPackage />
|
||||
}
|
||||
},
|
||||
supplier: {},
|
||||
SKU: {
|
||||
icon: <IconHash />
|
||||
},
|
||||
description: {},
|
||||
link: {
|
||||
icon: <IconLink />
|
||||
},
|
||||
note: {
|
||||
icon: <IconNote />
|
||||
},
|
||||
pack_quantity: {},
|
||||
packaging: {
|
||||
icon: <IconPackage />
|
||||
};
|
||||
|
||||
if (supplierPk !== undefined) {
|
||||
fields.supplier.value = supplierPk;
|
||||
}
|
||||
};
|
||||
|
||||
return fields;
|
||||
}, [part]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { IconPackages } from '@tabler/icons-react';
|
||||
|
||||
import { ApiFormFieldSet } from '../components/forms/fields/ApiFormField';
|
||||
import { ApiPaths } from '../enums/ApiEndpoints';
|
||||
@@ -54,8 +55,36 @@ export function partFields({
|
||||
// TODO: Set the value of the category field
|
||||
}
|
||||
|
||||
// Additional fields for creation
|
||||
if (!editing) {
|
||||
// TODO: Hide 'active' field
|
||||
|
||||
fields.copy_category_parameters = {};
|
||||
|
||||
fields.initial_stock = {
|
||||
icon: <IconPackages />,
|
||||
children: {
|
||||
quantity: {},
|
||||
location: {}
|
||||
}
|
||||
};
|
||||
|
||||
fields.initial_supplier = {
|
||||
children: {
|
||||
supplier: {
|
||||
filters: {
|
||||
is_supplier: true
|
||||
}
|
||||
},
|
||||
sku: {},
|
||||
manufacturer: {
|
||||
filters: {
|
||||
is_manufacturer: true
|
||||
}
|
||||
},
|
||||
mpn: {}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: pop 'expiry' field if expiry not enabled
|
||||
|
||||
@@ -7,10 +7,7 @@ import {
|
||||
IconSitemap
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
import {
|
||||
ApiFormData,
|
||||
ApiFormFieldSet
|
||||
} from '../components/forms/fields/ApiFormField';
|
||||
import { ApiFormFieldSet } from '../components/forms/fields/ApiFormField';
|
||||
|
||||
/*
|
||||
* Construct a set of fields for creating / editing a PurchaseOrderLineItem instance
|
||||
@@ -38,7 +35,7 @@ export function purchaseOrderLineItemFields({
|
||||
supplier_detail: true,
|
||||
supplier: supplierId
|
||||
},
|
||||
adjustFilters: (filters: any, _form: ApiFormData) => {
|
||||
adjustFilters: (filters: any) => {
|
||||
// TODO: Filter by the supplier associated with the order
|
||||
return filters;
|
||||
}
|
||||
|
||||
@@ -1,113 +1,112 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
ApiFormChangeCallback,
|
||||
ApiFormData,
|
||||
ApiFormFieldSet
|
||||
} from '../components/forms/fields/ApiFormField';
|
||||
import { ApiFormFieldSet } from '../components/forms/fields/ApiFormField';
|
||||
import { ApiPaths } from '../enums/ApiEndpoints';
|
||||
import { openCreateApiForm, openEditApiForm } from '../functions/forms';
|
||||
import { useCreateApiFormModal, useEditApiFormModal } from '../hooks/UseForm';
|
||||
|
||||
/**
|
||||
* Construct a set of fields for creating / editing a StockItem instance
|
||||
*/
|
||||
export function stockFields({
|
||||
export function useStockFields({
|
||||
create = false
|
||||
}: {
|
||||
create: boolean;
|
||||
}): ApiFormFieldSet {
|
||||
let fields: ApiFormFieldSet = {
|
||||
part: {
|
||||
hidden: !create,
|
||||
onValueChange: (change: ApiFormChangeCallback) => {
|
||||
// TODO: implement remaining functionality from old stock.py
|
||||
const [part, setPart] = useState<number | null>(null);
|
||||
const [supplierPart, setSupplierPart] = useState<number | null>(null);
|
||||
|
||||
// Clear the 'supplier_part' field if the part is changed
|
||||
change.form.setValues({
|
||||
supplier_part: null
|
||||
});
|
||||
}
|
||||
},
|
||||
supplier_part: {
|
||||
// TODO: icon
|
||||
filters: {
|
||||
part_detail: true,
|
||||
supplier_detail: true
|
||||
},
|
||||
adjustFilters: (filters: any, form: ApiFormData) => {
|
||||
let part = form.values.part;
|
||||
if (part) {
|
||||
filters.part = part;
|
||||
return useMemo(() => {
|
||||
const fields: ApiFormFieldSet = {
|
||||
part: {
|
||||
value: part,
|
||||
hidden: !create,
|
||||
onValueChange: (change) => {
|
||||
setPart(change);
|
||||
// TODO: implement remaining functionality from old stock.py
|
||||
|
||||
// Clear the 'supplier_part' field if the part is changed
|
||||
setSupplierPart(null);
|
||||
}
|
||||
},
|
||||
supplier_part: {
|
||||
// TODO: icon
|
||||
value: supplierPart,
|
||||
onValueChange: setSupplierPart,
|
||||
filters: {
|
||||
part_detail: true,
|
||||
supplier_detail: true,
|
||||
...(part ? { part } : {})
|
||||
}
|
||||
},
|
||||
use_pack_size: {
|
||||
hidden: !create,
|
||||
description: t`Add given quantity as packs instead of individual items`
|
||||
},
|
||||
location: {
|
||||
hidden: !create,
|
||||
filters: {
|
||||
structural: false
|
||||
}
|
||||
// TODO: icon
|
||||
},
|
||||
quantity: {
|
||||
hidden: !create,
|
||||
description: t`Enter initial quantity for this stock item`
|
||||
},
|
||||
serial_numbers: {
|
||||
// TODO: icon
|
||||
field_type: 'string',
|
||||
label: t`Serial Numbers`,
|
||||
description: t`Enter serial numbers for new stock (or leave blank)`,
|
||||
required: false,
|
||||
hidden: !create
|
||||
},
|
||||
serial: {
|
||||
hidden: create
|
||||
// TODO: icon
|
||||
},
|
||||
batch: {
|
||||
// TODO: icon
|
||||
},
|
||||
status: {},
|
||||
expiry_date: {
|
||||
// TODO: icon
|
||||
},
|
||||
purchase_price: {
|
||||
// TODO: icon
|
||||
},
|
||||
purchase_price_currency: {
|
||||
// TODO: icon
|
||||
},
|
||||
packaging: {
|
||||
// TODO: icon,
|
||||
},
|
||||
link: {
|
||||
// TODO: icon
|
||||
},
|
||||
owner: {
|
||||
// TODO: icon
|
||||
},
|
||||
delete_on_deplete: {}
|
||||
};
|
||||
|
||||
return filters;
|
||||
}
|
||||
},
|
||||
use_pack_size: {
|
||||
hidden: !create,
|
||||
description: t`Add given quantity as packs instead of individual items`
|
||||
},
|
||||
location: {
|
||||
hidden: !create,
|
||||
filters: {
|
||||
structural: false
|
||||
}
|
||||
// TODO: icon
|
||||
},
|
||||
quantity: {
|
||||
hidden: !create,
|
||||
description: t`Enter initial quantity for this stock item`
|
||||
},
|
||||
serial_numbers: {
|
||||
// TODO: icon
|
||||
field_type: 'string',
|
||||
label: t`Serial Numbers`,
|
||||
description: t`Enter serial numbers for new stock (or leave blank)`,
|
||||
required: false,
|
||||
hidden: !create
|
||||
},
|
||||
serial: {
|
||||
hidden: create
|
||||
// TODO: icon
|
||||
},
|
||||
batch: {
|
||||
// TODO: icon
|
||||
},
|
||||
status: {},
|
||||
expiry_date: {
|
||||
// TODO: icon
|
||||
},
|
||||
purchase_price: {
|
||||
// TODO: icon
|
||||
},
|
||||
purchase_price_currency: {
|
||||
// TODO: icon
|
||||
},
|
||||
packaging: {
|
||||
// TODO: icon,
|
||||
},
|
||||
link: {
|
||||
// TODO: icon
|
||||
},
|
||||
owner: {
|
||||
// TODO: icon
|
||||
},
|
||||
delete_on_deplete: {}
|
||||
};
|
||||
// TODO: Handle custom field management based on provided options
|
||||
// TODO: refer to stock.py in original codebase
|
||||
|
||||
// TODO: Handle custom field management based on provided options
|
||||
// TODO: refer to stock.py in original codebase
|
||||
|
||||
return fields;
|
||||
return fields;
|
||||
}, [part, supplierPart]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch a form to create a new StockItem instance
|
||||
*/
|
||||
export function createStockItem() {
|
||||
openCreateApiForm({
|
||||
export function useCreateStockItem() {
|
||||
const fields = useStockFields({ create: true });
|
||||
|
||||
return useCreateApiFormModal({
|
||||
url: ApiPaths.stock_item_list,
|
||||
fields: stockFields({ create: true }),
|
||||
fields: fields,
|
||||
title: t`Create Stock Item`
|
||||
});
|
||||
}
|
||||
@@ -116,17 +115,19 @@ export function createStockItem() {
|
||||
* Launch a form to edit an existing StockItem instance
|
||||
* @param item : primary key of the StockItem to edit
|
||||
*/
|
||||
export function editStockItem({
|
||||
export function useEditStockItem({
|
||||
item_id,
|
||||
callback
|
||||
}: {
|
||||
item_id: number;
|
||||
callback?: () => void;
|
||||
}) {
|
||||
openEditApiForm({
|
||||
const fields = useStockFields({ create: false });
|
||||
|
||||
return useEditApiFormModal({
|
||||
url: ApiPaths.stock_item_list,
|
||||
pk: item_id,
|
||||
fields: stockFields({ create: false }),
|
||||
fields: fields,
|
||||
title: t`Edit Stock Item`,
|
||||
successMessage: t`Stock item updated`,
|
||||
onFormSuccess: callback
|
||||
|
||||
@@ -5,17 +5,25 @@ import { AxiosResponse } from 'axios';
|
||||
|
||||
import { api } from '../App';
|
||||
import { ApiForm, ApiFormProps } from '../components/forms/ApiForm';
|
||||
import { ApiFormFieldType } from '../components/forms/fields/ApiFormField';
|
||||
import {
|
||||
ApiFormFieldSet,
|
||||
ApiFormFieldType
|
||||
} from '../components/forms/fields/ApiFormField';
|
||||
import { StylishText } from '../components/items/StylishText';
|
||||
import { apiUrl } from '../states/ApiState';
|
||||
import { ApiPaths } from '../enums/ApiEndpoints';
|
||||
import { PathParams, apiUrl } from '../states/ApiState';
|
||||
import { invalidResponse, permissionDenied } from './notifications';
|
||||
import { generateUniqueId } from './uid';
|
||||
|
||||
/**
|
||||
* Construct an API url from the provided ApiFormProps object
|
||||
*/
|
||||
export function constructFormUrl(props: ApiFormProps): string {
|
||||
return apiUrl(props.url, props.pk);
|
||||
export function constructFormUrl(
|
||||
url: ApiPaths,
|
||||
pk?: string | number,
|
||||
pathParams?: PathParams
|
||||
): string {
|
||||
return apiUrl(url, pk, pathParams);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -28,7 +36,7 @@ export function extractAvailableFields(
|
||||
method?: string
|
||||
): Record<string, ApiFormFieldType> | null {
|
||||
// OPTIONS request *must* return 200 status
|
||||
if (response.status != 200) {
|
||||
if (response.status !== 200) {
|
||||
invalidResponse(response.status);
|
||||
return null;
|
||||
}
|
||||
@@ -61,31 +69,118 @@ export function extractAvailableFields(
|
||||
return null;
|
||||
}
|
||||
|
||||
let fields: Record<string, ApiFormFieldType> = {};
|
||||
const processFields = (fields: any, _path?: string) => {
|
||||
const _fields: ApiFormFieldSet = {};
|
||||
|
||||
for (const fieldName in actions[method]) {
|
||||
const field = actions[method][fieldName];
|
||||
fields[fieldName] = {
|
||||
...field,
|
||||
name: fieldName,
|
||||
field_type: field.type,
|
||||
description: field.help_text,
|
||||
value: field.value ?? field.default,
|
||||
disabled: field.read_only ?? false
|
||||
};
|
||||
for (const [fieldName, field] of Object.entries(fields) as any) {
|
||||
const path = _path ? `${_path}.${fieldName}` : fieldName;
|
||||
_fields[fieldName] = {
|
||||
...field,
|
||||
name: path,
|
||||
field_type: field.type,
|
||||
description: field.help_text,
|
||||
value: field.value ?? field.default,
|
||||
disabled: field.read_only ?? false
|
||||
};
|
||||
|
||||
// Remove the 'read_only' field - plays havoc with react components
|
||||
delete fields['read_only'];
|
||||
// Remove the 'read_only' field - plays havoc with react components
|
||||
delete _fields[fieldName].read_only;
|
||||
|
||||
if (
|
||||
_fields[fieldName].field_type === 'nested object' &&
|
||||
_fields[fieldName].children
|
||||
) {
|
||||
_fields[fieldName].children = processFields(
|
||||
_fields[fieldName].children,
|
||||
path
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return _fields;
|
||||
};
|
||||
|
||||
return processFields(actions[method]);
|
||||
}
|
||||
|
||||
export type NestedDict = { [key: string]: string | number | NestedDict };
|
||||
export function mapFields(
|
||||
fields: ApiFormFieldSet,
|
||||
fieldFunction: (path: string, value: ApiFormFieldType, key: string) => any,
|
||||
_path?: string
|
||||
): NestedDict {
|
||||
const res: NestedDict = {};
|
||||
|
||||
for (const [k, v] of Object.entries(fields)) {
|
||||
const path = _path ? `${_path}.${k}` : k;
|
||||
let value;
|
||||
|
||||
if (v.field_type === 'nested object' && v.children) {
|
||||
value = mapFields(v.children, fieldFunction, path);
|
||||
} else {
|
||||
value = fieldFunction(path, v, k);
|
||||
}
|
||||
|
||||
if (value !== undefined) res[k] = value;
|
||||
}
|
||||
|
||||
return fields;
|
||||
return res;
|
||||
}
|
||||
|
||||
/*
|
||||
* Build a complete field definition based on the provided data
|
||||
*/
|
||||
export function constructField({
|
||||
field,
|
||||
definition
|
||||
}: {
|
||||
field: ApiFormFieldType;
|
||||
definition?: ApiFormFieldType;
|
||||
}) {
|
||||
const def = {
|
||||
...definition,
|
||||
...field
|
||||
};
|
||||
|
||||
switch (def.field_type) {
|
||||
case 'date':
|
||||
// Change value to a date object if required
|
||||
if (def.value) {
|
||||
def.value = new Date(def.value);
|
||||
}
|
||||
break;
|
||||
case 'nested object':
|
||||
def.children = {};
|
||||
for (const k of Object.keys(field.children ?? {})) {
|
||||
def.children[k] = constructField({
|
||||
field: field.children?.[k] ?? {},
|
||||
definition: definition?.children?.[k] ?? {}
|
||||
});
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// Clear out the 'read_only' attribute
|
||||
def.disabled = def.disabled ?? def.read_only ?? false;
|
||||
delete def['read_only'];
|
||||
|
||||
return def;
|
||||
}
|
||||
|
||||
export interface OpenApiFormProps extends ApiFormProps {
|
||||
title: string;
|
||||
cancelText?: string;
|
||||
cancelColor?: string;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
/*
|
||||
* Construct and open a modal form
|
||||
* @param title :
|
||||
*/
|
||||
export function openModalApiForm(props: ApiFormProps) {
|
||||
export function openModalApiForm(props: OpenApiFormProps) {
|
||||
// method property *must* be supplied
|
||||
if (!props.method) {
|
||||
notifications.show({
|
||||
@@ -96,7 +191,28 @@ export function openModalApiForm(props: ApiFormProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
let url = constructFormUrl(props);
|
||||
// Generate a random modal ID for controller
|
||||
let modalId: string =
|
||||
`modal-${props.title}-${props.url}-${props.method}` + generateUniqueId();
|
||||
|
||||
props.actions = [
|
||||
...(props.actions || []),
|
||||
{
|
||||
text: props.cancelText ?? t`Cancel`,
|
||||
color: props.cancelColor ?? 'blue',
|
||||
onClick: () => {
|
||||
modals.close(modalId);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const oldFormSuccess = props.onFormSuccess;
|
||||
props.onFormSuccess = (data) => {
|
||||
oldFormSuccess?.(data);
|
||||
modals.close(modalId);
|
||||
};
|
||||
|
||||
let url = constructFormUrl(props.url, props.pk, props.pathParams);
|
||||
|
||||
// Make OPTIONS request first
|
||||
api
|
||||
@@ -114,10 +230,16 @@ export function openModalApiForm(props: ApiFormProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a random modal ID for controller
|
||||
let modalId: string =
|
||||
`modal-${props.title}-${props.url}-${props.method}` +
|
||||
generateUniqueId();
|
||||
const _props = { ...props };
|
||||
|
||||
if (_props.fields) {
|
||||
for (const [k, v] of Object.entries(_props.fields)) {
|
||||
_props.fields[k] = constructField({
|
||||
field: v,
|
||||
definition: fields?.[k]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
modals.open({
|
||||
title: <StylishText size="xl">{props.title}</StylishText>,
|
||||
@@ -126,9 +248,7 @@ export function openModalApiForm(props: ApiFormProps) {
|
||||
onClose: () => {
|
||||
props.onClose ? props.onClose() : null;
|
||||
},
|
||||
children: (
|
||||
<ApiForm modalId={modalId} props={props} fieldDefinitions={fields} />
|
||||
)
|
||||
children: <ApiForm id={modalId} props={props} />
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -148,8 +268,8 @@ export function openModalApiForm(props: ApiFormProps) {
|
||||
/**
|
||||
* Opens a modal form to create a new model instance
|
||||
*/
|
||||
export function openCreateApiForm(props: ApiFormProps) {
|
||||
let createProps: ApiFormProps = {
|
||||
export function openCreateApiForm(props: OpenApiFormProps) {
|
||||
let createProps: OpenApiFormProps = {
|
||||
...props,
|
||||
method: 'POST'
|
||||
};
|
||||
@@ -160,8 +280,8 @@ export function openCreateApiForm(props: ApiFormProps) {
|
||||
/**
|
||||
* Open a modal form to edit a model instance
|
||||
*/
|
||||
export function openEditApiForm(props: ApiFormProps) {
|
||||
let editProps: ApiFormProps = {
|
||||
export function openEditApiForm(props: OpenApiFormProps) {
|
||||
let editProps: OpenApiFormProps = {
|
||||
...props,
|
||||
fetchInitialData: props.fetchInitialData ?? true,
|
||||
method: 'PUT'
|
||||
@@ -173,8 +293,8 @@ export function openEditApiForm(props: ApiFormProps) {
|
||||
/**
|
||||
* Open a modal form to delete a model instancel
|
||||
*/
|
||||
export function openDeleteApiForm(props: ApiFormProps) {
|
||||
let deleteProps: ApiFormProps = {
|
||||
export function openDeleteApiForm(props: OpenApiFormProps) {
|
||||
let deleteProps: OpenApiFormProps = {
|
||||
...props,
|
||||
method: 'DELETE',
|
||||
submitText: t`Delete`,
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { useId } from '@mantine/hooks';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import { ApiFormProps, OptionsApiForm } from '../components/forms/ApiForm';
|
||||
import { useModal } from './UseModal';
|
||||
|
||||
/**
|
||||
* @param title : The title to display in the modal header
|
||||
* @param cancelText : Optional custom text to display on the cancel button (default: Cancel)
|
||||
* @param cancelColor : Optional custom color for the cancel button (default: blue)
|
||||
* @param onClose : A callback function to call when the modal is closed.
|
||||
* @param onOpen : A callback function to call when the modal is opened.
|
||||
*/
|
||||
export interface ApiFormModalProps extends ApiFormProps {
|
||||
title: string;
|
||||
cancelText?: string;
|
||||
cancelColor?: string;
|
||||
onClose?: () => void;
|
||||
onOpen?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct and open a modal form
|
||||
*/
|
||||
export function useApiFormModal(props: ApiFormModalProps) {
|
||||
const id = useId();
|
||||
const modalClose = useRef(() => {});
|
||||
|
||||
const formProps = useMemo<ApiFormModalProps>(
|
||||
() => ({
|
||||
...props,
|
||||
actions: [
|
||||
...(props.actions || []),
|
||||
{
|
||||
text: props.cancelText ?? t`Cancel`,
|
||||
color: props.cancelColor ?? 'blue',
|
||||
onClick: () => {
|
||||
modalClose.current();
|
||||
}
|
||||
}
|
||||
],
|
||||
onFormSuccess: (data) => {
|
||||
modalClose.current();
|
||||
props.onFormSuccess?.(data);
|
||||
},
|
||||
onFormError: () => {
|
||||
modalClose.current();
|
||||
props.onFormError?.();
|
||||
}
|
||||
}),
|
||||
[props]
|
||||
);
|
||||
|
||||
const modal = useModal({
|
||||
title: formProps.title,
|
||||
onOpen: formProps.onOpen,
|
||||
onClose: formProps.onClose,
|
||||
size: 'xl',
|
||||
children: <OptionsApiForm props={formProps} id={id} />
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
modalClose.current = modal.close;
|
||||
}, [modal.close]);
|
||||
|
||||
return modal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a modal form to create a new model instance
|
||||
*/
|
||||
export function useCreateApiFormModal(props: ApiFormModalProps) {
|
||||
const createProps = useMemo<ApiFormModalProps>(
|
||||
() => ({
|
||||
...props,
|
||||
method: 'POST'
|
||||
}),
|
||||
[props]
|
||||
);
|
||||
|
||||
return useApiFormModal(createProps);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a modal form to edit a model instance
|
||||
*/
|
||||
export function useEditApiFormModal(props: ApiFormModalProps) {
|
||||
const editProps = useMemo<ApiFormModalProps>(
|
||||
() => ({
|
||||
...props,
|
||||
fetchInitialData: props.fetchInitialData ?? true,
|
||||
method: 'PUT'
|
||||
}),
|
||||
[props]
|
||||
);
|
||||
|
||||
return useApiFormModal(editProps);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a modal form to delete a model instance
|
||||
*/
|
||||
export function useDeleteApiFormModal(props: ApiFormModalProps) {
|
||||
const deleteProps = useMemo<ApiFormModalProps>(
|
||||
() => ({
|
||||
...props,
|
||||
method: 'DELETE',
|
||||
submitText: t`Delete`,
|
||||
submitColor: 'red',
|
||||
fields: {}
|
||||
}),
|
||||
[props]
|
||||
);
|
||||
|
||||
return useApiFormModal(deleteProps);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { MantineNumberSize, Modal } from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import { StylishText } from '../components/items/StylishText';
|
||||
|
||||
export interface UseModalProps {
|
||||
title: string;
|
||||
children: React.ReactElement;
|
||||
size?: MantineNumberSize;
|
||||
onOpen?: () => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function useModal(props: UseModalProps) {
|
||||
const onOpen = useCallback(() => {
|
||||
props.onOpen?.();
|
||||
}, [props.onOpen]);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
props.onClose?.();
|
||||
}, [props.onClose]);
|
||||
|
||||
const [opened, { open, close, toggle }] = useDisclosure(false, {
|
||||
onOpen,
|
||||
onClose
|
||||
});
|
||||
|
||||
return {
|
||||
open,
|
||||
close,
|
||||
toggle,
|
||||
modal: (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
size={props.size ?? 'xl'}
|
||||
title={<StylishText size="xl">{props.title}</StylishText>}
|
||||
>
|
||||
{props.children}
|
||||
</Modal>
|
||||
)
|
||||
};
|
||||
}
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: bg\n"
|
||||
"Project-Id-Version: inventree\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2023-11-13 21:28\n"
|
||||
"PO-Revision-Date: 2023-11-21 00:06\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Bulgarian\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
@@ -22,23 +22,23 @@ msgstr ""
|
||||
msgid "Title"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/forms/ApiForm.tsx:193
|
||||
#: src/components/forms/ApiForm.tsx:127
|
||||
#: src/functions/forms.tsx:48
|
||||
#: src/functions/forms.tsx:57
|
||||
#: src/functions/forms.tsx:260
|
||||
msgid "Form Error"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/forms/ApiForm.tsx:291
|
||||
#: src/components/widgets/MarkdownEditor.tsx:146
|
||||
msgid "Success"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/forms/ApiForm.tsx:267
|
||||
#: src/components/forms/ApiForm.tsx:363
|
||||
msgid "Form Errors Exist"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/forms/ApiForm.tsx:304
|
||||
#: src/components/tables/FilterSelectModal.tsx:166
|
||||
#: src/components/tables/plugin/PluginListTable.tsx:132
|
||||
#: src/contexts/ThemeContext.tsx:64
|
||||
msgid "Cancel"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/forms/ApiForm.tsx:313
|
||||
#: src/components/forms/ApiForm.tsx:406
|
||||
#: src/contexts/ThemeContext.tsx:64
|
||||
#: src/pages/Index/Settings/AccountSettings/AccountDetailPanel.tsx:51
|
||||
msgid "Submit"
|
||||
@@ -119,7 +119,7 @@ msgstr ""
|
||||
#: src/components/tables/settings/UserDrawer.tsx:163
|
||||
#: src/components/tables/settings/UserTable.tsx:51
|
||||
#: src/pages/Auth/Reset.tsx:31
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:48
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:49
|
||||
msgid "Email"
|
||||
msgstr ""
|
||||
|
||||
@@ -201,7 +201,7 @@ msgstr ""
|
||||
msgid "State: <0>worker</0> ({0}), <1>plugins</1>{1}"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/forms/fields/ApiFormField.tsx:326
|
||||
#: src/components/forms/fields/ApiFormField.tsx:279
|
||||
#: src/components/nav/SearchDrawer.tsx:412
|
||||
#: src/components/tables/InvenTreeTable.tsx:392
|
||||
#: src/components/tables/plugin/PluginListTable.tsx:163
|
||||
@@ -212,19 +212,19 @@ msgstr ""
|
||||
msgid "Error"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/forms/fields/RelatedModelField.tsx:214
|
||||
#: src/components/forms/fields/RelatedModelField.tsx:199
|
||||
#: src/pages/Index/Settings/UserSettings.tsx:64
|
||||
msgid "Search"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/forms/fields/RelatedModelField.tsx:215
|
||||
#: src/components/forms/fields/RelatedModelField.tsx:200
|
||||
#: src/components/modals/AboutInvenTreeModal.tsx:67
|
||||
#: src/components/widgets/WidgetLayout.tsx:134
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:298
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:301
|
||||
msgid "Loading"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/forms/fields/RelatedModelField.tsx:217
|
||||
#: src/components/forms/fields/RelatedModelField.tsx:202
|
||||
msgid "No results found"
|
||||
msgstr ""
|
||||
|
||||
@@ -233,59 +233,60 @@ msgstr ""
|
||||
msgid "Thumbnail"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/items/ActionDropdown.tsx:85
|
||||
#: src/components/items/ActionDropdown.tsx:84
|
||||
#: src/pages/build/BuildDetail.tsx:206
|
||||
msgid "Barcode Actions"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/items/ActionDropdown.tsx:102
|
||||
#: src/components/items/ActionDropdown.tsx:101
|
||||
msgid "View"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/items/ActionDropdown.tsx:103
|
||||
#: src/components/items/ActionDropdown.tsx:102
|
||||
msgid "View barcode"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/items/ActionDropdown.tsx:119
|
||||
#: src/components/items/ActionDropdown.tsx:118
|
||||
msgid "Link Barcode"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/items/ActionDropdown.tsx:120
|
||||
#: src/components/items/ActionDropdown.tsx:119
|
||||
msgid "Link custom barcode"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/items/ActionDropdown.tsx:136
|
||||
#: src/components/items/ActionDropdown.tsx:135
|
||||
msgid "Unlink Barcode"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/items/ActionDropdown.tsx:137
|
||||
#: src/components/items/ActionDropdown.tsx:136
|
||||
msgid "Unlink custom barcode"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/items/ActionDropdown.tsx:155
|
||||
#: src/components/items/ActionDropdown.tsx:154
|
||||
#: src/components/tables/RowActions.tsx:44
|
||||
msgid "Edit"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/items/ActionDropdown.tsx:174
|
||||
#: src/components/items/ActionDropdown.tsx:173
|
||||
#: src/components/tables/RowActions.tsx:61
|
||||
#: src/functions/forms.tsx:180
|
||||
#: src/functions/forms.tsx:300
|
||||
#: src/hooks/UseForm.tsx:109
|
||||
#: src/pages/Index/Scan.tsx:332
|
||||
#: src/pages/Notifications.tsx:79
|
||||
msgid "Delete"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/items/ActionDropdown.tsx:175
|
||||
#: src/components/items/ActionDropdown.tsx:174
|
||||
msgid "Delete item"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/items/ActionDropdown.tsx:193
|
||||
#: src/components/items/ActionDropdown.tsx:192
|
||||
#: src/components/tables/RowActions.tsx:27
|
||||
#: src/pages/stock/StockDetail.tsx:190
|
||||
#: src/pages/stock/StockDetail.tsx:195
|
||||
msgid "Duplicate"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/items/ActionDropdown.tsx:194
|
||||
#: src/components/items/ActionDropdown.tsx:193
|
||||
msgid "Duplicate item"
|
||||
msgstr ""
|
||||
|
||||
@@ -575,7 +576,7 @@ msgstr ""
|
||||
|
||||
#: src/components/nav/MainMenu.tsx:59
|
||||
#: src/defaults/menuItems.tsx:58
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:295
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:296
|
||||
msgid "System Settings"
|
||||
msgstr ""
|
||||
|
||||
@@ -631,7 +632,7 @@ msgid "About"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/nav/NotificationDrawer.tsx:70
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:123
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:124
|
||||
#: src/pages/Index/Settings/UserSettings.tsx:94
|
||||
#: src/pages/Notifications.tsx:28
|
||||
#: src/pages/Notifications.tsx:100
|
||||
@@ -649,7 +650,7 @@ msgstr ""
|
||||
|
||||
#: src/components/nav/PartCategoryTree.tsx:80
|
||||
#: src/components/render/ModelType.tsx:49
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:187
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:188
|
||||
#: src/pages/part/CategoryDetail.tsx:60
|
||||
msgid "Part Categories"
|
||||
msgstr ""
|
||||
@@ -658,19 +659,19 @@ msgstr ""
|
||||
msgid "results"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/nav/SearchDrawer.tsx:338
|
||||
#: src/components/nav/SearchDrawer.tsx:337
|
||||
msgid "Enter search text"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/nav/SearchDrawer.tsx:365
|
||||
#: src/components/nav/SearchDrawer.tsx:364
|
||||
msgid "Search Options"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/nav/SearchDrawer.tsx:368
|
||||
#: src/components/nav/SearchDrawer.tsx:367
|
||||
msgid "Regex search"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/nav/SearchDrawer.tsx:378
|
||||
#: src/components/nav/SearchDrawer.tsx:377
|
||||
msgid "Whole word search"
|
||||
msgstr ""
|
||||
|
||||
@@ -703,7 +704,7 @@ msgstr ""
|
||||
#: src/components/tables/part/PartTable.tsx:26
|
||||
#: src/components/tables/part/RelatedPartTable.tsx:41
|
||||
#: src/components/tables/purchasing/PurchaseOrderLineItemTable.tsx:98
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:38
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:35
|
||||
#: src/components/tables/stock/StockItemTable.tsx:27
|
||||
#: src/pages/part/PartDetail.tsx:328
|
||||
msgid "Part"
|
||||
@@ -713,7 +714,7 @@ msgstr ""
|
||||
#: src/components/tables/part/PartCategoryTable.tsx:36
|
||||
#: src/defaults/links.tsx:27
|
||||
#: src/defaults/menuItems.tsx:33
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:192
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:193
|
||||
#: src/pages/part/CategoryDetail.tsx:46
|
||||
#: src/pages/part/CategoryDetail.tsx:82
|
||||
#: src/pages/part/PartDetail.tsx:243
|
||||
@@ -729,7 +730,7 @@ msgid "Part Parameter Templates"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/render/ModelType.tsx:34
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:66
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:63
|
||||
msgid "Supplier Part"
|
||||
msgstr ""
|
||||
|
||||
@@ -751,7 +752,7 @@ msgid "Part Category"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/render/ModelType.tsx:55
|
||||
#: src/pages/stock/StockDetail.tsx:219
|
||||
#: src/pages/stock/StockDetail.tsx:220
|
||||
msgid "Stock Item"
|
||||
msgstr ""
|
||||
|
||||
@@ -802,7 +803,7 @@ msgid "Project Code"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/render/ModelType.tsx:89
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:105
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:106
|
||||
msgid "Project Codes"
|
||||
msgstr ""
|
||||
|
||||
@@ -812,7 +813,7 @@ msgid "Purchase Order"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/render/ModelType.tsx:96
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:262
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:263
|
||||
#: src/pages/company/CompanyDetail.tsx:88
|
||||
#: src/pages/part/PartDetail.tsx:175
|
||||
#: src/pages/purchasing/PurchasingIndex.tsx:20
|
||||
@@ -834,7 +835,7 @@ msgid "Sales Order"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/render/ModelType.tsx:108
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:275
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:276
|
||||
#: src/pages/company/CompanyDetail.tsx:106
|
||||
#: src/pages/part/PartDetail.tsx:181
|
||||
#: src/pages/sales/SalesIndex.tsx:21
|
||||
@@ -913,21 +914,21 @@ msgstr ""
|
||||
msgid "Quantity"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/SettingItem.tsx:29
|
||||
#: src/components/settings/SettingItem.tsx:70
|
||||
#: src/components/settings/SettingItem.tsx:32
|
||||
#: src/components/settings/SettingItem.tsx:74
|
||||
msgid "Setting updated"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/SettingItem.tsx:30
|
||||
#: src/components/settings/SettingItem.tsx:71
|
||||
#: src/components/settings/SettingItem.tsx:33
|
||||
#: src/components/settings/SettingItem.tsx:75
|
||||
msgid "{0} updated successfully"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/SettingItem.tsx:38
|
||||
#: src/components/settings/SettingItem.tsx:41
|
||||
msgid "Error editing setting"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/SettingItem.tsx:57
|
||||
#: src/components/settings/SettingItem.tsx:61
|
||||
msgid "Edit Setting"
|
||||
msgstr ""
|
||||
|
||||
@@ -1051,6 +1052,14 @@ msgstr ""
|
||||
msgid "Select filter value"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/tables/FilterSelectModal.tsx:166
|
||||
#: src/components/tables/plugin/PluginListTable.tsx:132
|
||||
#: src/contexts/ThemeContext.tsx:64
|
||||
#: src/functions/forms.tsx:201
|
||||
#: src/hooks/UseForm.tsx:36
|
||||
msgid "Cancel"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/tables/FilterSelectModal.tsx:172
|
||||
msgid "Add Filter"
|
||||
msgstr ""
|
||||
@@ -1197,7 +1206,7 @@ msgstr ""
|
||||
|
||||
#: src/components/tables/bom/BomTable.tsx:233
|
||||
#: src/components/tables/purchasing/PurchaseOrderLineItemTable.tsx:215
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:133
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:130
|
||||
#: src/pages/build/BuildDetail.tsx:169
|
||||
#: src/pages/company/CompanyDetail.tsx:152
|
||||
#: src/pages/part/PartDetail.tsx:228
|
||||
@@ -1454,7 +1463,7 @@ msgstr ""
|
||||
#: src/components/tables/stock/StockItemTable.tsx:51
|
||||
#: src/defaults/links.tsx:28
|
||||
#: src/defaults/menuItems.tsx:38
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:229
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:230
|
||||
#: src/pages/part/PartDetail.tsx:98
|
||||
#: src/pages/stock/LocationDetail.tsx:63
|
||||
#: src/pages/stock/StockDetail.tsx:135
|
||||
@@ -1721,8 +1730,8 @@ msgstr ""
|
||||
|
||||
#: src/components/tables/purchasing/PurchaseOrderLineItemTable.tsx:137
|
||||
#: src/components/tables/purchasing/PurchaseOrderLineItemTable.tsx:173
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:105
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:125
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:102
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:122
|
||||
msgid "Pack Quantity"
|
||||
msgstr ""
|
||||
|
||||
@@ -1771,7 +1780,7 @@ msgid "Receive items"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/tables/purchasing/PurchaseOrderTable.tsx:48
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:51
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:48
|
||||
#: src/pages/company/SupplierDetail.tsx:8
|
||||
msgid "Supplier"
|
||||
msgstr ""
|
||||
@@ -1780,64 +1789,64 @@ msgstr ""
|
||||
msgid "Supplier Reference"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:74
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:71
|
||||
#: src/pages/company/ManufacturerDetail.tsx:8
|
||||
msgid "Manufacturer"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:90
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:87
|
||||
msgid "MPN"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:95
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:92
|
||||
msgid "In Stock"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:100
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:97
|
||||
msgid "Packaging"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:116
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:113
|
||||
msgid "Base units"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:138
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:135
|
||||
msgid "Availability"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:147
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:144
|
||||
msgid "Updated"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:166
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:163
|
||||
msgid "Add Supplier Part"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:169
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:166
|
||||
msgid "Supplier part created"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:178
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:175
|
||||
msgid "Add supplier part"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:193
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:196
|
||||
msgid "Edit Supplier Part"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:196
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:199
|
||||
msgid "Supplier part updated"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:207
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:210
|
||||
msgid "Delete Supplier Part"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:208
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:211
|
||||
msgid "Supplier part deleted"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:211
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:214
|
||||
msgid "Are you sure you want to remove this supplier part?"
|
||||
msgstr ""
|
||||
|
||||
@@ -2208,123 +2217,123 @@ msgstr ""
|
||||
msgid "Show Boxes"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:13
|
||||
#: src/contexts/LanguageContext.tsx:14
|
||||
msgid "Bulgarian"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:14
|
||||
#: src/contexts/LanguageContext.tsx:15
|
||||
msgid "Czech"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:15
|
||||
#: src/contexts/LanguageContext.tsx:16
|
||||
msgid "Danish"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:16
|
||||
#: src/contexts/LanguageContext.tsx:17
|
||||
msgid "German"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:17
|
||||
#: src/contexts/LanguageContext.tsx:18
|
||||
msgid "Greek"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:18
|
||||
#: src/contexts/LanguageContext.tsx:19
|
||||
msgid "English"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:19
|
||||
#: src/contexts/LanguageContext.tsx:20
|
||||
msgid "Spanish"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:20
|
||||
#: src/contexts/LanguageContext.tsx:21
|
||||
msgid "Spanish (Mexican)"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:21
|
||||
#: src/contexts/LanguageContext.tsx:22
|
||||
msgid "Farsi / Persian"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:22
|
||||
#: src/contexts/LanguageContext.tsx:23
|
||||
msgid "Finnish"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:23
|
||||
#: src/contexts/LanguageContext.tsx:24
|
||||
msgid "French"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:24
|
||||
#: src/contexts/LanguageContext.tsx:25
|
||||
msgid "Hebrew"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:25
|
||||
#: src/contexts/LanguageContext.tsx:26
|
||||
msgid "Hindi"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:26
|
||||
#: src/contexts/LanguageContext.tsx:27
|
||||
msgid "Hungarian"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:27
|
||||
#: src/contexts/LanguageContext.tsx:28
|
||||
msgid "Italian"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:28
|
||||
#: src/contexts/LanguageContext.tsx:29
|
||||
msgid "Japanese"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:29
|
||||
#: src/contexts/LanguageContext.tsx:30
|
||||
msgid "Korean"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:30
|
||||
#: src/contexts/LanguageContext.tsx:31
|
||||
msgid "Dutch"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:31
|
||||
#: src/contexts/LanguageContext.tsx:32
|
||||
msgid "Norwegian"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:32
|
||||
#: src/contexts/LanguageContext.tsx:33
|
||||
msgid "Polish"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:33
|
||||
#: src/contexts/LanguageContext.tsx:34
|
||||
msgid "Portuguese"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:34
|
||||
#: src/contexts/LanguageContext.tsx:35
|
||||
msgid "Portuguese (Brazilian)"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:35
|
||||
#: src/contexts/LanguageContext.tsx:36
|
||||
msgid "Russian"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:36
|
||||
#: src/contexts/LanguageContext.tsx:37
|
||||
msgid "Slovenian"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:37
|
||||
#: src/contexts/LanguageContext.tsx:38
|
||||
msgid "Swedish"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:38
|
||||
#: src/contexts/LanguageContext.tsx:39
|
||||
msgid "Thai"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:39
|
||||
#: src/contexts/LanguageContext.tsx:40
|
||||
msgid "Turkish"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:40
|
||||
#: src/contexts/LanguageContext.tsx:41
|
||||
msgid "Vietnamese"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:41
|
||||
#: src/contexts/LanguageContext.tsx:42
|
||||
msgid "Chinese (Simplified)"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:42
|
||||
#: src/contexts/LanguageContext.tsx:43
|
||||
msgid "Chinese (Traditional)"
|
||||
msgstr ""
|
||||
|
||||
@@ -2440,7 +2449,7 @@ msgstr ""
|
||||
|
||||
#: src/defaults/links.tsx:34
|
||||
#: src/defaults/menuItems.tsx:71
|
||||
#: src/pages/Index/Playground.tsx:104
|
||||
#: src/pages/Index/Playground.tsx:171
|
||||
msgid "Playground"
|
||||
msgstr ""
|
||||
|
||||
@@ -2630,59 +2639,59 @@ msgstr ""
|
||||
msgid "Are you sure you want to delete this attachment?"
|
||||
msgstr ""
|
||||
|
||||
#: src/forms/CompanyForms.tsx:99
|
||||
#: src/forms/CompanyForms.tsx:120
|
||||
msgid "Edit Company"
|
||||
msgstr ""
|
||||
|
||||
#: src/forms/CompanyForms.tsx:103
|
||||
#: src/forms/CompanyForms.tsx:124
|
||||
msgid "Company updated"
|
||||
msgstr ""
|
||||
|
||||
#: src/forms/PartForms.tsx:77
|
||||
#: src/forms/PartForms.tsx:106
|
||||
msgid "Create Part"
|
||||
msgstr ""
|
||||
|
||||
#: src/forms/PartForms.tsx:79
|
||||
#: src/forms/PartForms.tsx:108
|
||||
msgid "Part created"
|
||||
msgstr ""
|
||||
|
||||
#: src/forms/PartForms.tsx:96
|
||||
#: src/forms/PartForms.tsx:125
|
||||
msgid "Edit Part"
|
||||
msgstr ""
|
||||
|
||||
#: src/forms/PartForms.tsx:100
|
||||
#: src/forms/PartForms.tsx:129
|
||||
msgid "Part updated"
|
||||
msgstr ""
|
||||
|
||||
#: src/forms/PartForms.tsx:111
|
||||
#: src/forms/PartForms.tsx:140
|
||||
msgid "Parent part category"
|
||||
msgstr ""
|
||||
|
||||
#: src/forms/StockForms.tsx:48
|
||||
#: src/forms/StockForms.tsx:44
|
||||
msgid "Add given quantity as packs instead of individual items"
|
||||
msgstr ""
|
||||
|
||||
#: src/forms/StockForms.tsx:59
|
||||
#: src/forms/StockForms.tsx:55
|
||||
msgid "Enter initial quantity for this stock item"
|
||||
msgstr ""
|
||||
|
||||
#: src/forms/StockForms.tsx:64
|
||||
#: src/forms/StockForms.tsx:60
|
||||
msgid "Serial Numbers"
|
||||
msgstr ""
|
||||
|
||||
#: src/forms/StockForms.tsx:65
|
||||
#: src/forms/StockForms.tsx:61
|
||||
msgid "Enter serial numbers for new stock (or leave blank)"
|
||||
msgstr ""
|
||||
|
||||
#: src/forms/StockForms.tsx:111
|
||||
#: src/forms/StockForms.tsx:110
|
||||
msgid "Create Stock Item"
|
||||
msgstr ""
|
||||
|
||||
#: src/forms/StockForms.tsx:130
|
||||
#: src/forms/StockForms.tsx:131
|
||||
msgid "Edit Stock Item"
|
||||
msgstr ""
|
||||
|
||||
#: src/forms/StockForms.tsx:131
|
||||
#: src/forms/StockForms.tsx:132
|
||||
msgid "Stock item updated"
|
||||
msgstr ""
|
||||
|
||||
@@ -2719,25 +2728,19 @@ msgstr ""
|
||||
msgid "Found an existing login - using it to log you in."
|
||||
msgstr ""
|
||||
|
||||
#: src/functions/forms.tsx:40
|
||||
#: src/functions/forms.tsx:49
|
||||
#: src/functions/forms.tsx:140
|
||||
msgid "Form Error"
|
||||
msgstr ""
|
||||
|
||||
#: src/functions/forms.tsx:41
|
||||
msgid "Form method not provided"
|
||||
msgstr ""
|
||||
|
||||
#: src/functions/forms.tsx:50
|
||||
#: src/functions/forms.tsx:58
|
||||
msgid "Response did not contain action data"
|
||||
msgstr ""
|
||||
|
||||
#: src/functions/forms.tsx:92
|
||||
#: src/functions/forms.tsx:187
|
||||
msgid "Invalid Form"
|
||||
msgstr ""
|
||||
|
||||
#: src/functions/forms.tsx:93
|
||||
#: src/functions/forms.tsx:188
|
||||
msgid "method parameter not supplied"
|
||||
msgstr ""
|
||||
|
||||
@@ -2831,7 +2834,7 @@ msgstr ""
|
||||
msgid "Welcome to your Dashboard{0}"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Playground.tsx:109
|
||||
#: src/pages/Index/Playground.tsx:176
|
||||
msgid "This page is a showcase for the possibilities of Platform UI."
|
||||
msgstr ""
|
||||
|
||||
@@ -2992,7 +2995,7 @@ msgid "Actions for {0}"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Scan.tsx:262
|
||||
#: src/pages/stock/StockDetail.tsx:163
|
||||
#: src/pages/stock/StockDetail.tsx:168
|
||||
msgid "Count"
|
||||
msgstr ""
|
||||
|
||||
@@ -3101,86 +3104,86 @@ msgstr ""
|
||||
msgid "Use pseudo language"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:52
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:53
|
||||
msgid "Single Sign On Accounts"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:59
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:77
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:60
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:78
|
||||
msgid "Not enabled"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:62
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:63
|
||||
msgid "Single Sign On is not enabled for this server"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:66
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:67
|
||||
msgid "Multifactor"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:80
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:81
|
||||
msgid "Multifactor authentication is not configured for your account"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:128
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:131
|
||||
msgid "The following email addresses are associated with your account:"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:140
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:143
|
||||
msgid "Primary"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:145
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:148
|
||||
msgid "Verified"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:149
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:152
|
||||
msgid "Unverified"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:162
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:165
|
||||
msgid "Add Email Address"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:165
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:168
|
||||
msgid "E-Mail"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:166
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:169
|
||||
msgid "E-Mail address"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:176
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:179
|
||||
msgid "Make Primary"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:179
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:182
|
||||
msgid "Re-send Verification"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:182
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:288
|
||||
#: src/pages/stock/StockDetail.tsx:173
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:185
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:291
|
||||
#: src/pages/stock/StockDetail.tsx:178
|
||||
msgid "Remove"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:188
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:191
|
||||
msgid "Add Email"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:252
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:255
|
||||
msgid "Provider has not been configured"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:262
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:265
|
||||
msgid "Not configured"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:265
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:268
|
||||
msgid "There are no social network accounts connected to this account."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:275
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:278
|
||||
msgid "You can sign in to your account using any of the following third party accounts"
|
||||
msgstr ""
|
||||
|
||||
@@ -3252,46 +3255,46 @@ msgstr ""
|
||||
msgid "Plugin Settings"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:69
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:70
|
||||
msgid "Login"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:91
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:92
|
||||
msgid "Barcodes"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:117
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:118
|
||||
msgid "Physical Units"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:128
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:129
|
||||
#: src/pages/part/PartDetail.tsx:151
|
||||
msgid "Pricing"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:157
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:158
|
||||
msgid "Exchange Rates"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:165
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:166
|
||||
msgid "Labels"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:171
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:172
|
||||
#: src/pages/Index/Settings/UserSettings.tsx:99
|
||||
msgid "Reporting"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:223
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:224
|
||||
msgid "Part Parameters"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:251
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:252
|
||||
#: src/pages/part/PartDetail.tsx:199
|
||||
msgid "Stocktake"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:256
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:257
|
||||
#: src/pages/build/BuildDetail.tsx:262
|
||||
#: src/pages/build/BuildIndex.tsx:36
|
||||
#: src/pages/part/PartDetail.tsx:130
|
||||
@@ -3299,7 +3302,7 @@ msgstr ""
|
||||
msgid "Build Orders"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:298
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:299
|
||||
msgid "Switch to User Setting"
|
||||
msgstr ""
|
||||
|
||||
@@ -3619,39 +3622,39 @@ msgstr ""
|
||||
#~ msgid "Link custom barcode to stock item"
|
||||
#~ msgstr "Link custom barcode to stock item"
|
||||
|
||||
#: src/pages/stock/StockDetail.tsx:159
|
||||
msgid "Stock Operations"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/stock/StockDetail.tsx:161
|
||||
#~ msgid "Unlink custom barcode from stock item"
|
||||
#~ msgstr "Unlink custom barcode from stock item"
|
||||
|
||||
#: src/pages/stock/StockDetail.tsx:164
|
||||
msgid "Count stock"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/stock/StockDetail.tsx:168
|
||||
msgid "Add"
|
||||
msgid "Stock Operations"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/stock/StockDetail.tsx:169
|
||||
msgid "Add stock"
|
||||
msgid "Count stock"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/stock/StockDetail.tsx:173
|
||||
msgid "Add"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/stock/StockDetail.tsx:174
|
||||
msgid "Remove stock"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/stock/StockDetail.tsx:178
|
||||
msgid "Transfer"
|
||||
msgid "Add stock"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/stock/StockDetail.tsx:179
|
||||
msgid "Remove stock"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/stock/StockDetail.tsx:183
|
||||
msgid "Transfer"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/stock/StockDetail.tsx:184
|
||||
msgid "Transfer stock"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/stock/StockDetail.tsx:191
|
||||
#: src/pages/stock/StockDetail.tsx:196
|
||||
msgid "Duplicate stock item"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: cs\n"
|
||||
"Project-Id-Version: inventree\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2023-11-13 21:28\n"
|
||||
"PO-Revision-Date: 2023-11-21 00:06\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Czech\n"
|
||||
"Plural-Forms: nplurals=4; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 3;\n"
|
||||
@@ -22,23 +22,23 @@ msgstr ""
|
||||
msgid "Title"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/forms/ApiForm.tsx:193
|
||||
#: src/components/forms/ApiForm.tsx:127
|
||||
#: src/functions/forms.tsx:48
|
||||
#: src/functions/forms.tsx:57
|
||||
#: src/functions/forms.tsx:260
|
||||
msgid "Form Error"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/forms/ApiForm.tsx:291
|
||||
#: src/components/widgets/MarkdownEditor.tsx:146
|
||||
msgid "Success"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/forms/ApiForm.tsx:267
|
||||
#: src/components/forms/ApiForm.tsx:363
|
||||
msgid "Form Errors Exist"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/forms/ApiForm.tsx:304
|
||||
#: src/components/tables/FilterSelectModal.tsx:166
|
||||
#: src/components/tables/plugin/PluginListTable.tsx:132
|
||||
#: src/contexts/ThemeContext.tsx:64
|
||||
msgid "Cancel"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/forms/ApiForm.tsx:313
|
||||
#: src/components/forms/ApiForm.tsx:406
|
||||
#: src/contexts/ThemeContext.tsx:64
|
||||
#: src/pages/Index/Settings/AccountSettings/AccountDetailPanel.tsx:51
|
||||
msgid "Submit"
|
||||
@@ -119,7 +119,7 @@ msgstr ""
|
||||
#: src/components/tables/settings/UserDrawer.tsx:163
|
||||
#: src/components/tables/settings/UserTable.tsx:51
|
||||
#: src/pages/Auth/Reset.tsx:31
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:48
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:49
|
||||
msgid "Email"
|
||||
msgstr ""
|
||||
|
||||
@@ -201,7 +201,7 @@ msgstr ""
|
||||
msgid "State: <0>worker</0> ({0}), <1>plugins</1>{1}"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/forms/fields/ApiFormField.tsx:326
|
||||
#: src/components/forms/fields/ApiFormField.tsx:279
|
||||
#: src/components/nav/SearchDrawer.tsx:412
|
||||
#: src/components/tables/InvenTreeTable.tsx:392
|
||||
#: src/components/tables/plugin/PluginListTable.tsx:163
|
||||
@@ -212,19 +212,19 @@ msgstr ""
|
||||
msgid "Error"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/forms/fields/RelatedModelField.tsx:214
|
||||
#: src/components/forms/fields/RelatedModelField.tsx:199
|
||||
#: src/pages/Index/Settings/UserSettings.tsx:64
|
||||
msgid "Search"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/forms/fields/RelatedModelField.tsx:215
|
||||
#: src/components/forms/fields/RelatedModelField.tsx:200
|
||||
#: src/components/modals/AboutInvenTreeModal.tsx:67
|
||||
#: src/components/widgets/WidgetLayout.tsx:134
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:298
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:301
|
||||
msgid "Loading"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/forms/fields/RelatedModelField.tsx:217
|
||||
#: src/components/forms/fields/RelatedModelField.tsx:202
|
||||
msgid "No results found"
|
||||
msgstr ""
|
||||
|
||||
@@ -233,59 +233,60 @@ msgstr ""
|
||||
msgid "Thumbnail"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/items/ActionDropdown.tsx:85
|
||||
#: src/components/items/ActionDropdown.tsx:84
|
||||
#: src/pages/build/BuildDetail.tsx:206
|
||||
msgid "Barcode Actions"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/items/ActionDropdown.tsx:102
|
||||
#: src/components/items/ActionDropdown.tsx:101
|
||||
msgid "View"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/items/ActionDropdown.tsx:103
|
||||
#: src/components/items/ActionDropdown.tsx:102
|
||||
msgid "View barcode"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/items/ActionDropdown.tsx:119
|
||||
#: src/components/items/ActionDropdown.tsx:118
|
||||
msgid "Link Barcode"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/items/ActionDropdown.tsx:120
|
||||
#: src/components/items/ActionDropdown.tsx:119
|
||||
msgid "Link custom barcode"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/items/ActionDropdown.tsx:136
|
||||
#: src/components/items/ActionDropdown.tsx:135
|
||||
msgid "Unlink Barcode"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/items/ActionDropdown.tsx:137
|
||||
#: src/components/items/ActionDropdown.tsx:136
|
||||
msgid "Unlink custom barcode"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/items/ActionDropdown.tsx:155
|
||||
#: src/components/items/ActionDropdown.tsx:154
|
||||
#: src/components/tables/RowActions.tsx:44
|
||||
msgid "Edit"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/items/ActionDropdown.tsx:174
|
||||
#: src/components/items/ActionDropdown.tsx:173
|
||||
#: src/components/tables/RowActions.tsx:61
|
||||
#: src/functions/forms.tsx:180
|
||||
#: src/functions/forms.tsx:300
|
||||
#: src/hooks/UseForm.tsx:109
|
||||
#: src/pages/Index/Scan.tsx:332
|
||||
#: src/pages/Notifications.tsx:79
|
||||
msgid "Delete"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/items/ActionDropdown.tsx:175
|
||||
#: src/components/items/ActionDropdown.tsx:174
|
||||
msgid "Delete item"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/items/ActionDropdown.tsx:193
|
||||
#: src/components/items/ActionDropdown.tsx:192
|
||||
#: src/components/tables/RowActions.tsx:27
|
||||
#: src/pages/stock/StockDetail.tsx:190
|
||||
#: src/pages/stock/StockDetail.tsx:195
|
||||
msgid "Duplicate"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/items/ActionDropdown.tsx:194
|
||||
#: src/components/items/ActionDropdown.tsx:193
|
||||
msgid "Duplicate item"
|
||||
msgstr ""
|
||||
|
||||
@@ -575,7 +576,7 @@ msgstr ""
|
||||
|
||||
#: src/components/nav/MainMenu.tsx:59
|
||||
#: src/defaults/menuItems.tsx:58
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:295
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:296
|
||||
msgid "System Settings"
|
||||
msgstr ""
|
||||
|
||||
@@ -631,7 +632,7 @@ msgid "About"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/nav/NotificationDrawer.tsx:70
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:123
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:124
|
||||
#: src/pages/Index/Settings/UserSettings.tsx:94
|
||||
#: src/pages/Notifications.tsx:28
|
||||
#: src/pages/Notifications.tsx:100
|
||||
@@ -649,7 +650,7 @@ msgstr ""
|
||||
|
||||
#: src/components/nav/PartCategoryTree.tsx:80
|
||||
#: src/components/render/ModelType.tsx:49
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:187
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:188
|
||||
#: src/pages/part/CategoryDetail.tsx:60
|
||||
msgid "Part Categories"
|
||||
msgstr ""
|
||||
@@ -658,19 +659,19 @@ msgstr ""
|
||||
msgid "results"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/nav/SearchDrawer.tsx:338
|
||||
#: src/components/nav/SearchDrawer.tsx:337
|
||||
msgid "Enter search text"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/nav/SearchDrawer.tsx:365
|
||||
#: src/components/nav/SearchDrawer.tsx:364
|
||||
msgid "Search Options"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/nav/SearchDrawer.tsx:368
|
||||
#: src/components/nav/SearchDrawer.tsx:367
|
||||
msgid "Regex search"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/nav/SearchDrawer.tsx:378
|
||||
#: src/components/nav/SearchDrawer.tsx:377
|
||||
msgid "Whole word search"
|
||||
msgstr ""
|
||||
|
||||
@@ -703,7 +704,7 @@ msgstr ""
|
||||
#: src/components/tables/part/PartTable.tsx:26
|
||||
#: src/components/tables/part/RelatedPartTable.tsx:41
|
||||
#: src/components/tables/purchasing/PurchaseOrderLineItemTable.tsx:98
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:38
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:35
|
||||
#: src/components/tables/stock/StockItemTable.tsx:27
|
||||
#: src/pages/part/PartDetail.tsx:328
|
||||
msgid "Part"
|
||||
@@ -713,7 +714,7 @@ msgstr ""
|
||||
#: src/components/tables/part/PartCategoryTable.tsx:36
|
||||
#: src/defaults/links.tsx:27
|
||||
#: src/defaults/menuItems.tsx:33
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:192
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:193
|
||||
#: src/pages/part/CategoryDetail.tsx:46
|
||||
#: src/pages/part/CategoryDetail.tsx:82
|
||||
#: src/pages/part/PartDetail.tsx:243
|
||||
@@ -729,7 +730,7 @@ msgid "Part Parameter Templates"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/render/ModelType.tsx:34
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:66
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:63
|
||||
msgid "Supplier Part"
|
||||
msgstr ""
|
||||
|
||||
@@ -751,7 +752,7 @@ msgid "Part Category"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/render/ModelType.tsx:55
|
||||
#: src/pages/stock/StockDetail.tsx:219
|
||||
#: src/pages/stock/StockDetail.tsx:220
|
||||
msgid "Stock Item"
|
||||
msgstr ""
|
||||
|
||||
@@ -802,7 +803,7 @@ msgid "Project Code"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/render/ModelType.tsx:89
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:105
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:106
|
||||
msgid "Project Codes"
|
||||
msgstr ""
|
||||
|
||||
@@ -812,7 +813,7 @@ msgid "Purchase Order"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/render/ModelType.tsx:96
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:262
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:263
|
||||
#: src/pages/company/CompanyDetail.tsx:88
|
||||
#: src/pages/part/PartDetail.tsx:175
|
||||
#: src/pages/purchasing/PurchasingIndex.tsx:20
|
||||
@@ -834,7 +835,7 @@ msgid "Sales Order"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/render/ModelType.tsx:108
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:275
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:276
|
||||
#: src/pages/company/CompanyDetail.tsx:106
|
||||
#: src/pages/part/PartDetail.tsx:181
|
||||
#: src/pages/sales/SalesIndex.tsx:21
|
||||
@@ -913,21 +914,21 @@ msgstr ""
|
||||
msgid "Quantity"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/SettingItem.tsx:29
|
||||
#: src/components/settings/SettingItem.tsx:70
|
||||
#: src/components/settings/SettingItem.tsx:32
|
||||
#: src/components/settings/SettingItem.tsx:74
|
||||
msgid "Setting updated"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/SettingItem.tsx:30
|
||||
#: src/components/settings/SettingItem.tsx:71
|
||||
#: src/components/settings/SettingItem.tsx:33
|
||||
#: src/components/settings/SettingItem.tsx:75
|
||||
msgid "{0} updated successfully"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/SettingItem.tsx:38
|
||||
#: src/components/settings/SettingItem.tsx:41
|
||||
msgid "Error editing setting"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/SettingItem.tsx:57
|
||||
#: src/components/settings/SettingItem.tsx:61
|
||||
msgid "Edit Setting"
|
||||
msgstr ""
|
||||
|
||||
@@ -1051,6 +1052,14 @@ msgstr ""
|
||||
msgid "Select filter value"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/tables/FilterSelectModal.tsx:166
|
||||
#: src/components/tables/plugin/PluginListTable.tsx:132
|
||||
#: src/contexts/ThemeContext.tsx:64
|
||||
#: src/functions/forms.tsx:201
|
||||
#: src/hooks/UseForm.tsx:36
|
||||
msgid "Cancel"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/tables/FilterSelectModal.tsx:172
|
||||
msgid "Add Filter"
|
||||
msgstr ""
|
||||
@@ -1197,7 +1206,7 @@ msgstr ""
|
||||
|
||||
#: src/components/tables/bom/BomTable.tsx:233
|
||||
#: src/components/tables/purchasing/PurchaseOrderLineItemTable.tsx:215
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:133
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:130
|
||||
#: src/pages/build/BuildDetail.tsx:169
|
||||
#: src/pages/company/CompanyDetail.tsx:152
|
||||
#: src/pages/part/PartDetail.tsx:228
|
||||
@@ -1454,7 +1463,7 @@ msgstr ""
|
||||
#: src/components/tables/stock/StockItemTable.tsx:51
|
||||
#: src/defaults/links.tsx:28
|
||||
#: src/defaults/menuItems.tsx:38
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:229
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:230
|
||||
#: src/pages/part/PartDetail.tsx:98
|
||||
#: src/pages/stock/LocationDetail.tsx:63
|
||||
#: src/pages/stock/StockDetail.tsx:135
|
||||
@@ -1721,8 +1730,8 @@ msgstr ""
|
||||
|
||||
#: src/components/tables/purchasing/PurchaseOrderLineItemTable.tsx:137
|
||||
#: src/components/tables/purchasing/PurchaseOrderLineItemTable.tsx:173
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:105
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:125
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:102
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:122
|
||||
msgid "Pack Quantity"
|
||||
msgstr ""
|
||||
|
||||
@@ -1771,7 +1780,7 @@ msgid "Receive items"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/tables/purchasing/PurchaseOrderTable.tsx:48
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:51
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:48
|
||||
#: src/pages/company/SupplierDetail.tsx:8
|
||||
msgid "Supplier"
|
||||
msgstr ""
|
||||
@@ -1780,64 +1789,64 @@ msgstr ""
|
||||
msgid "Supplier Reference"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:74
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:71
|
||||
#: src/pages/company/ManufacturerDetail.tsx:8
|
||||
msgid "Manufacturer"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:90
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:87
|
||||
msgid "MPN"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:95
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:92
|
||||
msgid "In Stock"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:100
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:97
|
||||
msgid "Packaging"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:116
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:113
|
||||
msgid "Base units"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:138
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:135
|
||||
msgid "Availability"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:147
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:144
|
||||
msgid "Updated"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:166
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:163
|
||||
msgid "Add Supplier Part"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:169
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:166
|
||||
msgid "Supplier part created"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:178
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:175
|
||||
msgid "Add supplier part"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:193
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:196
|
||||
msgid "Edit Supplier Part"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:196
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:199
|
||||
msgid "Supplier part updated"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:207
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:210
|
||||
msgid "Delete Supplier Part"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:208
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:211
|
||||
msgid "Supplier part deleted"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:211
|
||||
#: src/components/tables/purchasing/SupplierPartTable.tsx:214
|
||||
msgid "Are you sure you want to remove this supplier part?"
|
||||
msgstr ""
|
||||
|
||||
@@ -2208,123 +2217,123 @@ msgstr ""
|
||||
msgid "Show Boxes"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:13
|
||||
#: src/contexts/LanguageContext.tsx:14
|
||||
msgid "Bulgarian"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:14
|
||||
#: src/contexts/LanguageContext.tsx:15
|
||||
msgid "Czech"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:15
|
||||
#: src/contexts/LanguageContext.tsx:16
|
||||
msgid "Danish"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:16
|
||||
#: src/contexts/LanguageContext.tsx:17
|
||||
msgid "German"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:17
|
||||
#: src/contexts/LanguageContext.tsx:18
|
||||
msgid "Greek"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:18
|
||||
#: src/contexts/LanguageContext.tsx:19
|
||||
msgid "English"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:19
|
||||
#: src/contexts/LanguageContext.tsx:20
|
||||
msgid "Spanish"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:20
|
||||
#: src/contexts/LanguageContext.tsx:21
|
||||
msgid "Spanish (Mexican)"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:21
|
||||
#: src/contexts/LanguageContext.tsx:22
|
||||
msgid "Farsi / Persian"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:22
|
||||
#: src/contexts/LanguageContext.tsx:23
|
||||
msgid "Finnish"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:23
|
||||
#: src/contexts/LanguageContext.tsx:24
|
||||
msgid "French"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:24
|
||||
#: src/contexts/LanguageContext.tsx:25
|
||||
msgid "Hebrew"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:25
|
||||
#: src/contexts/LanguageContext.tsx:26
|
||||
msgid "Hindi"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:26
|
||||
#: src/contexts/LanguageContext.tsx:27
|
||||
msgid "Hungarian"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:27
|
||||
#: src/contexts/LanguageContext.tsx:28
|
||||
msgid "Italian"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:28
|
||||
#: src/contexts/LanguageContext.tsx:29
|
||||
msgid "Japanese"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:29
|
||||
#: src/contexts/LanguageContext.tsx:30
|
||||
msgid "Korean"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:30
|
||||
#: src/contexts/LanguageContext.tsx:31
|
||||
msgid "Dutch"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:31
|
||||
#: src/contexts/LanguageContext.tsx:32
|
||||
msgid "Norwegian"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:32
|
||||
#: src/contexts/LanguageContext.tsx:33
|
||||
msgid "Polish"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:33
|
||||
#: src/contexts/LanguageContext.tsx:34
|
||||
msgid "Portuguese"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:34
|
||||
#: src/contexts/LanguageContext.tsx:35
|
||||
msgid "Portuguese (Brazilian)"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:35
|
||||
#: src/contexts/LanguageContext.tsx:36
|
||||
msgid "Russian"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:36
|
||||
#: src/contexts/LanguageContext.tsx:37
|
||||
msgid "Slovenian"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:37
|
||||
#: src/contexts/LanguageContext.tsx:38
|
||||
msgid "Swedish"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:38
|
||||
#: src/contexts/LanguageContext.tsx:39
|
||||
msgid "Thai"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:39
|
||||
#: src/contexts/LanguageContext.tsx:40
|
||||
msgid "Turkish"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:40
|
||||
#: src/contexts/LanguageContext.tsx:41
|
||||
msgid "Vietnamese"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:41
|
||||
#: src/contexts/LanguageContext.tsx:42
|
||||
msgid "Chinese (Simplified)"
|
||||
msgstr ""
|
||||
|
||||
#: src/contexts/LanguageContext.tsx:42
|
||||
#: src/contexts/LanguageContext.tsx:43
|
||||
msgid "Chinese (Traditional)"
|
||||
msgstr ""
|
||||
|
||||
@@ -2440,7 +2449,7 @@ msgstr ""
|
||||
|
||||
#: src/defaults/links.tsx:34
|
||||
#: src/defaults/menuItems.tsx:71
|
||||
#: src/pages/Index/Playground.tsx:104
|
||||
#: src/pages/Index/Playground.tsx:171
|
||||
msgid "Playground"
|
||||
msgstr ""
|
||||
|
||||
@@ -2630,59 +2639,59 @@ msgstr ""
|
||||
msgid "Are you sure you want to delete this attachment?"
|
||||
msgstr ""
|
||||
|
||||
#: src/forms/CompanyForms.tsx:99
|
||||
#: src/forms/CompanyForms.tsx:120
|
||||
msgid "Edit Company"
|
||||
msgstr ""
|
||||
|
||||
#: src/forms/CompanyForms.tsx:103
|
||||
#: src/forms/CompanyForms.tsx:124
|
||||
msgid "Company updated"
|
||||
msgstr ""
|
||||
|
||||
#: src/forms/PartForms.tsx:77
|
||||
#: src/forms/PartForms.tsx:106
|
||||
msgid "Create Part"
|
||||
msgstr ""
|
||||
|
||||
#: src/forms/PartForms.tsx:79
|
||||
#: src/forms/PartForms.tsx:108
|
||||
msgid "Part created"
|
||||
msgstr ""
|
||||
|
||||
#: src/forms/PartForms.tsx:96
|
||||
#: src/forms/PartForms.tsx:125
|
||||
msgid "Edit Part"
|
||||
msgstr ""
|
||||
|
||||
#: src/forms/PartForms.tsx:100
|
||||
#: src/forms/PartForms.tsx:129
|
||||
msgid "Part updated"
|
||||
msgstr ""
|
||||
|
||||
#: src/forms/PartForms.tsx:111
|
||||
#: src/forms/PartForms.tsx:140
|
||||
msgid "Parent part category"
|
||||
msgstr ""
|
||||
|
||||
#: src/forms/StockForms.tsx:48
|
||||
#: src/forms/StockForms.tsx:44
|
||||
msgid "Add given quantity as packs instead of individual items"
|
||||
msgstr ""
|
||||
|
||||
#: src/forms/StockForms.tsx:59
|
||||
#: src/forms/StockForms.tsx:55
|
||||
msgid "Enter initial quantity for this stock item"
|
||||
msgstr ""
|
||||
|
||||
#: src/forms/StockForms.tsx:64
|
||||
#: src/forms/StockForms.tsx:60
|
||||
msgid "Serial Numbers"
|
||||
msgstr ""
|
||||
|
||||
#: src/forms/StockForms.tsx:65
|
||||
#: src/forms/StockForms.tsx:61
|
||||
msgid "Enter serial numbers for new stock (or leave blank)"
|
||||
msgstr ""
|
||||
|
||||
#: src/forms/StockForms.tsx:111
|
||||
#: src/forms/StockForms.tsx:110
|
||||
msgid "Create Stock Item"
|
||||
msgstr ""
|
||||
|
||||
#: src/forms/StockForms.tsx:130
|
||||
#: src/forms/StockForms.tsx:131
|
||||
msgid "Edit Stock Item"
|
||||
msgstr ""
|
||||
|
||||
#: src/forms/StockForms.tsx:131
|
||||
#: src/forms/StockForms.tsx:132
|
||||
msgid "Stock item updated"
|
||||
msgstr ""
|
||||
|
||||
@@ -2719,25 +2728,19 @@ msgstr ""
|
||||
msgid "Found an existing login - using it to log you in."
|
||||
msgstr ""
|
||||
|
||||
#: src/functions/forms.tsx:40
|
||||
#: src/functions/forms.tsx:49
|
||||
#: src/functions/forms.tsx:140
|
||||
msgid "Form Error"
|
||||
msgstr ""
|
||||
|
||||
#: src/functions/forms.tsx:41
|
||||
msgid "Form method not provided"
|
||||
msgstr ""
|
||||
|
||||
#: src/functions/forms.tsx:50
|
||||
#: src/functions/forms.tsx:58
|
||||
msgid "Response did not contain action data"
|
||||
msgstr ""
|
||||
|
||||
#: src/functions/forms.tsx:92
|
||||
#: src/functions/forms.tsx:187
|
||||
msgid "Invalid Form"
|
||||
msgstr ""
|
||||
|
||||
#: src/functions/forms.tsx:93
|
||||
#: src/functions/forms.tsx:188
|
||||
msgid "method parameter not supplied"
|
||||
msgstr ""
|
||||
|
||||
@@ -2831,7 +2834,7 @@ msgstr ""
|
||||
msgid "Welcome to your Dashboard{0}"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Playground.tsx:109
|
||||
#: src/pages/Index/Playground.tsx:176
|
||||
msgid "This page is a showcase for the possibilities of Platform UI."
|
||||
msgstr ""
|
||||
|
||||
@@ -2992,7 +2995,7 @@ msgid "Actions for {0}"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Scan.tsx:262
|
||||
#: src/pages/stock/StockDetail.tsx:163
|
||||
#: src/pages/stock/StockDetail.tsx:168
|
||||
msgid "Count"
|
||||
msgstr ""
|
||||
|
||||
@@ -3101,86 +3104,86 @@ msgstr ""
|
||||
msgid "Use pseudo language"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:52
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:53
|
||||
msgid "Single Sign On Accounts"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:59
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:77
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:60
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:78
|
||||
msgid "Not enabled"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:62
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:63
|
||||
msgid "Single Sign On is not enabled for this server"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:66
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:67
|
||||
msgid "Multifactor"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:80
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:81
|
||||
msgid "Multifactor authentication is not configured for your account"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:128
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:131
|
||||
msgid "The following email addresses are associated with your account:"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:140
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:143
|
||||
msgid "Primary"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:145
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:148
|
||||
msgid "Verified"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:149
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:152
|
||||
msgid "Unverified"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:162
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:165
|
||||
msgid "Add Email Address"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:165
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:168
|
||||
msgid "E-Mail"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:166
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:169
|
||||
msgid "E-Mail address"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:176
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:179
|
||||
msgid "Make Primary"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:179
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:182
|
||||
msgid "Re-send Verification"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:182
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:288
|
||||
#: src/pages/stock/StockDetail.tsx:173
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:185
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:291
|
||||
#: src/pages/stock/StockDetail.tsx:178
|
||||
msgid "Remove"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:188
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:191
|
||||
msgid "Add Email"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:252
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:255
|
||||
msgid "Provider has not been configured"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:262
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:265
|
||||
msgid "Not configured"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:265
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:268
|
||||
msgid "There are no social network accounts connected to this account."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:275
|
||||
#: src/pages/Index/Settings/AccountSettings/SecurityContent.tsx:278
|
||||
msgid "You can sign in to your account using any of the following third party accounts"
|
||||
msgstr ""
|
||||
|
||||
@@ -3252,46 +3255,46 @@ msgstr ""
|
||||
msgid "Plugin Settings"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:69
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:70
|
||||
msgid "Login"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:91
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:92
|
||||
msgid "Barcodes"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:117
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:118
|
||||
msgid "Physical Units"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:128
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:129
|
||||
#: src/pages/part/PartDetail.tsx:151
|
||||
msgid "Pricing"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:157
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:158
|
||||
msgid "Exchange Rates"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:165
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:166
|
||||
msgid "Labels"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:171
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:172
|
||||
#: src/pages/Index/Settings/UserSettings.tsx:99
|
||||
msgid "Reporting"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:223
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:224
|
||||
msgid "Part Parameters"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:251
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:252
|
||||
#: src/pages/part/PartDetail.tsx:199
|
||||
msgid "Stocktake"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:256
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:257
|
||||
#: src/pages/build/BuildDetail.tsx:262
|
||||
#: src/pages/build/BuildIndex.tsx:36
|
||||
#: src/pages/part/PartDetail.tsx:130
|
||||
@@ -3299,7 +3302,7 @@ msgstr ""
|
||||
msgid "Build Orders"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:298
|
||||
#: src/pages/Index/Settings/SystemSettings.tsx:299
|
||||
msgid "Switch to User Setting"
|
||||
msgstr ""
|
||||
|
||||
@@ -3619,39 +3622,39 @@ msgstr ""
|
||||
#~ msgid "Link custom barcode to stock item"
|
||||
#~ msgstr "Link custom barcode to stock item"
|
||||
|
||||
#: src/pages/stock/StockDetail.tsx:159
|
||||
msgid "Stock Operations"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/stock/StockDetail.tsx:161
|
||||
#~ msgid "Unlink custom barcode from stock item"
|
||||
#~ msgstr "Unlink custom barcode from stock item"
|
||||
|
||||
#: src/pages/stock/StockDetail.tsx:164
|
||||
msgid "Count stock"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/stock/StockDetail.tsx:168
|
||||
msgid "Add"
|
||||
msgid "Stock Operations"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/stock/StockDetail.tsx:169
|
||||
msgid "Add stock"
|
||||
msgid "Count stock"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/stock/StockDetail.tsx:173
|
||||
msgid "Add"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/stock/StockDetail.tsx:174
|
||||
msgid "Remove stock"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/stock/StockDetail.tsx:178
|
||||
msgid "Transfer"
|
||||
msgid "Add stock"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/stock/StockDetail.tsx:179
|
||||
msgid "Remove stock"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/stock/StockDetail.tsx:183
|
||||
msgid "Transfer"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/stock/StockDetail.tsx:184
|
||||
msgid "Transfer stock"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/stock/StockDetail.tsx:191
|
||||
#: src/pages/stock/StockDetail.tsx:196
|
||||
msgid "Duplicate stock item"
|
||||
msgstr ""
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user