Merge branch 'inventree:master' into fix-sso-signup

This commit is contained in:
Matthias Mair 2021-10-20 23:52:47 +02:00 committed by GitHub
commit edd2e16dfc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
49 changed files with 1421 additions and 1451 deletions

View File

@ -933,7 +933,8 @@ input[type="submit"] {
.panel-inventree { .panel-inventree {
padding: 10px; padding: 10px;
box-shadow: 1px 1px #DDD; box-shadow: 2px 2px #DDD;
border-color: #ccc;
} }
.panel-hidden { .panel-hidden {
@ -1074,6 +1075,14 @@ input[type='number']{
margin-top: 0.5rem; margin-top: 0.5rem;
} }
.product-card {
width: 20%;
padding: 5px;
min-height: 25px;
}
.product-card-panel{ .product-card-panel{
height: 100%; height: 100%;
border: 1px solid #ccc;
box-shadow: 2px 2px #DDD;
} }

View File

@ -1,12 +0,0 @@
var msDelay = 0;
var delay = (function(){
return function(callback, ms){
clearTimeout(msDelay);
msDelay = setTimeout(callback, ms);
};
})();
function cancelTimer(){
clearTimeout(msDelay);
}

View File

@ -1,249 +0,0 @@
function loadTree(url, tree, options={}) {
/* Load the side-nav tree view
Args:
url: URL to request tree data
tree: html ref to treeview
options:
data: data object to pass to the AJAX request
selected: ID of currently selected item
name: name of the tree
*/
var data = {};
if (options.data) {
data = options.data;
}
var key = "inventree-sidenav-items-";
if (options.name) {
key += options.name;
}
$.ajax({
url: url,
type: 'get',
dataType: 'json',
data: data,
success: function (response) {
if (response.tree) {
$(tree).treeview({
data: response.tree,
enableLinks: true,
showTags: true,
});
if (localStorage.getItem(key)) {
var saved_exp = localStorage.getItem(key).split(",");
// Automatically expand the desired notes
for (var q = 0; q < saved_exp.length; q++) {
$(tree).treeview('expandNode', parseInt(saved_exp[q]));
}
}
// Setup a callback whenever a node is toggled
$(tree).on('nodeExpanded nodeCollapsed', function(event, data) {
// Record the entire list of expanded items
var expanded = $(tree).treeview('getExpanded');
var exp = [];
for (var i = 0; i < expanded.length; i++) {
exp.push(expanded[i].nodeId);
}
// Save the expanded nodes
localStorage.setItem(key, exp);
});
}
},
error: function (xhr, ajaxOptions, thrownError) {
//TODO
}
});
}
/**
* Initialize navigation tree display
*/
function initNavTree(options) {
var resize = true;
if ('resize' in options) {
resize = options.resize;
}
var label = options.label || 'nav';
var stateLabel = `${label}-tree-state`;
var widthLabel = `${label}-tree-width`;
var treeId = options.treeId || '#sidenav-left';
var toggleId = options.toggleId;
// Initially hide the tree
$(treeId).animate({
width: '0px',
}, 0, function() {
if (resize) {
$(treeId).resizable({
minWidth: '0px',
maxWidth: '500px',
handles: 'e, se',
grid: [5, 5],
stop: function(event, ui) {
var width = Math.round(ui.element.width());
if (width < 75) {
$(treeId).animate({
width: '0px'
}, 50);
localStorage.setItem(stateLabel, 'closed');
} else {
localStorage.setItem(stateLabel, 'open');
localStorage.setItem(widthLabel, `${width}px`);
}
}
});
}
var state = localStorage.getItem(stateLabel);
var width = localStorage.getItem(widthLabel) || '300px';
if (state && state == 'open') {
$(treeId).animate({
width: width,
}, 50);
}
});
// Register callback for 'toggle' button
if (toggleId) {
$(toggleId).click(function() {
var state = localStorage.getItem(stateLabel) || 'closed';
var width = localStorage.getItem(widthLabel) || '300px';
if (state == 'open') {
$(treeId).animate({
width: '0px'
}, 50);
localStorage.setItem(stateLabel, 'closed');
} else {
$(treeId).animate({
width: width,
}, 50);
localStorage.setItem(stateLabel, 'open');
}
});
}
}
/**
* Handle left-hand icon menubar display
*/
function enableNavbar(options) {
var resize = true;
if ('resize' in options) {
resize = options.resize;
}
var label = options.label || 'nav';
label = `navbar-${label}`;
var stateLabel = `${label}-state`;
var widthLabel = `${label}-width`;
var navId = options.navId || '#sidenav-right';
var toggleId = options.toggleId;
// Extract the saved width for this element
$(navId).animate({
width: '45px',
'min-width': '45px',
display: 'block',
}, 50, function() {
// Make the navbar resizable
if (resize) {
$(navId).resizable({
minWidth: options.minWidth || '100px',
maxWidth: options.maxWidth || '500px',
handles: 'e, se',
grid: [5, 5],
stop: function(event, ui) {
// Record the new width
var width = Math.round(ui.element.width());
// Reasonably narrow? Just close it!
if (width <= 75) {
$(navId).animate({
width: '45px'
}, 50);
localStorage.setItem(stateLabel, 'closed');
} else {
localStorage.setItem(widthLabel, `${width}px`);
localStorage.setItem(stateLabel, 'open');
}
}
});
}
var state = localStorage.getItem(stateLabel);
var width = localStorage.getItem(widthLabel) || '250px';
if (state && state == 'open') {
$(navId).animate({
width: width
}, 100);
}
});
// Register callback for 'toggle' button
if (toggleId) {
$(toggleId).click(function() {
var state = localStorage.getItem(stateLabel) || 'closed';
var width = localStorage.getItem(widthLabel) || '250px';
if (state == 'open') {
$(navId).animate({
width: '45px',
minWidth: '45px',
}, 50);
localStorage.setItem(stateLabel, 'closed');
} else {
$(navId).animate({
'width': width
}, 50);
localStorage.setItem(stateLabel, 'open');
}
});
}
}

View File

@ -12,11 +12,14 @@ import common.models
INVENTREE_SW_VERSION = "0.6.0 dev" INVENTREE_SW_VERSION = "0.6.0 dev"
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 15 INVENTREE_API_VERSION = 16
""" """
Increment this API version number whenever there is a significant change to the API that any clients need to know about Increment this API version number whenever there is a significant change to the API that any clients need to know about
v16 -> 2021-10-17
- Adds API endpoint for completing build order outputs
v15 -> 2021-10-06 v15 -> 2021-10-06
- Adds detail endpoint for SalesOrderAllocation model - Adds detail endpoint for SalesOrderAllocation model
- Allows use of the API forms interface for adjusting SalesOrderAllocation objects - Allows use of the API forms interface for adjusting SalesOrderAllocation objects

View File

@ -5,12 +5,9 @@ JSON API for the Build app
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
from django.utils.translation import ugettext_lazy as _
from django.conf.urls import url, include from django.conf.urls import url, include
from rest_framework import filters, generics from rest_framework import filters, generics
from rest_framework.serializers import ValidationError
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from django_filters import rest_framework as rest_filters from django_filters import rest_framework as rest_filters
@ -21,7 +18,7 @@ from InvenTree.filters import InvenTreeOrderingFilter
from InvenTree.status_codes import BuildStatus from InvenTree.status_codes import BuildStatus
from .models import Build, BuildItem, BuildOrderAttachment from .models import Build, BuildItem, BuildOrderAttachment
from .serializers import BuildAttachmentSerializer, BuildSerializer, BuildItemSerializer from .serializers import BuildAttachmentSerializer, BuildCompleteSerializer, BuildSerializer, BuildItemSerializer
from .serializers import BuildAllocationSerializer, BuildUnallocationSerializer from .serializers import BuildAllocationSerializer, BuildUnallocationSerializer
@ -201,30 +198,43 @@ class BuildUnallocate(generics.CreateAPIView):
queryset = Build.objects.none() queryset = Build.objects.none()
serializer_class = BuildUnallocationSerializer serializer_class = BuildUnallocationSerializer
def get_build(self):
"""
Returns the BuildOrder associated with this API endpoint
"""
pk = self.kwargs.get('pk', None)
try:
build = Build.objects.get(pk=pk)
except (ValueError, Build.DoesNotExist):
raise ValidationError(_("Matching build order does not exist"))
return build
def get_serializer_context(self): def get_serializer_context(self):
ctx = super().get_serializer_context() ctx = super().get_serializer_context()
ctx['build'] = self.get_build()
try:
ctx['build'] = Build.objects.get(pk=self.kwargs.get('pk', None))
except:
pass
ctx['request'] = self.request ctx['request'] = self.request
return ctx return ctx
class BuildComplete(generics.CreateAPIView):
"""
API endpoint for completing build outputs
"""
queryset = Build.objects.none()
serializer_class = BuildCompleteSerializer
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['request'] = self.request
try:
ctx['build'] = Build.objects.get(pk=self.kwargs.get('pk', None))
except:
pass
return ctx
class BuildAllocate(generics.CreateAPIView): class BuildAllocate(generics.CreateAPIView):
""" """
API endpoint to allocate stock items to a build order API endpoint to allocate stock items to a build order
@ -241,20 +251,6 @@ class BuildAllocate(generics.CreateAPIView):
serializer_class = BuildAllocationSerializer serializer_class = BuildAllocationSerializer
def get_build(self):
"""
Returns the BuildOrder associated with this API endpoint
"""
pk = self.kwargs.get('pk', None)
try:
build = Build.objects.get(pk=pk)
except (Build.DoesNotExist, ValueError):
raise ValidationError(_("Matching build order does not exist"))
return build
def get_serializer_context(self): def get_serializer_context(self):
""" """
Provide the Build object to the serializer context Provide the Build object to the serializer context
@ -262,7 +258,11 @@ class BuildAllocate(generics.CreateAPIView):
context = super().get_serializer_context() context = super().get_serializer_context()
context['build'] = self.get_build() try:
context['build'] = Build.objects.get(pk=self.kwargs.get('pk', None))
except:
pass
context['request'] = self.request context['request'] = self.request
return context return context
@ -390,6 +390,7 @@ build_api_urls = [
# Build Detail # Build Detail
url(r'^(?P<pk>\d+)/', include([ url(r'^(?P<pk>\d+)/', include([
url(r'^allocate/', BuildAllocate.as_view(), name='api-build-allocate'), url(r'^allocate/', BuildAllocate.as_view(), name='api-build-allocate'),
url(r'^complete/', BuildComplete.as_view(), name='api-build-complete'),
url(r'^unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'), url(r'^unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'),
url(r'^.*$', BuildDetail.as_view(), name='api-build-detail'), url(r'^.*$', BuildDetail.as_view(), name='api-build-detail'),
])), ])),

View File

@ -10,63 +10,9 @@ from django.utils.translation import ugettext_lazy as _
from django import forms from django import forms
from InvenTree.forms import HelperForm from InvenTree.forms import HelperForm
from InvenTree.fields import RoundingDecimalFormField
from InvenTree.fields import DatePickerFormField
from InvenTree.status_codes import StockStatus
from .models import Build from .models import Build
from stock.models import StockLocation, StockItem
class EditBuildForm(HelperForm):
""" Form for editing a Build object.
"""
field_prefix = {
'reference': 'BO',
'link': 'fa-link',
'batch': 'fa-layer-group',
'serial-numbers': 'fa-hashtag',
'location': 'fa-map-marker-alt',
'target_date': 'fa-calendar-alt',
}
field_placeholder = {
'reference': _('Build Order reference'),
'target_date': _('Order target date'),
}
target_date = DatePickerFormField(
label=_('Target Date'),
help_text=_('Target date for build completion. Build will be overdue after this date.')
)
quantity = RoundingDecimalFormField(
max_digits=10, decimal_places=5,
label=_('Quantity'),
help_text=_('Number of items to build')
)
class Meta:
model = Build
fields = [
'reference',
'title',
'part',
'quantity',
'batch',
'target_date',
'take_from',
'destination',
'parent',
'sales_order',
'link',
'issued_by',
'responsible',
]
class BuildOutputCreateForm(HelperForm): class BuildOutputCreateForm(HelperForm):
""" """
@ -155,59 +101,6 @@ class CompleteBuildForm(HelperForm):
] ]
class CompleteBuildOutputForm(HelperForm):
"""
Form for completing a single build output
"""
field_prefix = {
'serial_numbers': 'fa-hashtag',
}
field_placeholder = {
}
location = forms.ModelChoiceField(
queryset=StockLocation.objects.all(),
label=_('Location'),
help_text=_('Location of completed parts'),
)
stock_status = forms.ChoiceField(
label=_('Status'),
help_text=_('Build output stock status'),
initial=StockStatus.OK,
choices=StockStatus.items(),
)
confirm_incomplete = forms.BooleanField(
required=False,
label=_('Confirm incomplete'),
help_text=_("Confirm completion with incomplete stock allocation")
)
confirm = forms.BooleanField(required=True, label=_('Confirm'), help_text=_('Confirm build completion'))
output = forms.ModelChoiceField(
queryset=StockItem.objects.all(), # Queryset is narrowed in the view
widget=forms.HiddenInput(),
)
class Meta:
model = Build
fields = [
'location',
'output',
'stock_status',
'confirm',
'confirm_incomplete',
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
class CancelBuildForm(HelperForm): class CancelBuildForm(HelperForm):
""" Form for cancelling a build """ """ Form for cancelling a build """

View File

@ -724,7 +724,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
items.all().delete() items.all().delete()
@transaction.atomic @transaction.atomic
def completeBuildOutput(self, output, user, **kwargs): def complete_build_output(self, output, user, **kwargs):
""" """
Complete a particular build output Complete a particular build output
@ -741,10 +741,6 @@ class Build(MPTTModel, ReferenceIndexingMixin):
allocated_items = output.items_to_install.all() allocated_items = output.items_to_install.all()
for build_item in allocated_items: for build_item in allocated_items:
# TODO: This is VERY SLOW as each deletion from the database takes ~1 second to complete
# TODO: Use the background worker process to handle this task!
# Complete the allocation of stock for that item # Complete the allocation of stock for that item
build_item.complete_allocation(user) build_item.complete_allocation(user)
@ -770,6 +766,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
# Increase the completed quantity for this build # Increase the completed quantity for this build
self.completed += output.quantity self.completed += output.quantity
self.save() self.save()
def requiredQuantity(self, part, output): def requiredQuantity(self, part, output):

View File

@ -18,9 +18,10 @@ from rest_framework.serializers import ValidationError
from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer
from InvenTree.serializers import InvenTreeAttachmentSerializerField, UserSerializerBrief from InvenTree.serializers import InvenTreeAttachmentSerializerField, UserSerializerBrief
from InvenTree.status_codes import StockStatus
import InvenTree.helpers import InvenTree.helpers
from stock.models import StockItem from stock.models import StockItem, StockLocation
from stock.serializers import StockItemSerializerBrief, LocationSerializer from stock.serializers import StockItemSerializerBrief, LocationSerializer
from part.models import BomItem from part.models import BomItem
@ -120,6 +121,124 @@ class BuildSerializer(InvenTreeModelSerializer):
] ]
class BuildOutputSerializer(serializers.Serializer):
"""
Serializer for a "BuildOutput"
Note that a "BuildOutput" is really just a StockItem which is "in production"!
"""
output = serializers.PrimaryKeyRelatedField(
queryset=StockItem.objects.all(),
many=False,
allow_null=False,
required=True,
label=_('Build Output'),
)
def validate_output(self, output):
build = self.context['build']
# The stock item must point to the build
if output.build != build:
raise ValidationError(_("Build output does not match the parent build"))
# The part must match!
if output.part != build.part:
raise ValidationError(_("Output part does not match BuildOrder part"))
# The build output must be "in production"
if not output.is_building:
raise ValidationError(_("This build output has already been completed"))
# The build output must have all tracked parts allocated
if not build.isFullyAllocated(output):
raise ValidationError(_("This build output is not fully allocated"))
return output
class Meta:
fields = [
'output',
]
class BuildCompleteSerializer(serializers.Serializer):
"""
DRF serializer for completing one or more build outputs
"""
class Meta:
fields = [
'outputs',
'location',
'status',
'notes',
]
outputs = BuildOutputSerializer(
many=True,
required=True,
)
location = serializers.PrimaryKeyRelatedField(
queryset=StockLocation.objects.all(),
required=True,
many=False,
label=_("Location"),
help_text=_("Location for completed build outputs"),
)
status = serializers.ChoiceField(
choices=list(StockStatus.items()),
default=StockStatus.OK,
label=_("Status"),
)
notes = serializers.CharField(
label=_("Notes"),
required=False,
allow_blank=True,
)
def validate(self, data):
super().validate(data)
outputs = data.get('outputs', [])
if len(outputs) == 0:
raise ValidationError(_("A list of build outputs must be provided"))
return data
def save(self):
"""
"save" the serializer to complete the build outputs
"""
build = self.context['build']
request = self.context['request']
data = self.validated_data
outputs = data.get('outputs', [])
# Mark the specified build outputs as "complete"
with transaction.atomic():
for item in outputs:
output = item['output']
build.complete_build_output(
output,
request.user,
status=data['status'],
notes=data.get('notes', '')
)
class BuildUnallocationSerializer(serializers.Serializer): class BuildUnallocationSerializer(serializers.Serializer):
""" """
DRF serializer for unallocating stock from a BuildOrder DRF serializer for unallocating stock from a BuildOrder
@ -190,6 +309,8 @@ class BuildAllocationItemSerializer(serializers.Serializer):
def validate_bom_item(self, bom_item): def validate_bom_item(self, bom_item):
# TODO: Fix this validation - allow for variants and substitutes!
build = self.context['build'] build = self.context['build']
# BomItem must point to the same 'part' as the parent build # BomItem must point to the same 'part' as the parent build

View File

@ -1,51 +0,0 @@
{% load i18n %}
{% load inventree_extras %}
{% define item.pk as pk %}
<div class="panel panel-default" id='allocation-panel-{{ pk }}'>
<div class="panel-heading" role="tab" id="heading-{{ pk }}">
<div class="panel-title">
<div class='row'>
{% if tracked_items %}
<a class='collapsed' aria-expanded='false' role="button" data-toggle="collapse" data-parent="#build-output-accordion" href="#collapse-{{ pk }}" aria-controls="collapse-{{ pk }}">
{% endif %}
<div class='col-sm-4'>
{% if tracked_items %}
<span class='fas fa-caret-right'></span>
{% endif %}
{{ item.part.full_name }}
</div>
<div class='col-sm-2'>
{% if item.serial %}
{% trans "Serial Number" %}: {{ item.serial }}
{% else %}
{% trans "Quantity" %}: {% decimal item.quantity %}
{% endif %}
</div>
{% if tracked_items %}
</a>
{% endif %}
<div class='col-sm-3'>
<div>
<div id='output-progress-{{ pk }}'>
{% if tracked_items %}
<span class='fas fa-spin fa-spinner'></span>
{% endif %}
</div>
</div>
</div>
<div class='col-sm-3'>
<div class='btn-group float-right' id='output-actions-{{ pk }}'>
<span class='fas fa-spin fa-spinner'></span>
</div>
</div>
</div>
</div>
</div>
<div id="collapse-{{ pk }}" class="panel-collapse collapse" role="tabpanel" aria-labelledby="heading-{{ pk }}">
<div class="panel-body">
<table class='table table-striped table-condensed' id='allocation-table-{{ pk }}'></table>
</div>
</div>
</div>

View File

@ -91,16 +91,11 @@ src="{% static 'img/blank_image.png' %}"
<span class='fas fa-print'></span> <span class='caret'></span> <span class='fas fa-print'></span> <span class='caret'></span>
</button> </button>
<ul class='dropdown-menu' role='menu'> <ul class='dropdown-menu' role='menu'>
<li><a href='#' id='print-build-report'><span class='fas fa-file-pdf'></span> {% trans "Print Build Order" %}</a></li> <li><a href='#' id='print-build-report'><span class='fas fa-file-pdf'></span> {% trans "Print build order report" %}</a></li>
</ul> </ul>
</div> </div>
<!-- Build actions --> <!-- Build actions -->
{% if roles.build.change %} {% if roles.build.change %}
{% if build.active %}
<button id='build-complete' title='{% trans "Complete Build" %}' class='btn btn-success'>
<span class='fas fa-paper-plane'></span>
</button>
{% endif %}
<div class='btn-group'> <div class='btn-group'>
<button id='build-options' title='{% trans "Build actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'> <button id='build-options' title='{% trans "Build actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'>
<span class='fas fa-tools'></span> <span class='caret'></span> <span class='fas fa-tools'></span> <span class='caret'></span>
@ -115,6 +110,11 @@ src="{% static 'img/blank_image.png' %}"
{% endif %} {% endif %}
</ul> </ul>
</div> </div>
{% if build.active %}
<button id='build-complete' title='{% trans "Complete Build" %}' class='btn btn-success'>
<span class='fas fa-check-circle'></span>
</button>
{% endif %}
{% endif %} {% endif %}
</div> </div>
{% endblock %} {% endblock %}
@ -153,8 +153,8 @@ src="{% static 'img/blank_image.png' %}"
</tr> </tr>
{% endif %} {% endif %}
<tr> <tr>
<td><span class='fas fa-spinner'></span></td> <td><span class='fas fa-check-circle'></span></td>
<td>{% trans "Progress" %}</td> <td>{% trans "Completed" %}</td>
<td> {{ build.completed }} / {{ build.quantity }}</td> <td> {{ build.completed }} / {{ build.quantity }}</td>
</tr> </tr>
{% if build.parent %} {% if build.parent %}

View File

@ -1,53 +0,0 @@
{% extends "modal_form.html" %}
{% load inventree_extras %}
{% load i18n %}
{% block pre_form_content %}
{% if not build.has_tracked_bom_items %}
{% elif fully_allocated %}
<div class='alert alert-block alert-success'>
{% trans "Stock allocation is complete for this output" %}
</div>
{% else %}
<div class='alert alert-block alert-danger'>
<h4>{% trans "Stock allocation is incomplete" %}</h4>
<div class='panel-group'>
<div class='panel panel-default'>
<div class='panel panel-heading'>
<a data-toggle='collapse' href='#collapse-unallocated'>
{{ unallocated_parts|length }} {% trans "tracked parts have not been fully allocated" %}
</a>
</div>
<div class='panel-collapse collapse' id='collapse-unallocated'>
<div class='panel-body'>
<ul class='list-group'>
{% for part in unallocated_parts %}
<li class='list-group-item'>
{% include "hover_image.html" with image=part.image %} {{ part }}
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
</div>
</div>
{% endif %}
<div class='panel panel-info'>
<div class='panel-heading'>
{% trans "The following items will be created" %}
</div>
<div class='panel-content' style='padding-bottom:16px'>
{% include "hover_image.html" with image=build.part.image %}
{% if output.serialized %}
{{ output.part.full_name }} - {% trans "Serial Number" %} {{ output.serial }}
{% else %}
{% decimal output.quantity %} x {{ output.part.full_name }}
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -63,10 +63,17 @@
<td>{% build_status_label build.status %}</td> <td>{% build_status_label build.status %}</td>
</tr> </tr>
<tr> <tr>
<td><span class='fas fa-spinner'></span></td> <td><span class='fas fa-check-circle'></span></td>
<td>{% trans "Progress" %}</td> <td>{% trans "Completed" %}</td>
<td>{{ build.completed }} / {{ build.quantity }}</td> <td>{{ build.completed }} / {{ build.quantity }}</td>
</tr> </tr>
{% if build.active and build.has_untracked_bom_items %}
<tr>
<td><span class='fas fa-list'></span></td>
<td>{% trans "Allocated Parts" %}</td>
<td id='output-progress-untracked'><span class='fas fa-spinner fa-spin'></span></td>
</tr>
{% endif %}
{% if build.batch %} {% if build.batch %}
<tr> <tr>
<td><span class='fas fa-layer-group'></span></td> <td><span class='fas fa-layer-group'></span></td>
@ -213,35 +220,35 @@
</div> </div>
<div class='panel panel-default panel-inventree panel-hidden' id='panel-outputs'> <div class='panel panel-default panel-inventree panel-hidden' id='panel-outputs'>
{% if not build.is_complete %}
<div class='panel-heading'> <div class='panel-heading'>
<h4>{% trans "Incomplete Build Outputs" %}</h4> <h4>{% trans "Incomplete Build Outputs" %}</h4>
</div> </div>
<div class='panel-content'> <div class='panel-content'>
<div class='btn-group' role='group'> <div id='build-output-toolbar'>
{% if build.active %} <div class='button-toolbar container-fluid'>
<button class='btn btn-primary' type='button' id='btn-create-output' title='{% trans "Create new build output" %}'> {% if build.active %}
<span class='fas fa-plus-circle'></span> {% trans "Create New Output" %} <div class='btn-group'>
</button> <button class='btn btn-success' type='button' id='btn-create-output' title='{% trans "Create new build output" %}'>
{% endif %} <span class='fas fa-plus-circle'></span>
</button>
<!-- Build output actions -->
<div class='btn-group'>
<button id='output-options' class='btn btn-primary dropdown-toiggle' type='button' data-toggle='dropdown' title='{% trans "Output Actions" %}'>
<span class='fas fa-tools'></span> <span class='caret'></span>
</button>
<ul class='dropdown-menu'>
<li><a href='#' id='multi-output-complete' title='{% trans "Complete selected items" %}'><span class='fas fa-check-circle icon-green'></span> {% trans "Complete outputs" %}</a></li>
</ul>
</div>
</div>
{% endif %}
</div>
</div> </div>
<table class='table table-striped table-condensed' id='build-output-table' data-toolbar='#build-output-toolbar'></table>
{% if build.incomplete_outputs %}
<div class="panel-group" id="build-output-accordion" role="tablist" aria-multiselectable="true">
{% for item in build.incomplete_outputs %}
{% include "build/allocation_card.html" with item=item tracked_items=build.has_tracked_bom_items %}
{% endfor %}
</div>
{% else %}
<div class='alert alert-block alert-info'>
<strong>{% trans "Create a new build output" %}</strong><br>
{% trans "No incomplete build outputs remain." %}<br>
{% trans "Create a new build output using the button above" %}
</div>
{% endif %}
</div> </div>
{% endif %} </div>
<div class='panel panel-default panel-inventree panel-hidden' id='panel-completed'>
<div class='panel-heading'> <div class='panel-heading'>
<h4> <h4>
{% trans "Completed Build Outputs" %} {% trans "Completed Build Outputs" %}
@ -313,26 +320,75 @@ loadStockTable($("#build-stock-table"), {
url: "{% url 'api-stock-list' %}", url: "{% url 'api-stock-list' %}",
}); });
var buildInfo = {
pk: {{ build.pk }},
quantity: {{ build.quantity }},
completed: {{ build.completed }},
part: {{ build.part.pk }},
{% if build.take_from %}
source_location: {{ build.take_from.pk }},
{% endif %}
};
{% for item in build.incomplete_outputs %} // Get the list of BOM items required for this build
// Get the build output as a javascript object inventreeGet(
inventreeGet('{% url 'api-stock-detail' item.pk %}', {}, '{% url "api-bom-list" %}',
{
part: {{ build.part.pk }},
sub_part_detail: true,
},
{ {
success: function(response) { success: function(response) {
loadBuildOutputAllocationTable(buildInfo, response);
var build_info = {
pk: {{ build.pk }},
part: {{ build.part.pk }},
quantity: {{ build.quantity }},
bom_items: response,
{% if build.take_from %}
source_location: {{ build.take_from.pk }},
{% endif %}
{% if build.has_tracked_bom_items %}
tracked_parts: true,
{% else %}
tracked_parts: false,
{% endif %}
};
{% if build.active %}
loadBuildOutputTable(build_info);
linkButtonsToSelection(
'#build-output-table',
[
'#output-options',
'#multi-output-complete',
]
);
$('#multi-output-complete').click(function() {
var outputs = $('#build-output-table').bootstrapTable('getSelections');
completeBuildOutputs(
build_info.pk,
outputs,
{
success: function() {
// Reload the "in progress" table
$('#build-output-table').bootstrapTable('refresh');
// Reload the "completed" table
$('#build-stock-table').bootstrapTable('refresh');
}
}
);
});
{% endif %}
{% if build.active and build.has_untracked_bom_items %}
// Load allocation table for un-tracked parts
loadBuildOutputAllocationTable(
build_info,
null,
{
search: true,
}
);
{% endif %}
} }
} }
); );
{% endfor %}
loadBuildTable($('#sub-build-table'), { loadBuildTable($('#sub-build-table'), {
url: '{% url "api-build-list" %}', url: '{% url "api-build-list" %}',
@ -342,6 +398,7 @@ loadBuildTable($('#sub-build-table'), {
} }
}); });
enableDragAndDrop( enableDragAndDrop(
'#attachment-dropzone', '#attachment-dropzone',
'{% url "api-build-attachment-list" %}', '{% url "api-build-attachment-list" %}',
@ -416,11 +473,6 @@ $('#edit-notes').click(function() {
}); });
}); });
{% if build.has_untracked_bom_items %}
// Load allocation table for un-tracked parts
loadBuildOutputAllocationTable(buildInfo, null);
{% endif %}
function reloadTable() { function reloadTable() {
$('#allocation-table-untracked').bootstrapTable('refresh'); $('#allocation-table-untracked').bootstrapTable('refresh');
} }
@ -471,6 +523,10 @@ $('#allocate-selected-items').click(function() {
var bom_items = $("#allocation-table-untracked").bootstrapTable("getSelections"); var bom_items = $("#allocation-table-untracked").bootstrapTable("getSelections");
if (bom_items.length == 0) {
bom_items = $("#allocation-table-untracked").bootstrapTable('getData');
}
allocateStockToBuild( allocateStockToBuild(
{{ build.pk }}, {{ build.pk }},
{{ build.part.pk }}, {{ build.part.pk }},

View File

@ -1,10 +0,0 @@
{% extends "modal_form.html" %}
{% load i18n %}
{% block pre_form_content %}
<div class='alert alert-block alert-info'>
<p>
{% trans "Alter the quantity of stock allocated to the build output" %}
</p>
</div>
{% endblock %}

View File

@ -19,16 +19,25 @@
{% if build.active %} {% if build.active %}
<li class='list-group-item' title='{% trans "Allocate Stock" %}'> <li class='list-group-item' title='{% trans "Allocate Stock" %}'>
<a href='#' id='select-allocate' class='nav-toggle'> <a href='#' id='select-allocate' class='nav-toggle'>
<span class='fas fa-tools sidebar-icon'></span> <span class='fas fa-tasks sidebar-icon'></span>
{% trans "Allocate Stock" %} {% trans "Allocate Stock" %}
</a> </a>
</li> </li>
{% endif %} {% endif %}
<li class='list-group-item' title='{% trans "Build Outputs" %}'> {% if not build.is_complete %}
<li class='list-group-item' title='{% trans "Pending Outputs" %}'>
<a href='#' id='select-outputs' class='nav-toggle'> <a href='#' id='select-outputs' class='nav-toggle'>
<span class='fas fa-box sidebar-icon'></span> <span class='fas fa-tools sidebar-icon'></span>
{% trans "Build Outputs" %} {% trans "Pending Outputs" %}
</a>
</li>
{% endif %}
<li class='list-group-item' title='{% trans "Completed Outputs" %}'>
<a href='#' id='select-completed' class='nav-toggle'>
<span class='fas fa-boxes sidebar-icon'></span>
{% trans "Completed Outputs" %}
</a> </a>
</li> </li>

View File

@ -7,6 +7,7 @@ from django.urls import reverse
from part.models import Part from part.models import Part
from build.models import Build, BuildItem from build.models import Build, BuildItem
from stock.models import StockItem
from InvenTree.status_codes import BuildStatus from InvenTree.status_codes import BuildStatus
from InvenTree.api_tester import InvenTreeAPITestCase from InvenTree.api_tester import InvenTreeAPITestCase
@ -37,6 +38,148 @@ class BuildAPITest(InvenTreeAPITestCase):
super().setUp() super().setUp()
class BuildCompleteTest(BuildAPITest):
"""
Unit testing for the build complete API endpoint
"""
def setUp(self):
super().setUp()
self.build = Build.objects.get(pk=1)
self.url = reverse('api-build-complete', kwargs={'pk': self.build.pk})
def test_invalid(self):
"""
Test with invalid data
"""
# Test with an invalid build ID
self.post(
reverse('api-build-complete', kwargs={'pk': 99999}),
{},
expected_code=400
)
data = self.post(self.url, {}, expected_code=400).data
self.assertIn("This field is required", str(data['outputs']))
self.assertIn("This field is required", str(data['location']))
# Test with an invalid location
data = self.post(
self.url,
{
"outputs": [],
"location": 999999,
},
expected_code=400
).data
self.assertIn(
"Invalid pk",
str(data["location"])
)
data = self.post(
self.url,
{
"outputs": [],
"location": 1,
},
expected_code=400
).data
self.assertIn("A list of build outputs must be provided", str(data))
stock_item = StockItem.objects.create(
part=self.build.part,
quantity=100,
)
post_data = {
"outputs": [
{
"output": stock_item.pk,
},
],
"location": 1,
}
# Post with a stock item that does not match the build
data = self.post(
self.url,
post_data,
expected_code=400
).data
self.assertIn(
"Build output does not match the parent build",
str(data["outputs"][0])
)
# Now, ensure that the stock item *does* match the build
stock_item.build = self.build
stock_item.save()
data = self.post(
self.url,
post_data,
expected_code=400,
).data
self.assertIn(
"This build output has already been completed",
str(data["outputs"][0]["output"])
)
def test_complete(self):
"""
Test build order completion
"""
# We start without any outputs assigned against the build
self.assertEqual(self.build.incomplete_outputs.count(), 0)
# Create some more build outputs
for ii in range(10):
self.build.create_build_output(10)
# Check that we are in a known state
self.assertEqual(self.build.incomplete_outputs.count(), 10)
self.assertEqual(self.build.incomplete_count, 100)
self.assertEqual(self.build.completed, 0)
# We shall complete 4 of these outputs
outputs = self.build.incomplete_outputs[0:4]
self.post(
self.url,
{
"outputs": [{"output": output.pk} for output in outputs],
"location": 1,
"status": 50, # Item requires attention
},
expected_code=201
)
# There now should be 6 incomplete build outputs remaining
self.assertEqual(self.build.incomplete_outputs.count(), 6)
# And there should be 4 completed outputs
outputs = self.build.complete_outputs
self.assertEqual(outputs.count(), 4)
for output in outputs:
self.assertFalse(output.is_building)
self.assertEqual(output.build, self.build)
self.build.refresh_from_db()
self.assertEqual(self.build.completed, 40)
class BuildAllocationTest(BuildAPITest): class BuildAllocationTest(BuildAPITest):
""" """
Unit tests for allocation of stock items against a build order. Unit tests for allocation of stock items against a build order.

View File

@ -339,11 +339,11 @@ class BuildTest(TestCase):
self.assertTrue(self.build.isFullyAllocated(self.output_1)) self.assertTrue(self.build.isFullyAllocated(self.output_1))
self.assertTrue(self.build.isFullyAllocated(self.output_2)) self.assertTrue(self.build.isFullyAllocated(self.output_2))
self.build.completeBuildOutput(self.output_1, None) self.build.complete_build_output(self.output_1, None)
self.assertFalse(self.build.can_complete) self.assertFalse(self.build.can_complete)
self.build.completeBuildOutput(self.output_2, None) self.build.complete_build_output(self.output_2, None)
self.assertTrue(self.build.can_complete) self.assertTrue(self.build.can_complete)

View File

@ -15,7 +15,7 @@ from datetime import datetime, timedelta
from .models import Build from .models import Build
from stock.models import StockItem from stock.models import StockItem
from InvenTree.status_codes import BuildStatus, StockStatus from InvenTree.status_codes import BuildStatus
class BuildTestSimple(TestCase): class BuildTestSimple(TestCase):
@ -252,53 +252,6 @@ class TestBuildViews(TestCase):
self.assertIn(build.title, content) self.assertIn(build.title, content)
def test_build_output_complete(self):
"""
Test the build output completion form
"""
# Firstly, check that the build cannot be completed!
self.assertFalse(self.build.can_complete)
url = reverse('build-output-complete', args=(1,))
# Test without confirmation
response = self.client.post(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
data = json.loads(response.content)
self.assertFalse(data['form_valid'])
# Test with confirmation, valid location
response = self.client.post(
url,
{
'confirm': 1,
'confirm_incomplete': 1,
'location': 1,
'output': self.output.pk,
'stock_status': StockStatus.DAMAGED
},
HTTP_X_REQUESTED_WITH='XMLHttpRequest'
)
self.assertEqual(response.status_code, 200)
data = json.loads(response.content)
self.assertTrue(data['form_valid'])
# Now the build should be able to be completed
self.build.refresh_from_db()
self.assertTrue(self.build.can_complete)
# Test with confirmation, invalid location
response = self.client.post(url, {'confirm': 1, 'location': 9999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
data = json.loads(response.content)
self.assertFalse(data['form_valid'])
def test_build_cancel(self): def test_build_cancel(self):
""" Test the build cancellation form """ """ Test the build cancellation form """

View File

@ -11,7 +11,6 @@ build_detail_urls = [
url(r'^delete/', views.BuildDelete.as_view(), name='build-delete'), url(r'^delete/', views.BuildDelete.as_view(), name='build-delete'),
url(r'^create-output/', views.BuildOutputCreate.as_view(), name='build-output-create'), url(r'^create-output/', views.BuildOutputCreate.as_view(), name='build-output-create'),
url(r'^delete-output/', views.BuildOutputDelete.as_view(), name='build-output-delete'), url(r'^delete-output/', views.BuildOutputDelete.as_view(), name='build-output-delete'),
url(r'^complete-output/', views.BuildOutputComplete.as_view(), name='build-output-complete'),
url(r'^complete/', views.BuildComplete.as_view(), name='build-complete'), url(r'^complete/', views.BuildComplete.as_view(), name='build-complete'),
url(r'^.*$', views.BuildDetail.as_view(), name='build-detail'), url(r'^.*$', views.BuildDetail.as_view(), name='build-detail'),

View File

@ -12,16 +12,17 @@ from django.forms import HiddenInput
from .models import Build from .models import Build
from . import forms from . import forms
from stock.models import StockLocation, StockItem from stock.models import StockItem
from InvenTree.views import AjaxUpdateView, AjaxDeleteView from InvenTree.views import AjaxUpdateView, AjaxDeleteView
from InvenTree.views import InvenTreeRoleMixin from InvenTree.views import InvenTreeRoleMixin
from InvenTree.helpers import str2bool, extract_serial_numbers from InvenTree.helpers import str2bool, extract_serial_numbers
from InvenTree.status_codes import BuildStatus, StockStatus from InvenTree.status_codes import BuildStatus
class BuildIndex(InvenTreeRoleMixin, ListView): class BuildIndex(InvenTreeRoleMixin, ListView):
""" View for displaying list of Builds """
View for displaying list of Builds
""" """
model = Build model = Build
template_name = 'build/index.html' template_name = 'build/index.html'
@ -278,178 +279,10 @@ class BuildComplete(AjaxUpdateView):
} }
class BuildOutputComplete(AjaxUpdateView):
"""
View to mark a particular build output as Complete.
- Notifies the user of which parts will be removed from stock.
- Assignes (tracked) allocated items from stock to the build output
- Deletes pending BuildItem objects
"""
model = Build
form_class = forms.CompleteBuildOutputForm
context_object_name = "build"
ajax_form_title = _("Complete Build Output")
ajax_template_name = "build/complete_output.html"
def get_form(self):
build = self.get_object()
form = super().get_form()
# Extract the build output object
output = None
output_id = form['output'].value()
try:
output = StockItem.objects.get(pk=output_id)
except (ValueError, StockItem.DoesNotExist):
pass
if output:
if build.isFullyAllocated(output):
form.fields['confirm_incomplete'].widget = HiddenInput()
return form
def validate(self, build, form, **kwargs):
"""
Custom validation steps for the BuildOutputComplete" form
"""
data = form.cleaned_data
output = data.get('output', None)
stock_status = data.get('stock_status', StockStatus.OK)
# Any "invalid" stock status defaults to OK
try:
stock_status = int(stock_status)
except (ValueError):
stock_status = StockStatus.OK
if int(stock_status) not in StockStatus.keys():
form.add_error('stock_status', _('Invalid stock status value selected'))
if output:
quantity = data.get('quantity', None)
if quantity and quantity > output.quantity:
form.add_error('quantity', _('Quantity to complete cannot exceed build output quantity'))
if not build.isFullyAllocated(output):
confirm = str2bool(data.get('confirm_incomplete', False))
if not confirm:
form.add_error('confirm_incomplete', _('Confirm completion of incomplete build'))
else:
form.add_error(None, _('Build output must be specified'))
def get_initial(self):
""" Get initial form data for the CompleteBuild form
- If the part being built has a default location, pre-select that location
"""
initials = super().get_initial()
build = self.get_object()
if build.part.default_location is not None:
try:
location = StockLocation.objects.get(pk=build.part.default_location.id)
initials['location'] = location
except StockLocation.DoesNotExist:
pass
output = self.get_param('output', None)
if output:
try:
output = StockItem.objects.get(pk=output)
except (ValueError, StockItem.DoesNotExist):
output = None
# Output has not been supplied? Try to "guess"
if not output:
incomplete = build.get_build_outputs(complete=False)
if incomplete.count() == 1:
output = incomplete[0]
if output is not None:
initials['output'] = output
initials['location'] = build.destination
return initials
def get_context_data(self, **kwargs):
"""
Get context data for passing to the rendered form
- Build information is required
"""
build = self.get_object()
context = {}
# Build object
context['build'] = build
form = self.get_form()
output = form['output'].value()
if output:
try:
output = StockItem.objects.get(pk=output)
context['output'] = output
context['fully_allocated'] = build.isFullyAllocated(output)
context['allocated_parts'] = build.allocatedParts(output)
context['unallocated_parts'] = build.unallocatedParts(output)
except (ValueError, StockItem.DoesNotExist):
pass
return context
def save(self, build, form, **kwargs):
data = form.cleaned_data
location = data.get('location', None)
output = data.get('output', None)
stock_status = data.get('stock_status', StockStatus.OK)
# Any "invalid" stock status defaults to OK
try:
stock_status = int(stock_status)
except (ValueError):
stock_status = StockStatus.OK
# Complete the build output
build.completeBuildOutput(
output,
self.request.user,
location=location,
status=stock_status,
)
def get_data(self):
""" Provide feedback data back to the form """
return {
'success': _('Build output completed')
}
class BuildDetail(InvenTreeRoleMixin, DetailView): class BuildDetail(InvenTreeRoleMixin, DetailView):
""" Detail view of a single Build object. """ """
Detail view of a single Build object.
"""
model = Build model = Build
template_name = 'build/detail.html' template_name = 'build/detail.html'
@ -477,7 +310,9 @@ class BuildDetail(InvenTreeRoleMixin, DetailView):
class BuildDelete(AjaxDeleteView): class BuildDelete(AjaxDeleteView):
""" View to delete a build """ """
View to delete a build
"""
model = Build model = Build
ajax_template_name = 'build/delete_build.html' ajax_template_name = 'build/delete_build.html'

View File

@ -998,6 +998,13 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
'validator': [int, MinValueValidator(1)] 'validator': [int, MinValueValidator(1)]
}, },
'SEARCH_SHOW_STOCK_LEVELS': {
'name': _('Search Show Stock'),
'description': _('Display stock levels in search preview window'),
'default': True,
'validator': bool,
},
'PART_SHOW_QUANTITY_IN_FORMS': { 'PART_SHOW_QUANTITY_IN_FORMS': {
'name': _('Show Quantity in Forms'), 'name': _('Show Quantity in Forms'),
'description': _('Display available part quantity in some forms'), 'description': _('Display available part quantity in some forms'),

View File

@ -1,7 +0,0 @@
{% extends "modal_delete_form.html" %}
{% block pre_form_content %}
Are you sure you wish to delete this currency?
{% endblock %}

View File

@ -5,7 +5,6 @@ JSON API for the Order app
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
from django.utils.translation import ugettext_lazy as _
from django.conf.urls import url, include from django.conf.urls import url, include
from django.db.models import Q, F from django.db.models import Q, F
@ -13,7 +12,6 @@ from django_filters import rest_framework as rest_filters
from rest_framework import generics from rest_framework import generics
from rest_framework import filters, status from rest_framework import filters, status
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.serializers import ValidationError
from InvenTree.filters import InvenTreeOrderingFilter from InvenTree.filters import InvenTreeOrderingFilter
@ -236,25 +234,15 @@ class POReceive(generics.CreateAPIView):
context = super().get_serializer_context() context = super().get_serializer_context()
# Pass the purchase order through to the serializer for validation # Pass the purchase order through to the serializer for validation
context['order'] = self.get_order() try:
context['order'] = PurchaseOrder.objects.get(pk=self.kwargs.get('pk', None))
except:
pass
context['request'] = self.request context['request'] = self.request
return context return context
def get_order(self):
"""
Returns the PurchaseOrder associated with this API endpoint
"""
pk = self.kwargs.get('pk', None)
try:
order = PurchaseOrder.objects.get(pk=pk)
except (PurchaseOrder.DoesNotExist, ValueError):
raise ValidationError(_("Matching purchase order does not exist"))
return order
class POLineItemFilter(rest_filters.FilterSet): class POLineItemFilter(rest_filters.FilterSet):
""" """

View File

@ -9,7 +9,7 @@ from django import forms
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from InvenTree.forms import HelperForm from InvenTree.forms import HelperForm
from InvenTree.fields import InvenTreeMoneyField, RoundingDecimalFormField from InvenTree.fields import InvenTreeMoneyField
from InvenTree.helpers import clean_decimal from InvenTree.helpers import clean_decimal
@ -19,7 +19,6 @@ import part.models
from .models import PurchaseOrder from .models import PurchaseOrder
from .models import SalesOrder, SalesOrderLineItem from .models import SalesOrder, SalesOrderLineItem
from .models import SalesOrderAllocation
class IssuePurchaseOrderForm(HelperForm): class IssuePurchaseOrderForm(HelperForm):
@ -81,6 +80,8 @@ class AllocateSerialsToSalesOrderForm(forms.Form):
""" """
Form for assigning stock to a sales order, Form for assigning stock to a sales order,
by serial number lookup by serial number lookup
TODO: Refactor this form / view to use the new API forms interface
""" """
line = forms.ModelChoiceField( line = forms.ModelChoiceField(
@ -115,22 +116,6 @@ class AllocateSerialsToSalesOrderForm(forms.Form):
] ]
class EditSalesOrderAllocationForm(HelperForm):
"""
Form for editing a SalesOrderAllocation item
"""
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, label=_('Quantity'))
class Meta:
model = SalesOrderAllocation
fields = [
'line',
'item',
'quantity']
class OrderMatchItemForm(MatchItemForm): class OrderMatchItemForm(MatchItemForm):
""" Override MatchItemForm fields """ """ Override MatchItemForm fields """

View File

@ -36,31 +36,39 @@ src="{% static 'img/blank_image.png' %}"
<p>{{ order.description }}{% include "clip.html"%}</p> <p>{{ order.description }}{% include "clip.html"%}</p>
<div class='btn-row'> <div class='btn-row'>
<div class='btn-group action-buttons' role='group'> <div class='btn-group action-buttons' role='group'>
<button type='button' class='btn btn-default' id='print-order-report' title='{% trans "Print" %}'> <!-- Printing options -->
<span class='fas fa-print'></span> <div class='btn-group'>
</button> <button id='print-options' title='{% trans "Print actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'>
<button type='button' class='btn btn-default' id='export-order' title='{% trans "Export order to file" %}'> <span class='fas fa-print'></span> <span class='caret'></span>
<span class='fas fa-file-download'></span> </button>
</button> <ul class='dropdown-menu' role='menu'>
<li><a href='#' id='print-order-report'><span class='fas fa-file-pdf'></span> {% trans "Print purchase order report" %}</a></li>
<li><a href='#' id='export-order'><span class='fas fa-file-download'></span> {% trans "Export order to file" %}</a></li>
</ul>
</div>
{% if roles.purchase_order.change %} {% if roles.purchase_order.change %}
<button type='button' class='btn btn-default' id='edit-order' title='{% trans "Edit order information" %}'> <!-- order actions -->
<span class='fas fa-edit icon-green'></span> <div class='btn-group'>
</button> <button id='order-options' title='{% trans "Order actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'>
<span class='fas fa-tools'></span> <span class='caret'></span>
</button>
<ul class='dropdown-menu' role='menu'>
<li><a href='#' id='edit-order'><span class='fas fa-edit icon-green'></span> {% trans "Edit order" %}</a></li>
{% if order.can_cancel %}
<li><a href='#' id='cancel-order'><span class='fas fa-times-circle icon-red'></span> {% trans "Cancel order" %}</a></li>
{% endif %}
</ul>
</div>
{% if order.status == PurchaseOrderStatus.PENDING and order.lines.count > 0 %} {% if order.status == PurchaseOrderStatus.PENDING and order.lines.count > 0 %}
<button type='button' class='btn btn-default' id='place-order' title='{% trans "Place order" %}'> <button type='button' class='btn btn-default' id='place-order' title='{% trans "Place order" %}'>
<span class='fas fa-paper-plane icon-blue'></span> <span class='fas fa-shopping-cart icon-blue'></span>
</button> </button>
{% elif order.status == PurchaseOrderStatus.PLACED %} {% elif order.status == PurchaseOrderStatus.PLACED %}
<button type='button' class='btn btn-default' id='receive-order' title='{% trans "Receive items" %}'> <button type='button' class='btn btn-default' id='receive-order' title='{% trans "Receive items" %}'>
<span class='fas fa-sign-in-alt'></span> <span class='fas fa-sign-in-alt icon-blue'></span>
</button> </button>
<button type='button' class='btn btn-default' id='complete-order' title='{% trans "Mark order as complete" %}'> <button type='button' class='btn btn-default' id='complete-order' title='{% trans "Mark order as complete" %}'>
<span class='fas fa-check-circle'></span> <span class='fas fa-check-circle icon-green'></span>
</button>
{% endif %}
{% if order.can_cancel %}
<button type='button' class='btn btn-default' id='cancel-order' title='{% trans "Cancel order" %}'>
<span class='fas fa-times-circle icon-red'></span>
</button> </button>
{% endif %} {% endif %}
{% endif %} {% endif %}

View File

@ -47,30 +47,39 @@ src="{% static 'img/blank_image.png' %}"
<p>{{ order.description }}{% include "clip.html"%}</p> <p>{{ order.description }}{% include "clip.html"%}</p>
<div class='btn-row'> <div class='btn-row'>
<div class='btn-group action-buttons'> <div class='btn-group action-buttons'>
<button type='button' class='btn btn-default' id='print-order-report' title='{% trans "Print" %}'> <!-- Printing actions -->
<span class='fas fa-print'></span> <div class='btn-group'>
</button> <button id='print-options' title='{% trans "Print actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'>
<button type='button' class='btn btn-default' id='export-order' title='{% trans "Export order to file" %}'> <span class='fas fa-print'></span> <span class='caret'></span>
<span class='fas fa-file-download'></span> </button>
</button> <ul class='dropdown-menu' role='menu'>
<li><a href='#' id='print-order-report'><span class='fas fa-file-pdf'></span> {% trans "Print sales order report" %}</a></li>
<li><a href='#' id='export-order'><span class='fas fa-file-download'></span> {% trans "Export order to file" %}</a></li>
<!--
<li><a href='#' id='print-packing-list'><span class='fas fa-clipboard-list'></span>{% trans "Print packing list" %}</a></li>
-->
</ul>
</div>
{% if roles.sales_order.change %} {% if roles.sales_order.change %}
<button type='button' class='btn btn-default' id='edit-order' title='{% trans "Edit order information" %}'> <!-- Order actions -->
<span class='fas fa-edit icon-green'></span> <div class='btn-group'>
</button> <button id='order-options' title='{% trans "Order actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'>
<span class='fas fa-tools'></span> <span class='caret'></span>
</button>
<ul class='dropdown-menu' role='menu'>
<li><a href='#' id='edit-order'><span class='fas fa-edit icon-green'></span> {% trans "Edit order" %}</a></li>
{% if order.status == SalesOrderStatus.PENDING %}
<li><a href='#' id='cancel-order'><span class='fas fa-times-circle icon-red'></span> {% trans "Cancel order" %}</a></li>
{% endif %}
</ul>
</div>
{% if order.status == SalesOrderStatus.PENDING %} {% if order.status == SalesOrderStatus.PENDING %}
<button type='button' class='btn btn-default' id='ship-order' title='{% trans "Ship order" %}'> <button type='button' class='btn btn-default' id='ship-order' title='{% trans "Ship order" %}'>
<span class='fas fa-paper-plane icon-blue'></span> <span class='fas fa-truck icon-blue'></span>
</button>
<button type='button' class='btn btn-default' id='cancel-order' title='{% trans "Cancel order" %}'>
<span class='fas fa-times-circle icon-red'></span>
</button> </button>
{% endif %} {% endif %}
{% endif %} {% endif %}
<!--
<button type='button' disabled='' class='btn btn-default' id='packing-list' title='{% trans "Packing List" %}'>
<span class='fas fa-clipboard-list'></span>
</button>
-->
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -203,6 +203,23 @@ class PurchaseOrderTest(OrderTest):
# And if we try to access the detail view again, it has gone # And if we try to access the detail view again, it has gone
response = self.get(url, expected_code=404) response = self.get(url, expected_code=404)
def test_po_create(self):
"""
Test that we can create a new PurchaseOrder via the API
"""
self.assignRole('purchase_order.add')
self.post(
reverse('api-po-list'),
{
'reference': '12345678',
'supplier': 1,
'description': 'A test purchase order',
},
expected_code=201
)
class PurchaseOrderReceiveTest(OrderTest): class PurchaseOrderReceiveTest(OrderTest):
""" """
@ -607,3 +624,20 @@ class SalesOrderTest(OrderTest):
# And the resource should no longer be available # And the resource should no longer be available
response = self.get(url, expected_code=404) response = self.get(url, expected_code=404)
def test_so_create(self):
"""
Test that we can create a new SalesOrder via the API
"""
self.assignRole('sales_order.add')
self.post(
reverse('api-so-list'),
{
'reference': '1234566778',
'customer': 4,
'description': 'A test sales order',
},
expected_code=201
)

View File

@ -18,7 +18,7 @@ import common.models
from common.forms import MatchItemForm from common.forms import MatchItemForm
from .models import Part, PartCategory, PartRelated from .models import Part, PartCategory, PartRelated
from .models import PartParameterTemplate, PartParameter from .models import PartParameterTemplate
from .models import PartCategoryParameterTemplate from .models import PartCategoryParameterTemplate
from .models import PartSellPriceBreak, PartInternalPriceBreak from .models import PartSellPriceBreak, PartInternalPriceBreak
@ -188,18 +188,6 @@ class EditPartParameterTemplateForm(HelperForm):
] ]
class EditPartParameterForm(HelperForm):
""" Form for editing a PartParameter object """
class Meta:
model = PartParameter
fields = [
'part',
'template',
'data'
]
class EditCategoryForm(HelperForm): class EditCategoryForm(HelperForm):
""" Form for editing a PartCategory object """ """ Form for editing a PartCategory object """

View File

@ -16,8 +16,6 @@ from InvenTree.forms import HelperForm
from InvenTree.fields import RoundingDecimalFormField from InvenTree.fields import RoundingDecimalFormField
from InvenTree.fields import DatePickerFormField from InvenTree.fields import DatePickerFormField
from report.models import TestReport
from part.models import Part from part.models import Part
from .models import StockLocation, StockItem, StockItemTracking from .models import StockLocation, StockItem, StockItemTracking
@ -26,6 +24,8 @@ from .models import StockLocation, StockItem, StockItemTracking
class AssignStockItemToCustomerForm(HelperForm): class AssignStockItemToCustomerForm(HelperForm):
""" """
Form for manually assigning a StockItem to a Customer Form for manually assigning a StockItem to a Customer
TODO: This could be a simple API driven form!
""" """
class Meta: class Meta:
@ -38,6 +38,8 @@ class AssignStockItemToCustomerForm(HelperForm):
class ReturnStockItemForm(HelperForm): class ReturnStockItemForm(HelperForm):
""" """
Form for manually returning a StockItem into stock Form for manually returning a StockItem into stock
TODO: This could be a simple API driven form!
""" """
class Meta: class Meta:
@ -48,7 +50,11 @@ class ReturnStockItemForm(HelperForm):
class EditStockLocationForm(HelperForm): class EditStockLocationForm(HelperForm):
""" Form for editing a StockLocation """ """
Form for editing a StockLocation
TODO: Migrate this form to the modern API forms interface
"""
class Meta: class Meta:
model = StockLocation model = StockLocation
@ -63,6 +69,8 @@ class EditStockLocationForm(HelperForm):
class ConvertStockItemForm(HelperForm): class ConvertStockItemForm(HelperForm):
""" """
Form for converting a StockItem to a variant of its current part. Form for converting a StockItem to a variant of its current part.
TODO: Migrate this form to the modern API forms interface
""" """
class Meta: class Meta:
@ -73,7 +81,11 @@ class ConvertStockItemForm(HelperForm):
class CreateStockItemForm(HelperForm): class CreateStockItemForm(HelperForm):
""" Form for creating a new StockItem """ """
Form for creating a new StockItem
TODO: Migrate this form to the modern API forms interface
"""
expiry_date = DatePickerFormField( expiry_date = DatePickerFormField(
label=_('Expiry Date'), label=_('Expiry Date'),
@ -129,7 +141,11 @@ class CreateStockItemForm(HelperForm):
class SerializeStockForm(HelperForm): class SerializeStockForm(HelperForm):
""" Form for serializing a StockItem. """ """
Form for serializing a StockItem.
TODO: Migrate this form to the modern API forms interface
"""
destination = TreeNodeChoiceField(queryset=StockLocation.objects.all(), label=_('Destination'), required=True, help_text=_('Destination for serialized stock (by default, will remain in current location)')) destination = TreeNodeChoiceField(queryset=StockLocation.objects.all(), label=_('Destination'), required=True, help_text=_('Destination for serialized stock (by default, will remain in current location)'))
@ -160,73 +176,11 @@ class SerializeStockForm(HelperForm):
] ]
class StockItemLabelSelectForm(HelperForm):
""" Form for selecting a label template for a StockItem """
label = forms.ChoiceField(
label=_('Label'),
help_text=_('Select test report template')
)
class Meta:
model = StockItem
fields = [
'label',
]
def get_label_choices(self, labels):
choices = []
if len(labels) > 0:
for label in labels:
choices.append((label.pk, label))
return choices
def __init__(self, labels, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['label'].choices = self.get_label_choices(labels)
class TestReportFormatForm(HelperForm):
""" Form for selection a test report template """
class Meta:
model = StockItem
fields = [
'template',
]
def __init__(self, stock_item, *args, **kwargs):
self.stock_item = stock_item
super().__init__(*args, **kwargs)
self.fields['template'].choices = self.get_template_choices()
def get_template_choices(self):
"""
Generate a list of of TestReport options for the StockItem
"""
choices = []
templates = TestReport.objects.filter(enabled=True)
for template in templates:
if template.enabled and template.matches_stock_item(self.stock_item):
choices.append((template.pk, template))
return choices
template = forms.ChoiceField(label=_('Template'), help_text=_('Select test report template'))
class InstallStockForm(HelperForm): class InstallStockForm(HelperForm):
""" """
Form for manually installing a stock item into another stock item Form for manually installing a stock item into another stock item
TODO: Migrate this form to the modern API forms interface
""" """
part = forms.ModelChoiceField( part = forms.ModelChoiceField(
@ -275,6 +229,8 @@ class InstallStockForm(HelperForm):
class UninstallStockForm(forms.ModelForm): class UninstallStockForm(forms.ModelForm):
""" """
Form for uninstalling a stock item which is installed in another item. Form for uninstalling a stock item which is installed in another item.
TODO: Migrate this form to the modern API forms interface
""" """
location = TreeNodeChoiceField(queryset=StockLocation.objects.all(), label=_('Location'), help_text=_('Destination location for uninstalled items')) location = TreeNodeChoiceField(queryset=StockLocation.objects.all(), label=_('Location'), help_text=_('Destination location for uninstalled items'))
@ -301,6 +257,8 @@ class EditStockItemForm(HelperForm):
location - Must be updated in a 'move' transaction location - Must be updated in a 'move' transaction
quantity - Must be updated in a 'stocktake' transaction quantity - Must be updated in a 'stocktake' transaction
part - Cannot be edited after creation part - Cannot be edited after creation
TODO: Migrate this form to the modern API forms interface
""" """
expiry_date = DatePickerFormField( expiry_date = DatePickerFormField(

View File

@ -1,50 +0,0 @@
{% load i18n %}
{% load inventree_extras %}
{% block pre_form_content %}
{% endblock %}
<form method="post" action='' class='js-modal-form' enctype="multipart/form-data">
{% csrf_token %}
{% load crispy_forms_tags %}
<input type='hidden' name='stock_action' value='{{ stock_action }}'/>
<table class='table table-condensed table-striped' id='stock-table'>
<tr>
<th>{% trans "Stock Item" %}</th>
<th>{% trans "Location" %}</th>
<th>{% trans "Quantity" %}</th>
{% if edit_quantity %}
<th>{{ stock_action_title }}</th>
{% endif %}
<th></th>
</tr>
{% for item in stock_items %}
<tr id='stock-row-{{ item.id }}' class='error'>
<td>{% include "hover_image.html" with image=item.part.image hover=True %}
{{ item.part.full_name }} <small><em>{{ item.part.description }}</em></small></td>
<td>{{ item.location.pathstring }}</td>
<td>{% decimal item.quantity %}</td>
<td>
{% if edit_quantity %}
<input class='numberinput'
min='0'
{% if stock_action == 'take' or stock_action == 'move' %} max='{{ item.quantity }}' {% endif %}
value='{% decimal item.new_quantity %}' type='number' name='stock-id-{{ item.id }}' id='stock-id-{{ item.id }}'/>
{% if item.error %}
<br><span class='help-inline'>{{ item.error }}</span>
{% endif %}
{% else %}
<input type='hidden' name='stock-id-{{ item.id }}' value='{{ item.new_quantity }}'/>
{% endif %}
</td>
<td><button class='btn btn-default btn-remove' onclick='removeStockRow()' id='del-{{ item.id }}' title='{% trans "Remove item" %}' type='button'><span row='stock-row-{{ item.id }}' class='fas fa-trash-alt icon-red'></span></button></td>
</tr>
{% endfor %}
</table>
{% crispy form %}
</form>

View File

@ -1 +0,0 @@
{% extends "modal_form.html" %}

View File

@ -12,12 +12,14 @@
<hr> <hr>
<div class='col-sm-3' id='item-panel'> <div class='col-sm-3' id='item-panel'>
<ul class='list-group' id='action-item-list'> <div class='panel panel-default panel-inventree'>
</ul> <ul class='list-group' id='action-item-list'>
</ul>
</div>
</div> </div>
<div class='col-sm-9' id='details-panel'> <div class='col-sm-9' id='details-panel'>
<ul class='list-group' id='detail-item-list'> <ul class='list-group' id='detail-item-list'>
<li class='list-group-item'> <li class='list-group-item panel panel-default panel-inventree'>
<div class='container'> <div class='container'>
<img class='index-bg' src='{% static "img/inventree.png" %}'> <img class='index-bg' src='{% static "img/inventree.png" %}'>
</div> </div>
@ -54,7 +56,7 @@ function addHeaderAction(label, title, icon, options) {
// Add a detail item to the detail item-panel // Add a detail item to the detail item-panel
$("#detail-item-list").append( $("#detail-item-list").append(
`<li class='list-group-item' id='detail-${label}'> `<li class='list-group-item panel panel-default panel-inventree' id='detail-${label}'>
<h4>${title}</h4> <h4>${title}</h4>
<table class='table table-condensed table-striped' id='table-${label}'></table> <table class='table table-condensed table-striped' id='table-${label}'></table>
</li>` </li>`

View File

@ -26,12 +26,14 @@
{% endif %} {% endif %}
<div class='col-sm-3' id='item-panel'> <div class='col-sm-3' id='item-panel'>
<ul class='list-group' id='search-item-list'> <div class='panel panel-default panel-inventree'>
</ul> <ul class='list-group' id='search-item-list'>
</ul>
</div>
</div> </div>
<div class='col-sm-9' id='details-panel'> <div class='col-sm-9' id='details-panel'>
<ul class='list-group' id='search-result-list'> <ul class='list-group' id='search-result-list'>
<li class='list-group-item'> <li class='list-group-item panel panel-default panel-inventree'>
<div class='container'> <div class='container'>
<img class='index-bg' src='{% static "img/inventree.png" %}'> <img class='index-bg' src='{% static "img/inventree.png" %}'>
</div> </div>
@ -67,7 +69,7 @@
// Add a results table // Add a results table
$('#search-result-list').append( $('#search-result-list').append(
`<li class='list-group-item' id='search-result-${label}'> `<li class='list-group-item panel panel-default panel-inventree' id='search-result-${label}'>
<h4>${title}</h4> <h4>${title}</h4>
<table class='table table-condensed table-striped' id='table-${label}'></table> <table class='table table-condensed table-striped' id='table-${label}'></table>
</li>` </li>`

View File

@ -9,12 +9,12 @@
</li> </li>
<li class='list-group-item'> <li class='list-group-item'>
<strong>{% trans "User Settings" %}</strong> <span class='fas fa-user'></span> <strong>{% trans "User Settings" %}</strong>
</li> </li>
<li class='list-group-item' title='{% trans "Account" %}'> <li class='list-group-item' title='{% trans "Account" %}'>
<a href='#' class='nav-toggle' id='select-account'> <a href='#' class='nav-toggle' id='select-account'>
<span class='fas fa-user'></span> {% trans "Account" %} <span class='fas fa-user-cog'></span> {% trans "Account" %}
</a> </a>
</li> </li>
@ -59,7 +59,7 @@
{% if user.is_staff %} {% if user.is_staff %}
<li class='list-group-item'> <li class='list-group-item'>
<strong>{% trans "InvenTree Settings" %}</strong> <span class='fas fa-cogs'></span> <strong>{% trans "Global Settings" %}</strong>
</li> </li>
<li class='list-group-item' title='{% trans "Server" %}'> <li class='list-group-item' title='{% trans "Server" %}'>

View File

@ -16,6 +16,7 @@
{% include "InvenTree/settings/header.html" %} {% include "InvenTree/settings/header.html" %}
<tbody> <tbody>
{% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_RESULTS" user_setting=True icon='fa-search' %} {% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_RESULTS" user_setting=True icon='fa-search' %}
{% include "InvenTree/settings/setting.html" with key="SEARCH_SHOW_STOCK_LEVELS" user_setting=True icon='fa-boxes' %}
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@ -1,7 +0,0 @@
{% extends "modal_delete_form.html" %}
{% load i18n %}
{% block pre_form_content %}
{% trans "Are you sure you want to delete this attachment?" %}
<br>
{% endblock %}

View File

@ -143,7 +143,6 @@
<!-- general InvenTree --> <!-- general InvenTree -->
<script type='text/javascript' src="{% static 'script/inventree/notification.js' %}"></script> <script type='text/javascript' src="{% static 'script/inventree/notification.js' %}"></script>
<script type='text/javascript' src="{% static 'script/inventree/sidenav.js' %}"></script>
<!-- dynamic javascript templates --> <!-- dynamic javascript templates -->
<script type='text/javascript' src="{% url 'inventree.js' %}"></script> <script type='text/javascript' src="{% url 'inventree.js' %}"></script>

View File

@ -1,23 +0,0 @@
{% block collapse_preamble %}
{% endblock %}
<div class='panel-group'>
<div class='panel panel-default'>
<div {% block collapse_panel_setup %}class='panel panel-heading'{% endblock %}>
<div class='row'>
<div class='col-sm-6'>
<div class='panel-title'>
<a data-toggle='collapse' href="#collapse-item-{{ collapse_id }}">{% block collapse_title %}Title{% endblock %}</a>
</div>
</div>
{% block collapse_heading %}
{% endblock %}
</div>
</div>
<div class='panel-collapse collapse' id='collapse-item-{{ collapse_id }}'>
<div class='panel-body'>
{% block collapse_content %}
{% endblock %}
</div>
</div>
</div>
</div>

View File

@ -1,19 +0,0 @@
{% block collapse_preamble %}
{% endblock %}
<div class='panel-group panel-index'>
<div class='panel panel-default'>
<div {% block collapse_panel_setup %}class='panel panel-heading'{% endblock %}>
<div class='panel-title'>
<a data-toggle='collapse' href="#collapse-item-{{ collapse_id }}">{% block collapse_title %}Title{% endblock %}</a>
</div>
{% block collapse_heading %}
{% endblock %}
</div>
<div class='panel-collapse collapse' id='collapse-item-{{ collapse_id }}'>
<div class='panel-body'>
{% block collapse_content %}
{% endblock %}
</div>
</div>
</div>
</div>

View File

@ -140,11 +140,13 @@ function inventreeDocReady() {
offset: 0 offset: 0
}, },
success: function(data) { success: function(data) {
var transformed = $.map(data.results, function(el) { var transformed = $.map(data.results, function(el) {
return { return {
label: el.full_name, label: el.full_name,
id: el.pk, id: el.pk,
thumbnail: el.thumbnail thumbnail: el.thumbnail,
data: el,
}; };
}); });
response(transformed); response(transformed);
@ -164,7 +166,18 @@ function inventreeDocReady() {
html += `'> `; html += `'> `;
html += item.label; html += item.label;
html += '</span></a>'; html += '</span>';
if (user_settings.SEARCH_SHOW_STOCK_LEVELS) {
html += partStockLabel(
item.data,
{
label_class: 'label-right',
}
);
}
html += '</a>';
return $('<li>').append(html).appendTo(ul); return $('<li>').append(html).appendTo(ul);
}; };
@ -290,3 +303,8 @@ function loadBrandIcon(element, name) {
element.addClass('fab fa-' + name); element.addClass('fab fa-' + name);
} }
} }
// Convenience function to determine if an element exists
$.fn.exists = function() {
return this.length !== 0;
};

View File

@ -3,6 +3,9 @@
/* exported /* exported
attachNavCallbacks, attachNavCallbacks,
enableNavbar,
initNavTree,
loadTree,
onPanelLoad, onPanelLoad,
*/ */
@ -113,3 +116,253 @@ function onPanelLoad(panel, callback) {
}); });
} }
function loadTree(url, tree, options={}) {
/* Load the side-nav tree view
Args:
url: URL to request tree data
tree: html ref to treeview
options:
data: data object to pass to the AJAX request
selected: ID of currently selected item
name: name of the tree
*/
var data = {};
if (options.data) {
data = options.data;
}
var key = 'inventree-sidenav-items-';
if (options.name) {
key += options.name;
}
$.ajax({
url: url,
type: 'get',
dataType: 'json',
data: data,
success: function(response) {
if (response.tree) {
$(tree).treeview({
data: response.tree,
enableLinks: true,
showTags: true,
});
if (localStorage.getItem(key)) {
var saved_exp = localStorage.getItem(key).split(',');
// Automatically expand the desired notes
for (var q = 0; q < saved_exp.length; q++) {
$(tree).treeview('expandNode', parseInt(saved_exp[q]));
}
}
// Setup a callback whenever a node is toggled
$(tree).on('nodeExpanded nodeCollapsed', function(event, data) {
// Record the entire list of expanded items
var expanded = $(tree).treeview('getExpanded');
var exp = [];
for (var i = 0; i < expanded.length; i++) {
exp.push(expanded[i].nodeId);
}
// Save the expanded nodes
localStorage.setItem(key, exp);
});
}
},
error: function(xhr, ajaxOptions, thrownError) {
// TODO
}
});
}
/**
* Initialize navigation tree display
*/
function initNavTree(options) {
var resize = true;
if ('resize' in options) {
resize = options.resize;
}
var label = options.label || 'nav';
var stateLabel = `${label}-tree-state`;
var widthLabel = `${label}-tree-width`;
var treeId = options.treeId || '#sidenav-left';
var toggleId = options.toggleId;
// Initially hide the tree
$(treeId).animate({
width: '0px',
}, 0, function() {
if (resize) {
$(treeId).resizable({
minWidth: '0px',
maxWidth: '500px',
handles: 'e, se',
grid: [5, 5],
stop: function(event, ui) {
var width = Math.round(ui.element.width());
if (width < 75) {
$(treeId).animate({
width: '0px'
}, 50);
localStorage.setItem(stateLabel, 'closed');
} else {
localStorage.setItem(stateLabel, 'open');
localStorage.setItem(widthLabel, `${width}px`);
}
}
});
}
var state = localStorage.getItem(stateLabel);
var width = localStorage.getItem(widthLabel) || '300px';
if (state && state == 'open') {
$(treeId).animate({
width: width,
}, 50);
}
});
// Register callback for 'toggle' button
if (toggleId) {
$(toggleId).click(function() {
var state = localStorage.getItem(stateLabel) || 'closed';
var width = localStorage.getItem(widthLabel) || '300px';
if (state == 'open') {
$(treeId).animate({
width: '0px'
}, 50);
localStorage.setItem(stateLabel, 'closed');
} else {
$(treeId).animate({
width: width,
}, 50);
localStorage.setItem(stateLabel, 'open');
}
});
}
}
/**
* Handle left-hand icon menubar display
*/
function enableNavbar(options) {
var resize = true;
if ('resize' in options) {
resize = options.resize;
}
var label = options.label || 'nav';
label = `navbar-${label}`;
var stateLabel = `${label}-state`;
var widthLabel = `${label}-width`;
var navId = options.navId || '#sidenav-right';
var toggleId = options.toggleId;
// Extract the saved width for this element
$(navId).animate({
'width': '45px',
'min-width': '45px',
'display': 'block',
}, 50, function() {
// Make the navbar resizable
if (resize) {
$(navId).resizable({
minWidth: options.minWidth || '100px',
maxWidth: options.maxWidth || '500px',
handles: 'e, se',
grid: [5, 5],
stop: function(event, ui) {
// Record the new width
var width = Math.round(ui.element.width());
// Reasonably narrow? Just close it!
if (width <= 75) {
$(navId).animate({
width: '45px'
}, 50);
localStorage.setItem(stateLabel, 'closed');
} else {
localStorage.setItem(widthLabel, `${width}px`);
localStorage.setItem(stateLabel, 'open');
}
}
});
}
var state = localStorage.getItem(stateLabel);
var width = localStorage.getItem(widthLabel) || '250px';
if (state && state == 'open') {
$(navId).animate({
width: width
}, 100);
}
});
// Register callback for 'toggle' button
if (toggleId) {
$(toggleId).click(function() {
var state = localStorage.getItem(stateLabel) || 'closed';
var width = localStorage.getItem(widthLabel) || '250px';
if (state == 'open') {
$(navId).animate({
width: '45px',
minWidth: '45px',
}, 50);
localStorage.setItem(stateLabel, 'closed');
} else {
$(navId).animate({
'width': width
}, 50);
localStorage.setItem(stateLabel, 'open');
}
});
}
}

View File

@ -24,7 +24,7 @@
loadAllocationTable, loadAllocationTable,
loadBuildOrderAllocationTable, loadBuildOrderAllocationTable,
loadBuildOutputAllocationTable, loadBuildOutputAllocationTable,
loadBuildPartsTable, loadBuildOutputTable,
loadBuildTable, loadBuildTable,
*/ */
@ -108,126 +108,56 @@ function newBuildOrder(options={}) {
} }
function makeBuildOutputActionButtons(output, buildInfo, lines) { /*
/* Generate action buttons for a build output. * Construct a set of output buttons for a particular build output
*/ */
function makeBuildOutputButtons(output_id, build_info, options={}) {
var buildId = buildInfo.pk;
var partId = buildInfo.part;
var outputId = 'untracked';
if (output) {
outputId = output.pk;
}
var panel = `#allocation-panel-${outputId}`;
function reloadTable() {
$(panel).find(`#allocation-table-${outputId}`).bootstrapTable('refresh');
}
// Find the div where the buttons will be displayed
var buildActions = $(panel).find(`#output-actions-${outputId}`);
var html = `<div class='btn-group float-right' role='group'>`; var html = `<div class='btn-group float-right' role='group'>`;
if (lines > 0) { // Tracked parts? Must be individually allocated
html += makeIconButton( if (build_info.tracked_parts) {
'fa-sign-in-alt icon-blue', 'button-output-auto', outputId,
'{% trans "Allocate stock items to this build output" %}',
);
}
if (lines > 0) { // Add a button to allocate stock against this build output
// Add a button to "cancel" the particular build output (unallocate)
html += makeIconButton( html += makeIconButton(
'fa-minus-circle icon-red', 'button-output-unallocate', outputId, 'fa-sign-in-alt icon-blue',
'button-output-allocate',
output_id,
'{% trans "Allocate stock items to this build output" %}',
{
disabled: true,
}
);
// Add a button to unallocate stock from this build output
html += makeIconButton(
'fa-minus-circle icon-red',
'button-output-unallocate',
output_id,
'{% trans "Unallocate stock from build output" %}', '{% trans "Unallocate stock from build output" %}',
); );
} }
if (output) { // Add a button to "complete" this build output
html += makeIconButton(
'fa-check-circle icon-green',
'button-output-complete',
output_id,
'{% trans "Complete build output" %}',
);
// Add a button to "complete" the particular build output // Add a button to "delete" this build output
html += makeIconButton( html += makeIconButton(
'fa-check icon-green', 'button-output-complete', outputId, 'fa-trash-alt icon-red',
'{% trans "Complete build output" %}', 'button-output-delete',
{ output_id,
// disabled: true '{% trans "Delete build output" %}',
} );
);
// Add a button to "delete" the particular build output html += `</div>`;
html += makeIconButton(
'fa-trash-alt icon-red', 'button-output-delete', outputId,
'{% trans "Delete build output" %}',
);
// TODO - Add a button to "destroy" the particular build output (mark as damaged, scrap) return html;
}
html += '</div>';
buildActions.html(html);
// Add callbacks for the buttons
$(panel).find(`#button-output-auto-${outputId}`).click(function() {
var bom_items = $(panel).find(`#allocation-table-${outputId}`).bootstrapTable('getData');
// Launch modal dialog to perform auto-allocation
allocateStockToBuild(
buildId,
partId,
bom_items,
{
source_location: buildInfo.source_location,
output: outputId,
success: reloadTable,
}
);
});
$(panel).find(`#button-output-complete-${outputId}`).click(function() {
var pk = $(this).attr('pk');
launchModalForm(
`/build/${buildId}/complete-output/`,
{
data: {
output: pk,
},
reload: true,
}
);
});
$(panel).find(`#button-output-unallocate-${outputId}`).click(function() {
var pk = $(this).attr('pk');
unallocateStock(buildId, {
output: pk,
table: table,
});
});
$(panel).find(`#button-output-delete-${outputId}`).click(function() {
var pk = $(this).attr('pk');
launchModalForm(
`/build/${buildId}/delete-output/`,
{
reload: true,
data: {
output: pk
}
}
);
});
} }
@ -270,14 +200,160 @@ function unallocateStock(build_id, options={}) {
} }
} }
}); });
} }
/**
* Launch a modal form to complete selected build outputs
*/
function completeBuildOutputs(build_id, outputs, options={}) {
if (outputs.length == 0) {
showAlertDialog(
'{% trans "Select Build Outputs" %}',
'{% trans "At least one build output must be selected" %}',
);
return;
}
// Render a single build output (StockItem)
function renderBuildOutput(output, opts={}) {
var pk = output.pk;
var output_html = imageHoverIcon(output.part_detail.thumbnail);
if (output.quantity == 1 && output.serial) {
output_html += `{% trans "Serial Number" %}: ${output.serial}`;
} else {
output_html += `{% trans "Quantity" %}: ${output.quantity}`;
}
var buttons = `<div class='btn-group float-right' role='group'>`;
buttons += makeIconButton('fa-times icon-red', 'button-row-remove', pk, '{% trans "Remove row" %}');
buttons += '</div>';
var field = constructField(
`outputs_output_${pk}`,
{
type: 'raw',
html: output_html,
},
{
hideLabels: true,
}
);
var html = `
<tr id='output_row_${pk}'>
<td>${field}</td>
<td>${output.part_detail.full_name}</td>
<td>${buttons}</td>
</tr>`;
return html;
}
// Construct table entries
var table_entries = '';
outputs.forEach(function(output) {
table_entries += renderBuildOutput(output);
});
var html = `
<table class='table table-striped table-condensed' id='build-complete-table'>
<thead>
<th colspan='2'>{% trans "Output" %}</th>
<th><!-- Actions --></th>
</thead>
<tbody>
${table_entries}
</tbody>
</table>`;
constructForm(`/api/build/${build_id}/complete/`, {
method: 'POST',
preFormContent: html,
fields: {
status: {},
location: {},
},
confirm: true,
title: '{% trans "Complete Build Outputs" %}',
afterRender: function(fields, opts) {
// Setup callbacks to remove outputs
$(opts.modal).find('.button-row-remove').click(function() {
var pk = $(this).attr('pk');
$(opts.modal).find(`#output_row_${pk}`).remove();
});
},
onSubmit: function(fields, opts) {
// Extract data elements from the form
var data = {
outputs: [],
status: getFormFieldValue('status', {}, opts),
location: getFormFieldValue('location', {}, opts),
};
var output_pk_values = [];
outputs.forEach(function(output) {
var pk = output.pk;
var row = $(opts.modal).find(`#output_row_${pk}`);
if (row.exists()) {
data.outputs.push({
output: pk,
});
output_pk_values.push(pk);
}
});
// Provide list of nested values
opts.nested = {
'outputs': output_pk_values,
};
inventreePut(
opts.url,
data,
{
method: 'POST',
success: function(response) {
// Hide the modal
$(opts.modal).modal('hide');
if (options.success) {
options.success(response);
}
},
error: function(xhr) {
switch (xhr.status) {
case 400:
handleFormErrors(xhr.responseJSON, fields, opts);
break;
default:
$(opts.modal).modal('hide');
showApiError(xhr);
break;
}
}
}
);
}
});
}
/**
* Load a table showing all the BuildOrder allocations for a given part
*/
function loadBuildOrderAllocationTable(table, options={}) { function loadBuildOrderAllocationTable(table, options={}) {
/**
* Load a table showing all the BuildOrder allocations for a given part
*/
options.params['part_detail'] = true; options.params['part_detail'] = true;
options.params['build_detail'] = true; options.params['build_detail'] = true;
@ -357,17 +433,256 @@ function loadBuildOrderAllocationTable(table, options={}) {
} }
function loadBuildOutputAllocationTable(buildInfo, output, options={}) { /*
* Display a "build output" table for a particular build.
*
* This displays a list of "active" (i.e. "in production") build outputs for a given build
*
*/
function loadBuildOutputTable(build_info, options={}) {
var table = options.table || '#build-output-table';
var params = options.params || {};
// Mandatory query filters
params.part_detail = true;
params.is_building = true;
params.build = build_info.pk;
// Construct a list of "tracked" BOM items
var tracked_bom_items = [];
var has_tracked_items = false;
build_info.bom_items.forEach(function(bom_item) {
if (bom_item.sub_part_detail.trackable) {
tracked_bom_items.push(bom_item);
has_tracked_items = true;
};
});
var filters = {};
for (var key in params) {
filters[key] = params[key];
}
// TODO: Initialize filter list
function setupBuildOutputButtonCallbacks() {
// Callback for the "allocate" button
$(table).find('.button-output-allocate').click(function() {
var pk = $(this).attr('pk');
// Find the "allocation" sub-table associated with this output
var subtable = $(`#output-sub-table-${pk}`);
if (subtable.exists()) {
var rows = subtable.bootstrapTable('getSelections');
// None selected? Use all!
if (rows.length == 0) {
rows = subtable.bootstrapTable('getData');
}
allocateStockToBuild(
build_info.pk,
build_info.part,
rows,
{
output: pk,
success: function() {
$(table).bootstrapTable('refresh');
}
}
);
} else {
console.log(`WARNING: Could not locate sub-table for output ${pk}`);
}
});
// Callack for the "unallocate" button
$(table).find('.button-output-unallocate').click(function() {
var pk = $(this).attr('pk');
unallocateStock(build_info.pk, {
output: pk,
table: table
});
});
// Callback for the "complete" button
$(table).find('.button-output-complete').click(function() {
var pk = $(this).attr('pk');
var output = $(table).bootstrapTable('getRowByUniqueId', pk);
completeBuildOutputs(
build_info.pk,
[
output,
],
{
success: function() {
$(table).bootstrapTable('refresh');
}
}
);
});
// Callback for the "delete" button
$(table).find('.button-output-delete').click(function() {
var pk = $(this).attr('pk');
// TODO: Move this to the API
launchModalForm(
`/build/${build_info.pk}/delete-output/`,
{
data: {
output: pk
},
onSuccess: function() {
$(table).bootstrapTable('refresh');
}
}
);
});
}
/* /*
* Load the "allocation table" for a particular build output. * Construct a "sub table" showing the required BOM items
*
* Args:
* - buildId: The PK of the Build object
* - partId: The PK of the Part object
* - output: The StockItem object which is the "output" of the build
* - options:
* -- table: The #id of the table (will be auto-calculated if not provided)
*/ */
function constructBuildOutputSubTable(index, row, element) {
var sub_table_id = `output-sub-table-${row.pk}`;
var html = `
<div class='sub-table'>
<table class='table table-striped table-condensed' id='${sub_table_id}'></table>
</div>
`;
element.html(html);
loadBuildOutputAllocationTable(
build_info,
row,
{
table: `#${sub_table_id}`,
parent_table: table,
}
);
}
$(table).inventreeTable({
url: '{% url "api-stock-list" %}',
queryParams: filters,
original: params,
showColumns: false,
uniqueId: 'pk',
name: 'build-outputs',
sortable: true,
search: false,
sidePagination: 'server',
detailView: has_tracked_items,
detailFilter: function(index, row) {
return true;
},
detailFormatter: function(index, row, element) {
constructBuildOutputSubTable(index, row, element);
},
formatNoMatches: function() {
return '{% trans "No active build outputs found" %}';
},
onPostBody: function() {
// Add callbacks for the buttons
setupBuildOutputButtonCallbacks();
$(table).bootstrapTable('expandAllRows');
},
columns: [
{
title: '',
visible: true,
checkbox: true,
switchable: false,
},
{
field: 'part',
title: '{% trans "Part" %}',
formatter: function(value, row) {
var thumb = row.part_detail.thumbnail;
return imageHoverIcon(thumb) + row.part_detail.full_name + makePartIcons(row.part_detail);
}
},
{
field: 'quantity',
title: '{% trans "Quantity" %}',
formatter: function(value, row) {
var url = `/stock/item/${row.pk}/`;
var text = '';
if (row.serial && row.quantity == 1) {
text = `{% trans "Serial Number" %}: ${row.serial}`;
} else {
text = `{% trans "Quantity" %}: ${row.quantity}`;
}
return renderLink(text, url);
}
},
{
field: 'allocated',
title: '{% trans "Allocated Parts" %}',
visible: has_tracked_items,
formatter: function(value, row) {
return `<div id='output-progress-${row.pk}'><span class='fas fa-spin fa-spinner'></span></div>`;
}
},
{
field: 'actions',
title: '',
switchable: false,
formatter: function(value, row) {
return makeBuildOutputButtons(
row.pk,
build_info,
);
}
}
]
});
// Enable the "allocate" button when the sub-table is exanded
$(table).on('expand-row.bs.table', function(detail, index, row) {
$(`#button-output-allocate-${row.pk}`).prop('disabled', false);
});
// Disable the "allocate" button when the sub-table is collapsed
$(table).on('collapse-row.bs.table', function(detail, index, row) {
$(`#button-output-allocate-${row.pk}`).prop('disabled', true);
});
}
/*
* Display the "allocation table" for a particular build output.
*
* This displays a table of required allocations for a particular build output
*
* Args:
* - buildId: The PK of the Build object
* - partId: The PK of the Part object
* - output: The StockItem object which is the "output" of the build
* - options:
* -- table: The #id of the table (will be auto-calculated if not provided)
*/
function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
var buildId = buildInfo.pk; var buildId = buildInfo.pk;
var partId = buildInfo.part; var partId = buildInfo.part;
@ -534,7 +849,11 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
}, },
name: 'build-allocation', name: 'build-allocation',
uniqueId: 'sub_part', uniqueId: 'sub_part',
onPostBody: setupCallbacks, search: options.search || false,
onPostBody: function(data) {
// Setup button callbacks
setupCallbacks();
},
onLoadSuccess: function(tableData) { onLoadSuccess: function(tableData) {
// Once the BOM data are loaded, request allocation data for this build output // Once the BOM data are loaded, request allocation data for this build output
@ -610,31 +929,31 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
$(table).bootstrapTable('updateByUniqueId', key, tableRow, true); $(table).bootstrapTable('updateByUniqueId', key, tableRow, true);
} }
// Update the total progress for this build output // Update the progress bar for this build output
var buildProgress = $(`#allocation-panel-${outputId}`).find($(`#output-progress-${outputId}`)); var build_progress = $(`#output-progress-${outputId}`);
if (totalLines > 0) { if (build_progress.exists()) {
if (totalLines > 0) {
var progress = makeProgressBar( var progress = makeProgressBar(
allocatedLines, allocatedLines,
totalLines totalLines
); );
buildProgress.html(progress); build_progress.html(progress);
} else {
build_progress.html('');
}
} else { } else {
buildProgress.html(''); console.log(`WARNING: Could not find progress bar for output ${outputId}`);
} }
// Update the available actions for this build output
makeBuildOutputActionButtons(output, buildInfo, totalLines);
} }
} }
); );
}, },
sortable: true, sortable: true,
showColumns: false, showColumns: false,
detailViewByClick: true,
detailView: true, detailView: true,
detailFilter: function(index, row) { detailFilter: function(index, row) {
return row.allocations != null; return row.allocations != null;
@ -883,9 +1202,6 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
}, },
] ]
}); });
// Initialize the action buttons
makeBuildOutputActionButtons(output, buildInfo, 0);
} }
@ -995,10 +1311,13 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
remaining = 0; remaining = 0;
} }
table_entries += renderBomItemRow(bom_item, remaining); // We only care about entries which are not yet fully allocated
if (remaining > 0) {
table_entries += renderBomItemRow(bom_item, remaining);
}
} }
if (bom_items.length == 0) { if (table_entries.length == 0) {
showAlertDialog( showAlertDialog(
'{% trans "Select Parts" %}', '{% trans "Select Parts" %}',
@ -1085,6 +1404,24 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
render_part_detail: true, render_part_detail: true,
render_location_detail: true, render_location_detail: true,
auto_fill: true, auto_fill: true,
onSelect: function(data, field, opts) {
// Adjust the 'quantity' field based on availability
if (!('quantity' in data)) {
return;
}
// Quantity remaining to be allocated
var remaining = Math.max((bom_item.required || 0) - (bom_item.allocated || 0), 0);
// Calculate the available quantity
var available = Math.max((data.quantity || 0) - (data.allocated || 0), 0);
// Maximum amount that we need
var desired = Math.min(available, remaining);
updateFieldValue(`items_quantity_${bom_item.pk}`, desired, {}, opts);
},
adjustFilters: function(filters) { adjustFilters: function(filters) {
// Restrict query to the selected location // Restrict query to the selected location
var location = getFormFieldValue( var location = getFormFieldValue(
@ -1198,9 +1535,10 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
} }
/*
* Display a table of Build orders
*/
function loadBuildTable(table, options) { function loadBuildTable(table, options) {
// Display a table of Build objects
var params = options.params || {}; var params = options.params || {};
@ -1467,190 +1805,4 @@ function loadAllocationTable(table, part_id, part, url, required, button) {
} }
}); });
}); });
}
function loadBuildPartsTable(table, options={}) {
/**
* Display a "required parts" table for build view.
*
* This is a simplified BOM view:
* - Does not display sub-bom items
* - Does not allow editing of BOM items
*
* Options:
*
* part: Part ID
* build: Build ID
* build_quantity: Total build quantity
* build_remaining: Number of items remaining
*/
// Query params
var params = {
sub_part_detail: true,
part: options.part,
};
var filters = {};
if (!options.disableFilters) {
filters = loadTableFilters('bom');
}
setupFilterList('bom', $(table));
for (var key in params) {
filters[key] = params[key];
}
function setupTableCallbacks() {
// Register button callbacks once the table data are loaded
// Callback for 'buy' button
$(table).find('.button-buy').click(function() {
var pk = $(this).attr('pk');
launchModalForm('{% url "order-parts" %}', {
data: {
parts: [
pk,
]
}
});
});
// Callback for 'build' button
$(table).find('.button-build').click(function() {
var pk = $(this).attr('pk');
newBuildOrder({
part: pk,
parent: options.build,
});
});
}
var columns = [
{
field: 'sub_part',
title: '{% trans "Part" %}',
switchable: false,
sortable: true,
formatter: function(value, row) {
var url = `/part/${row.sub_part}/`;
var html = imageHoverIcon(row.sub_part_detail.thumbnail) + renderLink(row.sub_part_detail.full_name, url);
var sub_part = row.sub_part_detail;
html += makePartIcons(row.sub_part_detail);
// Display an extra icon if this part is an assembly
if (sub_part.assembly) {
var text = `<span title='{% trans "Open subassembly" %}' class='fas fa-stream label-right'></span>`;
html += renderLink(text, `/part/${row.sub_part}/bom/`);
}
return html;
}
},
{
field: 'sub_part_detail.description',
title: '{% trans "Description" %}',
},
{
field: 'reference',
title: '{% trans "Reference" %}',
searchable: true,
sortable: true,
},
{
field: 'quantity',
title: '{% trans "Quantity" %}',
sortable: true
},
{
sortable: true,
switchable: false,
field: 'sub_part_detail.stock',
title: '{% trans "Available" %}',
formatter: function(value, row) {
return makeProgressBar(
value,
row.quantity * options.build_remaining,
{
id: `part-progress-${row.part}`
}
);
},
sorter: function(valA, valB, rowA, rowB) {
if (rowA.received == 0 && rowB.received == 0) {
return (rowA.quantity > rowB.quantity) ? 1 : -1;
}
var progressA = parseFloat(rowA.sub_part_detail.stock) / (rowA.quantity * options.build_remaining);
var progressB = parseFloat(rowB.sub_part_detail.stock) / (rowB.quantity * options.build_remaining);
return (progressA < progressB) ? 1 : -1;
}
},
{
field: 'actions',
title: '{% trans "Actions" %}',
switchable: false,
formatter: function(value, row) {
// Generate action buttons against the part
var html = `<div class='btn-group float-right' role='group'>`;
if (row.sub_part_detail.assembly) {
html += makeIconButton('fa-tools icon-blue', 'button-build', row.sub_part, '{% trans "Build stock" %}');
}
if (row.sub_part_detail.purchaseable) {
html += makeIconButton('fa-shopping-cart icon-blue', 'button-buy', row.sub_part, '{% trans "Order stock" %}');
}
html += `</div>`;
return html;
}
}
];
table.inventreeTable({
url: '{% url "api-bom-list" %}',
showColumns: true,
name: 'build-parts',
sortable: true,
search: true,
onPostBody: setupTableCallbacks,
rowStyle: function(row) {
var classes = [];
// Shade rows differently if they are for different parent parts
if (row.part != options.part) {
classes.push('rowinherited');
}
if (row.validated) {
classes.push('rowvalid');
} else {
classes.push('rowinvalid');
}
return {
classes: classes.join(' '),
};
},
formatNoMatches: function() {
return '{% trans "No BOM items found" %}';
},
clickToSelect: true,
queryParams: filters,
original: params,
columns: columns,
});
} }

View File

@ -1426,6 +1426,11 @@ function initializeRelatedField(field, fields, options) {
data = item.element.instance; data = item.element.instance;
} }
// Run optional callback function
if (field.onSelect && data) {
field.onSelect(data, field, options);
}
if (!data.pk) { if (!data.pk) {
return field.placeholder || ''; return field.placeholder || '';
} }
@ -1843,6 +1848,8 @@ function constructInput(name, parameters, options) {
case 'candy': case 'candy':
func = constructCandyInput; func = constructCandyInput;
break; break;
case 'raw':
func = constructRawInput;
default: default:
// Unsupported field type! // Unsupported field type!
break; break;
@ -2086,6 +2093,17 @@ function constructCandyInput(name, parameters) {
} }
/*
* Construct a "raw" field input
* No actual field data!
*/
function constructRawInput(name, parameters) {
return parameters.html;
}
/* /*
* Construct a 'help text' div based on the field parameters * Construct a 'help text' div based on the field parameters
* *

View File

@ -87,8 +87,10 @@ function select2Thumbnail(image) {
} }
/*
* Construct an 'icon badge' which floats to the right of an object
*/
function makeIconBadge(icon, title) { function makeIconBadge(icon, title) {
// Construct an 'icon badge' which floats to the right of an object
var html = `<span class='fas ${icon} label-right' title='${title}'></span>`; var html = `<span class='fas ${icon} label-right' title='${title}'></span>`;
@ -96,8 +98,10 @@ function makeIconBadge(icon, title) {
} }
/*
* Construct an 'icon button' using the fontawesome set
*/
function makeIconButton(icon, cls, pk, title, options={}) { function makeIconButton(icon, cls, pk, title, options={}) {
// Construct an 'icon button' using the fontawesome set
var classes = `btn btn-default btn-glyph ${cls}`; var classes = `btn btn-default btn-glyph ${cls}`;

View File

@ -168,11 +168,7 @@ function renderPart(name, data, parameters, options) {
// Display available part quantity // Display available part quantity
if (user_settings.PART_SHOW_QUANTITY_IN_FORMS) { if (user_settings.PART_SHOW_QUANTITY_IN_FORMS) {
if (data.in_stock == 0) { extra += partStockLabel(data);
extra += `<span class='label-form label-red'>{% trans "No Stock" %}</span>`;
} else {
extra += `<span class='label-form label-green'>{% trans "Stock" %}: ${data.in_stock}</span>`;
}
} }
if (!data.active) { if (!data.active) {

View File

@ -1641,6 +1641,13 @@ function loadSalesOrderLineItemTable(table, options={}) {
var line_item = $(table).bootstrapTable('getRowByUniqueId', pk); var line_item = $(table).bootstrapTable('getRowByUniqueId', pk);
// Quantity remaining to be allocated
var remaining = (line_item.quantity || 0) - (line_item.allocated || 0);
if (remaining < 0) {
remaining = 0;
}
var fields = { var fields = {
// SalesOrderLineItem reference // SalesOrderLineItem reference
line: { line: {
@ -1654,9 +1661,26 @@ function loadSalesOrderLineItemTable(table, options={}) {
in_stock: true, in_stock: true,
part: line_item.part, part: line_item.part,
exclude_so_allocation: options.order, exclude_so_allocation: options.order,
} },
auto_fill: true,
onSelect: function(data, field, opts) {
// Quantity available from this stock item
if (!('quantity' in data)) {
return;
}
// Calculate the available quantity
var available = Math.max((data.quantity || 0) - (data.allocated || 0), 0);
// Maximum amount that we need
var desired = Math.min(available, remaining);
updateFieldValue('quantity', desired, {}, opts);
}
}, },
quantity: { quantity: {
value: remaining,
}, },
}; };
@ -1752,7 +1776,7 @@ function loadSalesOrderLineItemTable(table, options={}) {
showFooter: true, showFooter: true,
uniqueId: 'pk', uniqueId: 'pk',
detailView: show_detail, detailView: show_detail,
detailViewByClick: show_detail, detailViewByClick: false,
detailFilter: function(index, row) { detailFilter: function(index, row) {
if (pending) { if (pending) {
// Order is pending // Order is pending

View File

@ -35,6 +35,7 @@
loadSellPricingChart, loadSellPricingChart,
loadSimplePartTable, loadSimplePartTable,
loadStockPricingChart, loadStockPricingChart,
partStockLabel,
toggleStar, toggleStar,
*/ */
@ -409,6 +410,18 @@ function toggleStar(options) {
} }
function partStockLabel(part, options={}) {
var label_class = options.label_class || 'label-form';
if (part.in_stock) {
return `<span class='label ${label_class} label-green'>{% trans "Stock" %}: ${part.in_stock}</span>`;
} else {
return `<span class='label ${label_class} label-red'>{% trans "No Stock" %}</span>`;
}
}
function makePartIcons(part) { function makePartIcons(part) {
/* Render a set of icons for the given part. /* Render a set of icons for the given part.
*/ */
@ -778,7 +791,7 @@ function partGridTile(part) {
var html = ` var html = `
<div class='col-sm-3 card'> <div class='product-card card'>
<div class='panel panel-default panel-inventree product-card-panel'> <div class='panel panel-default panel-inventree product-card-panel'>
<div class='panel-heading'> <div class='panel-heading'>
<a href='/part/${part.pk}/'> <a href='/part/${part.pk}/'>
@ -1000,8 +1013,8 @@ function loadPartTable(table, url, options={}) {
data.forEach(function(row, index) { data.forEach(function(row, index) {
// Force a new row every 4 columns, to prevent visual issues // Force a new row every 5 columns
if ((index > 0) && (index % 4 == 0) && (index < data.length)) { if ((index > 0) && (index % 5 == 0) && (index < data.length)) {
html += `</div><div class='row full-height'>`; html += `</div><div class='row full-height'>`;
} }

View File

@ -56,10 +56,10 @@ function enableButtons(elements, enabled) {
} }
/* Link a bootstrap-table object to one or more buttons.
* The buttons will only be enabled if there is at least one row selected
*/
function linkButtonsToSelection(table, buttons) { function linkButtonsToSelection(table, buttons) {
/* Link a bootstrap-table object to one or more buttons.
* The buttons will only be enabled if there is at least one row selected
*/
if (typeof table === 'string') { if (typeof table === 'string') {
table = $(table); table = $(table);

View File

@ -1,23 +0,0 @@
<table class='table table-striped table-condensed' id='{{ table_id }}'>
<tr>
<th data-field='part' data-sortable='true' data-searchable='true'>Part</th>
<th data-field='part' data-sortable='true' data-searchable='true'>Description</th>
<th data-field='part' data-sortable='true' data-searchable='true'>In Stock</th>
<th data-field='part' data-sortable='true' data-searchable='true'>On Order</th>
<th data-field='part' data-sortable='true' data-searchable='true'>Allocted</th>
<th data-field='part' data-sortable='true' data-searchable='true'>Net Stock</th>
</tr>
{% for part in parts %}
<tr>
<td>
{% include "hover_image.html" with image=part.image hover=True %}
<a href="{% url 'part-detail' part.id %}">{{ part.full_name }}</a>
</td>
<td>{{ part.description }}</td>
<td>{{ part.total_stock }}</td>
<td>{{ part.on_order }}</td>
<td>{{ part.allocation_count }}</td>
<td{% if part.net_stock < 0 %} class='red-cell'{% endif %}>{{ part.net_stock }}</td>
</tr>
{% endfor %}
</table>

View File

@ -1,3 +0,0 @@
<div>
<input fieldname='{{ field }}' class='slidey' type="checkbox" data-offstyle='warning' data-onstyle="success" data-size='small' data-toggle="toggle" {% if disabled or not roles.part.change %}disabled {% endif %}{% if state %}checked=""{% endif %} autocomplete="off">
</div>