2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-07-03 04:00:57 +00:00

Merge branch 'inventree:master' into fr-1421-sso

This commit is contained in:
Matthias Mair
2021-10-06 02:01:16 +02:00
committed by GitHub
52 changed files with 2195 additions and 1717 deletions

View File

@ -4,7 +4,6 @@
/* globals
buildStatusDisplay,
constructForm,
getFieldByName,
global_settings,
imageHoverIcon,
inventreeGet,
@ -20,6 +19,7 @@
*/
/* exported
allocateStockToBuild,
editBuildOrder,
loadAllocationTable,
loadBuildOrderAllocationTable,
@ -102,6 +102,7 @@ function makeBuildOutputActionButtons(output, buildInfo, lines) {
*/
var buildId = buildInfo.pk;
var partId = buildInfo.part;
var outputId = 'untracked';
@ -120,11 +121,10 @@ function makeBuildOutputActionButtons(output, buildInfo, lines) {
var html = `<div class='btn-group float-right' role='group'>`;
// "Auto" allocation only works for untracked stock items
if (!output && lines > 0) {
if (lines > 0) {
html += makeIconButton(
'fa-magic icon-blue', 'button-output-auto', outputId,
'{% trans "Auto-allocate stock items to this output" %}',
'fa-sign-in-alt icon-blue', 'button-output-auto', outputId,
'{% trans "Allocate stock items to this build output" %}',
);
}
@ -136,7 +136,6 @@ function makeBuildOutputActionButtons(output, buildInfo, lines) {
);
}
if (output) {
// Add a button to "complete" the particular build output
@ -163,11 +162,17 @@ function makeBuildOutputActionButtons(output, buildInfo, lines) {
// Add callbacks for the buttons
$(panel).find(`#button-output-auto-${outputId}`).click(function() {
var bom_items = $(panel).find(`#allocation-table-${outputId}`).bootstrapTable('getData');
// Launch modal dialog to perform auto-allocation
launchModalForm(`/build/${buildId}/auto-allocate/`,
allocateStockToBuild(
buildId,
partId,
bom_items,
{
data: {
},
source_location: buildInfo.source_location,
output: outputId,
success: reloadTable,
}
);
@ -344,18 +349,26 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
function requiredQuantity(row) {
// Return the requied quantity for a given row
var quantity = 0;
if (output) {
// "Tracked" parts are calculated against individual build outputs
return row.quantity * output.quantity;
quantity = row.quantity * output.quantity;
} else {
// "Untracked" parts are specified against the build itself
return row.quantity * buildInfo.quantity;
quantity = row.quantity * buildInfo.quantity;
}
// Store the required quantity in the row data
row.required = quantity;
return quantity;
}
function sumAllocations(row) {
// Calculat total allocations for a given row
if (!row.allocations) {
row.allocated = 0;
return 0;
}
@ -365,6 +378,8 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
quantity += item.quantity;
});
row.allocated = quantity;
return quantity;
}
@ -377,52 +392,28 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
// Primary key of the 'sub_part'
var pk = $(this).attr('pk');
// Launch form to allocate new stock against this output
launchModalForm('{% url "build-item-create" %}', {
success: reloadTable,
data: {
part: pk,
build: buildId,
install_into: outputId,
},
secondary: [
{
field: 'stock_item',
label: '{% trans "New Stock Item" %}',
title: '{% trans "Create new Stock Item" %}',
url: '{% url "stock-item-create" %}',
data: {
part: pk,
},
},
// Extract BomItem information from this row
var row = $(table).bootstrapTable('getRowByUniqueId', pk);
if (!row) {
console.log('WARNING: getRowByUniqueId returned null');
return;
}
allocateStockToBuild(
buildId,
partId,
[
row,
],
callback: [
{
field: 'stock_item',
action: function(value) {
inventreeGet(
`/api/stock/${value}/`, {},
{
success: function(response) {
// How many items are actually available for the given stock item?
var available = response.quantity - response.allocated;
var field = getFieldByName('#modal-form', 'quantity');
// Allocation quantity initial value
var initial = field.attr('value');
if (available < initial) {
field.val(available);
}
}
}
);
}
}
]
});
{
source_location: buildInfo.source_location,
success: function(data) {
$(table).bootstrapTable('refresh');
},
output: output == null ? null : output.pk,
}
);
});
// Callback for 'buy' button
@ -623,17 +614,22 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
var url = '';
if (row.serial && row.quantity == 1) {
text = `{% trans "Serial Number" %}: ${row.serial}`;
var serial = row.serial;
if (row.stock_item_detail) {
serial = row.stock_item_detail.serial;
}
if (serial && row.quantity == 1) {
text = `{% trans "Serial Number" %}: ${serial}`;
} else {
text = `{% trans "Quantity" %}: ${row.quantity}`;
}
{% if build.status == BuildStatus.COMPLETE %}
url = `/stock/item/${row.pk}/`;
{% else %}
url = `/stock/item/${row.stock_item}/`;
{% endif %}
var pk = row.stock_item || row.pk;
url = `/stock/item/${pk}/`;
return renderLink(text, url);
}
@ -680,22 +676,31 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
// Assign button callbacks to the newly created allocation buttons
subTable.find('.button-allocation-edit').click(function() {
var pk = $(this).attr('pk');
launchModalForm(`/build/item/${pk}/edit/`, {
success: reloadTable,
constructForm(`/api/build/item/${pk}/`, {
fields: {
quantity: {},
},
title: '{% trans "Edit Allocation" %}',
onSuccess: reloadTable,
});
});
subTable.find('.button-allocation-delete').click(function() {
var pk = $(this).attr('pk');
launchModalForm(`/build/item/${pk}/delete/`, {
success: reloadTable,
constructForm(`/api/build/item/${pk}/`, {
method: 'DELETE',
title: '{% trans "Remove Allocation" %}',
onSuccess: reloadTable,
});
});
},
columns: [
{
field: 'pk',
visible: false,
visible: true,
switchable: false,
checkbox: true,
},
{
field: 'sub_part_detail.full_name',
@ -817,6 +822,316 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
}
/**
* Allocate stock items to a build
*
* arguments:
* - buildId: ID / PK value for the build
* - partId: ID / PK value for the part being built
* - bom_items: A list of BomItem objects to be allocated
*
* options:
* - output: ID / PK of the associated build output (or null for untracked items)
* - source_location: ID / PK of the top-level StockLocation to take parts from (or null)
*/
function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
// ID of the associated "build output" (or null)
var output_id = options.output || null;
var source_location = options.source_location;
function renderBomItemRow(bom_item, quantity) {
var pk = bom_item.pk;
var sub_part = bom_item.sub_part_detail;
var thumb = thumbnailImage(bom_item.sub_part_detail.thumbnail);
var delete_button = `<div class='btn-group float-right' role='group'>`;
delete_button += makeIconButton(
'fa-times icon-red',
'button-row-remove',
pk,
'{% trans "Remove row" %}',
);
delete_button += `</div>`;
var quantity_input = constructField(
`items_quantity_${pk}`,
{
type: 'decimal',
min_value: 0,
value: quantity || 0,
title: '{% trans "Specify stock allocation quantity" %}',
required: true,
},
{
hideLabels: true,
}
);
var allocated_display = makeProgressBar(
bom_item.allocated,
bom_item.required,
);
var stock_input = constructField(
`items_stock_item_${pk}`,
{
type: 'related field',
required: 'true',
},
{
hideLabels: true,
}
);
// var stock_input = constructRelatedFieldInput(`items_stock_item_${pk}`);
var html = `
<tr id='allocation_row_${pk}' class='part-allocation-row'>
<td id='part_${pk}'>
${thumb} ${sub_part.full_name}
</td>
<td id='allocated_${pk}'>
${allocated_display}
</td>
<td id='stock_item_${pk}'>
${stock_input}
</td>
<td id='quantity_${pk}'>
${quantity_input}
</td>
<td id='buttons_${pk}'>
${delete_button}
</td>
</tr>
`;
return html;
}
var table_entries = '';
for (var idx = 0; idx < bom_items.length; idx++) {
var bom_item = bom_items[idx];
var required = bom_item.required || 0;
var allocated = bom_item.allocated || 0;
var remaining = required - allocated;
if (remaining < 0) {
remaining = 0;
}
table_entries += renderBomItemRow(bom_item, remaining);
}
if (bom_items.length == 0) {
showAlertDialog(
'{% trans "Select Parts" %}',
'{% trans "You must select at least one part to allocate" %}',
);
return;
}
var html = ``;
// Render a "take from" input
html += constructField(
'take_from',
{
type: 'related field',
label: '{% trans "Source Location" %}',
help_text: '{% trans "Select source location (leave blank to take from all locations)" %}',
required: false,
},
{},
);
// Create table of parts
html += `
<table class='table table-striped table-condensed' id='stock-allocation-table'>
<thead>
<tr>
<th>{% trans "Part" %}</th>
<th>{% trans "Allocated" %}</th>
<th style='min-width: 250px;'>{% trans "Stock Item" %}</th>
<th>{% trans "Quantity" %}</th>
<th></th>
</tr>
</thead>
<tbody>
${table_entries}
</tbody>
</table>
`;
constructForm(`/api/build/${build_id}/allocate/`, {
method: 'POST',
fields: {},
preFormContent: html,
confirm: true,
confirmMessage: '{% trans "Confirm stock allocation" %}',
title: '{% trans "Allocate Stock Items to Build Order" %}',
afterRender: function(fields, options) {
var take_from_field = {
name: 'take_from',
model: 'stocklocation',
api_url: '{% url "api-location-list" %}',
required: false,
type: 'related field',
value: source_location,
noResults: function(query) {
return '{% trans "No matching stock locations" %}';
},
};
// Initialize "take from" field
initializeRelatedField(
take_from_field,
null,
options,
);
// Initialize stock item fields
bom_items.forEach(function(bom_item) {
initializeRelatedField(
{
name: `items_stock_item_${bom_item.pk}`,
api_url: '{% url "api-stock-list" %}',
filters: {
bom_item: bom_item.pk,
in_stock: true,
part_detail: false,
location_detail: true,
},
model: 'stockitem',
required: true,
render_part_detail: false,
render_location_detail: true,
auto_fill: true,
adjustFilters: function(filters) {
// Restrict query to the selected location
var location = getFormFieldValue(
'take_from',
{},
{
modal: options.modal,
}
);
filters.location = location;
filters.cascade = true;
return filters;
},
noResults: function(query) {
return '{% trans "No matching stock items" %}';
}
},
null,
options,
);
});
// Add callback to "clear" button for take_from field
addClearCallback(
'take_from',
take_from_field,
options,
);
// Add button callbacks
$(options.modal).find('.button-row-remove').click(function() {
var pk = $(this).attr('pk');
$(options.modal).find(`#allocation_row_${pk}`).remove();
});
},
onSubmit: function(fields, opts) {
// Extract elements from the form
var data = {
items: []
};
var item_pk_values = [];
bom_items.forEach(function(item) {
var quantity = getFormFieldValue(
`items_quantity_${item.pk}`,
{},
{
modal: opts.modal,
},
);
var stock_item = getFormFieldValue(
`items_stock_item_${item.pk}`,
{},
{
modal: opts.modal,
}
);
if (quantity != null) {
data.items.push({
bom_item: item.pk,
stock_item: stock_item,
quantity: quantity,
output: output_id,
});
item_pk_values.push(item.pk);
}
});
// Provide nested values
opts.nested = {
'items': item_pk_values
};
inventreePut(
opts.url,
data,
{
method: 'POST',
success: function(response) {
// Hide the modal
$(opts.modal).modal('hide');
if (options.success) {
options.success(response);
}
},
error: function(xhr) {
switch (xhr.status) {
case 400:
handleFormErrors(xhr.responseJSON, fields, opts);
break;
default:
$(opts.modal).modal('hide');
showApiError(xhr);
break;
}
}
}
);
},
});
}
function loadBuildTable(table, options) {
// Display a table of Build objects

View File

@ -273,6 +273,11 @@ function setupFilterList(tableKey, table, target) {
var element = $(target);
if (!element) {
console.log(`WARNING: setupFilterList could not find target '${target}'`);
return;
}
// One blank slate, please
element.empty();

View File

@ -728,10 +728,17 @@ function updateFieldValues(fields, options) {
}
}
/*
* Update the value of a named field
*/
function updateFieldValue(name, value, field, options) {
var el = $(options.modal).find(`#id_${name}`);
if (!el) {
console.log(`WARNING: updateFieldValue could not find field '${name}'`);
return;
}
switch (field.type) {
case 'boolean':
el.prop('checked', value);
@ -864,6 +871,78 @@ function clearFormErrors(options) {
$(options.modal).find('#non-field-errors').html('');
}
/*
* Display form error messages as returned from the server,
* specifically for errors returned in an array.
*
* We need to know the unique ID of each item in the array,
* and the array length must equal the length of the array returned from the server
*
* arguments:
* - response: The JSON error response from the server
* - parent: The name of the parent field e.g. "items"
* - options: The global options struct
*
* options:
* - nested: A map of nested ID values for the "parent" field
* e.g.
* {
* "items": [
* 1,
* 2,
* 12
* ]
* }
*
*/
function handleNestedErrors(errors, field_name, options) {
var error_list = errors[field_name];
// Ignore null or empty list
if (!error_list) {
return;
}
var nest_list = nest_list = options['nested'][field_name];
// Nest list must be provided!
if (!nest_list) {
console.log(`WARNING: handleNestedErrors missing nesting options for field '${fieldName}'`);
return;
}
for (var idx = 0; idx < error_list.length; idx++) {
var error_item = error_list[idx];
if (idx >= nest_list.length) {
console.log(`WARNING: handleNestedErrors returned greater number of errors (${error_list.length}) than could be handled (${nest_list.length})`);
break;
}
// Extract the particular ID of the nested item
var nest_id = nest_list[idx];
// Here, error_item is a map of field names to error messages
for (sub_field_name in error_item) {
var errors = error_item[sub_field_name];
// Find the target (nested) field
var target = `${field_name}_${sub_field_name}_${nest_id}`;
for (var ii = errors.length-1; ii >= 0; ii--) {
var error_text = errors[ii];
addFieldErrorMessage(target, error_text, ii, options);
}
}
}
}
/*
* Display form error messages as returned from the server.
@ -913,28 +992,30 @@ function handleFormErrors(errors, fields, options) {
for (var field_name in errors) {
// Add the 'has-error' class
$(options.modal).find(`#div_id_${field_name}`).addClass('has-error');
if (field_name in fields) {
var field_dom = $(options.modal).find(`#errors-${field_name}`); // $(options.modal).find(`#id_${field_name}`);
var field = fields[field_name];
var field_errors = errors[field_name];
if ((field.type == 'field') && ('child' in field)) {
// This is a "nested" field
handleNestedErrors(errors, field_name, options);
} else {
// This is a "simple" field
if (field_errors && !first_error_field && isFieldVisible(field_name, options)) {
first_error_field = field_name;
}
var field_errors = errors[field_name];
// Add an entry for each returned error message
for (var ii = field_errors.length-1; ii >= 0; ii--) {
if (field_errors && !first_error_field && isFieldVisible(field_name, options)) {
first_error_field = field_name;
}
var error_text = field_errors[ii];
// Add an entry for each returned error message
for (var ii = field_errors.length-1; ii >= 0; ii--) {
var error_html = `
<span id='error_${ii+1}_id_${field_name}' class='help-block form-error-message'>
<strong>${error_text}</strong>
</span>`;
var error_text = field_errors[ii];
field_dom.append(error_html);
addFieldErrorMessage(field_name, error_text, ii, options);
}
}
}
}
@ -952,6 +1033,30 @@ function handleFormErrors(errors, fields, options) {
}
/*
* Add a rendered error message to the provided field
*/
function addFieldErrorMessage(field_name, error_text, error_idx, options) {
// Add the 'has-error' class
$(options.modal).find(`#div_id_${field_name}`).addClass('has-error');
var field_dom = $(options.modal).find(`#errors-${field_name}`);
if (field_dom) {
var error_html = `
<span id='error_${error_idx}_id_${field_name}' class='help-block form-error-message'>
<strong>${error_text}</strong>
</span>`;
field_dom.append(error_html);
} else {
console.log(`WARNING: addFieldErrorMessage could not locate field '${field_name}`);
}
}
function isFieldVisible(field, options) {
return $(options.modal).find(`#div_id_${field}`).is(':visible');
@ -1007,7 +1112,14 @@ function addClearCallbacks(fields, options) {
function addClearCallback(name, field, options) {
$(options.modal).find(`#clear_${name}`).click(function() {
var el = $(options.modal).find(`#clear_${name}`);
if (!el) {
console.log(`WARNING: addClearCallback could not find field '${name}'`);
return;
}
el.click(function() {
updateFieldValue(name, null, field, options);
});
}
@ -1168,7 +1280,7 @@ function addSecondaryModal(field, fields, options) {
/*
* Initializea single related-field
* Initialize a single related-field
*
* argument:
* - modal: DOM identifier for the modal window
@ -1182,7 +1294,7 @@ function initializeRelatedField(field, fields, options) {
if (!field.api_url) {
// TODO: Provide manual api_url option?
console.log(`Related field '${name}' missing 'api_url' parameter.`);
console.log(`WARNING: Related field '${name}' missing 'api_url' parameter.`);
return;
}
@ -1203,6 +1315,15 @@ function initializeRelatedField(field, fields, options) {
placeholder: '',
dropdownParent: $(options.modal),
dropdownAutoWidth: false,
language: {
noResults: function(query) {
if (field.noResults) {
return field.noResults(query);
} else {
return '{% trans "No results found" %}';
}
}
},
ajax: {
url: field.api_url,
dataType: 'json',
@ -1225,6 +1346,11 @@ function initializeRelatedField(field, fields, options) {
query.search = params.term;
query.offset = offset;
query.limit = pageSize;
// Allow custom run-time filter augmentation
if ('adjustFilters' in field) {
query = field.adjustFilters(query);
}
return query;
},
@ -1319,6 +1445,7 @@ function initializeRelatedField(field, fields, options) {
// 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('//', '/');
@ -1327,6 +1454,24 @@ function initializeRelatedField(field, fields, options) {
setRelatedFieldData(name, data, options);
}
});
} else if (field.auto_fill) {
// Attempt to auto-fill the field
var filters = field.filters || {};
// Enforce pagination, limit to a single return (for fast query)
filters.limit = 1;
filters.offset = 0;
inventreeGet(field.api_url, field.filters || {}, {
success: function(data) {
// Only a single result is available, given the provided filters
if (data.count == 1) {
setRelatedFieldData(name, data.results[0], options);
}
}
});
}
}
@ -1370,6 +1515,7 @@ function initializeChoiceField(field, fields, options) {
select.select2({
dropdownAutoWidth: false,
dropdownParent: $(options.modal),
width: '100%',
});
}
@ -1884,7 +2030,7 @@ function constructChoiceInput(name, parameters) {
*/
function constructRelatedFieldInput(name) {
var html = `<select id='id_${name}' class='select form-control' name='${name}'></select>`;
var html = `<select id='id_${name}' class='select form-control' name='${name}' style='width: 100%;'></select>`;
// Don't load any options - they will be filled via an AJAX request

View File

@ -65,7 +65,7 @@ function imageHoverIcon(url) {
function thumbnailImage(url) {
if (!url) {
url = '/static/img/blank_img.png';
url = blankImage();
}
// TODO: Support insertion of custom classes

View File

@ -37,7 +37,7 @@ function renderCompany(name, data, parameters, options) {
html += `<span><b>${data.name}</b></span> - <i>${data.description}</i>`;
html += `<span class='float-right'>{% trans "Company ID" %}: ${data.pk}</span>`;
html += `<span class='float-right'><small>{% trans "Company ID" %}: ${data.pk}</small></span>`;
return html;
}
@ -47,22 +47,59 @@ function renderCompany(name, data, parameters, options) {
// eslint-disable-next-line no-unused-vars
function renderStockItem(name, data, parameters, options) {
var image = data.part_detail.thumbnail || data.part_detail.image || blankImage();
var html = `<img src='${image}' class='select2-thumbnail'>`;
html += ` <span>${data.part_detail.full_name || data.part_detail.name}</span>`;
if (data.serial && data.quantity == 1) {
html += ` - <i>{% trans "Serial Number" %}: ${data.serial}`;
} else {
html += ` - <i>{% trans "Quantity" %}: ${data.quantity}`;
var image = blankImage();
if (data.part_detail) {
image = data.part_detail.thumbnail || data.part_detail.image || blankImage();
}
if (data.part_detail.description) {
var html = '';
var render_part_detail = true;
if ('render_part_detail' in parameters) {
render_part_detail = parameters['render_part_detail'];
}
if (render_part_detail) {
html += `<img src='${image}' class='select2-thumbnail'>`;
html += ` <span>${data.part_detail.full_name || data.part_detail.name}</span>`;
}
html += '<span>';
if (data.serial && data.quantity == 1) {
html += `{% trans "Serial Number" %}: ${data.serial}`;
} else {
html += `{% trans "Quantity" %}: ${data.quantity}`;
}
html += '</span>';
if (render_part_detail && data.part_detail.description) {
html += `<p><small>${data.part_detail.description}</small></p>`;
}
var render_stock_id = true;
if ('render_stock_id' in parameters) {
render_stock_id = parameters['render_stock_id'];
}
if (render_stock_id) {
html += `<span class='float-right'><small>{% trans "Stock ID" %}: ${data.pk}</small></span>`;
}
var render_location_detail = false;
if ('render_location_detail' in parameters) {
render_location_detail = parameters['render_location_detail'];
}
if (render_location_detail && data.location_detail) {
html += `<span> - ${data.location_detail.name}</span>`;
}
return html;
}
@ -75,11 +112,17 @@ function renderStockLocation(name, data, parameters, options) {
var html = `<span>${level}${data.pathstring}</span>`;
if (data.description) {
var render_description = true;
if ('render_description' in parameters) {
render_description = parameters['render_description'];
}
if (render_description && data.description) {
html += ` - <i>${data.description}</i>`;
}
html += `<span class='float-right'>{% trans "Location ID" %}: ${data.pk}</span>`;
html += `<span class='float-right'><small>{% trans "Location ID" %}: ${data.pk}</small></span>`;
return html;
}
@ -96,7 +139,7 @@ function renderBuild(name, data, parameters, options) {
var html = select2Thumbnail(image);
html += `<span><b>${data.reference}</b></span> - ${data.quantity} x ${data.part_detail.full_name}`;
html += `<span class='float-right'>{% trans "Build ID" %}: ${data.pk}</span>`;
html += `<span class='float-right'><small>{% trans "Build ID" %}: ${data.pk}</span></span>`;
html += `<p><i>${data.title}</i></p>`;
@ -116,7 +159,7 @@ function renderPart(name, data, parameters, options) {
html += ` - <i>${data.description}</i>`;
}
html += `<span class='float-right'>{% trans "Part ID" %}: ${data.pk}</span>`;
html += `<span class='float-right'><small>{% trans "Part ID" %}: ${data.pk}</small></span>`;
return html;
}
@ -168,7 +211,7 @@ function renderPartCategory(name, data, parameters, options) {
html += ` - <i>${data.description}</i>`;
}
html += `<span class='float-right'>{% trans "Category ID" %}: ${data.pk}</span>`;
html += `<span class='float-right'><small>{% trans "Category ID" %}: ${data.pk}</small></span>`;
return html;
}
@ -205,7 +248,7 @@ function renderManufacturerPart(name, data, parameters, options) {
html += ` <span><b>${data.manufacturer_detail.name}</b> - ${data.MPN}</span>`;
html += ` - <i>${data.part_detail.full_name}</i>`;
html += `<span class='float-right'>{% trans "Manufacturer Part ID" %}: ${data.pk}</span>`;
html += `<span class='float-right'><small>{% trans "Manufacturer Part ID" %}: ${data.pk}</small></span>`;
return html;
}
@ -234,7 +277,7 @@ function renderSupplierPart(name, data, parameters, options) {
html += ` <span><b>${data.supplier_detail.name}</b> - ${data.SKU}</span>`;
html += ` - <i>${data.part_detail.full_name}</i>`;
html += `<span class='float-right'>{% trans "Supplier Part ID" %}: ${data.pk}</span>`;
html += `<span class='float-right'><small>{% trans "Supplier Part ID" %}: ${data.pk}</small></span>`;
return html;

View File

@ -12,6 +12,7 @@
loadTableFilters,
makeIconBadge,
purchaseOrderStatusDisplay,
receivePurchaseOrderItems,
renderLink,
salesOrderStatusDisplay,
setupFilterList,
@ -234,6 +235,291 @@ function newPurchaseOrderFromOrderWizard(e) {
});
}
/**
* Receive stock items against a PurchaseOrder
* Uses the POReceive API endpoint
*
* arguments:
* - order_id, ID / PK for the PurchaseOrder instance
* - line_items: A list of PurchaseOrderLineItems objects to be allocated
*
* options:
* -
*/
function receivePurchaseOrderItems(order_id, line_items, options={}) {
if (line_items.length == 0) {
showAlertDialog(
'{% trans "Select Line Items" %}',
'{% trans "At least one line item must be selected" %}',
);
return;
}
function renderLineItem(line_item, opts={}) {
var pk = line_item.pk;
// Part thumbnail + description
var thumb = thumbnailImage(line_item.part_detail.thumbnail);
var quantity = (line_item.quantity || 0) - (line_item.received || 0);
if (quantity < 0) {
quantity = 0;
}
// Quantity to Receive
var quantity_input = constructField(
`items_quantity_${pk}`,
{
type: 'decimal',
min_value: 0,
value: quantity,
title: '{% trans "Quantity to receive" %}',
required: true,
},
{
hideLabels: true,
}
);
// Construct list of StockItem status codes
var choices = [];
for (var key in stockCodes) {
choices.push({
value: key,
display_name: stockCodes[key].value,
});
}
var destination_input = constructField(
`items_location_${pk}`,
{
type: 'related field',
label: '{% trans "Location" %}',
required: false,
},
{
hideLabels: true,
}
);
var status_input = constructField(
`items_status_${pk}`,
{
type: 'choice',
label: '{% trans "Stock Status" %}',
required: true,
choices: choices,
value: 10, // OK
},
{
hideLabels: true,
}
);
// Button to remove the row
var delete_button = `<div class='btn-group float-right' role='group'>`;
delete_button += makeIconButton(
'fa-times icon-red',
'button-row-remove',
pk,
'{% trans "Remove row" %}',
);
delete_button += '</div>';
var html = `
<tr id='receive_row_${pk}' class='stock-receive-row'>
<td id='part_${pk}'>
${thumb} ${line_item.part_detail.full_name}
</td>
<td id='sku_${pk}'>
${line_item.supplier_part_detail.SKU}
</td>
<td id='on_order_${pk}'>
${line_item.quantity}
</td>
<td id='received_${pk}'>
${line_item.received}
</td>
<td id='quantity_${pk}'>
${quantity_input}
</td>
<td id='status_${pk}'>
${status_input}
</td>
<td id='desination_${pk}'>
${destination_input}
</td>
<td id='actions_${pk}'>
${delete_button}
</td>
</tr>`;
return html;
}
var table_entries = '';
line_items.forEach(function(item) {
table_entries += renderLineItem(item);
});
var html = ``;
// Add table
html += `
<table class='table table-striped table-condensed' id='order-receive-table'>
<thead>
<tr>
<th>{% trans "Part" %}</th>
<th>{% trans "Order Code" %}</th>
<th>{% trans "Ordered" %}</th>
<th>{% trans "Received" %}</th>
<th style='min-width: 50px;'>{% trans "Receive" %}</th>
<th style='min-width: 150px;'>{% trans "Status" %}</th>
<th style='min-width: 300px;'>{% trans "Destination" %}</th>
<th></th>
</tr>
</thead>
<tbody>
${table_entries}
</tbody>
</table>
`;
constructForm(`/api/order/po/${order_id}/receive/`, {
method: 'POST',
fields: {
location: {},
},
preFormContent: html,
confirm: true,
confirmMessage: '{% trans "Confirm receipt of items" %}',
title: '{% trans "Receive Purchase Order Items" %}',
afterRender: function(fields, opts) {
// Initialize the "destination" field for each item
line_items.forEach(function(item) {
var pk = item.pk;
var name = `items_location_${pk}`;
var field_details = {
name: name,
api_url: '{% url "api-location-list" %}',
filters: {
},
type: 'related field',
model: 'stocklocation',
required: false,
auto_fill: false,
value: item.destination || item.part_detail.default_location,
render_description: false,
};
initializeRelatedField(
field_details,
null,
opts,
);
addClearCallback(
name,
field_details,
opts
);
initializeChoiceField(
{
name: `items_status_${pk}`,
},
null,
opts
);
});
// Add callbacks to remove rows
$(opts.modal).find('.button-row-remove').click(function() {
var pk = $(this).attr('pk');
$(opts.modal).find(`#receive_row_${pk}`).remove();
});
},
onSubmit: function(fields, opts) {
// Extract data elements from the form
var data = {
items: [],
location: getFormFieldValue('location', {}, opts),
};
var item_pk_values = [];
line_items.forEach(function(item) {
var pk = item.pk;
var quantity = getFormFieldValue(`items_quantity_${pk}`, {}, opts);
var status = getFormFieldValue(`items_status_${pk}`, {}, opts);
var location = getFormFieldValue(`items_location_${pk}`, {}, opts);
if (quantity != null) {
data.items.push({
line_item: pk,
quantity: quantity,
status: status,
location: location,
});
item_pk_values.push(pk);
}
});
// Provide list of nested values
opts.nested = {
'items': item_pk_values,
};
inventreePut(
opts.url,
data,
{
method: 'POST',
success: function(response) {
// Hide the modal
$(opts.modal).modal('hide');
if (options.success) {
options.success(response);
}
},
error: function(xhr) {
switch (xhr.status) {
case 400:
handleFormErrors(xhr.responseJSON, fields, opts);
break;
default:
$(opts.modal).modal('hide');
showApiError(xhr);
break;
}
}
}
);
}
});
}
function editPurchaseOrderLineItem(e) {
/* Edit a purchase order line item in a modal form.
@ -280,12 +566,10 @@ function loadPurchaseOrderTable(table, options) {
filters[key] = options.params[key];
}
options.url = options.url || '{% url "api-po-list" %}';
setupFilterList('purchaseorder', $(table));
$(table).inventreeTable({
url: options.url,
url: '{% url "api-po-list" %}',
queryParams: filters,
name: 'purchaseorder',
groupBy: false,
@ -379,6 +663,21 @@ function loadPurchaseOrderTable(table, options) {
*/
function loadPurchaseOrderLineItemTable(table, options={}) {
options.params = options.params || {};
options.params['order'] = options.order;
options.params['part_detail'] = true;
var filters = loadTableFilters('purchaseorderlineitem');
for (var key in options.params) {
filters[key] = options.params[key];
}
var target = options.filter_target || '#filter-list-purchase-order-lines';
setupFilterList('purchaseorderlineitem', $(table), target);
function setupCallbacks() {
if (options.allow_edit) {
$(table).find('.button-line-edit').click(function() {
@ -424,22 +723,24 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
$(table).find('.button-line-receive').click(function() {
var pk = $(this).attr('pk');
launchModalForm(`/order/purchase-order/${options.order}/receive/`, {
success: function() {
$(table).bootstrapTable('refresh');
},
data: {
line: pk,
},
secondary: [
{
field: 'location',
label: '{% trans "New Location" %}',
title: '{% trans "Create new stock location" %}',
url: '{% url "stock-location-create" %}',
},
]
});
var line_item = $(table).bootstrapTable('getRowByUniqueId', pk);
if (!line_item) {
console.log('WARNING: getRowByUniqueId returned null');
return;
}
receivePurchaseOrderItems(
options.order,
[
line_item,
],
{
success: function() {
$(table).bootstrapTable('refresh');
}
}
);
});
}
}
@ -451,17 +752,15 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
formatNoMatches: function() {
return '{% trans "No line items found" %}';
},
queryParams: {
order: options.order,
part_detail: true
},
queryParams: filters,
original: options.params,
url: '{% url "api-po-line-list" %}',
showFooter: true,
uniqueId: 'pk',
columns: [
{
field: 'pk',
title: 'ID',
visible: false,
checkbox: true,
visible: true,
switchable: false,
},
{
@ -618,7 +917,7 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
}
if (options.allow_receive && row.received < row.quantity) {
html += makeIconButton('fa-clipboard-check', 'button-line-receive', pk, '{% trans "Receive line item" %}');
html += makeIconButton('fa-sign-in-alt', 'button-line-receive', pk, '{% trans "Receive line item" %}');
}
html += `</div>`;

View File

@ -4,15 +4,12 @@
/* globals
attachSelect,
attachToggle,
blankImage,
enableField,
clearField,
clearFieldOptions,
closeModal,
constructField,
constructFormBody,
constructNumberInput,
createNewModal,
getFormFieldValue,
global_settings,
handleFormErrors,
@ -247,7 +244,7 @@ function adjustStock(action, items, options={}) {
break;
}
var image = item.part_detail.thumbnail || item.part_detail.image || blankImage();
var thumb = thumbnailImage(item.part_detail.thumbnail || item.part_detail.image);
var status = stockStatusDisplay(item.status, {
classes: 'float-right'
@ -268,14 +265,18 @@ function adjustStock(action, items, options={}) {
var actionInput = '';
if (actionTitle != null) {
actionInput = constructNumberInput(
item.pk,
actionInput = constructField(
`items_quantity_${pk}`,
{
value: value,
type: 'decimal',
min_value: minValue,
max_value: maxValue,
read_only: readonly,
value: value,
title: readonly ? '{% trans "Quantity cannot be adjusted for serialized stock" %}' : '{% trans "Specify stock quantity" %}',
required: true,
},
{
hideLabels: true,
}
);
}
@ -293,7 +294,7 @@ function adjustStock(action, items, options={}) {
html += `
<tr id='stock_item_${pk}' class='stock-item-row'>
<td id='part_${pk}'><img src='${image}' class='hover-img-thumb'> ${item.part_detail.full_name}</td>
<td id='part_${pk}'>${thumb} ${item.part_detail.full_name}</td>
<td id='stock_${pk}'>${quantity}${status}</td>
<td id='location_${pk}'>${location}</td>
<td id='action_${pk}'>
@ -319,50 +320,89 @@ function adjustStock(action, items, options={}) {
html += `</tbody></table>`;
var modal = createNewModal({
title: formTitle,
});
var extraFields = {};
// 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',
name: 'location',
},
notes: {
label: '{% trans "Notes" %}',
help_text: '{% trans "Stock transaction notes" %}',
type: 'string',
name: 'notes',
}
};
if (!specifyLocation) {
delete extraFields.location;
if (specifyLocation) {
extraFields.location = {};
}
constructFormBody({}, {
preFormContent: html,
if (action != 'delete') {
extraFields.notes = {};
}
constructForm(url, {
method: 'POST',
fields: extraFields,
preFormContent: html,
confirm: true,
confirmMessage: '{% trans "Confirm stock adjustment" %}',
modal: modal,
onSubmit: function(fields) {
title: formTitle,
afterRender: function(fields, opts) {
// Add button callbacks to remove rows
$(opts.modal).find('.button-stock-item-remove').click(function() {
var pk = $(this).attr('pk');
// "Delete" action gets handled differently
$(opts.modal).find(`#stock_item_${pk}`).remove();
});
// Initialize "location" field
if (specifyLocation) {
initializeRelatedField(
{
name: 'location',
type: 'related field',
model: 'stocklocation',
required: true,
},
null,
opts
);
}
},
onSubmit: function(fields, opts) {
// Extract data elements from the form
var data = {
items: [],
};
if (action != 'delete') {
data.notes = getFormFieldValue('notes', {}, opts);
}
if (specifyLocation) {
data.location = getFormFieldValue('location', {}, opts);
}
var item_pk_values = [];
items.forEach(function(item) {
var pk = item.pk;
// Does the row exist in the form?
var row = $(opts.modal).find(`#stock_item_${pk}`);
if (row) {
item_pk_values.push(pk);
var quantity = getFormFieldValue(`items_quantity_${pk}`, {}, opts);
data.items.push({
pk: pk,
quantity: quantity,
});
}
});
// Delete action is handled differently
if (action == 'delete') {
var requests = [];
items.forEach(function(item) {
item_pk_values.forEach(function(pk) {
requests.push(
inventreeDelete(
`/api/stock/${item.pk}/`,
`/api/stock/${pk}/`,
)
);
});
@ -370,72 +410,40 @@ function adjustStock(action, items, options={}) {
// Wait for *all* the requests to complete
$.when.apply($, requests).done(function() {
// Destroy the modal window
$(modal).modal('hide');
$(opts.modal).modal('hide');
if (options.onSuccess) {
options.onSuccess();
if (options.success) {
options.success();
}
});
return;
}
// Data to transmit
var data = {
items: [],
opts.nested = {
'items': item_pk_values,
};
// 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 (var field_name in extraFields) {
data[field_name] = getFormFieldValue(
field_name,
fields[field_name],
{
modal: modal,
}
);
}
inventreePut(
url,
data,
{
method: 'POST',
success: function() {
success: function(response) {
// Hide the modal
$(opts.modal).modal('hide');
// Destroy the modal window
$(modal).modal('hide');
if (options.onSuccess) {
options.onSuccess();
if (options.success) {
options.success(response);
}
},
error: function(xhr) {
switch (xhr.status) {
case 400:
// Handle errors for standard fields
handleFormErrors(
xhr.responseJSON,
extraFields,
{
modal: modal,
}
);
handleFormErrors(xhr.responseJSON, fields, opts);
break;
default:
$(modal).modal('hide');
$(opts.modal).modal('hide');
showApiError(xhr);
break;
}
@ -444,18 +452,6 @@ function adjustStock(action, items, options={}) {
);
}
});
// 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%');
}
@ -1258,7 +1254,7 @@ function loadStockTable(table, options) {
var items = $(table).bootstrapTable('getSelections');
adjustStock(action, items, {
onSuccess: function() {
success: function() {
$(table).bootstrapTable('refresh');
}
});

View File

@ -274,7 +274,16 @@ function getAvailableTableFilters(tableKey) {
};
}
// Filters for the "Order" table
// Filters for PurchaseOrderLineItem table
if (tableKey == 'purchaseorderlineitem') {
return {
completed: {
type: 'bool',
title: '{% trans "Completed" %}',
},
};
}
// Filters for the PurchaseOrder table
if (tableKey == 'purchaseorder') {
return {