diff --git a/InvenTree/static/script/bootstrap/bootstrap-table-group-by.js b/InvenTree/static/script/bootstrap/bootstrap-table-group-by.js
new file mode 100644
index 0000000000..4195ba394f
--- /dev/null
+++ b/InvenTree/static/script/bootstrap/bootstrap-table-group-by.js
@@ -0,0 +1,263 @@
+(function (global, factory) {
+ if (typeof define === "function" && define.amd) {
+ define([], factory);
+ } else if (typeof exports !== "undefined") {
+ factory();
+ } else {
+ var mod = {
+ exports: {}
+ };
+ factory();
+ global.bootstrapTableGroupBy = mod.exports;
+ }
+})(this, function () {
+ 'use strict';
+
+ /**
+ * @author: Yura Knoxville
+ * @version: v1.1.0
+ */
+
+ (function ($) {
+
+ 'use strict';
+
+ var initBodyCaller, tableGroups;
+
+ // it only does '%s', and return '' when arguments are undefined
+ var sprintf = function sprintf(str) {
+ var args = arguments,
+ flag = true,
+ i = 1;
+
+ str = str.replace(/%s/g, function () {
+ var arg = args[i++];
+
+ if (typeof arg === 'undefined') {
+ flag = false;
+ return '';
+ }
+ return arg;
+ });
+ return flag ? str : '';
+ };
+
+ var groupBy = function groupBy(array, f) {
+ var groups = {};
+ array.forEach(function (o) {
+ var group = f(o);
+ groups[group] = groups[group] || [];
+ groups[group].push(o);
+ });
+
+ return groups;
+ };
+
+ $.extend($.fn.bootstrapTable.defaults, {
+ groupBy: false,
+ groupByField: '',
+ groupByFormatter: undefined
+ });
+
+ var BootstrapTable = $.fn.bootstrapTable.Constructor,
+ _initSort = BootstrapTable.prototype.initSort,
+ _initBody = BootstrapTable.prototype.initBody,
+ _updateSelected = BootstrapTable.prototype.updateSelected;
+
+ BootstrapTable.prototype.initSort = function () {
+ _initSort.apply(this, Array.prototype.slice.apply(arguments));
+
+ var that = this;
+ tableGroups = [];
+
+ console.log('Sorting...');
+
+ console.log(typeof this.options.groupByField);
+
+ if (this.options.groupBy && this.options.groupByField !== '') {
+
+ if (1 || (this.options.sortName != this.options.groupByField)) {
+ this.data.sort(function (a, b) {
+ console.log('x');
+ return a[that.options.groupByField].localeCompare(b[that.options.groupByField]);
+ });
+ }
+
+ var that = this;
+ var groups = groupBy(that.data, function (item) {
+ return [item[that.options.groupByField]];
+ });
+
+ var index = 0;
+ $.each(groups, function (key, value) {
+ tableGroups.push({
+ id: index,
+ name: key,
+ data: value
+ });
+
+ value.forEach(function (item) {
+ if (!item._data) {
+ item._data = {};
+ }
+
+ item._data['parent-index'] = index;
+ });
+
+ index++;
+ });
+ }
+ };
+
+ BootstrapTable.prototype.initBody = function () {
+ initBodyCaller = true;
+
+ _initBody.apply(this, Array.prototype.slice.apply(arguments));
+
+ if (this.options.groupBy && this.options.groupByField !== '') {
+ var that = this,
+ checkBox = false,
+ visibleColumns = 0;
+
+ var cols = [];
+
+ this.columns.forEach(function (column) {
+ if (column.checkbox) {
+ checkBox = true;
+ } else {
+ if (column.visible) {
+ visibleColumns += 1;
+ cols.push(column);
+ }
+ }
+ });
+
+ if (this.options.detailView && !this.options.cardView) {
+ visibleColumns += 1;
+ }
+
+ tableGroups.forEach(function (item) {
+ var html = [];
+
+ html.push(sprintf('
', item.id));
+
+ if (that.options.detailView && !that.options.cardView) {
+ html.push(' | ');
+ }
+
+ if (checkBox) {
+ html.push('', '', ' | ');
+ }
+
+ cols.forEach(function(col) {
+ var cell = '';
+
+ if (typeof that.options.groupByFormatter == 'function') {
+ cell += that.options.groupByFormatter(col.title, item.id, item.data);
+ }
+
+ cell += " | ";
+
+ html.push(cell);
+ });
+
+ /*
+ var formattedValue = item.name;
+ if (typeof that.options.groupByFormatter == "function") {
+ formattedValue = that.options.groupByFormatter(item.name, item.id, item.data);
+ }
+ html.push('', formattedValue, ' | ');
+
+ cols.forEach(function(col) {
+ html.push('' + item.data[0][col.field] + ' | ');
+ });
+ */
+
+ html.push('
');
+
+ if(item.data.length > 1) {
+ that.$body.find('tr[data-parent-index=' + item.id + ']:first').before($(html.join('')));
+ }
+ });
+
+ this.$selectGroup = [];
+ this.$body.find('[name="btSelectGroup"]').each(function () {
+ var self = $(this);
+
+ that.$selectGroup.push({
+ group: self,
+ item: that.$selectItem.filter(function () {
+ return $(this).closest('tr').data('parent-index') === self.closest('tr').data('group-index');
+ })
+ });
+ });
+
+ this.$container.off('click', '.groupBy').on('click', '.groupBy', function () {
+ $(this).toggleClass('expanded');
+ that.$body.find('tr[data-parent-index=' + $(this).closest('tr').data('group-index') + ']').toggleClass('hidden');
+ });
+
+ this.$container.off('click', '[name="btSelectGroup"]').on('click', '[name="btSelectGroup"]', function (event) {
+ event.stopImmediatePropagation();
+
+ var self = $(this);
+ var checked = self.prop('checked');
+ that[checked ? 'checkGroup' : 'uncheckGroup']($(this).closest('tr').data('group-index'));
+ });
+ }
+
+ initBodyCaller = false;
+ this.updateSelected();
+ };
+
+ BootstrapTable.prototype.updateSelected = function () {
+ if (!initBodyCaller) {
+ _updateSelected.apply(this, Array.prototype.slice.apply(arguments));
+
+ if (this.options.groupBy && this.options.groupByField !== '') {
+ this.$selectGroup.forEach(function (item) {
+ var checkGroup = item.item.filter(':enabled').length === item.item.filter(':enabled').filter(':checked').length;
+
+ item.group.prop('checked', checkGroup);
+ });
+ }
+ }
+ };
+
+ BootstrapTable.prototype.getGroupSelections = function (index) {
+ var that = this;
+
+ return $.grep(this.data, function (row) {
+ return row[that.header.stateField] && row._data['parent-index'] === index;
+ });
+ };
+
+ BootstrapTable.prototype.checkGroup = function (index) {
+ this.checkGroup_(index, true);
+ };
+
+ BootstrapTable.prototype.uncheckGroup = function (index) {
+ this.checkGroup_(index, false);
+ };
+
+ BootstrapTable.prototype.checkGroup_ = function (index, checked) {
+ var rows;
+ var filter = function filter() {
+ return $(this).closest('tr').data('parent-index') === index;
+ };
+
+ if (!checked) {
+ rows = this.getGroupSelections(index);
+ }
+
+ this.$selectItem.filter(filter).prop('checked', checked);
+
+ this.updateRows();
+ this.updateSelected();
+ if (checked) {
+ rows = this.getGroupSelections(index);
+ }
+ this.trigger(checked ? 'check-all' : 'uncheck-all', rows);
+ };
+ })(jQuery);
+});
\ No newline at end of file
diff --git a/InvenTree/static/script/inventree/stock.js b/InvenTree/static/script/inventree/stock.js
index 0d18c064e9..66c1fcc1de 100644
--- a/InvenTree/static/script/inventree/stock.js
+++ b/InvenTree/static/script/inventree/stock.js
@@ -372,14 +372,45 @@ function moveStockItems(items, options) {
function loadStockTable(table, options) {
+ var params = options.params || {};
+
+ // Aggregate stock items
+ //params.aggregate = true;
+
table.bootstrapTable({
sortable: true,
search: true,
method: 'get',
pagination: true,
- pageSize: 50,
+ pageSize: 25,
rememberOrder: true,
- queryParams: options.params,
+ groupBy: true,
+ groupByField: 'part_name',
+ groupByFields: ['part_name', 'test'],
+ groupByFormatter: function(field, id, data) {
+
+ if (field == 'Part') {
+ return imageHoverIcon(data[0].part_detail.image_url) +
+ data[0].part_detail.full_name +
+ ' (' + data.length + ' items)';
+ }
+ else if (field == 'Description') {
+ return data[0].part_detail.description;
+ }
+ else if (field == 'Stock') {
+ var stock = 0;
+
+ data.forEach(function(item) {
+ stock += item.quantity;
+ });
+
+ return stock;
+ }
+
+ else {
+ return '';
+ }
+ },
columns: [
{
checkbox: true,
@@ -419,7 +450,7 @@ function loadStockTable(table, options) {
},
{
field: 'quantity',
- title: 'Quantity',
+ title: 'Stock',
sortable: true,
formatter: function(value, row, index, field) {
var text = renderLink(value, row.url);
@@ -433,6 +464,7 @@ function loadStockTable(table, options) {
}
],
url: options.url,
+ queryParams: params,
});
if (options.buttons) {
diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py
index f6651f6dd4..4efa347ecd 100644
--- a/InvenTree/stock/models.py
+++ b/InvenTree/stock/models.py
@@ -183,6 +183,9 @@ class StockItem(models.Model):
def get_absolute_url(self):
return reverse('stock-item-detail', kwargs={'pk': self.id})
+ def get_part_name(self):
+ return self.part.full_name
+
class Meta:
unique_together = [
('part', 'serial'),
diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py
index 6037d82031..21f2ef4e9e 100644
--- a/InvenTree/stock/serializers.py
+++ b/InvenTree/stock/serializers.py
@@ -57,6 +57,8 @@ class StockItemSerializer(serializers.ModelSerializer):
url = serializers.CharField(source='get_absolute_url', read_only=True)
status_text = serializers.CharField(source='get_status_display', read_only=True)
+ part_name = serializers.CharField(source='get_part_name', read_only=True)
+
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
location_detail = LocationBriefSerializer(source='location', many=False, read_only=True)
@@ -79,6 +81,7 @@ class StockItemSerializer(serializers.ModelSerializer):
'pk',
'url',
'part',
+ 'part_name',
'part_detail',
'supplier_part',
'location',
diff --git a/InvenTree/templates/base.html b/InvenTree/templates/base.html
index dd2f8bd706..69e72248d2 100644
--- a/InvenTree/templates/base.html
+++ b/InvenTree/templates/base.html
@@ -89,6 +89,7 @@ InvenTree
+