mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-30 20:55:42 +00:00 
			
		
		
		
	Merge branch 'inventree:master' into plugin-2037
This commit is contained in:
		| @@ -933,7 +933,8 @@ input[type="submit"] { | ||||
|  | ||||
| .panel-inventree { | ||||
|     padding: 10px; | ||||
|     box-shadow: 1px 1px #DDD; | ||||
|     box-shadow: 2px 2px #DDD; | ||||
|     border-color: #ccc; | ||||
| } | ||||
|  | ||||
| .panel-hidden { | ||||
| @@ -1074,6 +1075,14 @@ input[type='number']{ | ||||
|     margin-top: 0.5rem; | ||||
| } | ||||
|  | ||||
| .product-card { | ||||
|     width: 20%; | ||||
|     padding: 5px; | ||||
|     min-height: 25px; | ||||
| } | ||||
|  | ||||
| .product-card-panel{ | ||||
|     height: 100%; | ||||
|     border: 1px solid #ccc; | ||||
|     box-shadow: 2px 2px #DDD; | ||||
| } | ||||
|   | ||||
| @@ -1,12 +0,0 @@ | ||||
| var msDelay = 0; | ||||
|  | ||||
| var delay = (function(){ | ||||
|     return function(callback, ms){ | ||||
|         clearTimeout(msDelay); | ||||
|         msDelay = setTimeout(callback, ms); | ||||
|     }; | ||||
| })(); | ||||
|  | ||||
| function cancelTimer(){ | ||||
|     clearTimeout(msDelay); | ||||
| } | ||||
| @@ -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'); | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
| } | ||||
| @@ -12,11 +12,14 @@ import common.models | ||||
| INVENTREE_SW_VERSION = "0.6.0 dev" | ||||
|  | ||||
| # 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 | ||||
|  | ||||
| v16 -> 2021-10-17 | ||||
|     - Adds API endpoint for completing build order outputs | ||||
|  | ||||
| v15 -> 2021-10-06 | ||||
|     - Adds detail endpoint for SalesOrderAllocation model | ||||
|     - Allows use of the API forms interface for adjusting SalesOrderAllocation objects | ||||
|   | ||||
| @@ -5,12 +5,9 @@ JSON API for the Build app | ||||
| # -*- coding: utf-8 -*- | ||||
| from __future__ import unicode_literals | ||||
|  | ||||
| from django.utils.translation import ugettext_lazy as _ | ||||
|  | ||||
| from django.conf.urls import url, include | ||||
|  | ||||
| from rest_framework import filters, generics | ||||
| from rest_framework.serializers import ValidationError | ||||
|  | ||||
| from django_filters.rest_framework import DjangoFilterBackend | ||||
| 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 .models import Build, BuildItem, BuildOrderAttachment | ||||
| from .serializers import BuildAttachmentSerializer, BuildSerializer, BuildItemSerializer | ||||
| from .serializers import BuildAttachmentSerializer, BuildCompleteSerializer, BuildSerializer, BuildItemSerializer | ||||
| from .serializers import BuildAllocationSerializer, BuildUnallocationSerializer | ||||
|  | ||||
|  | ||||
| @@ -201,30 +198,43 @@ class BuildUnallocate(generics.CreateAPIView): | ||||
|     queryset = Build.objects.none() | ||||
|  | ||||
|     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): | ||||
|  | ||||
|         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 | ||||
|  | ||||
|         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): | ||||
|     """ | ||||
|     API endpoint to allocate stock items to a build order | ||||
| @@ -241,20 +251,6 @@ class BuildAllocate(generics.CreateAPIView): | ||||
|  | ||||
|     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): | ||||
|         """ | ||||
|         Provide the Build object to the serializer context | ||||
| @@ -262,7 +258,11 @@ class BuildAllocate(generics.CreateAPIView): | ||||
|  | ||||
|         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 | ||||
|  | ||||
|         return context | ||||
| @@ -390,6 +390,7 @@ build_api_urls = [ | ||||
|     # Build Detail | ||||
|     url(r'^(?P<pk>\d+)/', include([ | ||||
|         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'^.*$', BuildDetail.as_view(), name='api-build-detail'), | ||||
|     ])), | ||||
|   | ||||
| @@ -10,63 +10,9 @@ from django.utils.translation import ugettext_lazy as _ | ||||
| from django import forms | ||||
|  | ||||
| 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 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): | ||||
|     """ | ||||
| @@ -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): | ||||
|     """ Form for cancelling a build """ | ||||
|  | ||||
|   | ||||
| @@ -724,7 +724,7 @@ class Build(MPTTModel, ReferenceIndexingMixin): | ||||
|         items.all().delete() | ||||
|  | ||||
|     @transaction.atomic | ||||
|     def completeBuildOutput(self, output, user, **kwargs): | ||||
|     def complete_build_output(self, output, user, **kwargs): | ||||
|         """ | ||||
|         Complete a particular build output | ||||
|  | ||||
| @@ -741,10 +741,6 @@ class Build(MPTTModel, ReferenceIndexingMixin): | ||||
|         allocated_items = output.items_to_install.all() | ||||
|  | ||||
|         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 | ||||
|             build_item.complete_allocation(user) | ||||
|  | ||||
| @@ -770,6 +766,7 @@ class Build(MPTTModel, ReferenceIndexingMixin): | ||||
|  | ||||
|         # Increase the completed quantity for this build | ||||
|         self.completed += output.quantity | ||||
|  | ||||
|         self.save() | ||||
|  | ||||
|     def requiredQuantity(self, part, output): | ||||
|   | ||||
| @@ -18,9 +18,10 @@ from rest_framework.serializers import ValidationError | ||||
| from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer | ||||
| from InvenTree.serializers import InvenTreeAttachmentSerializerField, UserSerializerBrief | ||||
|  | ||||
| from InvenTree.status_codes import StockStatus | ||||
| import InvenTree.helpers | ||||
|  | ||||
| from stock.models import StockItem | ||||
| from stock.models import StockItem, StockLocation | ||||
| from stock.serializers import StockItemSerializerBrief, LocationSerializer | ||||
|  | ||||
| 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): | ||||
|     """ | ||||
|     DRF serializer for unallocating stock from a BuildOrder | ||||
| @@ -190,6 +309,8 @@ class BuildAllocationItemSerializer(serializers.Serializer): | ||||
|  | ||||
|     def validate_bom_item(self, bom_item): | ||||
|          | ||||
|         # TODO: Fix this validation - allow for variants and substitutes! | ||||
|  | ||||
|         build = self.context['build'] | ||||
|  | ||||
|         # BomItem must point to the same 'part' as the parent build | ||||
|   | ||||
| @@ -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> | ||||
| @@ -91,16 +91,11 @@ src="{% static 'img/blank_image.png' %}" | ||||
|             <span class='fas fa-print'></span> <span class='caret'></span> | ||||
|         </button> | ||||
|         <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> | ||||
|     </div> | ||||
|     <!-- Build actions --> | ||||
|     {% 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'> | ||||
|         <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> | ||||
| @@ -115,6 +110,11 @@ src="{% static 'img/blank_image.png' %}" | ||||
|             {% endif %} | ||||
|         </ul> | ||||
|     </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 %} | ||||
| </div> | ||||
| {% endblock %} | ||||
| @@ -153,8 +153,8 @@ src="{% static 'img/blank_image.png' %}" | ||||
|     </tr> | ||||
|     {% endif %} | ||||
|     <tr> | ||||
|         <td><span class='fas fa-spinner'></span></td> | ||||
|         <td>{% trans "Progress" %}</td> | ||||
|         <td><span class='fas fa-check-circle'></span></td> | ||||
|         <td>{% trans "Completed" %}</td> | ||||
|         <td> {{ build.completed }} / {{ build.quantity }}</td> | ||||
|     </tr> | ||||
|     {% if build.parent %} | ||||
|   | ||||
| @@ -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 %} | ||||
| @@ -63,10 +63,17 @@ | ||||
|                     <td>{% build_status_label build.status %}</td> | ||||
|                 </tr> | ||||
|                 <tr> | ||||
|                     <td><span class='fas fa-spinner'></span></td> | ||||
|                     <td>{% trans "Progress" %}</td> | ||||
|                     <td><span class='fas fa-check-circle'></span></td> | ||||
|                     <td>{% trans "Completed" %}</td> | ||||
|                     <td>{{ build.completed }} / {{ build.quantity }}</td> | ||||
|                 </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 %} | ||||
|                 <tr> | ||||
|                     <td><span class='fas fa-layer-group'></span></td> | ||||
| @@ -213,35 +220,35 @@ | ||||
| </div> | ||||
|  | ||||
| <div class='panel panel-default panel-inventree panel-hidden' id='panel-outputs'> | ||||
|     {% if not build.is_complete %} | ||||
|     <div class='panel-heading'> | ||||
|         <h4>{% trans "Incomplete Build Outputs" %}</h4> | ||||
|     </div> | ||||
|     <div class='panel-content'> | ||||
|         <div class='btn-group' role='group'> | ||||
|             {% if build.active %} | ||||
|             <button class='btn btn-primary' type='button' id='btn-create-output' title='{% trans "Create new build output" %}'> | ||||
|                 <span class='fas fa-plus-circle'></span> {% trans "Create New Output" %} | ||||
|             </button> | ||||
|             {% endif %} | ||||
|         <div id='build-output-toolbar'> | ||||
|             <div class='button-toolbar container-fluid'> | ||||
|                 {% if build.active %} | ||||
|                 <div class='btn-group'> | ||||
|                     <button class='btn btn-success' type='button' id='btn-create-output' title='{% trans "Create new build output" %}'> | ||||
|                         <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> | ||||
|  | ||||
|         {% 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 %} | ||||
|         <table class='table table-striped table-condensed' id='build-output-table' data-toolbar='#build-output-toolbar'></table> | ||||
|     </div> | ||||
|     {% endif %} | ||||
| </div> | ||||
|  | ||||
| <div class='panel panel-default panel-inventree panel-hidden' id='panel-completed'> | ||||
|     <div class='panel-heading'> | ||||
|         <h4> | ||||
|             {% trans "Completed Build Outputs" %} | ||||
| @@ -313,26 +320,75 @@ loadStockTable($("#build-stock-table"), { | ||||
|     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 build output as a javascript object | ||||
| inventreeGet('{% url 'api-stock-detail' item.pk %}', {}, | ||||
| // Get the list of BOM items required for this build | ||||
| inventreeGet( | ||||
|     '{% url "api-bom-list" %}', | ||||
|     { | ||||
|         part: {{ build.part.pk }}, | ||||
|         sub_part_detail: true, | ||||
|     }, | ||||
|     { | ||||
|         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'), { | ||||
|     url: '{% url "api-build-list" %}', | ||||
| @@ -342,6 +398,7 @@ loadBuildTable($('#sub-build-table'), { | ||||
|     } | ||||
| }); | ||||
|  | ||||
|  | ||||
| enableDragAndDrop( | ||||
|     '#attachment-dropzone', | ||||
|     '{% 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() { | ||||
|     $('#allocation-table-untracked').bootstrapTable('refresh'); | ||||
| } | ||||
| @@ -471,6 +523,10 @@ $('#allocate-selected-items').click(function() { | ||||
|  | ||||
|     var bom_items = $("#allocation-table-untracked").bootstrapTable("getSelections"); | ||||
|  | ||||
|     if (bom_items.length == 0) { | ||||
|         bom_items = $("#allocation-table-untracked").bootstrapTable('getData'); | ||||
|     } | ||||
|  | ||||
|     allocateStockToBuild( | ||||
|         {{ build.pk }}, | ||||
|         {{ build.part.pk }}, | ||||
|   | ||||
| @@ -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 %} | ||||
| @@ -19,16 +19,25 @@ | ||||
|     {% if build.active %} | ||||
|     <li class='list-group-item' title='{% trans "Allocate Stock" %}'> | ||||
|         <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" %} | ||||
|         </a> | ||||
|     </li> | ||||
|     {% 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'> | ||||
|             <span class='fas fa-box sidebar-icon'></span> | ||||
|             {% trans "Build Outputs" %} | ||||
|             <span class='fas fa-tools sidebar-icon'></span> | ||||
|             {% 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> | ||||
|     </li> | ||||
|  | ||||
|   | ||||
| @@ -7,6 +7,7 @@ from django.urls import reverse | ||||
|  | ||||
| from part.models import Part | ||||
| from build.models import Build, BuildItem | ||||
| from stock.models import StockItem | ||||
|  | ||||
| from InvenTree.status_codes import BuildStatus | ||||
| from InvenTree.api_tester import InvenTreeAPITestCase | ||||
| @@ -37,6 +38,148 @@ class BuildAPITest(InvenTreeAPITestCase): | ||||
|         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): | ||||
|     """ | ||||
|     Unit tests for allocation of stock items against a build order. | ||||
|   | ||||
| @@ -339,11 +339,11 @@ class BuildTest(TestCase): | ||||
|         self.assertTrue(self.build.isFullyAllocated(self.output_1)) | ||||
|         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.build.completeBuildOutput(self.output_2, None) | ||||
|         self.build.complete_build_output(self.output_2, None) | ||||
|  | ||||
|         self.assertTrue(self.build.can_complete) | ||||
|  | ||||
|   | ||||
| @@ -15,7 +15,7 @@ from datetime import datetime, timedelta | ||||
| from .models import Build | ||||
| from stock.models import StockItem | ||||
|  | ||||
| from InvenTree.status_codes import BuildStatus, StockStatus | ||||
| from InvenTree.status_codes import BuildStatus | ||||
|  | ||||
|  | ||||
| class BuildTestSimple(TestCase): | ||||
| @@ -252,53 +252,6 @@ class TestBuildViews(TestCase): | ||||
|  | ||||
|         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): | ||||
|         """ Test the build cancellation form """ | ||||
|  | ||||
|   | ||||
| @@ -11,7 +11,6 @@ build_detail_urls = [ | ||||
|     url(r'^delete/', views.BuildDelete.as_view(), name='build-delete'), | ||||
|     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'^complete-output/', views.BuildOutputComplete.as_view(), name='build-output-complete'), | ||||
|     url(r'^complete/', views.BuildComplete.as_view(), name='build-complete'), | ||||
|  | ||||
|     url(r'^.*$', views.BuildDetail.as_view(), name='build-detail'), | ||||
|   | ||||
| @@ -12,16 +12,17 @@ from django.forms import HiddenInput | ||||
|  | ||||
| from .models import Build | ||||
| from . import forms | ||||
| from stock.models import StockLocation, StockItem | ||||
| from stock.models import StockItem | ||||
|  | ||||
| from InvenTree.views import AjaxUpdateView, AjaxDeleteView | ||||
| from InvenTree.views import InvenTreeRoleMixin | ||||
| 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): | ||||
|     """ View for displaying list of Builds | ||||
|     """ | ||||
|     View for displaying list of Builds | ||||
|     """ | ||||
|     model = Build | ||||
|     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): | ||||
|     """ Detail view of a single Build object. """ | ||||
|     """ | ||||
|     Detail view of a single Build object. | ||||
|     """ | ||||
|  | ||||
|     model = Build | ||||
|     template_name = 'build/detail.html' | ||||
| @@ -477,7 +310,9 @@ class BuildDetail(InvenTreeRoleMixin, DetailView): | ||||
|  | ||||
|  | ||||
| class BuildDelete(AjaxDeleteView): | ||||
|     """ View to delete a build """ | ||||
|     """ | ||||
|     View to delete a build | ||||
|     """ | ||||
|  | ||||
|     model = Build | ||||
|     ajax_template_name = 'build/delete_build.html' | ||||
|   | ||||
| @@ -1045,6 +1045,13 @@ class InvenTreeUserSetting(BaseInvenTreeSetting): | ||||
|             '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': { | ||||
|             'name': _('Show Quantity in Forms'), | ||||
|             'description': _('Display available part quantity in some forms'), | ||||
|   | ||||
| @@ -1,7 +0,0 @@ | ||||
| {% extends "modal_delete_form.html" %} | ||||
|  | ||||
| {% block pre_form_content %} | ||||
|  | ||||
| Are you sure you wish to delete this currency? | ||||
|  | ||||
| {% endblock %} | ||||
| @@ -5,7 +5,6 @@ JSON API for the Order app | ||||
| # -*- coding: utf-8 -*- | ||||
| from __future__ import unicode_literals | ||||
|  | ||||
| from django.utils.translation import ugettext_lazy as _ | ||||
| from django.conf.urls import url, include | ||||
| 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 filters, status | ||||
| from rest_framework.response import Response | ||||
| from rest_framework.serializers import ValidationError | ||||
|  | ||||
|  | ||||
| from InvenTree.filters import InvenTreeOrderingFilter | ||||
| @@ -236,25 +234,15 @@ class POReceive(generics.CreateAPIView): | ||||
|         context = super().get_serializer_context() | ||||
|  | ||||
|         # 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 | ||||
|  | ||||
|         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): | ||||
|     """ | ||||
|   | ||||
| @@ -9,7 +9,7 @@ from django import forms | ||||
| from django.utils.translation import ugettext_lazy as _ | ||||
|  | ||||
| from InvenTree.forms import HelperForm | ||||
| from InvenTree.fields import InvenTreeMoneyField, RoundingDecimalFormField | ||||
| from InvenTree.fields import InvenTreeMoneyField | ||||
|  | ||||
| from InvenTree.helpers import clean_decimal | ||||
|  | ||||
| @@ -19,7 +19,6 @@ import part.models | ||||
|  | ||||
| from .models import PurchaseOrder | ||||
| from .models import SalesOrder, SalesOrderLineItem | ||||
| from .models import SalesOrderAllocation | ||||
|  | ||||
|  | ||||
| class IssuePurchaseOrderForm(HelperForm): | ||||
| @@ -81,6 +80,8 @@ class AllocateSerialsToSalesOrderForm(forms.Form): | ||||
|     """ | ||||
|     Form for assigning stock to a sales order, | ||||
|     by serial number lookup | ||||
|  | ||||
|     TODO: Refactor this form / view to use the new API forms interface | ||||
|     """ | ||||
|  | ||||
|     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): | ||||
|     """ Override MatchItemForm fields """ | ||||
|  | ||||
|   | ||||
| @@ -36,31 +36,39 @@ src="{% static 'img/blank_image.png' %}" | ||||
| <p>{{ order.description }}{% include "clip.html"%}</p> | ||||
| <div class='btn-row'> | ||||
|     <div class='btn-group action-buttons' role='group'> | ||||
|         <button type='button' class='btn btn-default' id='print-order-report' title='{% trans "Print" %}'> | ||||
|             <span class='fas fa-print'></span>  | ||||
|         </button> | ||||
|         <button type='button' class='btn btn-default' id='export-order' title='{% trans "Export order to file" %}'> | ||||
|             <span class='fas fa-file-download'></span> | ||||
|         </button> | ||||
|         <!-- Printing options --> | ||||
|         <div class='btn-group'> | ||||
|             <button id='print-options' title='{% trans "Print actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'> | ||||
|                 <span class='fas fa-print'></span> <span class='caret'></span> | ||||
|             </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 %} | ||||
|         <button type='button' class='btn btn-default' id='edit-order' title='{% trans "Edit order information" %}'> | ||||
|             <span class='fas fa-edit icon-green'></span> | ||||
|         </button> | ||||
|         <!-- order actions --> | ||||
|         <div class='btn-group'> | ||||
|             <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 %} | ||||
|         <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> | ||||
|         {% elif order.status == PurchaseOrderStatus.PLACED %} | ||||
|         <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 type='button' class='btn btn-default' id='complete-order' title='{% trans "Mark order as complete" %}'> | ||||
|             <span class='fas fa-check-circle'></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> | ||||
|             <span class='fas fa-check-circle icon-green'></span> | ||||
|         </button> | ||||
|         {% endif %} | ||||
|         {% endif %} | ||||
|   | ||||
| @@ -47,30 +47,39 @@ src="{% static 'img/blank_image.png' %}" | ||||
| <p>{{ order.description }}{% include "clip.html"%}</p> | ||||
| <div class='btn-row'> | ||||
|     <div class='btn-group action-buttons'> | ||||
|         <button type='button' class='btn btn-default' id='print-order-report' title='{% trans "Print" %}'> | ||||
|             <span class='fas fa-print'></span>  | ||||
|         </button> | ||||
|         <button type='button' class='btn btn-default' id='export-order' title='{% trans "Export order to file" %}'> | ||||
|             <span class='fas fa-file-download'></span> | ||||
|         </button> | ||||
|         <!-- Printing actions --> | ||||
|         <div class='btn-group'> | ||||
|             <button id='print-options' title='{% trans "Print actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'> | ||||
|                 <span class='fas fa-print'></span> <span class='caret'></span> | ||||
|             </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 %} | ||||
|         <button type='button' class='btn btn-default' id='edit-order' title='{% trans "Edit order information" %}'> | ||||
|             <span class='fas fa-edit icon-green'></span> | ||||
|         </button> | ||||
|         <!-- Order actions --> | ||||
|         <div class='btn-group'> | ||||
|             <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 %} | ||||
|         <button type='button' class='btn btn-default' id='ship-order' title='{% trans "Ship order" %}'> | ||||
|             <span class='fas fa-paper-plane 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> | ||||
|             <span class='fas fa-truck icon-blue'></span> | ||||
|         </button> | ||||
|         {% 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> | ||||
| {% endblock %} | ||||
|   | ||||
| @@ -203,6 +203,23 @@ class PurchaseOrderTest(OrderTest): | ||||
|         # And if we try to access the detail view again, it has gone | ||||
|         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): | ||||
|     """ | ||||
| @@ -607,3 +624,20 @@ class SalesOrderTest(OrderTest): | ||||
|  | ||||
|         # And the resource should no longer be available | ||||
|         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 | ||||
|         ) | ||||
|   | ||||
| @@ -18,7 +18,7 @@ import common.models | ||||
| from common.forms import MatchItemForm | ||||
|  | ||||
| from .models import Part, PartCategory, PartRelated | ||||
| from .models import PartParameterTemplate, PartParameter | ||||
| from .models import PartParameterTemplate | ||||
| from .models import PartCategoryParameterTemplate | ||||
| 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): | ||||
|     """ Form for editing a PartCategory object """ | ||||
|  | ||||
|   | ||||
| @@ -16,8 +16,6 @@ from InvenTree.forms import HelperForm | ||||
| from InvenTree.fields import RoundingDecimalFormField | ||||
| from InvenTree.fields import DatePickerFormField | ||||
|  | ||||
| from report.models import TestReport | ||||
|  | ||||
| from part.models import Part | ||||
|  | ||||
| from .models import StockLocation, StockItem, StockItemTracking | ||||
| @@ -26,6 +24,8 @@ from .models import StockLocation, StockItem, StockItemTracking | ||||
| class AssignStockItemToCustomerForm(HelperForm): | ||||
|     """ | ||||
|     Form for manually assigning a StockItem to a Customer | ||||
|  | ||||
|     TODO: This could be a simple API driven form! | ||||
|     """ | ||||
|  | ||||
|     class Meta: | ||||
| @@ -38,6 +38,8 @@ class AssignStockItemToCustomerForm(HelperForm): | ||||
| class ReturnStockItemForm(HelperForm): | ||||
|     """ | ||||
|     Form for manually returning a StockItem into stock | ||||
|  | ||||
|     TODO: This could be a simple API driven form! | ||||
|     """ | ||||
|  | ||||
|     class Meta: | ||||
| @@ -48,7 +50,11 @@ class ReturnStockItemForm(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: | ||||
|         model = StockLocation | ||||
| @@ -63,6 +69,8 @@ class EditStockLocationForm(HelperForm): | ||||
| class ConvertStockItemForm(HelperForm): | ||||
|     """ | ||||
|     Form for converting a StockItem to a variant of its current part. | ||||
|  | ||||
|     TODO: Migrate this form to the modern API forms interface | ||||
|     """ | ||||
|  | ||||
|     class Meta: | ||||
| @@ -73,7 +81,11 @@ class ConvertStockItemForm(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( | ||||
|         label=_('Expiry Date'), | ||||
| @@ -129,7 +141,11 @@ class CreateStockItemForm(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)')) | ||||
|  | ||||
| @@ -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): | ||||
|     """ | ||||
|     Form for manually installing a stock item into another stock item | ||||
|  | ||||
|     TODO: Migrate this form to the modern API forms interface | ||||
|     """ | ||||
|  | ||||
|     part = forms.ModelChoiceField( | ||||
| @@ -275,6 +229,8 @@ class InstallStockForm(HelperForm): | ||||
| class UninstallStockForm(forms.ModelForm): | ||||
|     """ | ||||
|     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')) | ||||
| @@ -301,6 +257,8 @@ class EditStockItemForm(HelperForm): | ||||
|     location - Must be updated in a 'move' transaction | ||||
|     quantity - Must be updated in a 'stocktake' transaction | ||||
|     part - Cannot be edited after creation | ||||
|  | ||||
|     TODO: Migrate this form to the modern API forms interface | ||||
|     """ | ||||
|  | ||||
|     expiry_date = DatePickerFormField( | ||||
|   | ||||
| @@ -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> | ||||
| @@ -1 +0,0 @@ | ||||
| {% extends "modal_form.html" %} | ||||
| @@ -12,12 +12,14 @@ | ||||
| <hr> | ||||
|  | ||||
| <div class='col-sm-3' id='item-panel'> | ||||
|     <ul class='list-group' id='action-item-list'> | ||||
|     </ul> | ||||
|     <div class='panel panel-default panel-inventree'> | ||||
|         <ul class='list-group' id='action-item-list'> | ||||
|         </ul> | ||||
|     </div> | ||||
| </div> | ||||
| <div class='col-sm-9' id='details-panel'> | ||||
|     <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'> | ||||
|                 <img class='index-bg' src='{% static "img/inventree.png" %}'> | ||||
|             </div> | ||||
| @@ -54,7 +56,7 @@ function addHeaderAction(label, title, icon, options) { | ||||
|  | ||||
|     // Add a detail item to the detail item-panel | ||||
|     $("#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> | ||||
|             <table class='table table-condensed table-striped' id='table-${label}'></table> | ||||
|         </li>` | ||||
|   | ||||
| @@ -26,12 +26,14 @@ | ||||
| {% endif %} | ||||
|  | ||||
| <div class='col-sm-3' id='item-panel'> | ||||
|     <ul class='list-group' id='search-item-list'> | ||||
|     </ul> | ||||
|     <div class='panel panel-default panel-inventree'> | ||||
|         <ul class='list-group' id='search-item-list'> | ||||
|         </ul> | ||||
|     </div> | ||||
| </div> | ||||
| <div class='col-sm-9' id='details-panel'> | ||||
|     <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'> | ||||
|                 <img class='index-bg' src='{% static "img/inventree.png" %}'> | ||||
|             </div> | ||||
| @@ -67,7 +69,7 @@ | ||||
|  | ||||
|         // Add a results table | ||||
|         $('#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> | ||||
|                 <table class='table table-condensed table-striped' id='table-${label}'></table> | ||||
|             </li>` | ||||
|   | ||||
| @@ -10,12 +10,12 @@ | ||||
|     </li> | ||||
|  | ||||
|     <li class='list-group-item'> | ||||
|         <strong>{% trans "User Settings" %}</strong> | ||||
|         <span class='fas fa-user'></span> <strong>{% trans "User Settings" %}</strong> | ||||
|     </li> | ||||
|  | ||||
|     <li class='list-group-item' title='{% trans "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> | ||||
|     </li> | ||||
|  | ||||
| @@ -60,7 +60,7 @@ | ||||
|     {% if user.is_staff %} | ||||
|  | ||||
|     <li class='list-group-item'> | ||||
|         <strong>{% trans "InvenTree Settings" %}</strong> | ||||
|         <span class='fas fa-cogs'></span> <strong>{% trans "Global Settings" %}</strong> | ||||
|     </li> | ||||
|  | ||||
|     <li class='list-group-item' title='{% trans "Server" %}'> | ||||
|   | ||||
| @@ -16,6 +16,7 @@ | ||||
|         {% include "InvenTree/settings/header.html" %} | ||||
|         <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_SHOW_STOCK_LEVELS" user_setting=True icon='fa-boxes' %} | ||||
|         </tbody> | ||||
|     </table> | ||||
| </div> | ||||
|   | ||||
| @@ -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 %} | ||||
| @@ -143,7 +143,6 @@ | ||||
|  | ||||
| <!-- general InvenTree --> | ||||
| <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 --> | ||||
| <script type='text/javascript' src="{% url 'inventree.js' %}"></script> | ||||
|   | ||||
| @@ -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> | ||||
| @@ -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> | ||||
| @@ -140,11 +140,13 @@ function inventreeDocReady() { | ||||
|                     offset: 0 | ||||
|                 }, | ||||
|                 success: function(data) { | ||||
|  | ||||
|                     var transformed = $.map(data.results, function(el) { | ||||
|                         return { | ||||
|                             label: el.full_name, | ||||
|                             id: el.pk, | ||||
|                             thumbnail: el.thumbnail | ||||
|                             thumbnail: el.thumbnail, | ||||
|                             data: el, | ||||
|                         }; | ||||
|                     }); | ||||
|                     response(transformed); | ||||
| @@ -164,7 +166,18 @@ function inventreeDocReady() { | ||||
|                 html += `'> `; | ||||
|                 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); | ||||
|             }; | ||||
| @@ -290,3 +303,8 @@ function loadBrandIcon(element, name) { | ||||
|         element.addClass('fab fa-' + name); | ||||
|     } | ||||
| } | ||||
|  | ||||
| // Convenience function to determine if an element exists | ||||
| $.fn.exists = function() { | ||||
|     return this.length !== 0; | ||||
| }; | ||||
|   | ||||
| @@ -3,6 +3,9 @@ | ||||
|  | ||||
| /* exported | ||||
|     attachNavCallbacks, | ||||
|     enableNavbar, | ||||
|     initNavTree, | ||||
|     loadTree, | ||||
|     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'); | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -24,7 +24,7 @@ | ||||
|     loadAllocationTable, | ||||
|     loadBuildOrderAllocationTable, | ||||
|     loadBuildOutputAllocationTable, | ||||
|     loadBuildPartsTable, | ||||
|     loadBuildOutputTable, | ||||
|     loadBuildTable, | ||||
| */ | ||||
|  | ||||
| @@ -108,126 +108,56 @@ function newBuildOrder(options={}) { | ||||
| } | ||||
|  | ||||
|  | ||||
| function makeBuildOutputActionButtons(output, buildInfo, lines) { | ||||
|     /* Generate action buttons for a build output. | ||||
|      */ | ||||
|  | ||||
|     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}`); | ||||
|  | ||||
| /* | ||||
|  * Construct a set of output buttons for a particular build output | ||||
|  */ | ||||
| function makeBuildOutputButtons(output_id, build_info, options={}) { | ||||
|   | ||||
|     var html = `<div class='btn-group float-right' role='group'>`; | ||||
|  | ||||
|     if (lines > 0) { | ||||
|         html += makeIconButton( | ||||
|             'fa-sign-in-alt icon-blue', 'button-output-auto', outputId, | ||||
|             '{% trans "Allocate stock items to this build output" %}', | ||||
|         ); | ||||
|     } | ||||
|     // Tracked parts? Must be individually allocated | ||||
|     if (build_info.tracked_parts) { | ||||
|  | ||||
|     if (lines > 0) { | ||||
|         // Add a button to "cancel" the particular build output (unallocate) | ||||
|         // Add a button to allocate stock against this build output | ||||
|         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" %}', | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     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 | ||||
|         html += makeIconButton( | ||||
|             'fa-check icon-green', 'button-output-complete', outputId, | ||||
|             '{% trans "Complete build output" %}', | ||||
|             { | ||||
|                 // disabled: true | ||||
|             } | ||||
|         ); | ||||
|     // Add a button to "delete" this build output | ||||
|     html += makeIconButton( | ||||
|         'fa-trash-alt icon-red', | ||||
|         'button-output-delete', | ||||
|         output_id, | ||||
|         '{% trans "Delete build output" %}', | ||||
|     ); | ||||
|  | ||||
|         // Add a button to "delete" the particular build output | ||||
|         html += makeIconButton( | ||||
|             'fa-trash-alt icon-red', 'button-output-delete', outputId, | ||||
|             '{% trans "Delete build output" %}', | ||||
|         ); | ||||
|     html += `</div>`; | ||||
|  | ||||
|         // 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={}) { | ||||
|     /** | ||||
|      * Load a table showing all the BuildOrder allocations for a given part | ||||
|      */ | ||||
|  | ||||
|     options.params['part_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. | ||||
|      *  | ||||
|      * 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) | ||||
|      * Construct a "sub table" showing the required BOM items | ||||
|      */ | ||||
|     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 partId = buildInfo.part; | ||||
| @@ -534,7 +849,11 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { | ||||
|         }, | ||||
|         name: 'build-allocation', | ||||
|         uniqueId: 'sub_part', | ||||
|         onPostBody: setupCallbacks, | ||||
|         search: options.search || false, | ||||
|         onPostBody: function(data) { | ||||
|             // Setup button callbacks | ||||
|             setupCallbacks(); | ||||
|         }, | ||||
|         onLoadSuccess: function(tableData) { | ||||
|             // 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); | ||||
|                         } | ||||
|  | ||||
|                         // Update the total progress for this build output | ||||
|                         var buildProgress = $(`#allocation-panel-${outputId}`).find($(`#output-progress-${outputId}`)); | ||||
|                         // Update the progress bar for this build output | ||||
|                         var build_progress = $(`#output-progress-${outputId}`); | ||||
|  | ||||
|                         if (totalLines > 0) { | ||||
|                         if (build_progress.exists()) { | ||||
|                             if (totalLines > 0) { | ||||
|  | ||||
|                             var progress = makeProgressBar( | ||||
|                                 allocatedLines, | ||||
|                                 totalLines | ||||
|                             ); | ||||
|  | ||||
|                             buildProgress.html(progress); | ||||
|                                 var progress = makeProgressBar( | ||||
|                                     allocatedLines, | ||||
|                                     totalLines | ||||
|                                 ); | ||||
|      | ||||
|                                 build_progress.html(progress); | ||||
|                             } else { | ||||
|                                 build_progress.html(''); | ||||
|                             } | ||||
|      | ||||
|                         } 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, | ||||
|         showColumns: false, | ||||
|         detailViewByClick: true, | ||||
|         detailView: true, | ||||
|         detailFilter: function(index, row) { | ||||
|             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; | ||||
|         } | ||||
|  | ||||
|         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( | ||||
|             '{% trans "Select Parts" %}', | ||||
| @@ -1085,6 +1404,24 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { | ||||
|                         render_part_detail: true, | ||||
|                         render_location_detail: 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) { | ||||
|                             // Restrict query to the selected location | ||||
|                             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) { | ||||
|     // Display a table of Build objects | ||||
|  | ||||
|     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, | ||||
|     });     | ||||
| } | ||||
|   | ||||
| @@ -1426,6 +1426,11 @@ function initializeRelatedField(field, fields, options) { | ||||
|                 data = item.element.instance; | ||||
|             } | ||||
|  | ||||
|             // Run optional callback function | ||||
|             if (field.onSelect && data) { | ||||
|                 field.onSelect(data, field, options); | ||||
|             } | ||||
|  | ||||
|             if (!data.pk) { | ||||
|                 return field.placeholder || ''; | ||||
|             } | ||||
| @@ -1843,6 +1848,8 @@ function constructInput(name, parameters, options) { | ||||
|     case 'candy': | ||||
|         func = constructCandyInput; | ||||
|         break; | ||||
|     case 'raw': | ||||
|         func = constructRawInput; | ||||
|     default: | ||||
|         // Unsupported field type! | ||||
|         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 | ||||
|  *  | ||||
|   | ||||
| @@ -87,8 +87,10 @@ function select2Thumbnail(image) { | ||||
| } | ||||
|  | ||||
|  | ||||
| /* | ||||
|  * Construct an 'icon badge' which floats to the right of an object | ||||
|  */ | ||||
| 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>`; | ||||
|  | ||||
| @@ -96,8 +98,10 @@ function makeIconBadge(icon, title) { | ||||
| } | ||||
|  | ||||
|  | ||||
| /* | ||||
|  * Construct an 'icon button' using the fontawesome set | ||||
|  */ | ||||
| function makeIconButton(icon, cls, pk, title, options={}) { | ||||
|     // Construct an 'icon button' using the fontawesome set | ||||
|  | ||||
|     var classes = `btn btn-default btn-glyph ${cls}`; | ||||
|  | ||||
|   | ||||
| @@ -168,11 +168,7 @@ function renderPart(name, data, parameters, options) { | ||||
|  | ||||
|     // Display available part quantity | ||||
|     if (user_settings.PART_SHOW_QUANTITY_IN_FORMS) { | ||||
|         if (data.in_stock == 0) { | ||||
|             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>`; | ||||
|         } | ||||
|         extra += partStockLabel(data); | ||||
|     } | ||||
|  | ||||
|     if (!data.active) { | ||||
|   | ||||
| @@ -1641,6 +1641,13 @@ function loadSalesOrderLineItemTable(table, options={}) { | ||||
|  | ||||
|             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 = { | ||||
|                 // SalesOrderLineItem reference | ||||
|                 line: { | ||||
| @@ -1654,9 +1661,26 @@ function loadSalesOrderLineItemTable(table, options={}) { | ||||
|                         in_stock: true, | ||||
|                         part: line_item.part, | ||||
|                         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: { | ||||
|                     value: remaining, | ||||
|                 }, | ||||
|             }; | ||||
|  | ||||
| @@ -1752,7 +1776,7 @@ function loadSalesOrderLineItemTable(table, options={}) { | ||||
|         showFooter: true, | ||||
|         uniqueId: 'pk', | ||||
|         detailView: show_detail, | ||||
|         detailViewByClick: show_detail, | ||||
|         detailViewByClick: false, | ||||
|         detailFilter: function(index, row) { | ||||
|             if (pending) { | ||||
|                 // Order is pending | ||||
|   | ||||
| @@ -35,6 +35,7 @@ | ||||
|     loadSellPricingChart, | ||||
|     loadSimplePartTable, | ||||
|     loadStockPricingChart, | ||||
|     partStockLabel, | ||||
|     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) { | ||||
|     /* Render a set of icons for the given part. | ||||
|      */ | ||||
| @@ -778,7 +791,7 @@ function partGridTile(part) { | ||||
|  | ||||
|     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-heading'> | ||||
|                 <a href='/part/${part.pk}/'> | ||||
| @@ -1000,8 +1013,8 @@ function loadPartTable(table, url, options={}) { | ||||
|  | ||||
|             data.forEach(function(row, index) { | ||||
|                  | ||||
|                 // Force a new row every 4 columns, to prevent visual issues | ||||
|                 if ((index > 0) && (index % 4 == 0) && (index < data.length)) { | ||||
|                 // Force a new row every 5 columns | ||||
|                 if ((index > 0) && (index % 5 == 0) && (index < data.length)) { | ||||
|                     html += `</div><div class='row full-height'>`; | ||||
|                 } | ||||
|  | ||||
|   | ||||
| @@ -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) { | ||||
|     /* 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') { | ||||
|         table = $(table); | ||||
|   | ||||
| @@ -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> | ||||
| @@ -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> | ||||
		Reference in New Issue
	
	Block a user