@@ -58,7 +76,7 @@
{{ block.super }}
$("#param-table").inventreeTable({
- url: "{% url 'api-part-param-template-list' %}",
+ url: "{% url 'api-part-parameter-template-list' %}",
queryParams: {
ordering: 'name',
},
@@ -125,4 +143,8 @@
});
});
+ $("#import-part").click(function() {
+ launchModalForm("{% url 'api-part-import' %}?reset", {});
+ });
+
{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/templates/attachment_table.html b/InvenTree/templates/attachment_table.html
index 35b114cc05..18a4da9acc 100644
--- a/InvenTree/templates/attachment_table.html
+++ b/InvenTree/templates/attachment_table.html
@@ -10,35 +10,6 @@
-
-
- {% trans "File" %} |
- {% trans "Comment" %} |
- {% trans "Uploaded" %} |
- |
-
-
-
- {% for attachment in attachments %}
-
- {{ attachment.basename }} |
- {{ attachment.comment }} |
-
- {% if attachment.upload_date %}{{ attachment.upload_date }}{% endif %}
- {% if attachment.user %}{{ attachment.user.username }}{% endif %}
- |
-
-
-
-
-
- |
-
- {% endfor %}
-
+
\ No newline at end of file
diff --git a/InvenTree/templates/base.html b/InvenTree/templates/base.html
index 65712b7394..9d4eaa5142 100644
--- a/InvenTree/templates/base.html
+++ b/InvenTree/templates/base.html
@@ -40,8 +40,7 @@
-
-
+
@@ -136,7 +135,7 @@
-
+
@@ -144,25 +143,29 @@
-
+
+
+
+
-
-
+
+
+
+
+
+
+
-
-
-
-
diff --git a/InvenTree/InvenTree/static/script/inventree/api.js b/InvenTree/templates/js/api.js
similarity index 60%
rename from InvenTree/InvenTree/static/script/inventree/api.js
rename to InvenTree/templates/js/api.js
index b43bcc8419..8169671836 100644
--- a/InvenTree/InvenTree/static/script/inventree/api.js
+++ b/InvenTree/templates/js/api.js
@@ -1,5 +1,16 @@
+{% load i18n %}
+{% load inventree_extras %}
+
var jQuery = window.$;
+$.urlParam = function(name){
+ var results = new RegExp('[\?&]' + name + '=([^]*)').exec(window.location.href);
+ if (results==null) {
+ return null;
+ }
+ return decodeURI(results[1]) || 0;
+}
+
// using jQuery
function getCookie(name) {
var cookieValue = null;
@@ -18,7 +29,15 @@ function getCookie(name) {
}
function inventreeGet(url, filters={}, options={}) {
+
+ // Middleware token required for data update
+ //var csrftoken = jQuery("[name=csrfmiddlewaretoken]").val();
+ var csrftoken = getCookie('csrftoken');
+
return $.ajax({
+ beforeSend: function(xhr, settings) {
+ xhr.setRequestHeader('X-CSRFToken', csrftoken);
+ },
url: url,
type: 'GET',
data: filters,
@@ -103,10 +122,11 @@ function inventreePut(url, data={}, options={}) {
}
},
error: function(xhr, ajaxOptions, thrownError) {
- console.error('Error on UPDATE to ' + url);
- console.error(thrownError);
if (options.error) {
options.error(xhr, ajaxOptions, thrownError);
+ } else {
+ console.error(`Error on ${method} to '${url}' - STATUS ${xhr.status}`);
+ console.error(thrownError);
}
},
complete: function(xhr, status) {
@@ -129,4 +149,49 @@ function inventreeDelete(url, options={}) {
inventreePut(url, {}, options);
+}
+
+
+function showApiError(xhr) {
+
+ var title = null;
+ var message = null;
+
+ switch (xhr.status) {
+ case 0: // No response
+ title = '{% trans "No Response" %}';
+ message = '{% trans "No response from the InvenTree server" %}';
+ break;
+ case 400: // Bad request
+ // Note: Normally error code 400 is handled separately,
+ // and should now be shown here!
+ title = '{% trans "Error 400: Bad request" %}';
+ message = '{% trans "API request returned error code 400" %}';
+ break;
+ case 401: // Not authenticated
+ title = '{% trans "Error 401: Not Authenticated" %}';
+ message = '{% trans "Authentication credentials not supplied" %}';
+ break;
+ case 403: // Permission denied
+ title = '{% trans "Error 403: Permission Denied" %}';
+ message = '{% trans "You do not have the required permissions to access this function" %}';
+ break;
+ case 404: // Resource not found
+ title = '{% trans "Error 404: Resource Not Found" %}';
+ message = '{% trans "The requested resource could not be located on the server" %}';
+ break;
+ case 408: // Timeout
+ title = '{% trans "Error 408: Timeout" %}';
+ message = '{% trans "Connection timeout while requesting data from server" %}';
+ break;
+ default:
+ title = '{% trans "Unhandled Error Code" %}';
+ message = `{% trans "Error code" %}: ${xhr.status}`;
+ break;
+ }
+
+ message += "
";
+ message += renderErrorMessage(xhr);
+
+ showAlertDialog(title, message);
}
\ No newline at end of file
diff --git a/InvenTree/templates/js/attachment.js b/InvenTree/templates/js/attachment.js
new file mode 100644
index 0000000000..4b9d522a59
--- /dev/null
+++ b/InvenTree/templates/js/attachment.js
@@ -0,0 +1,86 @@
+{% load i18n %}
+
+function reloadAttachmentTable() {
+
+ $('#attachment-table').bootstrapTable("refresh");
+}
+
+
+function loadAttachmentTable(url, options) {
+
+ var table = options.table || '#attachment-table';
+
+ $(table).inventreeTable({
+ url: url,
+ name: options.name || 'attachments',
+ formatNoMatches: function() { return '{% trans "No attachments found" %}'},
+ sortable: true,
+ search: false,
+ queryParams: options.filters || {},
+ onPostBody: function() {
+ // Add callback for 'edit' button
+ $(table).find('.button-attachment-edit').click(function() {
+ var pk = $(this).attr('pk');
+
+ if (options.onEdit) {
+ options.onEdit(pk);
+ }
+ });
+
+ // Add callback for 'delete' button
+ $(table).find('.button-attachment-delete').click(function() {
+ var pk = $(this).attr('pk');
+
+ if (options.onDelete) {
+ options.onDelete(pk);
+ }
+ });
+ },
+ columns: [
+ {
+ field: 'attachment',
+ title: '{% trans "File" %}',
+ formatter: function(value, row) {
+
+ var split = value.split('/');
+
+ return renderLink(split[split.length - 1], value);
+ }
+ },
+ {
+ field: 'comment',
+ title: '{% trans "Comment" %}',
+ },
+ {
+ field: 'upload_date',
+ title: '{% trans "Upload Date" %}',
+ },
+ {
+ field: 'actions',
+ formatter: function(value, row) {
+ var html = '';
+
+ html = `
`;
+
+ html += makeIconButton(
+ 'fa-edit icon-blue',
+ 'button-attachment-edit',
+ row.pk,
+ '{% trans "Edit attachment" %}',
+ );
+
+ html += makeIconButton(
+ 'fa-trash-alt icon-red',
+ 'button-attachment-delete',
+ row.pk,
+ '{% trans "Delete attachment" %}',
+ );
+
+ html += `
`;
+
+ return html;
+ }
+ }
+ ]
+ });
+}
\ No newline at end of file
diff --git a/InvenTree/templates/js/bom.js b/InvenTree/templates/js/bom.js
index 7328bcb331..32166d972a 100644
--- a/InvenTree/templates/js/bom.js
+++ b/InvenTree/templates/js/bom.js
@@ -242,7 +242,7 @@ function loadBomTable(table, options) {
return renderLink(text, url);
}
});
-
+
cols.push(
{
field: 'purchase_price_range',
@@ -259,26 +259,19 @@ function loadBomTable(table, options) {
sortable: true,
});
- /*
-
- // TODO - Re-introduce the pricing column at a later stage,
- // once the pricing has been "fixed"
- // O.W. 2020-11-24
-
cols.push(
{
field: 'price_range',
- title: '{% trans "Price" %}',
+ title: '{% trans "Buy Price" %}',
sortable: true,
formatter: function(value, row, index, field) {
if (value) {
return value;
} else {
- return "
{% trans "No pricing available" %}";
+ return "
{% trans 'No pricing available' %}";
}
}
});
- */
cols.push({
field: 'optional',
@@ -521,14 +514,13 @@ function loadBomTable(table, options) {
var pk = $(this).attr('pk');
var url = `/part/bom/${pk}/delete/`;
- launchModalForm(
- url,
- {
- success: function() {
- reloadBomTable(table);
- }
+ constructForm(`/api/bom/${pk}/`, {
+ method: 'DELETE',
+ title: '{% trans "Delete BOM Item" %}',
+ onSuccess: function() {
+ reloadBomTable(table);
}
- );
+ });
});
table.on('click', '.bom-edit-button', function() {
diff --git a/InvenTree/templates/js/build.js b/InvenTree/templates/js/build.js
index 3e5e438add..f43de6ec2b 100644
--- a/InvenTree/templates/js/build.js
+++ b/InvenTree/templates/js/build.js
@@ -1,34 +1,72 @@
{% load i18n %}
{% load inventree_extras %}
+
+function buildFormFields() {
+ return {
+ reference: {
+ prefix: "{% settings_value 'BUILDORDER_REFERENCE_PREFIX' %}",
+ },
+ title: {},
+ part: {},
+ quantity: {},
+ parent: {
+ filters: {
+ part_detail: true,
+ }
+ },
+ batch: {},
+ target_date: {},
+ take_from: {},
+ destination: {},
+ link: {
+ icon: 'fa-link',
+ },
+ issued_by: {
+ icon: 'fa-user',
+ },
+ responsible: {
+ icon: 'fa-users',
+ },
+ };
+}
+
+
+function editBuildOrder(pk, options={}) {
+
+ var fields = buildFormFields();
+
+ constructForm(`/api/build/${pk}/`, {
+ fields: fields,
+ reload: true,
+ title: '{% trans "Edit Build Order" %}',
+ });
+}
+
function newBuildOrder(options={}) {
/* Launch modal form to create a new BuildOrder.
*/
- launchModalForm(
- "{% url 'build-create' %}",
- {
- follow: true,
- data: options.data || {},
- callback: [
- {
- field: 'part',
- action: function(value) {
- inventreeGet(
- `/api/part/${value}/`, {},
- {
- success: function(response) {
+ var fields = buildFormFields();
- //enableField('serial_numbers', response.trackable);
- //clearField('serial_numbers');
- }
- }
- );
- },
- }
- ],
- }
- )
+ if (options.part) {
+ fields.part.value = options.part;
+ }
+
+ if (options.quantity) {
+ fields.quantity.value = options.quantity;
+ }
+
+ if (options.parent) {
+ fields.parent.value = options.parent;
+ }
+
+ constructForm(`/api/build/`, {
+ fields: fields,
+ follow: true,
+ method: 'POST',
+ title: '{% trans "Create Build Order" %}'
+ });
}
@@ -231,6 +269,7 @@ function loadBuildOrderAllocationTable(table, options={}) {
{
field: 'quantity',
title: '{% trans "Quantity" %}',
+ sortable: true,
}
]
});
@@ -383,14 +422,10 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
var idx = $(this).closest('tr').attr('data-index');
var row = $(table).bootstrapTable('getData')[idx];
- // Launch form to create a new build order
- launchModalForm('{% url "build-create" %}', {
- follow: true,
- data: {
- part: pk,
- parent: buildId,
- quantity: requiredQuantity(row) - sumAllocations(row),
- }
+ newBuildOrder({
+ part: pk,
+ parent: buildId,
+ quantity: requiredQuantity(row) - sumAllocations(row),
});
});
@@ -875,6 +910,36 @@ function loadBuildTable(table, options) {
title: '{% trans "Created" %}',
sortable: true,
},
+ {
+ field: 'issued_by',
+ title: '{% trans "Issued by" %}',
+ sortable: true,
+ formatter: function(value, row, index, field) {
+ if (value)
+ {
+ return row.issued_by_detail.username;
+ }
+ else
+ {
+ return `
{% trans "No user information" %}`;
+ }
+ }
+ },
+ {
+ field: 'responsible',
+ title: '{% trans "Resposible" %}',
+ sortable: true,
+ formatter: function(value, row, index, field) {
+ if (value)
+ {
+ return row.responsible_detail.name;
+ }
+ else
+ {
+ return '{% trans "No information" %}';
+ }
+ }
+ },
{
field: 'target_date',
title: '{% trans "Target Date" %}',
@@ -1061,13 +1126,9 @@ function loadBuildPartsTable(table, options={}) {
var idx = $(this).closest('tr').attr('data-index');
var row = $(table).bootstrapTable('getData')[idx];
- // Launch form to create a new build order
- launchModalForm('{% url "build-create" %}', {
- follow: true,
- data: {
- part: pk,
- parent: options.build,
- }
+ newBuildOrder({
+ part: pk,
+ parent: options.build,
});
});
}
diff --git a/InvenTree/templates/js/company.js b/InvenTree/templates/js/company.js
index 078b40f4b9..b202fbcd52 100644
--- a/InvenTree/templates/js/company.js
+++ b/InvenTree/templates/js/company.js
@@ -1,5 +1,210 @@
{% load i18n %}
+
+function manufacturerPartFields() {
+
+ return {
+ part: {},
+ manufacturer: {},
+ MPN: {
+ icon: 'fa-hashtag',
+ },
+ description: {},
+ link: {
+ icon: 'fa-link',
+ }
+ };
+}
+
+
+function createManufacturerPart(options={}) {
+
+ var fields = manufacturerPartFields();
+
+ if (options.part) {
+ fields.part.value = options.part;
+ fields.part.hidden = true;
+ }
+
+ if (options.manufacturer) {
+ fields.manufacturer.value = options.manufacturer;
+ }
+
+ constructForm('{% url "api-manufacturer-part-list" %}', {
+ fields: fields,
+ method: 'POST',
+ title: '{% trans "Add Manufacturer Part" %}',
+ onSuccess: options.onSuccess
+ });
+}
+
+
+function editManufacturerPart(part, options={}) {
+
+ var url = `/api/company/part/manufacturer/${part}/`;
+
+ constructForm(url, {
+ fields: manufacturerPartFields(),
+ title: '{% trans "Edit Manufacturer Part" %}',
+ onSuccess: options.onSuccess
+ });
+}
+
+function deleteManufacturerPart(part, options={}) {
+
+ constructForm(`/api/company/part/manufacturer/${part}/`, {
+ method: 'DELETE',
+ title: '{% trans "Delete Manufacturer Part" %}',
+ onSuccess: options.onSuccess,
+ });
+}
+
+
+function supplierPartFields() {
+
+ return {
+ part: {},
+ supplier: {},
+ SKU: {
+ icon: 'fa-hashtag',
+ },
+ manufacturer_part: {
+ filters: {
+ part_detail: true,
+ manufacturer_detail: true,
+ }
+ },
+ description: {},
+ link: {
+ icon: 'fa-link',
+ },
+ note: {
+ icon: 'fa-pencil-alt',
+ },
+ packaging: {
+ icon: 'fa-box',
+ }
+ };
+}
+
+/*
+ * Launch a form to create a new ManufacturerPart
+ */
+function createSupplierPart(options={}) {
+
+ var fields = supplierPartFields();
+
+ if (options.part) {
+ fields.manufacturer_part.filters.part = options.part;
+ fields.part.hidden = true;
+ fields.part.value = options.part;
+ }
+
+ if (options.supplier) {
+ fields.supplier.value = options.supplier;
+ }
+
+ if (options.manufacturer_part) {
+ fields.manufacturer_part.value = options.manufacturer_part;
+ }
+
+ constructForm('{% url "api-supplier-part-list" %}', {
+ fields: fields,
+ method: 'POST',
+ title: '{% trans "Add Supplier Part" %}',
+ onSuccess: options.onSuccess,
+ });
+}
+
+
+function editSupplierPart(part, options={}) {
+
+ constructForm(`/api/company/part/${part}/`, {
+ fields: supplierPartFields(),
+ title: '{% trans "Edit Supplier Part" %}',
+ onSuccess: options.onSuccess
+ });
+}
+
+
+function deleteSupplierPart(part, options={}) {
+
+ constructForm(`/api/company/part/${part}/`, {
+ method: 'DELETE',
+ title: '{% trans "Delete Supplier Part" %}',
+ onSuccess: options.onSuccess,
+ });
+}
+
+
+// Returns a default form-set for creating / editing a Company object
+function companyFormFields(options={}) {
+
+ return {
+ name: {},
+ description: {},
+ website: {
+ icon: 'fa-globe',
+ },
+ address: {
+ icon: 'fa-envelope',
+ },
+ currency: {
+ icon: 'fa-dollar-sign',
+ },
+ phone: {
+ icon: 'fa-phone',
+ },
+ email: {
+ icon: 'fa-at',
+ },
+ contact: {
+ icon: 'fa-address-card',
+ },
+ is_supplier: {},
+ is_manufacturer: {},
+ is_customer: {}
+ };
+}
+
+
+function editCompany(pk, options={}) {
+
+ var fields = options.fields || companyFormFields();
+
+ constructForm(
+ `/api/company/${pk}/`,
+ {
+ method: 'PATCH',
+ fields: fields,
+ reload: true,
+ title: '{% trans "Edit Company" %}',
+ }
+ );
+};
+
+/*
+ * Launches a form to create a new company.
+ * As this can be called from many different contexts,
+ * we abstract it here!
+ */
+function createCompany(options={}) {
+
+ // Default field set
+ var fields = options.fields || companyFormFields();
+
+ constructForm(
+ '{% url "api-company-list" %}',
+ {
+ method: 'POST',
+ fields: fields,
+ follow: true,
+ title: '{% trans "Add new Company" %}',
+ }
+ );
+}
+
+
function loadCompanyTable(table, url, options={}) {
/*
* Load company listing data into specified table.
@@ -101,6 +306,61 @@ function loadCompanyTable(table, url, options={}) {
}
+function deleteManufacturerParts(selections, options={}) {
+
+ if (selections.length == 0) {
+ return;
+ }
+
+ var parts = [];
+
+ var text = `
+
+
{% trans "The following manufacturer parts will be deleted" %}:
+
+
`;
+
+ showQuestionDialog(
+ '{% trans "Delete Manufacturer Parts" %}',
+ text,
+ {
+ accept_text: '{% trans "Delete" %}',
+ accept: function() {
+
+ // Delete each manufacturer part
+ var requests = [];
+
+ parts.forEach(function(pk) {
+ var url = `/api/company/part/manufacturer/${pk}`;
+
+ requests.push(inventreeDelete(url));
+ });
+
+ // Wait for all the requests to complete
+ $.when.apply($, requests).then(function() {
+
+ if (options.onSuccess) {
+ options.onSuccess();
+ }
+ })
+ }
+ }
+ );
+}
+
+
function loadManufacturerPartTable(table, url, options) {
/*
* Load manufacturer part table
@@ -194,7 +454,57 @@ function loadManufacturerPartTable(table, url, options) {
}
}
},
+ {
+ field: 'description',
+ title: '{% trans "Description" %}',
+ sortable: false,
+ switchable: true,
+ },
+ {
+ field: 'actions',
+ title: '',
+ sortable: false,
+ switchable: false,
+ formatter: function(value, row) {
+ var pk = row.pk;
+
+ var html = `
`;
+
+ html += makeIconButton('fa-edit icon-blue', 'button-manufacturer-part-edit', pk, '{% trans "Edit manufacturer part" %}');
+ html += makeIconButton('fa-trash-alt icon-red', 'button-manufacturer-part-delete', pk, '{% trans "Delete manufacturer part" %}');
+
+ html += '
';
+
+ return html;
+ }
+ }
],
+ onPostBody: function() {
+ // Callbacks
+ $(table).find('.button-manufacturer-part-edit').click(function() {
+ var pk = $(this).attr('pk');
+
+ editManufacturerPart(
+ pk,
+ {
+ onSuccess: function() {
+ $(table).bootstrapTable('refresh');
+ }
+ });
+ });
+
+ $(table).find('.button-manufacturer-part-delete').click(function() {
+ var pk = $(this).attr('pk');
+
+ deleteManufacturerPart(
+ pk,
+ {
+ onSuccess: function() {
+ $(table).bootstrapTable('refresh');
+ }
+ });
+ })
+ }
});
}
@@ -228,7 +538,7 @@ function loadManufacturerPartParameterTable(table, url, options) {
{
checkbox: true,
switchable: false,
- visible: false,
+ visible: true,
},
{
field: 'name',
@@ -273,27 +583,28 @@ function loadManufacturerPartParameterTable(table, url, options) {
$(table).find('.button-parameter-edit').click(function() {
var pk = $(this).attr('pk');
- launchModalForm(
- `/manufacturer-part/parameter/${pk}/edit/`,
- {
- success: function() {
- $(table).bootstrapTable('refresh');
- }
+ constructForm(`/api/company/part/manufacturer/parameter/${pk}/`, {
+ fields: {
+ name: {},
+ value: {},
+ units: {},
+ },
+ title: '{% trans "Edit Parameter" %}',
+ onSuccess: function() {
+ $(table).bootstrapTable('refresh');
}
- );
-
+ });
});
$(table).find('.button-parameter-delete').click(function() {
var pk = $(this).attr('pk');
- launchModalForm(
- `/manufacturer-part/parameter/${pk}/delete/`,
- {
- success: function() {
- $(table).bootstrapTable('refresh');
- }
+ constructForm(`/api/company/part/manufacturer/parameter/${pk}/`, {
+ method: 'DELETE',
+ title: '{% trans "Delete Parameter" %}',
+ onSuccess: function() {
+ $(table).bootstrapTable('refresh');
}
- );
+ });
});
}
});
@@ -408,7 +719,7 @@ function loadSupplierPartTable(table, url, options) {
title: '{% trans "MPN" %}',
formatter: function(value, row, index, field) {
if (value && row.manufacturer_part) {
- return renderLink(value, `/manufacturer-part/${row.manufacturer_part.pk}/`);
+ return renderLink(value, `/manufacturer-part/${row.manufacturer_part}/`);
} else {
return "-";
}
@@ -425,6 +736,65 @@ function loadSupplierPartTable(table, url, options) {
}
}
},
+ {
+ field: 'description',
+ title: '{% trans "Description" %}',
+ sortable: false,
+ },
+ {
+ field: 'note',
+ title: '{% trans "Notes" %}',
+ sortable: false,
+ },
+ {
+ field: 'packaging',
+ title: '{% trans "Packaging" %}',
+ sortable: false,
+ },
+ {
+ field: 'actions',
+ title: '',
+ sortable: false,
+ switchable: false,
+ formatter: function(value, row) {
+ var pk = row.pk;
+
+ var html = `
`;
+
+ html += makeIconButton('fa-edit icon-blue', 'button-supplier-part-edit', pk, '{% trans "Edit supplier part" %}');
+ html += makeIconButton('fa-trash-alt icon-red', 'button-supplier-part-delete', pk, '{% trans "Delete supplier part" %}');
+
+ html += '
';
+
+ return html;
+ }
+ }
],
+ onPostBody: function() {
+ // Callbacks
+ $(table).find('.button-supplier-part-edit').click(function() {
+ var pk = $(this).attr('pk');
+
+ editSupplierPart(
+ pk,
+ {
+ onSuccess: function() {
+ $(table).bootstrapTable('refresh');
+ }
+ });
+ });
+
+ $(table).find('.button-supplier-part-delete').click(function() {
+ var pk = $(this).attr('pk');
+
+ deleteSupplierPart(
+ pk,
+ {
+ onSuccess: function() {
+ $(table).bootstrapTable('refresh');
+ }
+ });
+ })
+ }
});
}
\ No newline at end of file
diff --git a/InvenTree/templates/js/filters.js b/InvenTree/templates/js/filters.js
index a27e91d5dc..4ee08affdf 100644
--- a/InvenTree/templates/js/filters.js
+++ b/InvenTree/templates/js/filters.js
@@ -314,7 +314,7 @@ function setupFilterList(tableKey, table, target) {
// Only add the new filter if it is not empty!
if (tag && tag.length > 0) {
var filters = addTableFilter(tableKey, tag, val);
- reloadTable(table, filters);
+ reloadTableFilters(table, filters);
// Run this function again
setupFilterList(tableKey, table, target);
@@ -333,7 +333,7 @@ function setupFilterList(tableKey, table, target) {
element.find(`#${clear}`).click(function() {
var filters = clearTableFilters(tableKey);
- reloadTable(table, filters);
+ reloadTableFilters(table, filters);
setupFilterList(tableKey, table, target);
});
@@ -346,7 +346,7 @@ function setupFilterList(tableKey, table, target) {
var filters = removeTableFilter(tableKey, filter);
- reloadTable(table, filters);
+ reloadTableFilters(table, filters);
// Run this function again!
setupFilterList(tableKey, table, target);
diff --git a/InvenTree/templates/js/forms.js b/InvenTree/templates/js/forms.js
new file mode 100644
index 0000000000..587ea07a16
--- /dev/null
+++ b/InvenTree/templates/js/forms.js
@@ -0,0 +1,1668 @@
+{% load i18n %}
+{% load inventree_extras %}
+
+/**
+ *
+ * This file contains code for rendering (and managing) HTML forms
+ * which are served via the django-drf API.
+ *
+ * The django DRF library provides an OPTIONS method for each API endpoint,
+ * which allows us to introspect the available fields at any given endpoint.
+ *
+ * The OPTIONS method provides the following information for each available field:
+ *
+ * - Field name
+ * - Field label (translated)
+ * - Field help text (translated)
+ * - Field type
+ * - Read / write status
+ * - Field required status
+ * - min_value / max_value
+ *
+ */
+
+/*
+ * Return true if the OPTIONS specify that the user
+ * can perform a GET method at the endpoint.
+ */
+function canView(OPTIONS) {
+
+ if ('actions' in OPTIONS) {
+ return ('GET' in OPTIONS.actions);
+ } else {
+ return false;
+ }
+}
+
+
+/*
+ * Return true if the OPTIONS specify that the user
+ * can perform a POST method at the endpoint
+ */
+function canCreate(OPTIONS) {
+
+ if ('actions' in OPTIONS) {
+ return ('POST' in OPTIONS.actions);
+ } else {
+ return false;
+ }
+}
+
+
+/*
+ * Return true if the OPTIONS specify that the user
+ * can perform a PUT or PATCH method at the endpoint
+ */
+function canChange(OPTIONS) {
+
+ if ('actions' in OPTIONS) {
+ return ('PUT' in OPTIONS.actions || 'PATCH' in OPTIONS.actions);
+ } else {
+ return false;
+ }
+}
+
+
+/*
+ * Return true if the OPTIONS specify that the user
+ * can perform a DELETE method at the endpoint
+ */
+function canDelete(OPTIONS) {
+
+ if ('actions' in OPTIONS) {
+ return ('DELETE' in OPTIONS.actions);
+ } else {
+ return false;
+ }
+}
+
+
+/*
+ * Get the API endpoint options at the provided URL,
+ * using a HTTP options request.
+ */
+function getApiEndpointOptions(url, callback, options) {
+
+ // Return the ajax request object
+ $.ajax({
+ url: url,
+ type: 'OPTIONS',
+ contentType: 'application/json',
+ dataType: 'json',
+ accepts: {
+ json: 'application/json',
+ },
+ success: callback,
+ error: function(request, status, error) {
+ // TODO: Handle error
+ console.log(`ERROR in getApiEndpointOptions at '${url}'`);
+ }
+ });
+}
+
+
+/*
+ * Construct a 'creation' (POST) form, to create a new model in the database.
+ *
+ * arguments:
+ * - fields: The 'actions' object provided by the OPTIONS endpoint
+ *
+ * options:
+ * -
+ */
+function constructCreateForm(fields, options) {
+
+ // Check if default values were provided for any fields
+ for (const name in fields) {
+
+ var field = fields[name];
+
+ var field_options = options.fields[name] || {};
+
+ // If a 'value' is not provided for the field,
+ if (field.value == null) {
+
+ if ('value' in field_options) {
+ // Client has specified the default value for the field
+ field.value = field_options.value;
+ } else if (field.default != null) {
+ // OPTIONS endpoint provided default value for this field
+ field.value = field.default;
+ }
+ }
+ }
+
+ // We should have enough information to create the form!
+ constructFormBody(fields, options);
+}
+
+
+/*
+ * Construct a 'change' (PATCH) form, to create a new model in the database.
+ *
+ * arguments:
+ * - fields: The 'actions' object provided by the OPTIONS endpoint
+ *
+ * options:
+ * -
+ */
+function constructChangeForm(fields, options) {
+
+ // Request existing data from the API endpoint
+ $.ajax({
+ url: options.url,
+ type: 'GET',
+ contentType: 'application/json',
+ dataType: 'json',
+ accepts: {
+ json: 'application/json',
+ },
+ success: function(data) {
+
+ // Push existing 'value' to each field
+ for (const field in data) {
+
+ if (field in fields) {
+ fields[field].value = data[field];
+ }
+ }
+
+ // Store the entire data object
+ options.instance = data;
+
+ constructFormBody(fields, options);
+ },
+ error: function(request, status, error) {
+ // TODO: Handle error here
+ console.log(`ERROR in constructChangeForm at '${options.url}'`);
+ }
+ });
+}
+
+
+/*
+ * Construct a 'delete' form, to remove a model instance from the database.
+ *
+ * arguments:
+ * - fields: The 'actions' object provided by the OPTIONS request
+ * - options: The 'options' object provided by the client
+ */
+function constructDeleteForm(fields, options) {
+
+ // Force the "confirm" property if not set
+ if (!('confirm' in options)) {
+ options.confirm = true;
+ }
+
+ // Request existing data from the API endpoint
+ // This data can be used to render some information on the form
+ $.ajax({
+ url: options.url,
+ type: 'GET',
+ contentType: 'application/json',
+ dataType: 'json',
+ accepts: {
+ json: 'application/json',
+ },
+ success: function(data) {
+
+ // Store the instance data
+ options.instance = data;
+
+ constructFormBody(fields, options);
+ },
+ error: function(request, status, error) {
+ // TODO: Handle error here
+ console.log(`ERROR in constructDeleteForm at '${options.url}`);
+ }
+ });
+}
+
+
+/*
+ * Request API OPTIONS data from the server,
+ * and construct a modal form based on the response.
+ *
+ * url: API URL which defines form data
+ * options:
+ * - method: The HTTP method e.g. 'PUT', 'POST', 'DELETE' (default='PATCH')
+ * - title: The form title
+ * - submitText: Text for the "submit" button
+ * - closeText: Text for the "close" button
+ * - fields: list of fields to display, with the following options
+ * - filters: API query filters
+ * - onEdit: callback when field is edited
+ * - secondary: Define a secondary modal form for this field
+ * - label: Specify custom label
+ * - help_text: Specify custom help_text
+ * - placeholder: Specify custom placeholder text
+ * - value: Specify initial value
+ * - hidden: Set to true to hide the field
+ * - icon: font-awesome icon to display before the field
+ * - prefix: Custom HTML prefix to display before the field
+ * - focus: Name of field to focus on when modal is displayed
+ * - preventClose: Set to true to prevent form from closing on success
+ * - onSuccess: callback function when form action is successful
+ * - follow: If a 'url' is provided by the API on success, redirect to it
+ * - redirect: A URL to redirect to after form success
+ * - reload: Set to true to reload the current page after form success
+ * - confirm: Set to true to require a "confirm" button
+ * - confirmText: Text for confirm button (default = "Confirm")
+ *
+ */
+function constructForm(url, options) {
+
+ // An "empty" form will be defined locally
+ if (url == null) {
+ constructFormBody({}, options);
+ }
+
+ // Save the URL
+ options.url = url;
+
+ // Default HTTP method
+ options.method = options.method || 'PATCH';
+
+ // Request OPTIONS endpoint from the API
+ getApiEndpointOptions(url, function(OPTIONS) {
+
+ /*
+ * Determine what "type" of form we want to construct,
+ * based on the requested action.
+ *
+ * First we must determine if the user has the correct permissions!
+ */
+
+ switch (options.method) {
+ case 'POST':
+ if (canCreate(OPTIONS)) {
+ constructCreateForm(OPTIONS.actions.POST, options);
+ } else {
+ // User does not have permission to POST to the endpoint
+ showAlertDialog(
+ '{% trans "Action Prohibited" %}',
+ '{% trans "Create operation not allowed" %}'
+ );
+ console.log(`'POST action unavailable at ${url}`);
+ }
+ break;
+ case 'PUT':
+ case 'PATCH':
+ if (canChange(OPTIONS)) {
+ constructChangeForm(OPTIONS.actions.PUT, options);
+ } else {
+ // User does not have permission to PUT/PATCH to the endpoint
+ showAlertDialog(
+ '{% trans "Action Prohibited" %}',
+ '{% trans "Update operation not allowed" %}'
+ );
+ console.log(`${options.method} action unavailable at ${url}`);
+ }
+ break;
+ case 'DELETE':
+ if (canDelete(OPTIONS)) {
+ constructDeleteForm(OPTIONS.actions.DELETE, options);
+ } else {
+ // User does not have permission to DELETE to the endpoint
+ showAlertDialog(
+ '{% trans "Action Prohibited" %}',
+ '{% trans "Delete operation not allowed" %}'
+ );
+ console.log(`DELETE action unavailable at ${url}`);
+ }
+ break;
+ case 'GET':
+ if (canView(OPTIONS)) {
+ // TODO?
+ } else {
+ // User does not have permission to GET to the endpoint
+ showAlertDialog(
+ '{% trans "Action Prohibited" %}',
+ '{% trans "View operation not allowed" %}'
+ );
+ console.log(`GET action unavailable at ${url}`);
+ }
+ break;
+ default:
+ console.log(`constructForm() called with invalid method '${options.method}'`);
+ break;
+ }
+ });
+}
+
+
+/*
+ * Construct a modal form based on the provided options
+ *
+ * arguments:
+ * - fields: The endpoint description returned from the OPTIONS request
+ * - options: form options object provided by the client.
+ */
+function constructFormBody(fields, options) {
+
+ var html = '';
+
+ // Client must provide set of fields to be displayed,
+ // otherwise *all* fields will be displayed
+ var displayed_fields = options.fields || fields;
+
+ // Provide each field object with its own name
+ for(field in fields) {
+ fields[field].name = field;
+
+ var field_options = displayed_fields[field];
+
+ // Copy custom options across to the fields object
+ if (field_options) {
+
+ // Override existing query filters (if provided!)
+ fields[field].filters = Object.assign(fields[field].filters || {}, field_options.filters);
+
+ // TODO: Refactor the following code with Object.assign (see above)
+
+ // Secondary modal options
+ fields[field].secondary = field_options.secondary;
+
+ // Edit callback
+ fields[field].onEdit = field_options.onEdit;
+
+ fields[field].multiline = field_options.multiline;
+
+ // Custom help_text
+ if (field_options.help_text) {
+ fields[field].help_text = field_options.help_text;
+ }
+
+ // Custom label
+ if (field_options.label) {
+ fields[field].label = field_options.label;
+ }
+
+ // Custom placeholder
+ if (field_options.placeholder) {
+ fields[field].placeholder = field_options.placeholder;
+ }
+
+ // Choices
+ if (field_options.choices) {
+ fields[field].choices = field_options.choices;
+ }
+
+ // Field prefix
+ if (field_options.prefix) {
+ fields[field].prefix = field_options.prefix;
+ } else if (field_options.icon) {
+ // Specify icon like 'fa-user'
+ fields[field].prefix = `
`;
+ }
+
+ fields[field].hidden = field_options.hidden;
+
+ if (field_options.read_only != null) {
+ fields[field].read_only = field_options.read_only;
+ }
+ }
+ }
+
+ // Construct an ordered list of field names
+ var field_names = [];
+
+ for (var name in displayed_fields) {
+
+ field_names.push(name);
+
+ // Field not specified in the API, but the client wishes to add it!
+ if (!(name in fields)) {
+ fields[name] = displayed_fields[name];
+ }
+ }
+
+ // Push the ordered field names into the options,
+ // allowing successive functions to access them.
+ options.field_names = field_names;
+
+ // Render selected fields
+
+ for (var idx = 0; idx < field_names.length; idx++) {
+
+ var name = field_names[idx];
+
+ var field = fields[name];
+
+ switch (field.type) {
+ // Skip field types which are simply not supported
+ case 'nested object':
+ continue;
+ default:
+ break;
+ }
+
+ html += constructField(name, field, options);
+ }
+
+ // TODO: Dynamically create the modals,
+ // so that we can have an infinite number of stacks!
+
+ // Create a new modal if one does not exists
+ if (!options.modal) {
+ options.modal = createNewModal(options);
+ }
+
+ var modal = options.modal;
+
+ modalEnable(modal, true);
+
+ // Insert generated form content
+ $(modal).find('#form-content').html(html);
+
+ if (options.preFormContent) {
+ $(modal).find('#pre-form-content').html(options.preFormContent);
+ }
+
+ if (options.postFormContent) {
+ $(modal).find('#post-form-content').html(options.postFormContent);
+ }
+
+ // Clear any existing buttons from the modal
+ $(modal).find('#modal-footer-buttons').html('');
+
+ // Insert "confirm" button (if required)
+ if (options.confirm) {
+ insertConfirmButton(options);
+ }
+
+ // Display the modal
+ $(modal).modal('show');
+
+ updateFieldValues(fields, options);
+
+ // Setup related fields
+ initializeRelatedFields(fields, options);
+
+ // Attach edit callbacks (if required)
+ addFieldCallbacks(fields, options);
+
+ // Attach clear callbacks (if required)
+ addClearCallbacks(fields, options);
+
+ attachToggle(modal);
+
+ $(modal + ' .select2-container').addClass('select-full-width');
+ $(modal + ' .select2-container').css('width', '100%');
+
+ modalShowSubmitButton(modal, true);
+
+ $(modal).on('click', '#modal-form-submit', function() {
+
+ // Immediately disable the "submit" button,
+ // to prevent the form being submitted multiple times!
+ $(options.modal).find('#modal-form-submit').prop('disabled', true);
+
+ // Run custom code before normal form submission
+ if (options.beforeSubmit) {
+ options.beforeSubmit(fields, options);
+ }
+
+ // Run custom code instead of normal form submission
+ if (options.onSubmit) {
+ options.onSubmit(fields, options);
+ } else {
+ submitFormData(fields, options);
+ }
+ });
+}
+
+
+// Add a "confirm" checkbox to the modal
+// The "submit" button will be disabled unless "confirm" is checked
+function insertConfirmButton(options) {
+
+ var message = options.confirmMessage || '{% trans "Confirm" %}';
+
+ var confirm = `
+
+ ${message}
+
+ `;
+
+ $(options.modal).find('#modal-footer-buttons').append(confirm);
+
+ // Disable the 'submit' button
+ $(options.modal).find('#modal-form-submit').prop('disabled', true);
+
+ // Trigger event
+ $(options.modal).find('#modal-confirm').change(function() {
+ var enabled = this.checked;
+
+ $(options.modal).find('#modal-form-submit').prop('disabled', !enabled);
+ });
+}
+
+
+/*
+ * Submit form data to the server.
+ *
+ */
+function submitFormData(fields, options) {
+
+ // Form data to be uploaded to the server
+ // Only used if file / image upload is required
+ var form_data = new FormData();
+
+ var data = {};
+
+ var has_files = false;
+
+ // Extract values for each field
+ options.field_names.forEach(function(name) {
+
+ var field = fields[name] || null;
+
+ if (field) {
+
+ var value = getFormFieldValue(name, field, options);
+
+ // Handle file inputs
+ if (field.type == 'image upload' || field.type == 'file upload') {
+
+ var field_el = $(options.modal).find(`#id_${name}`)[0];
+
+ var field_files = field_el.files;
+
+ if (field_files.length > 0) {
+ // One file per field, please!
+ var file = field_files[0];
+
+ form_data.append(name, file);
+
+ has_files = true;
+ }
+ } else {
+
+ // Normal field (not a file or image)
+ form_data.append(name, value);
+
+ data[name] = value;
+ }
+ } else {
+ console.log(`WARNING: Could not find field matching '${name}'`);
+ }
+ });
+
+ var upload_func = inventreePut;
+
+ if (has_files) {
+ upload_func = inventreeFormDataUpload;
+ data = form_data;
+ }
+
+ // Submit data
+ upload_func(
+ options.url,
+ data,
+ {
+ method: options.method,
+ success: function(response, status) {
+ handleFormSuccess(response, options);
+ },
+ error: function(xhr, status, thrownError) {
+
+ switch (xhr.status) {
+ case 400: // Bad request
+ handleFormErrors(xhr.responseJSON, fields, options);
+ break;
+ default:
+ $(options.modal).modal('hide');
+ showApiError(xhr);
+ break;
+ }
+ }
+ }
+ );
+}
+
+
+/*
+ * Update (set) the field values based on the specified data.
+ *
+ * Iterate through each of the displayed fields,
+ * and set the 'val' attribute of each one.
+ *
+ */
+function updateFieldValues(fields, options) {
+
+ for (var idx = 0; idx < options.field_names.length; idx++) {
+
+ var name = options.field_names[idx];
+
+ var field = fields[name] || null;
+
+ if (field == null) { continue; }
+
+ var value = field.value;
+
+ if (value == null) {
+ value = field.default;
+ }
+
+ if (value == null) { continue; }
+
+ updateFieldValue(name, value, field, options);
+ }
+}
+
+
+function updateFieldValue(name, value, field, options) {
+ var el = $(options.modal).find(`#id_${name}`);
+
+ switch (field.type) {
+ case 'boolean':
+ el.prop('checked', value);
+ break;
+ case 'related field':
+ // Clear?
+ if (value == null && !field.required) {
+ el.val(null).trigger('change');
+ }
+ // TODO - Specify an actual value!
+ break;
+ case 'file upload':
+ case 'image upload':
+ break;
+ default:
+ el.val(value);
+ break;
+ }
+}
+
+
+/*
+ * Extract and field value before sending back to the server
+ *
+ * arguments:
+ * - name: The name of the field
+ * - field: The field specification provided from the OPTIONS request
+ * - options: The original options object provided by the client
+ */
+function getFormFieldValue(name, field, options) {
+
+ // Find the HTML element
+ var el = $(options.modal).find(`#id_${name}`);
+
+ if (!el) {
+ return null;
+ }
+
+ var value = null;
+
+ switch (field.type) {
+ case 'boolean':
+ value = el.is(":checked");
+ break;
+ case 'date':
+ case 'datetime':
+ value = el.val();
+
+ // Ensure empty values are sent as nulls
+ if (!value || value.length == 0) {
+ value = null;
+ }
+ break;
+ default:
+ value = el.val();
+ break;
+ }
+
+ return value;
+}
+
+
+/*
+ * Handle successful form posting
+ *
+ * arguments:
+ * - response: The JSON response object from the server
+ * - options: The original options object provided by the client
+ */
+function handleFormSuccess(response, options) {
+
+ // Close the modal
+ if (!options.preventClose) {
+ // Note: The modal will be deleted automatically after closing
+ $(options.modal).modal('hide');
+ }
+
+ // Display any required messages
+ // Should we show alerts immediately or cache them?
+ var cache = (options.follow && response.url) || options.redirect || options.reload;
+
+ // Display any messages
+ if (response && response.success) {
+ showAlertOrCache("alert-success", response.success, cache);
+ }
+
+ if (response && response.info) {
+ showAlertOrCache("alert-info", response.info, cache);
+ }
+
+ if (response && response.warning) {
+ showAlertOrCache("alert-warning", response.warning, cache);
+ }
+
+ if (response && response.danger) {
+ showAlertOrCache("alert-danger", response.danger, cache);
+ }
+
+ if (options.onSuccess) {
+ // Callback function
+ options.onSuccess(response, options);
+ }
+
+ if (options.follow && response.url) {
+ // Follow the returned URL
+ window.location.href = response.url;
+ } else if (options.reload) {
+ // Reload the current page
+ location.reload();
+ } else if (options.redirect) {
+ // Redirect to a specified URL
+ window.location.href = options.redirect;
+ }
+}
+
+
+
+/*
+ * Remove all error text items from the form
+ */
+function clearFormErrors(options) {
+
+ // Remove the individual error messages
+ $(options.modal).find('.form-error-message').remove();
+
+ // Remove the "has error" class
+ $(options.modal).find('.has-error').removeClass('has-error');
+
+ // Hide the 'non field errors'
+ $(options.modal).find('#non-field-errors').html('');
+}
+
+
+/*
+ * Display form error messages as returned from the server.
+ *
+ * arguments:
+ * - errors: The JSON error response from the server
+ * - fields: The form data object
+ * - options: Form options provided by the client
+ */
+function handleFormErrors(errors, fields, options) {
+
+ // Reset the status of the "submit" button
+ $(options.modal).find('#modal-form-submit').prop('disabled', false);
+
+ // Remove any existing error messages from the form
+ clearFormErrors(options);
+
+ var non_field_errors = $(options.modal).find('#non-field-errors');
+
+ non_field_errors.append(
+ `
+ {% trans "Form errors exist" %}
+
`
+ );
+
+ // Non-field errors?
+ if ('non_field_errors' in errors) {
+
+ var nfe = errors.non_field_errors;
+
+ for (var idx = 0; idx < nfe.length; idx++) {
+ var err = nfe[idx];
+
+ var html = `
+
+ ${err}
+
`;
+
+ non_field_errors.append(html);
+ }
+ }
+
+ for (field_name in errors) {
+
+ // Add the 'has-error' class
+ $(options.modal).find(`#div_id_${field_name}`).addClass('has-error');
+
+ var field_dom = $(options.modal).find(`#errors-${field_name}`); // $(options.modal).find(`#id_${field_name}`);
+
+ var field_errors = errors[field_name];
+
+ // Add an entry for each returned error message
+ for (var idx = field_errors.length-1; idx >= 0; idx--) {
+
+ var error_text = field_errors[idx];
+
+ var html = `
+
+ ${error_text}
+ `;
+
+ field_dom.append(html);
+ }
+ }
+}
+
+
+/*
+ * Attach callbacks to specified fields,
+ * triggered after the field value is edited.
+ *
+ * Callback function is called with arguments (name, field, options)
+ */
+function addFieldCallbacks(fields, options) {
+
+ for (var idx = 0; idx < options.field_names.length; idx++) {
+
+ var name = options.field_names[idx];
+
+ var field = fields[name];
+
+ if (!field || !field.onEdit) continue;
+
+ addFieldCallback(name, field, options);
+ }
+}
+
+
+function addFieldCallback(name, field, options) {
+
+ $(options.modal).find(`#id_${name}`).change(function() {
+ field.onEdit(name, field, options);
+ });
+}
+
+
+function addClearCallbacks(fields, options) {
+
+ for (var idx = 0; idx < options.field_names.length; idx++) {
+
+ var name = options.field_names[idx];
+
+ var field = fields[name];
+
+ if (!field || field.required) continue;
+
+ addClearCallback(name, field, options);
+ }
+}
+
+
+function addClearCallback(name, field, options) {
+
+ $(options.modal).find(`#clear_${name}`).click(function() {
+ updateFieldValue(name, null, field, options);
+ });
+}
+
+
+function initializeRelatedFields(fields, options) {
+
+ var field_names = options.field_names;
+
+ for (var idx = 0; idx < field_names.length; idx++) {
+
+ var name = field_names[idx];
+
+ var field = fields[name] || null;
+
+ if (!field || field.hidden) continue;
+
+ switch (field.type) {
+ case 'related field':
+ initializeRelatedField(name, field, options);
+ break;
+ case 'choice':
+ initializeChoiceField(name, field, options);
+ break;
+ }
+ }
+}
+
+
+/*
+ * Add a button to launch a secondary modal, to create a new modal instance.
+ *
+ * arguments:
+ * - name: The name of the field
+ * - field: The field data object
+ * - options: The options object provided by the client
+ */
+function addSecondaryModal(name, field, options) {
+
+ var secondary = field.secondary;
+
+ var html = `
+
+
+ ${secondary.label || secondary.title}
+
+ `;
+
+ $(options.modal).find(`label[for="id_${name}"]`).append(html);
+
+ // TODO: Launch a callback
+ $(options.modal).find(`#btn-new-${name}`).click(function() {
+
+ if (secondary.callback) {
+ // A "custom" callback can be specified for the button
+ secondary.callback(field, options);
+ } else if (secondary.api_url) {
+ // By default, a new modal form is created, with the parameters specified
+ // The parameters match the "normal" form creation parameters
+
+ secondary.onSuccess = function(data, opts) {
+ setRelatedFieldData(name, data, options);
+ };
+
+ constructForm(secondary.api_url, secondary);
+ }
+ });
+}
+
+
+/*
+ * Initializea single related-field
+ *
+ * argument:
+ * - modal: DOM identifier for the modal window
+ * - name: name of the field e.g. 'location'
+ * - field: Field definition from the OPTIONS request
+ * - options: Original options object provided by the client
+ */
+function initializeRelatedField(name, field, options) {
+
+ if (!field.api_url) {
+ // TODO: Provide manual api_url option?
+ console.log(`Related field '${name}' missing 'api_url' parameter.`);
+ return;
+ }
+
+ // Find the select element and attach a select2 to it
+ var select = $(options.modal).find(`#id_${name}`);
+
+ // Add a button to launch a 'secondary' modal
+ if (field.secondary != null) {
+ addSecondaryModal(name, field, options);
+ }
+
+ // TODO: Add 'placeholder' support for entry select2 fields
+
+ // limit size for AJAX requests
+ var pageSize = options.pageSize || 25;
+
+ select.select2({
+ placeholder: '',
+ dropdownParent: $(options.modal),
+ dropdownAutoWidth: false,
+ ajax: {
+ url: field.api_url,
+ dataType: 'json',
+ delay: 250,
+ cache: true,
+ data: function(params) {
+
+ if (!params.page) {
+ offset = 0;
+ } else {
+ offset = (params.page - 1) * pageSize;
+ }
+
+ // Custom query filters can be specified against each field
+ var query = field.filters || {};
+
+ // Add search and pagination options
+ query.search = params.term;
+ query.offset = offset;
+ query.limit = pageSize;
+
+ return query;
+ },
+ processResults: function(response) {
+ // Convert the returned InvenTree data into select2-friendly format
+
+ var data = [];
+
+ var more = false;
+
+ if ('count' in response && 'results' in response) {
+ // Response is paginated
+ data = response.results;
+
+ // Any more data available?
+ if (response.next) {
+ more = true;
+ }
+
+ } else {
+ // Non-paginated response
+ data = response;
+ }
+
+ // Each 'row' must have the 'id' attribute
+ for (var idx = 0; idx < data.length; idx++) {
+ data[idx].id = data[idx].pk;
+ }
+
+ // Ref: https://select2.org/data-sources/formats
+ var results = {
+ results: data,
+ pagination: {
+ more: more,
+ }
+ };
+
+ return results;
+ },
+ },
+ templateResult: function(item, container) {
+
+ // Extract 'instance' data passed through from an initial value
+ // Or, use the raw 'item' data as a backup
+ var data = item;
+
+ if (item.element && item.element.instance) {
+ data = item.element.instance;
+ }
+
+ if (!data.pk) {
+ return $(searching());
+ }
+
+ // Custom formatting for the search results
+ if (field.model) {
+ // If the 'model' is specified, hand it off to the custom model render
+ var html = renderModelData(name, field.model, data, field, options);
+ return $(html);
+ } else {
+ // Return a simple renderering
+ console.log(`WARNING: templateResult() missing 'field.model' for '${name}'`);
+ return `${name} - ${item.id}`;
+ }
+ },
+ templateSelection: function(item, container) {
+
+ // Extract 'instance' data passed through from an initial value
+ // Or, use the raw 'item' data as a backup
+ var data = item;
+
+ if (item.element && item.element.instance) {
+ data = item.element.instance;
+ }
+
+ if (!data.pk) {
+ return field.placeholder || '';
+ return $(searching());
+ }
+
+ // Custom formatting for selected item
+ if (field.model) {
+ // If the 'model' is specified, hand it off to the custom model render
+ var html = renderModelData(name, field.model, data, field, options);
+ return $(html);
+ } else {
+ // Return a simple renderering
+ console.log(`WARNING: templateSelection() missing 'field.model' for '${name}'`);
+ return `${name} - ${item.id}`;
+ }
+ }
+ });
+
+ // If a 'value' is already defined, grab the model info from the server
+ if (field.value) {
+ var pk = field.value;
+ var url = `${field.api_url}/${pk}/`.replace('//', '/');
+
+ inventreeGet(url, field.filters || {}, {
+ success: function(data) {
+ setRelatedFieldData(name, data, options);
+ }
+ });
+ }
+}
+
+
+/*
+ * Set the value of a select2 instace for a "related field",
+ * e.g. with data returned from a secondary modal
+ *
+ * arguments:
+ * - name: The name of the field
+ * - data: JSON data representing the model instance
+ * - options: The modal form specifications
+ */
+function setRelatedFieldData(name, data, options) {
+
+ var select = $(options.modal).find(`#id_${name}`);
+
+ var option = new Option(name, data.pk, true, true);
+
+ // Assign the JSON data to the 'instance' attribute,
+ // so we can access and render it later
+ option.instance = data;
+
+ select.append(option).trigger('change');
+
+ select.trigger({
+ type: 'select2:select',
+ params: {
+ data: data
+ }
+ });
+}
+
+
+function initializeChoiceField(name, field, options) {
+
+ var select = $(options.modal).find(`#id_${name}`);
+
+ select.select2({
+ dropdownAutoWidth: false,
+ dropdownParent: $(options.modal),
+ });
+}
+
+
+// Render a 'no results' element
+function searching() {
+ return `
{% trans "Searching" %}...`;
+}
+
+/*
+ * Render a "foreign key" model reference in a select2 instance.
+ * Allows custom rendering with access to the entire serialized object.
+ *
+ * arguments:
+ * - name: The name of the field e.g. 'location'
+ * - model: The name of the InvenTree model e.g. 'stockitem'
+ * - data: The JSON data representation of the modal instance (GET request)
+ * - parameters: The field definition (OPTIONS) request
+ * - options: Other options provided at time of modal creation by the client
+ */
+function renderModelData(name, model, data, parameters, options) {
+
+ if (!data) {
+ return parameters.placeholder || '';
+ }
+
+ // TODO: Implement this function for various models
+
+ var html = null;
+
+ var renderer = null;
+
+ // Find a custom renderer
+ switch (model) {
+ case 'company':
+ renderer = renderCompany;
+ break;
+ case 'stockitem':
+ renderer = renderStockItem;
+ break;
+ case 'stocklocation':
+ renderer = renderStockLocation;
+ break;
+ case 'part':
+ renderer = renderPart;
+ break;
+ case 'partcategory':
+ renderer = renderPartCategory;
+ break;
+ case 'partparametertemplate':
+ renderer = renderPartParameterTemplate;
+ break;
+ case 'manufacturerpart':
+ renderer = renderManufacturerPart;
+ break;
+ case 'supplierpart':
+ renderer = renderSupplierPart;
+ break;
+ case 'build':
+ renderer = renderBuild;
+ break;
+ case 'owner':
+ renderer = renderOwner;
+ break;
+ case 'user':
+ renderer = renderUser;
+ break;
+ default:
+ break;
+ }
+
+ if (renderer != null) {
+ html = renderer(name, data, parameters, options);
+ }
+
+ if (html != null) {
+ return html;
+ } else {
+ console.log(`ERROR: Rendering not implemented for model '${model}'`);
+ // Simple text rendering
+ return `${model} - ID ${data.id}`;
+ }
+}
+
+
+/*
+ * Construct a single form 'field' for rendering in a form.
+ *
+ * arguments:
+ * - name: The 'name' of the field
+ * - parameters: The field parameters supplied by the DRF OPTIONS method
+ *
+ * options:
+ * -
+ *
+ * The function constructs a fieldset which mostly replicates django "crispy" forms:
+ *
+ * - Field name
+ * - Field
(depends on specified field type)
+ * - Field description (help text)
+ * - Field errors
+ */
+function constructField(name, parameters, options) {
+
+ var field_name = `id_${name}`;
+
+ // Hidden inputs are rendered without label / help text / etc
+ if (parameters.hidden) {
+ return constructHiddenInput(name, parameters, options);
+ }
+
+ var form_classes = 'form-group';
+
+ if (parameters.errors) {
+ form_classes += ' has-error';
+ }
+
+ var html = `
`; // form-group
+
+ return html;
+}
+
+
+/*
+ * Construct a 'label' div
+ *
+ * arguments:
+ * - name: The name of the field
+ * - required: Is this a required field?
+ */
+function constructLabel(name, parameters) {
+
+ var label_classes = 'control-label';
+
+ if (parameters.required) {
+ label_classes += ' requiredField';
+ }
+
+ var html = `
`;
+
+ return html;
+}
+
+
+/*
+ * Construct a form input based on the field parameters
+ *
+ * arguments:
+ * - name: The name of the field
+ * - parameters: Field parameters returned by the OPTIONS method
+ *
+ */
+function constructInput(name, parameters, options) {
+
+ var html = '';
+
+ var func = null;
+
+ switch (parameters.type) {
+ case 'boolean':
+ func = constructCheckboxInput;
+ break;
+ case 'string':
+ case 'url':
+ case 'email':
+ func = constructTextInput;
+ break;
+ case 'integer':
+ case 'float':
+ case 'decimal':
+ func = constructNumberInput;
+ break;
+ case 'choice':
+ func = constructChoiceInput;
+ break;
+ case 'related field':
+ func = constructRelatedFieldInput;
+ break;
+ case 'image upload':
+ case 'file upload':
+ func = constructFileUploadInput;
+ break;
+ case 'date':
+ func = constructDateInput;
+ break;
+ default:
+ // Unsupported field type!
+ break;
+ }
+
+ if (func != null) {
+ html = func(name, parameters, options);
+ } else {
+ console.log(`WARNING: Unhandled form field type: '${parameters.type}'`);
+ }
+
+ return html;
+}
+
+
+// Construct a set of default input options which apply to all input types
+function constructInputOptions(name, classes, type, parameters) {
+
+ var opts = [];
+
+ opts.push(`id='id_${name}'`);
+
+ opts.push(`class='${classes}'`);
+
+ opts.push(`name='${name}'`);
+
+ opts.push(`type='${type}'`);
+
+ // Read only?
+ if (parameters.read_only) {
+ opts.push(`readonly=''`);
+ }
+
+ if (parameters.value != null) {
+ // Existing value?
+ opts.push(`value='${parameters.value}'`);
+ } else if (parameters.default != null) {
+ // Otherwise, a defualt value?
+ opts.push(`value='${parameters.default}'`);
+ }
+
+ // Maximum input length
+ if (parameters.max_length != null) {
+ opts.push(`maxlength='${parameters.max_length}'`);
+ }
+
+ // Minimum input length
+ if (parameters.min_length != null) {
+ opts.push(`minlength='${parameters.min_length}'`);
+ }
+
+ // Maximum value
+ if (parameters.max_value != null) {
+ opts.push(`max='${parameters.max_value}'`);
+ }
+
+ // Minimum value
+ if (parameters.min_value != null) {
+ opts.push(`min='${parameters.min_value}'`);
+ }
+
+ // Field is required?
+ if (parameters.required) {
+ opts.push(`required=''`);
+ }
+
+ // Custom mouseover title?
+ if (parameters.title != null) {
+ opts.push(`title='${parameters.title}'`);
+ }
+
+ // Placeholder?
+ if (parameters.placeholder != null) {
+ opts.push(`placeholder='${parameters.placeholder}'`);
+ }
+
+ if (parameters.multiline) {
+ return `
`;
+ } else {
+ return `
`;
+ }
+}
+
+
+// Construct a "hidden" input
+function constructHiddenInput(name, parameters, options) {
+
+ return constructInputOptions(
+ name,
+ 'hiddeninput',
+ 'hidden',
+ parameters
+ );
+}
+
+
+// Construct a "checkbox" input
+function constructCheckboxInput(name, parameters, options) {
+
+ return constructInputOptions(
+ name,
+ 'checkboxinput',
+ 'checkbox',
+ parameters
+ );
+}
+
+
+// Construct a "text" input
+function constructTextInput(name, parameters, options) {
+
+ var classes = '';
+ var type = '';
+
+ switch (parameters.type) {
+ default:
+ classes = 'textinput textInput form-control';
+ type = 'text';
+ break;
+ case 'url':
+ classes = 'urlinput form-control';
+ type = 'url';
+ break;
+ case 'email':
+ classes = 'emailinput form-control';
+ type = 'email';
+ break;
+ }
+
+ return constructInputOptions(
+ name,
+ classes,
+ type,
+ parameters
+ );
+}
+
+
+// Construct a "number" field
+function constructNumberInput(name, parameters, options) {
+
+ return constructInputOptions(
+ name,
+ 'numberinput form-control',
+ 'number',
+ parameters
+ );
+}
+
+
+// Construct a "choice" input
+function constructChoiceInput(name, parameters, options) {
+
+ var html = `
`;
+
+ return html;
+}
+
+
+/*
+ * Construct a "related field" input.
+ * This will create a "select" input which will then, (after form is loaded),
+ * be converted into a select2 input.
+ * This will then be served custom data from the API (as required)...
+ */
+function constructRelatedFieldInput(name, parameters, options) {
+
+ var html = `
`;
+
+ // Don't load any options - they will be filled via an AJAX request
+
+ return html;
+}
+
+
+/*
+ * Construct a field for file upload
+ */
+function constructFileUploadInput(name, parameters, options) {
+
+ var cls = 'clearablefileinput';
+
+ if (parameters.required) {
+ cls = 'fileinput';
+ }
+
+ return constructInputOptions(
+ name,
+ cls,
+ 'file',
+ parameters
+ );
+}
+
+
+/*
+ * Construct a field for a date input
+ */
+function constructDateInput(name, parameters, options) {
+
+ return constructInputOptions(
+ name,
+ 'dateinput form-control',
+ 'date',
+ parameters
+ );
+}
+
+
+/*
+ * Construct a 'help text' div based on the field parameters
+ *
+ * arguments:
+ * - name: The name of the field
+ * - parameters: Field parameters returned by the OPTIONS method
+ *
+ */
+function constructHelpText(name, parameters, options) {
+
+ var html = `
${parameters.help_text}
`;
+
+ return html;
+}
\ No newline at end of file
diff --git a/InvenTree/templates/js/label.js b/InvenTree/templates/js/label.js
index dab9c6dcfa..dc9e8fa935 100644
--- a/InvenTree/templates/js/label.js
+++ b/InvenTree/templates/js/label.js
@@ -105,6 +105,61 @@ function printStockLocationLabels(locations, options={}) {
}
+function printPartLabels(parts, options={}) {
+ /**
+ * Print labels for the provided parts
+ */
+
+ if (parts.length == 0) {
+ showAlertDialog(
+ '{% trans "Select Parts" %}',
+ '{% trans "Part(s) must be selected before printing labels" %}',
+ );
+
+ return;
+ }
+
+ // Request available labels from the server
+ inventreeGet(
+ '{% url "api-part-label-list" %}',
+ {
+ enabled: true,
+ parts: parts,
+ },
+ {
+ success: function(response) {
+
+ if (response.length == 0) {
+ showAlertDialog(
+ '{% trans "No Labels Found" %}',
+ '{% trans "No labels found which match the selected part(s)" %}',
+ );
+
+ return;
+ }
+
+ // Select label to print
+ selectLabel(
+ response,
+ parts,
+ {
+ success: function(pk) {
+ var url = `/api/label/part/${pk}/print/?`;
+
+ parts.forEach(function(part) {
+ url += `parts[]=${part}&`;
+ });
+
+ window.location.href = url;
+ }
+ }
+ );
+ }
+ }
+ );
+}
+
+
function selectLabel(labels, items, options={}) {
/**
* Present the user with the available labels,
diff --git a/InvenTree/templates/js/modals.js b/InvenTree/templates/js/modals.js
index 03893a47b8..4bcc31fffa 100644
--- a/InvenTree/templates/js/modals.js
+++ b/InvenTree/templates/js/modals.js
@@ -1,5 +1,120 @@
{% load i18n %}
+
+/*
+ * Create and display a new modal dialog
+ *
+ * options:
+ * - title: Form title to render
+ * - submitText: Text to render on 'submit' button (default = "Submit")
+ * - closeText: Text to render on 'close' button (default = "Cancel")
+ * - focus: Name of field to focus on after launching
+ */
+function createNewModal(options={}) {
+
+ var id = 1;
+
+ // Check out what modal forms are already being displayed
+ $('.inventree-modal').each(function() {
+
+ var split = this.id.split('-');
+ var modal_id = parseInt(split[2]);
+
+ if (modal_id >= id) {
+ id = modal_id + 1;
+ }
+ });
+
+ var html = `
+
+ `;
+
+ $('body').append(html);
+
+ var modal_name = `#modal-form-${id}`;
+
+ $(modal_name).on('shown.bs.modal', function() {
+ $(modal_name + ' .modal-form-content').scrollTop(0);
+
+ if (options.focus) {
+ getFieldByName(modal_name, options.focus).focus();
+ }
+ });
+
+ // Automatically remove the modal when it is deleted!
+ $(modal_name).on('hidden.bs.modal', function(e) {
+ $(modal_name).remove();
+ });
+
+ // Capture "enter" key input
+ $(modal_name).on('keydown', 'input', function(event) {
+ if (event.keyCode == 13) {
+ event.preventDefault();
+ // Simulate a click on the 'Submit' button
+ $(modal_name).find("#modal-form-submit").click();
+
+ return false;
+ }
+ });
+
+ $(modal_name).modal({
+ backdrop: 'static',
+ keyboard: false,
+ });
+
+ // Set labels based on supplied options
+ modalSetTitle(modal_name, options.title || '{% trans "Form Title" %}');
+ modalSetSubmitText(modal_name, options.submitText || '{% trans "Submit" %}');
+ modalSetCloseText(modal_name, options.cancelText || '{% trans "Cancel" %}');
+
+ if (options.hideSubmitButton) {
+ $(modal_name).find('#modal-form-submit').hide();
+ }
+
+ if (options.hideCloseButton) {
+ $(modal_name).find('#modal-form-cancel').hide();
+ }
+
+ // Return the "name" of the modal
+ return modal_name;
+}
+
+
function makeOption(text, value, title) {
/* Format an option for a select element
*/
@@ -444,25 +559,18 @@ function showAlertDialog(title, content, options={}) {
*
* title - Title text
* content - HTML content of the dialog window
- * options:
- * modal - modal form to use (default = '#modal-alert-dialog')
*/
- var modal = options.modal || '#modal-alert-dialog';
- $(modal).on('shown.bs.modal', function() {
- $(modal + ' .modal-form-content').scrollTop(0);
+ var modal = createNewModal({
+ title: title,
+ cancelText: '{% trans "Close" %}',
+ hideSubmitButton: true,
});
- modalSetTitle(modal, title);
- modalSetContent(modal, content);
+ modalSetContent(modal, content);
- $(modal).modal({
- backdrop: 'static',
- keyboard: false,
- });
-
- $(modal).modal('show');
+ $(modal).modal('show');
}
@@ -479,22 +587,15 @@ function showQuestionDialog(title, content, options={}) {
* cancel - Functino to run if the user presses 'Cancel'
*/
- var modal = options.modal || '#modal-question-dialog';
-
- $(modal).on('shown.bs.modal', function() {
- $(modal + ' .modal-form-content').scrollTop(0);
+ var modal = createNewModal({
+ title: title,
+ submitText: options.accept_text || '{% trans "Accept" %}',
+ cancelText: options.cancel_text || '{% trans "Cancel" %}',
});
- modalSetTitle(modal, title);
modalSetContent(modal, content);
- var accept_text = options.accept_text || '{% trans "Accept" %}';
- var cancel_text = options.cancel_text || '{% trans "Cancel" %}';
-
- $(modal).find('#modal-form-cancel').html(cancel_text);
- $(modal).find('#modal-form-accept').html(accept_text);
-
- $(modal).on('click', '#modal-form-accept', function() {
+ $(modal).on('click', "#modal-form-submit", function() {
$(modal).modal('hide');
if (options.accept) {
@@ -502,14 +603,6 @@ function showQuestionDialog(title, content, options={}) {
}
});
- $(modal).on('click', 'modal-form-cancel', function() {
- $(modal).modal('hide');
-
- if (options.cancel) {
- options.cancel();
- }
- });
-
$(modal).modal('show');
}
@@ -672,6 +765,9 @@ function attachSecondaryModal(modal, options) {
function attachSecondaries(modal, secondaries) {
/* Attach a provided list of secondary modals */
+ // 2021-07-18 - Secondary modals will be disabled for now, until they are re-implemented in the "API forms" architecture
+ return;
+
for (var i = 0; i < secondaries.length; i++) {
attachSecondaryModal(modal, secondaries[i]);
}
@@ -991,8 +1087,6 @@ function hideModalImage() {
function showModalImage(image_url) {
// Display full-screen modal image
- console.log('showing modal image: ' + image_url);
-
var modal = $('#modal-image-dialog');
// Set image content
diff --git a/InvenTree/templates/js/model_renderers.js b/InvenTree/templates/js/model_renderers.js
new file mode 100644
index 0000000000..12e00cab2c
--- /dev/null
+++ b/InvenTree/templates/js/model_renderers.js
@@ -0,0 +1,232 @@
+{% load i18n %}
+
+
+function blankImage() {
+ return `/static/img/blank_image.png`;
+}
+
+// Render a select2 thumbnail image
+function select2Thumbnail(image) {
+ if (!image) {
+ image = blankImage();
+ }
+
+ return `

`;
+}
+
+
+/*
+ * This file contains functions for rendering various InvenTree database models,
+ * in particular for displaying them in modal forms in a 'select2' context.
+ *
+ * Each renderer is provided with three arguments:
+ *
+ * - name: The 'name' of the model instance in the referring model
+ * - data: JSON data which represents the model instance. Returned via a GET request.
+ * - parameters: The field parameters provided via an OPTIONS request to the endpoint.
+ * - options: User options provided by the client
+ */
+
+
+// Renderer for "Company" model
+function renderCompany(name, data, parameters, options) {
+
+ var html = select2Thumbnail(data.image);
+
+ html += `
${data.name} -
${data.description}`;
+
+ html += `
{% trans "Company ID" %}: ${data.pk}`;
+
+ return html;
+}
+
+
+// Renderer for "StockItem" model
+function renderStockItem(name, data, parameters, options) {
+
+ var image = data.part_detail.thumbnail || data.part_detail.image || blankImage();
+
+ var html = `

`;
+
+ html += `
${data.part_detail.full_name || data.part_detail.name}`;
+
+ if (data.serial && data.quantity == 1) {
+ html += ` -
{% trans "Serial Number" %}: ${data.serial}`;
+ } else {
+ html += ` - {% trans "Quantity" %}: ${data.quantity}`;
+ }
+
+ if (data.part_detail.description) {
+ html += `${data.part_detail.description}
`;
+ }
+
+ return html;
+}
+
+
+// Renderer for "StockLocation" model
+function renderStockLocation(name, data, parameters, options) {
+
+ var level = '- '.repeat(data.level);
+
+ var html = `
${level}${data.pathstring}`;
+
+ if (data.description) {
+ html += ` -
${data.description}`;
+ }
+
+ html += `
{% trans "Location ID" %}: ${data.pk}`;
+
+ return html;
+}
+
+
+function renderBuild(name, data, parameters, options) {
+
+ var image = null;
+
+ if (data.part_detail && data.part_detail.thumbnail) {
+ image = data.part_detail.thumbnail;
+ }
+
+ var html = select2Thumbnail(image);
+
+ html += `
${data.reference} - ${data.quantity} x ${data.part_detail.full_name}`;
+ html += `
{% trans "Build ID" %}: ${data.pk}`;
+
+ html += `
${data.title}
`;
+
+ return html;
+}
+
+
+// Renderer for "Part" model
+function renderPart(name, data, parameters, options) {
+
+ var html = select2Thumbnail(data.image);
+
+ html += `
${data.full_name || data.name}`;
+
+ if (data.description) {
+ html += ` -
${data.description}`;
+ }
+
+ html += `
{% trans "Part ID" %}: ${data.pk}`;
+
+ return html;
+}
+
+// Renderer for "User" model
+function renderUser(name, data, parameters, options) {
+
+ var html = `
${data.username}`;
+
+ if (data.first_name && data.last_name) {
+ html += ` -
${data.first_name} ${data.last_name}`;
+ }
+
+ return html;
+}
+
+
+// Renderer for "Owner" model
+function renderOwner(name, data, parameters, options) {
+
+ var html = `
${data.name}`;
+
+ switch (data.label) {
+ case 'user':
+ html += `
`;
+ break;
+ case 'group':
+ html += `
`;
+ break;
+ default:
+ break;
+ }
+
+ return html;
+}
+
+
+// Renderer for "PartCategory" model
+function renderPartCategory(name, data, parameters, options) {
+
+ var level = '- '.repeat(data.level);
+
+ var html = `
${level}${data.pathstring}`;
+
+ if (data.description) {
+ html += ` -
${data.description}`;
+ }
+
+ html += `
{% trans "Category ID" %}: ${data.pk}`;
+
+ return html;
+}
+
+
+function renderPartParameterTemplate(name, data, parameters, options) {
+
+ var html = `
${data.name} - [${data.units}]`;
+
+ return html;
+}
+
+
+// Renderer for "ManufacturerPart" model
+function renderManufacturerPart(name, data, parameters, options) {
+
+ var manufacturer_image = null;
+ var part_image = null;
+
+ if (data.manufacturer_detail) {
+ manufacturer_image = data.manufacturer_detail.image;
+ }
+
+ if (data.part_detail) {
+ part_image = data.part_detail.thumbnail || data.part_detail.image;
+ }
+
+ var html = '';
+
+ html += select2Thumbnail(manufacturer_image);
+ html += select2Thumbnail(part_image);
+
+ html += `
${data.manufacturer_detail.name} - ${data.MPN}`;
+ html += ` -
${data.part_detail.full_name}`;
+
+ html += `
{% trans "Manufacturer Part ID" %}: ${data.pk}`;
+
+ return html;
+}
+
+
+// Renderer for "SupplierPart" model
+function renderSupplierPart(name, data, parameters, options) {
+
+ var supplier_image = null;
+ var part_image = null;
+
+ if (data.supplier_detail) {
+ supplier_image = data.supplier_detail.image;
+ }
+
+ if (data.part_detail) {
+ part_image = data.part_detail.thumbnail || data.part_detail.image;
+ }
+
+ var html = '';
+
+ html += select2Thumbnail(supplier_image);
+ html += select2Thumbnail(part_image);
+
+ html += `
${data.supplier_detail.name} - ${data.SKU}`;
+ html += ` -
${data.part_detail.full_name}`;
+
+ html += `
{% trans "Supplier Part ID" %}: ${data.pk}`;
+
+
+ return html;
+
+}
diff --git a/InvenTree/templates/js/nav.js b/InvenTree/templates/js/nav.js
new file mode 100644
index 0000000000..aff3435b21
--- /dev/null
+++ b/InvenTree/templates/js/nav.js
@@ -0,0 +1,84 @@
+
+/*
+* Attach callbacks to navigation bar elements.
+*
+* Searches for elements with the class 'nav-toggle'.
+* A callback is added to each element,
+* to display the matching panel.
+*
+* The 'id' of the .nav-toggle element should be of the form "select-
",
+* and point to a matching "panel-"
+*/
+function attachNavCallbacks(options={}) {
+
+ $('.nav-toggle').click(function() {
+ var el = $(this);
+
+ // Find the matching "panel" element
+ var panelName = el.attr('id').replace('select-', '');
+
+ activatePanel(panelName, options);
+ });
+
+ var panelClass = options.name || 'unknown';
+
+ /* Look for a default panel to initialize
+ * First preference = URL parameter e.g. ?display=part-stock
+ * Second preference = localStorage
+ * Third preference = default
+ */
+ var defaultPanel = $.urlParam('display') || localStorage.getItem(`inventree-selected-panel-${panelClass}`) || options.default;
+
+ if (defaultPanel) {
+ activatePanel(defaultPanel);
+ }
+}
+
+
+function activatePanel(panelName, options={}) {
+
+ var panelClass = options.name || 'unknown';
+
+ // First, cause any other panels to "fade out"
+ $('.panel-visible').hide();
+ $('.panel-visible').removeClass('panel-visible');
+
+ // Find the target panel
+ var panel = `#panel-${panelName}`;
+ var select = `#select-${panelName}`;
+
+ // Check that the selected panel (and select) exist
+ if ($(panel).length && $(select).length) {
+ // Yep, both are displayed
+ } else {
+ // Either the select or the panel are not displayed!
+ // Iterate through the available 'select' elements until one matches
+ panelName = null;
+
+ $('.nav-toggle').each(function(item) {
+ var panel_name = $(this).attr('id').replace('select-', '');
+
+ if ($(`#panel-${panel_name}`).length && (panelName == null)) {
+ panelName = panel_name;
+ }
+
+ panel = `#panel-${panelName}`;
+ select = `#select-${panelName}`;
+ });
+ }
+
+ // Save the selected panel
+ localStorage.setItem(`inventree-selected-panel-${panelClass}`, panelName);
+
+ // Display the panel
+ $(panel).addClass('panel-visible');
+ $(panel).fadeIn(100);
+
+ // Un-select all selectors
+ $('.list-group-item').removeClass('active');
+
+ // Find the associated selector
+ var select = `#select-${panelName}`;
+
+ $(select).parent('.list-group-item').addClass('active');
+}
\ No newline at end of file
diff --git a/InvenTree/templates/js/order.js b/InvenTree/templates/js/order.js
index 649357b083..7091eb0577 100644
--- a/InvenTree/templates/js/order.js
+++ b/InvenTree/templates/js/order.js
@@ -1,6 +1,70 @@
{% load i18n %}
{% load inventree_extras %}
+
+// Create a new SalesOrder
+function createSalesOrder(options={}) {
+
+ constructForm('{% url "api-so-list" %}', {
+ method: 'POST',
+ fields: {
+ reference: {
+ prefix: '{% settings_value "SALESORDER_REFERENCE_PREFIX" %}',
+ },
+ customer: {
+ value: options.customer,
+ },
+ customer_reference: {},
+ description: {},
+ target_date: {
+ icon: 'fa-calendar-alt',
+ },
+ link: {
+ icon: 'fa-link',
+ },
+ responsible: {
+ icon: 'fa-user',
+ }
+ },
+ onSuccess: function(data) {
+ location.href = `/order/sales-order/${data.pk}/`;
+ },
+ title: '{% trans "Create Sales Order" %}',
+ });
+}
+
+// Create a new PurchaseOrder
+function createPurchaseOrder(options={}) {
+
+ constructForm('{% url "api-po-list" %}', {
+ method: 'POST',
+ fields: {
+ reference: {
+ prefix: "{% settings_value 'PURCHASEORDER_REFERENCE_PREFIX' %}",
+ },
+ supplier: {
+ value: options.supplier,
+ },
+ supplier_reference: {},
+ description: {},
+ target_date: {
+ icon: 'fa-calendar-alt',
+ },
+ link: {
+ icon: 'fa-link',
+ },
+ responsible: {
+ icon: 'fa-user',
+ }
+ },
+ onSuccess: function(data) {
+ location.href = `/order/purchase-order/${data.pk}/`;
+ },
+ title: '{% trans "Create Purchase Order" %}',
+ });
+}
+
+
function removeOrderRowFromOrderWizard(e) {
/* Remove a part selection from an order form. */
@@ -266,6 +330,11 @@ function loadSalesOrderTable(table, options) {
field: 'customer_detail',
title: '{% trans "Customer" %}',
formatter: function(value, row, index, field) {
+
+ if (!row.customer_detail) {
+ return '{% trans "Invalid Customer" %}';
+ }
+
return imageHoverIcon(row.customer_detail.image) + renderLink(row.customer_detail.name, `/company/${row.customer}/sales-orders/`);
}
},
@@ -391,6 +460,7 @@ function loadSalesOrderAllocationTable(table, options={}) {
{
field: 'quantity',
title: '{% trans "Quantity" %}',
+ sortable: true,
}
]
});
diff --git a/InvenTree/templates/js/part.js b/InvenTree/templates/js/part.js
index 66174e2f15..169c722d79 100644
--- a/InvenTree/templates/js/part.js
+++ b/InvenTree/templates/js/part.js
@@ -13,6 +13,94 @@ function yesNoLabel(value) {
}
}
+
+function editPart(pk, options={}) {
+
+ var url = `/api/part/${pk}/`;
+
+ var fields = {
+ category: {
+ /*
+ secondary: {
+ label: '{% trans "New Category" %}',
+ title: '{% trans "Create New Part Category" %}',
+ api_url: '{% url "api-part-category-list" %}',
+ method: 'POST',
+ fields: {
+ name: {},
+ description: {},
+ parent: {
+ secondary: {
+ title: '{% trans "New Parent" %}',
+ api_url: '{% url "api-part-category-list" %}',
+ method: 'POST',
+ fields: {
+ name: {},
+ description: {},
+ parent: {},
+ }
+ }
+ },
+ }
+ },
+ */
+ },
+ name: {
+ placeholder: 'part name',
+ },
+ IPN: {},
+ description: {},
+ revision: {},
+ keywords: {
+ icon: 'fa-key',
+ },
+ variant_of: {},
+ link: {
+ icon: 'fa-link',
+ },
+ default_location: {
+ /*
+ secondary: {
+ label: '{% trans "New Location" %}',
+ title: '{% trans "Create new stock location" %}',
+ },
+ */
+ },
+ default_supplier: {
+ filters: {
+ part: pk,
+ part_detail: true,
+ manufacturer_detail: true,
+ supplier_detail: true,
+ },
+ /*
+ secondary: {
+ label: '{% trans "New Supplier Part" %}',
+ title: '{% trans "Create new supplier part" %}',
+ }
+ */
+ },
+ units: {},
+ minimum_stock: {},
+ virtual: {},
+ is_template: {},
+ assembly: {},
+ component: {},
+ trackable: {},
+ purchaseable: {},
+ salable: {},
+ active: {},
+ };
+
+ constructForm(url, {
+ fields: fields,
+ title: '{% trans "Edit Part" %}',
+ reload: true,
+ });
+
+}
+
+
function toggleStar(options) {
/* Toggle the 'starred' status of a part.
* Performs AJAX queries and updates the display on the button.
@@ -220,6 +308,107 @@ function loadSimplePartTable(table, url, options={}) {
}
+function loadPartParameterTable(table, url, options) {
+
+ var params = options.params || {};
+
+ // Load filters
+ var filters = loadTableFilters("part-parameters");
+
+ for (var key in params) {
+ filters[key] = params[key];
+ }
+
+ // setupFilterLsit("#part-parameters", $(table));
+
+ $(table).inventreeTable({
+ url: url,
+ original: params,
+ queryParams: filters,
+ name: 'partparameters',
+ groupBy: false,
+ formatNoMatches: function() { return '{% trans "No parameters found" %}'; },
+ columns: [
+ {
+ checkbox: true,
+ switchable: false,
+ visible: true,
+ },
+ {
+ field: 'name',
+ title: '{% trans "Name" %}',
+ switchable: false,
+ sortable: true,
+ formatter: function(value, row) {
+ return row.template_detail.name;
+ }
+ },
+ {
+ field: 'data',
+ title: '{% trans "Value" %}',
+ switchable: false,
+ sortable: true,
+ },
+ {
+ field: 'units',
+ title: '{% trans "Units" %}',
+ switchable: true,
+ sortable: true,
+ formatter: function(value, row) {
+ return row.template_detail.units;
+ }
+ },
+ {
+ field: 'actions',
+ title: '',
+ switchable: false,
+ sortable: false,
+ formatter: function(value, row) {
+ var pk = row.pk;
+
+ var html = ``;
+
+ html += makeIconButton('fa-edit icon-blue', 'button-parameter-edit', pk, '{% trans "Edit parameter" %}');
+ html += makeIconButton('fa-trash-alt icon-red', 'button-parameter-delete', pk, '{% trans "Delete parameter" %}');
+
+ html += `
`;
+
+ return html;
+ }
+ }
+ ],
+ onPostBody: function() {
+ // Setup button callbacks
+ $(table).find('.button-parameter-edit').click(function() {
+ var pk = $(this).attr('pk');
+
+ constructForm(`/api/part/parameter/${pk}/`, {
+ fields: {
+ data: {},
+ },
+ title: '{% trans "Edit Parameter" %}',
+ onSuccess: function() {
+ $(table).bootstrapTable('refresh');
+ }
+ });
+ });
+
+ $(table).find('.button-parameter-delete').click(function() {
+ var pk = $(this).attr('pk');
+
+ constructForm(`/api/part/parameter/${pk}/`, {
+ method: 'DELETE',
+ title: '{% trans "Delete Parameter" %}',
+ onSuccess: function() {
+ $(table).bootstrapTable('refresh');
+ }
+ });
+ });
+ }
+ });
+}
+
+
function loadParametricPartTable(table, options={}) {
/* Load parametric table for part parameters
*
@@ -525,7 +714,7 @@ function loadPartTable(table, url, options={}) {
var html = '';
- html = ``;
+ html = `
`;
data.forEach(function(row, index) {
@@ -769,6 +958,159 @@ function loadPartTestTemplateTable(table, options) {
}
+function loadPriceBreakTable(table, options) {
+ /*
+ * Load PriceBreak table.
+ */
+
+ var name = options.name || 'pricebreak';
+ var human_name = options.human_name || 'price break';
+ var linkedGraph = options.linkedGraph || null;
+ var chart = null;
+
+ table.inventreeTable({
+ name: name,
+ method: 'get',
+ formatNoMatches: function() {
+ return `{% trans "No ${human_name} information found" %}`;
+ },
+ url: options.url,
+ onLoadSuccess: function(tableData) {
+ if (linkedGraph) {
+ // sort array
+ tableData = tableData.sort((a,b)=>a.quantity-b.quantity);
+
+ // split up for graph definition
+ var graphLabels = Array.from(tableData, x => x.quantity);
+ var graphData = Array.from(tableData, x => parseFloat(x.price));
+
+ // destroy chart if exists
+ if (chart){
+ chart.destroy();
+ }
+ chart = loadLineChart(linkedGraph,
+ {
+ labels: graphLabels,
+ datasets: [
+ {
+ label: '{% trans "Unit Price" %}',
+ data: graphData,
+ backgroundColor: 'rgba(255, 206, 86, 0.2)',
+ borderColor: 'rgb(255, 206, 86)',
+ stepped: true,
+ fill: true,
+ },]
+ }
+ );
+ }
+ },
+ columns: [
+ {
+ field: 'pk',
+ title: 'ID',
+ visible: false,
+ switchable: false,
+ },
+ {
+ field: 'quantity',
+ title: '{% trans "Quantity" %}',
+ sortable: true,
+ },
+ {
+ field: 'price',
+ title: '{% trans "Price" %}',
+ sortable: true,
+ formatter: function(value, row, index) {
+ var html = value;
+
+ html += `
`
+
+ html += makeIconButton('fa-edit icon-blue', `button-${name}-edit`, row.pk, `{% trans "Edit ${human_name}" %}`);
+ html += makeIconButton('fa-trash-alt icon-red', `button-${name}-delete`, row.pk, `{% trans "Delete ${human_name}" %}`);
+
+ html += `
`;
+
+ return html;
+ }
+ },
+ ]
+ });
+}
+
+function loadLineChart(context, data) {
+ return new Chart(context, {
+ type: 'line',
+ data: data,
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: {position: 'bottom'},
+ }
+ }
+ });
+}
+
+function initPriceBreakSet(table, options) {
+
+ var part_id = options.part_id;
+ var pb_human_name = options.pb_human_name;
+ var pb_url_slug = options.pb_url_slug;
+ var pb_url = options.pb_url;
+ var pb_new_btn = options.pb_new_btn;
+ var pb_new_url = options.pb_new_url;
+
+ var linkedGraph = options.linkedGraph || null;
+
+ loadPriceBreakTable(
+ table,
+ {
+ name: pb_url_slug,
+ human_name: pb_human_name,
+ url: pb_url,
+ linkedGraph: linkedGraph,
+ }
+ );
+
+ function reloadPriceBreakTable(){
+ table.bootstrapTable("refresh");
+ }
+
+ pb_new_btn.click(function() {
+ launchModalForm(pb_new_url,
+ {
+ success: reloadPriceBreakTable,
+ data: {
+ part: part_id,
+ }
+ }
+ );
+ });
+
+ table.on('click', `.button-${pb_url_slug}-delete`, function() {
+ var pk = $(this).attr('pk');
+
+ launchModalForm(
+ `/part/${pb_url_slug}/${pk}/delete/`,
+ {
+ success: reloadPriceBreakTable
+ }
+ );
+ });
+
+ table.on('click', `.button-${pb_url_slug}-edit`, function() {
+ var pk = $(this).attr('pk');
+
+ launchModalForm(
+ `/part/${pb_url_slug}/${pk}/edit/`,
+ {
+ success: reloadPriceBreakTable
+ }
+ );
+ });
+}
+
+
function loadStockPricingChart(context, data) {
return new Chart(context, {
type: 'bar',
@@ -824,3 +1166,36 @@ function loadBomChart(context, data) {
}
});
}
+
+function loadSellPricingChart(context, data) {
+ return new Chart(context, {
+ type: 'line',
+ data: data,
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {legend: {position: 'bottom'}},
+ scales: {
+ y: {
+ type: 'linear',
+ position: 'left',
+ grid: {display: false},
+ title: {
+ display: true,
+ text: '{% trans "Unit Price" %}'
+ }
+ },
+ y1: {
+ type: 'linear',
+ position: 'right',
+ grid: {display: false},
+ titel: {
+ display: true,
+ text: '{% trans "Quantity" %}',
+ position: 'right'
+ }
+ },
+ },
+ }
+ });
+}
diff --git a/InvenTree/templates/js/stock.js b/InvenTree/templates/js/stock.js
index 06bbb7c20e..947e7fb3e9 100644
--- a/InvenTree/templates/js/stock.js
+++ b/InvenTree/templates/js/stock.js
@@ -20,6 +20,364 @@ function stockStatusCodes() {
}
+/*
+ * Export stock table
+ */
+function exportStock(params={}) {
+
+ constructFormBody({}, {
+ title: '{% trans "Export Stock" %}',
+ fields: {
+ format: {
+ label: '{% trans "Format" %}',
+ help_text: '{% trans "Select file format" %}',
+ required: true,
+ type: 'choice',
+ value: 'csv',
+ choices: [
+ { value: 'csv', display_name: 'CSV' },
+ { value: 'tsv', display_name: 'TSV' },
+ { value: 'xls', display_name: 'XLS' },
+ { value: 'xlsx', display_name: 'XLSX' },
+ ]
+ },
+ sublocations: {
+ label: '{% trans "Include Sublocations" %}',
+ help_text: '{% trans "Include stock items in sublocations" %}',
+ type: 'boolean',
+ value: 'true',
+ }
+ },
+ onSubmit: function(fields, form_options) {
+
+ var format = getFormFieldValue('format', fields['format'], form_options);
+ var cascade = getFormFieldValue('sublocations', fields['sublocations'], form_options);
+
+ // Hide the modal
+ $(form_options.modal).modal('hide');
+
+ var url = `{% url "stock-export" %}?format=${format}&cascade=${cascade}`;
+
+ for (var key in params) {
+ url += `&${key}=${params[key]}`;
+ }
+
+ console.log(url);
+ location.href = url;
+ }
+ });
+}
+
+
+/**
+ * Perform stock adjustments
+ */
+function adjustStock(action, items, options={}) {
+
+ var formTitle = 'Form Title Here';
+ var actionTitle = null;
+
+ // API url
+ var url = null;
+
+ var specifyLocation = false;
+ var allowSerializedStock = false;
+
+ switch (action) {
+ case 'move':
+ formTitle = '{% trans "Transfer Stock" %}';
+ actionTitle = '{% trans "Move" %}';
+ specifyLocation = true;
+ allowSerializedStock = true;
+ url = '{% url "api-stock-transfer" %}';
+ break;
+ case 'count':
+ formTitle = '{% trans "Count Stock" %}';
+ actionTitle = '{% trans "Count" %}';
+ url = '{% url "api-stock-count" %}';
+ break;
+ case 'take':
+ formTitle = '{% trans "Remove Stock" %}';
+ actionTitle = '{% trans "Take" %}';
+ url = '{% url "api-stock-remove" %}';
+ break;
+ case 'add':
+ formTitle = '{% trans "Add Stock" %}';
+ actionTitle = '{% trans "Add" %}';
+ url = '{% url "api-stock-add" %}';
+ break;
+ case 'delete':
+ formTitle = '{% trans "Delete Stock" %}';
+ allowSerializedStock = true;
+ break;
+ default:
+ break;
+ }
+
+ // Generate modal HTML content
+ var html = `
+
+
+
+ {% trans "Part" %} |
+ {% trans "Stock" %} |
+ {% trans "Location" %} |
+ ${actionTitle || ''} |
+ |
+
+
+
+ `;
+
+ var itemCount = 0;
+
+ for (var idx = 0; idx < items.length; idx++) {
+
+ var item = items[idx];
+
+ if ((item.serial != null) && !allowSerializedStock) {
+ continue;
+ }
+
+ var pk = item.pk;
+
+ var readonly = (item.serial != null);
+ var minValue = null;
+ var maxValue = null;
+ var value = null;
+
+ switch (action) {
+ case 'move':
+ minValue = 0;
+ maxValue = item.quantity;
+ value = item.quantity;
+ break;
+ case 'add':
+ minValue = 0;
+ value = 0;
+ break;
+ case 'take':
+ minValue = 0;
+ value = 0;
+ break;
+ case 'count':
+ minValue = 0;
+ value = item.quantity;
+ break;
+ default:
+ break;
+ }
+
+ var image = item.part_detail.thumbnail || item.part_detail.image || blankImage();
+
+ var status = stockStatusDisplay(item.status, {
+ classes: 'float-right'
+ });
+
+ var quantity = item.quantity;
+
+ var location = locationDetail(item, false);
+
+ if (item.location_detail) {
+ location = item.location_detail.pathstring;
+ }
+
+ if (item.serial != null) {
+ quantity = `#${item.serial}`;
+ }
+
+ var actionInput = '';
+
+ if (actionTitle != null) {
+ actionInput = constructNumberInput(
+ item.pk,
+ {
+ value: value,
+ min_value: minValue,
+ max_value: maxValue,
+ read_only: readonly,
+ title: readonly ? '{% trans "Quantity cannot be adjusted for serialized stock" %}' : '{% trans "Specify stock quantity" %}',
+ }
+ )
+ };
+
+ var buttons = ``;
+
+ buttons += makeIconButton(
+ 'fa-times icon-red',
+ 'button-stock-item-remove',
+ pk,
+ '{% trans "Remove stock item" %}',
+ );
+
+ buttons += `
`;
+
+ html += `
+
+ ${item.part_detail.full_name} |
+ ${quantity}${status} |
+ ${location} |
+
+
+ |
+ ${buttons} |
+
`;
+
+ itemCount += 1;
+ }
+
+ if (itemCount == 0) {
+ showAlertDialog(
+ '{% trans "Select Stock Items" %}',
+ '{% trans "You must select at least one available stock item" %}',
+ );
+
+ return;
+ }
+
+ html += `
`;
+
+ var modal = createNewModal({
+ title: formTitle,
+ });
+
+ // Extra fields
+ var extraFields = {
+ location: {
+ label: '{% trans "Location" %}',
+ help_text: '{% trans "Select destination stock location" %}',
+ type: 'related field',
+ required: true,
+ api_url: `/api/stock/location/`,
+ model: 'stocklocation',
+ },
+ notes: {
+ label: '{% trans "Notes" %}',
+ help_text: '{% trans "Stock transaction notes" %}',
+ type: 'string',
+ }
+ };
+
+ if (!specifyLocation) {
+ delete extraFields.location;
+ }
+
+ constructFormBody({}, {
+ preFormContent: html,
+ fields: extraFields,
+ confirm: true,
+ confirmMessage: '{% trans "Confirm stock adjustment" %}',
+ modal: modal,
+ onSubmit: function(fields, opts) {
+
+ // "Delete" action gets handled differently
+ if (action == 'delete') {
+
+ var requests = [];
+
+ items.forEach(function(item) {
+ requests.push(
+ inventreeDelete(
+ `/api/stock/${item.pk}/`,
+ )
+ )
+ });
+
+ // Wait for *all* the requests to complete
+ $.when.apply($, requests).then(function() {
+ // Destroy the modal window
+ $(modal).modal('hide');
+
+ if (options.onSuccess) {
+ options.onSuccess();
+ }
+ });
+
+ return;
+ }
+
+ // Data to transmit
+ var data = {
+ items: [],
+ };
+
+ // Add values for each selected stock item
+ items.forEach(function(item) {
+
+ var q = getFormFieldValue(item.pk, {}, {modal: modal});
+
+ if (q != null) {
+ data.items.push({pk: item.pk, quantity: q});
+ }
+ });
+
+ // Add in extra field data
+ for (field_name in extraFields) {
+ data[field_name] = getFormFieldValue(
+ field_name,
+ fields[field_name],
+ {
+ modal: modal,
+ }
+ );
+ }
+
+ inventreePut(
+ url,
+ data,
+ {
+ method: 'POST',
+ success: function(response, status) {
+
+ // Destroy the modal window
+ $(modal).modal('hide');
+
+ if (options.onSuccess) {
+ options.onSuccess();
+ }
+ },
+ error: function(xhr) {
+ switch (xhr.status) {
+ case 400:
+
+ // Handle errors for standard fields
+ handleFormErrors(
+ xhr.responseJSON,
+ extraFields,
+ {
+ modal: modal,
+ }
+ )
+
+ break;
+ default:
+ $(modal).modal('hide');
+ showApiError(xhr);
+ break;
+ }
+ }
+ }
+ );
+ }
+ });
+
+ // Attach callbacks for the action buttons
+ $(modal).find('.button-stock-item-remove').click(function() {
+ var pk = $(this).attr('pk');
+
+ $(modal).find(`#stock_item_${pk}`).remove();
+ });
+
+ attachToggle(modal);
+
+ $(modal + ' .select2-container').addClass('select-full-width');
+ $(modal + ' .select2-container').css('width', '100%');
+}
+
+
function removeStockRow(e) {
// Remove a selected row from a stock modal form
@@ -179,27 +537,32 @@ function loadStockTestResultsTable(table, options) {
var match = false;
var override = false;
+ // Extract the simplified test key
var key = item.key;
// Attempt to associate this result with an existing test
- tableData.forEach(function(row, index) {
+ for (var idx = 0; idx < tableData.length; idx++) {
+
+ var row = tableData[idx];
if (key == row.key) {
item.test_name = row.test_name;
item.required = row.required;
- match = true;
-
if (row.result == null) {
item.parent = parent_node;
- tableData[index] = item;
+ tableData[idx] = item;
override = true;
} else {
item.parent = row.pk;
}
+
+ match = true;
+
+ break;
}
- });
+ }
// No match could be found
if (!match) {
@@ -223,6 +586,58 @@ function loadStockTestResultsTable(table, options) {
}
+
+function locationDetail(row, showLink=true) {
+ /*
+ * Function to display a "location" of a StockItem.
+ *
+ * Complicating factors: A StockItem may not actually *be* in a location!
+ * - Could be at a customer
+ * - Could be installed in another stock item
+ * - Could be assigned to a sales order
+ * - Could be currently in production!
+ *
+ * So, instead of being naive, we'll check!
+ */
+
+ // Display text
+ var text = '';
+
+ // URL (optional)
+ var url = '';
+
+ if (row.is_building && row.build) {
+ // StockItem is currently being built!
+ text = '{% trans "In production" %}';
+ url = `/build/${row.build}/`;
+ } else if (row.belongs_to) {
+ // StockItem is installed inside a different StockItem
+ text = `{% trans "Installed in Stock Item" %} ${row.belongs_to}`;
+ url = `/stock/item/${row.belongs_to}/installed/`;
+ } else if (row.customer) {
+ // StockItem has been assigned to a customer
+ text = '{% trans "Shipped to customer" %}';
+ url = `/company/${row.customer}/assigned-stock/`;
+ } else if (row.sales_order) {
+ // StockItem has been assigned to a sales order
+ text = '{% trans "Assigned to Sales Order" %}';
+ url = `/order/sales-order/${row.sales_order}/`;
+ } else if (row.location) {
+ text = row.location_detail.pathstring;
+ url = `/stock/location/${row.location}/`;
+ } else {
+ text = '
{% trans "No stock location set" %}';
+ url = '';
+ }
+
+ if (showLink && url) {
+ return renderLink(text, url);
+ } else {
+ return text;
+ }
+}
+
+
function loadStockTable(table, options) {
/* Load data into a stock table with adjustable options.
* Fetches data (via AJAX) and loads into a bootstrap table.
@@ -266,56 +681,6 @@ function loadStockTable(table, options) {
filters[key] = params[key];
}
- function locationDetail(row) {
- /*
- * Function to display a "location" of a StockItem.
- *
- * Complicating factors: A StockItem may not actually *be* in a location!
- * - Could be at a customer
- * - Could be installed in another stock item
- * - Could be assigned to a sales order
- * - Could be currently in production!
- *
- * So, instead of being naive, we'll check!
- */
-
- // Display text
- var text = '';
-
- // URL (optional)
- var url = '';
-
- if (row.is_building && row.build) {
- // StockItem is currently being built!
- text = '{% trans "In production" %}';
- url = `/build/${row.build}/`;
- } else if (row.belongs_to) {
- // StockItem is installed inside a different StockItem
- text = `{% trans "Installed in Stock Item" %} ${row.belongs_to}`;
- url = `/stock/item/${row.belongs_to}/installed/`;
- } else if (row.customer) {
- // StockItem has been assigned to a customer
- text = '{% trans "Shipped to customer" %}';
- url = `/company/${row.customer}/assigned-stock/`;
- } else if (row.sales_order) {
- // StockItem has been assigned to a sales order
- text = '{% trans "Assigned to Sales Order" %}';
- url = `/order/sales-order/${row.sales_order}/`;
- } else if (row.location) {
- text = row.location_detail.pathstring;
- url = `/stock/location/${row.location}/`;
- } else {
- text = '
{% trans "No stock location set" %}';
- url = '';
- }
-
- if (url) {
- return renderLink(text, url);
- } else {
- return text;
- }
- }
-
var grouping = true;
if ('grouping' in options) {
@@ -680,6 +1045,29 @@ function loadStockTable(table, options) {
return renderLink(text, link);
}
},
+ {
+ field: 'supplier_part',
+ title: '{% trans "Supplier Part" %}',
+ visible: params['supplier_part_detail'] || false,
+ switchable: params['supplier_part_detail'] || false,
+ formatter: function(value, row) {
+ if (!value) {
+ return '-';
+ }
+
+ var link = `/supplier-part/${row.supplier_part}/stock/`;
+
+ var text = '';
+
+ if (row.supplier_part_detail) {
+ text = `${row.supplier_part_detail.SKU}`;
+ } else {
+ text = `
{% trans "Supplier part not specified" %}`;
+ }
+
+ return renderLink(text, link);
+ }
+ },
{
field: 'purchase_price',
title: '{% trans "Purchase Price" %}',
@@ -713,39 +1101,15 @@ function loadStockTable(table, options) {
]
);
+
function stockAdjustment(action) {
var items = $("#stock-table").bootstrapTable("getSelections");
- var stock = [];
-
- items.forEach(function(item) {
- stock.push(item.pk);
- });
-
- // Buttons for launching secondary modals
- var secondary = [];
-
- if (action == 'move') {
- secondary.push({
- field: 'destination',
- label: '{% trans "New Location" %}',
- title: '{% trans "Create new location" %}',
- url: "/stock/location/new/",
- });
- }
-
- launchModalForm("/stock/adjust/",
- {
- data: {
- action: action,
- stock: stock,
- },
- success: function() {
- $("#stock-table").bootstrapTable('refresh');
- },
- secondary: secondary,
+ adjustStock(action, items, {
+ onSuccess: function() {
+ $('#stock-table').bootstrapTable('refresh');
}
- );
+ });
}
// Automatically link button callbacks
@@ -1180,7 +1544,7 @@ function loadStockTrackingTable(table, options) {
}
else
{
- return '{% trans "No user information" %}';
+ return `
{% trans "No user information" %}`;
}
}
});
@@ -1301,27 +1665,6 @@ function createNewStockItem(options) {
},
];
- options.secondary = [
- {
- field: 'part',
- label: '{% trans "New Part" %}',
- title: '{% trans "Create New Part" %}',
- url: "{% url 'part-create' %}",
- },
- {
- field: 'supplier_part',
- label: '{% trans "New Supplier Part" %}',
- title: '{% trans "Create new Supplier Part" %}',
- url: "{% url 'supplier-part-create' %}"
- },
- {
- field: 'location',
- label: '{% trans "New Location" %}',
- title: '{% trans "Create New Location" %}',
- url: "{% url 'stock-location-create' %}",
- },
- ];
-
launchModalForm("{% url 'stock-item-create' %}", options);
}
diff --git a/InvenTree/templates/js/table_filters.js b/InvenTree/templates/js/table_filters.js
index d02fa50d80..78632d6d56 100644
--- a/InvenTree/templates/js/table_filters.js
+++ b/InvenTree/templates/js/table_filters.js
@@ -42,6 +42,10 @@ function getAvailableTableFilters(tableKey) {
type: 'bool',
title: '{% trans "Trackable Part" %}'
},
+ sub_part_assembly: {
+ type: 'bool',
+ title: '{% trans "Assembled Part" %}',
+ },
validated: {
type: 'bool',
title: '{% trans "Validated" %}',
@@ -205,7 +209,12 @@ function getAvailableTableFilters(tableKey) {
batch: {
title: '{% trans "Batch" %}',
description: '{% trans "Batch code" %}',
- }
+ },
+ has_purchase_price: {
+ type: 'bool',
+ title: '{% trans "Has purchase price" %}',
+ description: '{% trans "Show stock items which have a purchase price set" %}',
+ },
};
}
diff --git a/InvenTree/templates/js/tables.js b/InvenTree/templates/js/tables.js
index 96eb3f8123..afe1fefbc9 100644
--- a/InvenTree/templates/js/tables.js
+++ b/InvenTree/templates/js/tables.js
@@ -1,5 +1,11 @@
{% load i18n %}
+
+function reloadtable(table) {
+ $(table).bootstrapTable('refresh');
+}
+
+
function editButton(url, text='Edit') {
return "
";
}
@@ -68,7 +74,7 @@ function isNumeric(n) {
* Reload a table which has already been made into a bootstrap table.
* New filters can be optionally provided, to change the query params.
*/
-function reloadTable(table, filters) {
+function reloadTableFilters(table, filters) {
// Simply perform a refresh
if (filters == null) {
@@ -94,16 +100,11 @@ function reloadTable(table, filters) {
}
options.queryParams = function(tableParams) {
-
- for (key in params) {
- tableParams[key] = params[key];
- }
-
- return tableParams;
- }
+ return convertQueryParameters(tableParams, params);
+ };
table.bootstrapTable('refreshOptions', options);
- table.bootstrapTable('refresh');
+ table.bootstrapTable('refresh', filters);
}
@@ -122,6 +123,55 @@ function visibleColumnString(columns) {
}
+/*
+ * Convert bootstrap-table style parameters to "InvenTree" style
+*/
+function convertQueryParameters(params, filters) {
+
+ // Override the way that we ask the server to sort results
+ // It seems bootstrap-table does not offer a "native" way to do this...
+ if ('sort' in params) {
+ var order = params['order'];
+
+ var ordering = params['sort'] || null;
+
+ if (ordering) {
+
+ if (order == 'desc') {
+ ordering = `-${ordering}`;
+ }
+
+ params['ordering'] = ordering;
+ }
+
+ delete params['sort'];
+ delete params['order'];
+
+ }
+
+ for (var key in filters) {
+ params[key] = filters[key];
+ }
+
+ // Add "order" back in (if it was originally specified by InvenTree)
+ // Annoyingly, "order" shadows some field names in InvenTree...
+ if ('order' in filters) {
+ params['order'] = filters['order'];
+ }
+
+ // Remove searchable[] array (generated by bootstrap-table)
+ if ('searchable' in params) {
+ delete params['searchable'];
+ }
+
+ if ('sortable' in params) {
+ delete params['sortable'];
+ }
+
+ return params;
+}
+
+
/* Wrapper function for bootstrapTable.
* Sets some useful defaults, and manage persistent settings.
*/
@@ -147,39 +197,8 @@ $.fn.inventreeTable = function(options) {
var filters = options.queryParams || options.filters || {};
options.queryParams = function(params) {
-
- // Override the way that we ask the server to sort results
- // It seems bootstrap-table does not offer a "native" way to do this...
- if ('sort' in params) {
- var order = params['order'];
-
- var ordering = params['sort'] || null;
-
- if (ordering) {
-
- if (order == 'desc') {
- ordering = `-${ordering}`;
- }
-
- params['ordering'] = ordering;
- }
-
- delete params['sort'];
- delete params['order'];
-
- }
-
- for (var key in filters) {
- params[key] = filters[key];
- }
-
- // Add "order" back in (if it was originally specified by InvenTree)
- // Annoyingly, "order" shadows some field names in InvenTree...
- if ('order' in filters) {
- params['order'] = filters['order'];
- }
- return params;
- }
+ return convertQueryParameters(params, filters);
+ };
options.rememberOrder = true;
diff --git a/InvenTree/templates/modals.html b/InvenTree/templates/modals.html
index e0cae3e580..11ddc40938 100644
--- a/InvenTree/templates/modals.html
+++ b/InvenTree/templates/modals.html
@@ -7,7 +7,7 @@
-