1
0
mirror of https://github.com/invoiceninja/invoiceninja.git synced 2024-11-09 12:42:36 +01:00

Support both (Unit Cost/Quantity) and (Rate/Hour) #506

This commit is contained in:
Hillel Coren 2017-06-19 22:19:52 +03:00
parent 973ceec719
commit e871916116
9 changed files with 200 additions and 138 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -15,6 +15,24 @@ NINJA.TEMPLATES = {
function GetPdfMake(invoice, javascript, callback) {
// check if we need to add a second table for tasks
var itemsTable = false;
if (invoice.hasSecondTable) {
var json = JSON.parse(javascript);
for (var i=0; i<json.content.length; i++) {
var item = json.content[i];
if (item.style == 'invoiceLineItemsTable') {
itemsTable = JSON.stringify(item);
itemsTable = itemsTable.replace('$invoiceLineItems', '$taskLineItems');
//itemsTable = itemsTable.replace('$invoiceLineItemColumns', '$taskLineItemColumns');
break;
}
}
itemsTable = JSON.parse(itemsTable);
json.content.splice(i+1, 0, itemsTable);
javascript = JSON.stringify(json);
}
javascript = NINJA.decodeJavascript(invoice, javascript);
function jsonCallBack(key, val) {
@ -201,6 +219,8 @@ NINJA.decodeJavascript = function(invoice, javascript)
'invoiceDetailsHeight': (NINJA.invoiceDetails(invoice).length * 16) + 16,
'invoiceLineItems': invoice.is_statement ? NINJA.statementLines(invoice) : NINJA.invoiceLines(invoice),
'invoiceLineItemColumns': invoice.is_statement ? NINJA.statementColumns(invoice) : NINJA.invoiceColumns(invoice),
'taskLineItems': NINJA.invoiceLines(invoice, true),
//'taskLineItemColumns': NINJA.invoiceColumns(invoice),
'invoiceDocuments' : isEdge ? [] : NINJA.invoiceDocuments(invoice),
'quantityWidth': NINJA.quantityWidth(invoice),
'taxWidth': NINJA.taxWidth(invoice),
@ -433,12 +453,13 @@ NINJA.taxWidth = function(invoice)
return invoice.account.show_item_taxes == '1' ? '"14%", ' : '';
}
NINJA.invoiceLines = function(invoice) {
NINJA.invoiceLines = function(invoice, isSecondTable) {
var account = invoice.account;
var total = 0;
var shownItem = false;
var hideQuantity = invoice.account.hide_quantity == '1';
var showItemTaxes = invoice.account.show_item_taxes == '1';
var isTasks = isSecondTable || (invoice.hasTasks && !invoice.hasStandard);
var grid = [[]];
@ -456,8 +477,8 @@ NINJA.invoiceLines = function(invoice) {
}
if (!hideQuantity) {
grid[0].push({text: invoiceLabels.unit_cost, style: ['tableHeader', 'costTableHeader']});
grid[0].push({text: invoiceLabels.quantity, style: ['tableHeader', 'qtyTableHeader']});
grid[0].push({text: isTasks ? invoiceLabels.rate : invoiceLabels.unit_cost, style: ['tableHeader', 'costTableHeader']});
grid[0].push({text: isTasks ? invoiceLabels.hours : invoiceLabels.quantity, style: ['tableHeader', 'qtyTableHeader']});
}
if (showItemTaxes) {
grid[0].push({text: invoiceLabels.tax, style: ['tableHeader', 'taxTableHeader']});
@ -477,6 +498,17 @@ NINJA.invoiceLines = function(invoice) {
var custom_value1 = item.custom_value1;
var custom_value2 = item.custom_value2;
console.log('isTasks: %s', isTasks);
if (isTasks) {
if (item.invoice_item_type_id != 2) {
continue;
}
} else {
if (item.invoice_item_type_id == 2) {
continue;
}
}
if (showItemTaxes) {
if (item.tax_name1) {
tax1 = parseFloat(item.tax_rate1);

View File

@ -660,6 +660,9 @@ function calculateAmounts(invoice) {
invoice.has_product_key = true;
}
var hasStandard;
var hasTask;
// sum line item
for (var i=0; i<invoice.invoice_items.length; i++) {
var item = invoice.invoice_items[i];
@ -668,8 +671,20 @@ function calculateAmounts(invoice) {
if (lineTotal) {
total += lineTotal;
}
if (!item.notes && !item.product_key) {
continue;
}
if (item.invoice_item_type_id == 2) {
hasTask = true;
} else {
hasStandard = true;
}
}
invoice.hasTasks = hasTask;
invoice.hasStandard = hasStandard;
invoice.hasSecondTable = hasTask && hasStandard;
for (var i=0; i<invoice.invoice_items.length; i++) {
var item = invoice.invoice_items[i];
var taxRate1 = 0;

View File

@ -2241,7 +2241,7 @@ $LANG = array(
'resume_task' => 'Resume Task',
'resumed_task' => 'Successfully resumed task',
'quote_design' => 'Quote Design',
'default_design' => 'Default Design',
'default_design' => 'Standard Design',
'custom_design1' => 'Custom Design 1',
'custom_design2' => 'Custom Design 2',
'custom_design3' => 'Custom Design 3',

View File

@ -249,93 +249,10 @@
</div>
</div>
<div class="table-responsive" style="padding-top:4px">
<table class="table invoice-table">
<thead>
<tr>
<th style="min-width:32px;" class="hide-border"></th>
<th style="min-width:120px;width:25%">{{ $invoiceLabels['item'] }}</th>
<th style="width:100%">{{ $invoiceLabels['description'] }}</th>
@if ($account->showCustomField('custom_invoice_item_label1'))
<th style="min-width:120px">{{ $account->custom_invoice_item_label1 }}</th>
@endif
@if ($account->showCustomField('custom_invoice_item_label2'))
<th style="min-width:120px">{{ $account->custom_invoice_item_label2 }}</th>
@endif
<th style="min-width:120px" data-bind="text: costLabel">{{ $invoiceLabels['unit_cost'] }}</th>
<th style="{{ $account->hide_quantity ? 'display:none' : 'min-width:120px' }}" data-bind="text: qtyLabel">{{ $invoiceLabels['quantity'] }}</th>
<th style="min-width:{{ $account->enable_second_tax_rate ? 180 : 120 }}px;display:none;" data-bind="visible: $root.invoice_item_taxes.show">{{ trans('texts.tax') }}</th>
<th style="min-width:120px;">{{ trans('texts.line_total') }}</th>
<th style="min-width:32px;" class="hide-border"></th>
</tr>
</thead>
<tbody data-bind="sortable: { data: invoice_items, afterMove: onDragged }">
<tr data-bind="event: { mouseover: showActions, mouseout: hideActions }" class="sortable-row">
<td class="hide-border td-icon">
<i style="display:none" data-bind="visible: actionsVisible() &amp;&amp;
$parent.invoice_items().length > 1" class="fa fa-sort"></i>
</td>
<td>
<div id="scrollable-dropdown-menu">
<input id="product_key" type="text" data-bind="productTypeahead: product_key, items: $root.products, key: 'product_key', valueUpdate: 'afterkeydown', attr: {name: 'invoice_items[' + $index() + '][product_key]'}" class="form-control invoice-item handled"/>
</div>
</td>
<td>
<textarea data-bind="value: notes, valueUpdate: 'afterkeydown', attr: {name: 'invoice_items[' + $index() + '][notes]'}"
rows="1" cols="60" style="resize: vertical;height:42px" class="form-control word-wrap"></textarea>
<input type="text" data-bind="value: task_public_id, attr: {name: 'invoice_items[' + $index() + '][task_public_id]'}" style="display: none"/>
<input type="text" data-bind="value: expense_public_id, attr: {name: 'invoice_items[' + $index() + '][expense_public_id]'}" style="display: none"/>
<input type="text" data-bind="value: invoice_item_type_id, attr: {name: 'invoice_items[' + $index() + '][invoice_item_type_id]'}" style="display: none"/>
</td>
@if ($account->showCustomField('custom_invoice_item_label1'))
<td>
<input data-bind="value: custom_value1, valueUpdate: 'afterkeydown', attr: {name: 'invoice_items[' + $index() + '][custom_value1]'}" class="form-control invoice-item"/>
</td>
@endif
@if ($account->showCustomField('custom_invoice_item_label2'))
<td>
<input data-bind="value: custom_value2, valueUpdate: 'afterkeydown', attr: {name: 'invoice_items[' + $index() + '][custom_value2]'}" class="form-control invoice-item"/>
</td>
@endif
<td>
<input data-bind="value: prettyCost, valueUpdate: 'afterkeydown', attr: {name: 'invoice_items[' + $index() + '][cost]'}"
style="text-align: right" class="form-control invoice-item"/>
</td>
<td style="{{ $account->hide_quantity ? 'display:none' : '' }}">
<input data-bind="value: prettyQty, valueUpdate: 'afterkeydown', attr: {name: 'invoice_items[' + $index() + '][qty]'}"
style="text-align: right" class="form-control invoice-item" name="quantity"/>
</td>
<td style="display:none;" data-bind="visible: $root.invoice_item_taxes.show">
{!! Former::select('')
->addOption('', '')
->options($taxRateOptions)
->data_bind('value: tax1, event:{change:onTax1Change}')
->addClass($account->enable_second_tax_rate ? 'tax-select' : '')
->raw() !!}
<input type="text" data-bind="value: tax_name1, attr: {name: 'invoice_items[' + $index() + '][tax_name1]'}" style="display:none">
<input type="text" data-bind="value: tax_rate1, attr: {name: 'invoice_items[' + $index() + '][tax_rate1]'}" style="display:none">
<div data-bind="visible: $root.invoice().account.enable_second_tax_rate == '1'">
{!! Former::select('')
->addOption('', '')
->options($taxRateOptions)
->data_bind('value: tax2, event:{change:onTax2Change}')
->addClass('tax-select')
->raw() !!}
</div>
<input type="text" data-bind="value: tax_name2, attr: {name: 'invoice_items[' + $index() + '][tax_name2]'}" style="display:none">
<input type="text" data-bind="value: tax_rate2, attr: {name: 'invoice_items[' + $index() + '][tax_rate2]'}" style="display:none">
</td>
<td style="text-align:right;padding-top:9px !important" nowrap>
<div class="line-total" data-bind="text: totals.total"></div>
</td>
<td style="cursor:pointer" class="hide-border td-icon">
<i style="padding-left:2px" data-bind="click: $parent.removeItem, visible: actionsVisible() &amp;&amp;
$index() < ($parent.invoice_items().length - 1) &amp;&amp;
$parent.invoice_items().length > 1" class="fa fa-minus-circle redlink" title="Remove item"/>
</td>
</tr>
</tbody>
<div class="table-responsivex" style="padding-top:4px;">
@include('invoices.edit_table', ['isTasks' => false])
@include('invoices.edit_table', ['isTasks' => true])
<tfoot>
<tr>
@ -936,7 +853,7 @@
item.notes(task.description);
item.qty(task.duration);
item.task_public_id(task.publicId);
item.invoice_item_type_id = {{ INVOICE_ITEM_TYPE_TASK }};
item.invoice_item_type_id({{ INVOICE_ITEM_TYPE_TASK }});
}
model.invoice().invoice_items.push(blank);
model.invoice().has_tasks(true);
@ -1233,10 +1150,6 @@
invoice.imageHeight = {{ $account->getLogoHeight() }};
@endif
//invoiceLabels.item = invoice.has_tasks ? invoiceLabels.date : invoiceLabels.item_orig;
invoiceLabels.quantity = invoice.has_tasks ? invoiceLabels.hours : invoiceLabels.quantity_orig;
invoiceLabels.unit_cost = invoice.has_tasks ? invoiceLabels.rate : invoiceLabels.unit_cost_orig;
return invoice;
}
@ -1607,17 +1520,26 @@
function onItemChange(silent)
{
var hasEmpty = false;
var hasEmptyStandard = false;
var hasEmptyTask = false;
for(var i=0; i<model.invoice().invoice_items().length; i++) {
var item = model.invoice().invoice_items()[i];
if (item.isEmpty()) {
hasEmpty = true;
if (item.invoice_item_type_id() == {{ INVOICE_ITEM_TYPE_TASK }}) {
hasEmptyTask = true;
} else {
hasEmptyStandard = true;
}
}
}
if (!hasEmpty) {
if (!hasEmptyStandard) {
model.invoice().addItem();
}
if (!hasEmptyTask) {
item = model.invoice().addItem();
item.invoice_item_type_id({{ INVOICE_ITEM_TYPE_TASK }});
}
if (!silent) {
NINJA.formIsChanged = true;

View File

@ -0,0 +1,85 @@
<table class="table invoice-table" {!! $isTasks ? 'style="display:none;margin-top:24px;" data-bind="visible: $root.hasTasks"' : '' !!}>
<thead>
<tr>
<th style="min-width:32px;" class="hide-border"></th>
<th style="min-width:120px;width:25%">{{ $invoiceLabels['item'] }}</th>
<th style="width:100%">{{ $invoiceLabels['description'] }}</th>
@if ($account->showCustomField('custom_invoice_item_label1'))
<th style="min-width:120px">{{ $account->custom_invoice_item_label1 }}</th>
@endif
@if ($account->showCustomField('custom_invoice_item_label2'))
<th style="min-width:120px">{{ $account->custom_invoice_item_label2 }}</th>
@endif
<th style="min-width:120px">{{ $invoiceLabels[$isTasks ? 'rate' : 'unit_cost'] }}</th>
<th style="{{ $account->hide_quantity ? 'display:none' : 'min-width:120px' }}">{{ $invoiceLabels[$isTasks ? 'hours' : 'quantity'] }}</th>
<th style="min-width:{{ $account->enable_second_tax_rate ? 180 : 120 }}px;display:none;" data-bind="visible: $root.invoice_item_taxes.show">{{ trans('texts.tax') }}</th>
<th style="min-width:120px;">{{ trans('texts.line_total') }}</th>
<th style="min-width:32px;" class="hide-border"></th>
</tr>
</thead>
<tbody data-bind="sortable: { data: {{ $isTasks ? 'invoice_items_with_tasks' : 'invoice_items_without_tasks' }}, afterMove: onDragged }">
<tr data-bind="event: { mouseover: showActions, mouseout: hideActions }" class="sortable-row">
<td class="hide-border td-icon">
<i style="display:none" data-bind="visible: actionsVisible() &amp;&amp;
$parent.invoice_items().length > 1" class="fa fa-sort"></i>
</td>
<td>
<div id="scrollable-dropdown-menu">
<input id="product_key" type="text" data-bind="productTypeahead: product_key, items: $root.products, key: 'product_key', valueUpdate: 'afterkeydown', attr: {name: 'invoice_items[{{ $isTasks ? 'T' : '' }}' + $index() + '][product_key]'}" class="form-control invoice-item handled"/>
</div>
</td>
<td>
<textarea data-bind="value: notes, valueUpdate: 'afterkeydown', attr: {name: 'invoice_items[{{ $isTasks ? 'T' : '' }}' + $index() + '][notes]'}"
rows="1" cols="60" style="resize: vertical;height:42px" class="form-control word-wrap"></textarea>
<input type="text" data-bind="value: task_public_id, attr: {name: 'invoice_items[{{ $isTasks ? 'T' : '' }}' + $index() + '][task_public_id]'}" style="display: none"/>
<input type="text" data-bind="value: expense_public_id, attr: {name: 'invoice_items[{{ $isTasks ? 'T' : '' }}' + $index() + '][expense_public_id]'}" style="display: none"/>
<input type="text" data-bind="value: invoice_item_type_id, attr: {name: 'invoice_items[{{ $isTasks ? 'T' : '' }}' + $index() + '][invoice_item_type_id]'}" style="display: none"/>
</td>
@if ($account->showCustomField('custom_invoice_item_label1'))
<td>
<input data-bind="value: custom_value1, valueUpdate: 'afterkeydown', attr: {name: 'invoice_items[{{ $isTasks ? 'T' : '' }}' + $index() + '][custom_value1]'}" class="form-control invoice-item"/>
</td>
@endif
@if ($account->showCustomField('custom_invoice_item_label2'))
<td>
<input data-bind="value: custom_value2, valueUpdate: 'afterkeydown', attr: {name: 'invoice_items[{{ $isTasks ? 'T' : '' }}' + $index() + '][custom_value2]'}" class="form-control invoice-item"/>
</td>
@endif
<td>
<input data-bind="value: prettyCost, valueUpdate: 'afterkeydown', attr: {name: 'invoice_items[{{ $isTasks ? 'T' : '' }}' + $index() + '][cost]'}"
style="text-align: right" class="form-control invoice-item"/>
</td>
<td style="{{ $account->hide_quantity ? 'display:none' : '' }}">
<input data-bind="value: prettyQty, valueUpdate: 'afterkeydown', attr: {name: 'invoice_items[{{ $isTasks ? 'T' : '' }}' + $index() + '][qty]'}"
style="text-align: right" class="form-control invoice-item" name="quantity"/>
</td>
<td style="display:none;" data-bind="visible: $root.invoice_item_taxes.show">
{!! Former::select('')
->addOption('', '')
->options($taxRateOptions)
->data_bind('value: tax1, event:{change:onTax1Change}')
->addClass($account->enable_second_tax_rate ? 'tax-select' : '')
->raw() !!}
<input type="text" data-bind="value: tax_name1, attr: {name: 'invoice_items[{{ $isTasks ? 'T' : '' }}' + $index() + '][tax_name1]'}" style="display:none">
<input type="text" data-bind="value: tax_rate1, attr: {name: 'invoice_items[{{ $isTasks ? 'T' : '' }}' + $index() + '][tax_rate1]'}" style="display:none">
<div data-bind="visible: $root.invoice().account.enable_second_tax_rate == '1'">
{!! Former::select('')
->addOption('', '')
->options($taxRateOptions)
->data_bind('value: tax2, event:{change:onTax2Change}')
->addClass('tax-select')
->raw() !!}
</div>
<input type="text" data-bind="value: tax_name2, attr: {name: 'invoice_items[{{ $isTasks ? 'T' : '' }}' + $index() + '][tax_name2]'}" style="display:none">
<input type="text" data-bind="value: tax_rate2, attr: {name: 'invoice_items[{{ $isTasks ? 'T' : '' }}' + $index() + '][tax_rate2]'}" style="display:none">
</td>
<td style="text-align:right;padding-top:9px !important" nowrap>
<div class="line-total" data-bind="text: totals.total"></div>
</td>
<td style="cursor:pointer" class="hide-border td-icon">
<i style="padding-left:2px" data-bind="click: $parent.removeItem, visible: actionsVisible() &amp;&amp;
$index() < ($parent.invoice_items().length - 1) &amp;&amp;
$parent.invoice_items().length > 1" class="fa fa-minus-circle redlink" title="Remove item"/>
</td>
</tr>
</tbody>

View File

@ -165,6 +165,17 @@ function ViewModel(data) {
}
}
});
self.hasTasks = ko.computed(function() {
invoice = self.invoice();
for (var i=0; i<invoice.invoice_items().length; ++i) {
var item = invoice.invoice_items()[i];
if (! item.isEmpty() && item.invoice_item_type_id() == {{ INVOICE_ITEM_TYPE_TASK }}) {
return true;
}
}
return false;
});
}
function InvoiceModel(data) {
@ -288,14 +299,6 @@ function InvoiceModel(data) {
self.addItem();
}
self.qtyLabel = ko.computed(function() {
return self.has_tasks() ? invoiceLabels['hours'] : invoiceLabels['quantity'];
}, this);
self.costLabel = ko.computed(function() {
return self.has_tasks() ? invoiceLabels['rate'] : invoiceLabels['unit_cost'];
}, this);
this.tax1 = ko.computed({
read: function () {
return self.tax_rate1IsInclusive() + ' ' + self.tax_rate1() + ' ' + self.tax_name1();
@ -560,6 +563,18 @@ function InvoiceModel(data) {
}
self.applyInclusivTax(taxRate);
}
self.invoice_items_with_tasks = ko.computed(function() {
return ko.utils.arrayFilter(self.invoice_items(), function(item) {
return item.invoice_item_type_id() == {{ INVOICE_ITEM_TYPE_TASK }};
});
});
self.invoice_items_without_tasks = ko.computed(function() {
return ko.utils.arrayFilter(self.invoice_items(), function(item) {
return item.invoice_item_type_id() != {{ INVOICE_ITEM_TYPE_TASK }};
});
});
}
function ClientModel(data) {

View File

@ -103,13 +103,6 @@
@endif
var invoiceLabels = {!! json_encode($account->getInvoiceLabels()) !!};
if (window.invoice) {
//invoiceLabels.item = invoice.has_tasks ? invoiceLabels.date : invoiceLabels.item_orig;
invoiceLabels.quantity = invoice.has_tasks ? invoiceLabels.hours : invoiceLabels.quantity_orig;
invoiceLabels.unit_cost = invoice.has_tasks ? invoiceLabels.rate : invoiceLabels.unit_cost_orig;
}
var isRefreshing = false;
var needsRefresh = false;