Custom invoice/quote numbers

This commit is contained in:
Hillel Coren 2015-10-22 21:48:12 +03:00
parent ab3de15381
commit 7ca98c9080
21 changed files with 1328 additions and 952 deletions

View File

@ -157,6 +157,8 @@ class AccountController extends BaseController
return self::showLocalization();
} elseif ($section == ACCOUNT_PAYMENTS) {
return self::showOnlinePayments();
} elseif ($section == ACCOUNT_INVOICE_SETTINGS) {
return self::showInvoiceSettings();
} elseif ($section == ACCOUNT_IMPORT_EXPORT) {
return View::make('accounts.import_export', ['title' => trans('texts.import_export')]);
} elseif ($section == ACCOUNT_INVOICE_DESIGN || $section == ACCOUNT_CUSTOMIZE_DESIGN) {
@ -177,6 +179,29 @@ class AccountController extends BaseController
}
}
private function showInvoiceSettings()
{
$account = Auth::user()->account;
$recurringHours = [];
for ($i=0; $i<24; $i++) {
if ($account->military_time) {
$format = 'H:i';
} else {
$format = 'g:i a';
}
$recurringHours[$i] = date($format, strtotime("{$i}:00"));
}
$data = [
'account' => Account::with('users')->findOrFail(Auth::user()->account_id),
'title' => trans("texts.invoice_settings"),
'section' => ACCOUNT_INVOICE_SETTINGS,
'recurringHours' => $recurringHours
];
return View::make("accounts.invoice_settings", $data);
}
private function showCompanyDetails()
{
$data = [
@ -487,18 +512,36 @@ class AccountController extends BaseController
$account->custom_invoice_text_label1 = trim(Input::get('custom_invoice_text_label1'));
$account->custom_invoice_text_label2 = trim(Input::get('custom_invoice_text_label2'));
$account->invoice_number_prefix = Input::get('invoice_number_prefix');
$account->invoice_number_counter = Input::get('invoice_number_counter');
$account->quote_number_prefix = Input::get('quote_number_prefix');
$account->share_counter = Input::get('share_counter') ? true : false;
$account->pdf_email_attachment = Input::get('pdf_email_attachment') ? true : false;
$account->auto_wrap = Input::get('auto_wrap') ? true : false;
if (Input::has('recurring_hour')) {
$account->recurring_hour = Input::get('recurring_hour');
}
if (!$account->share_counter) {
$account->quote_number_counter = Input::get('quote_number_counter');
}
if (Input::get('invoice_number_type') == 'prefix') {
$account->invoice_number_prefix = trim(Input::get('invoice_number_prefix'));
$account->invoice_number_pattern = null;
} else {
$account->invoice_number_pattern = trim(Input::get('invoice_number_pattern'));
$account->invoice_number_prefix = null;
}
if (Input::get('quote_number_type') == 'prefix') {
$account->quote_number_prefix = trim(Input::get('quote_number_prefix'));
$account->quote_number_pattern = null;
} else {
$account->quote_number_pattern = trim(Input::get('quote_number_pattern'));
$account->quote_number_prefix = null;
}
if (!$account->share_counter && $account->invoice_number_prefix == $account->quote_number_prefix) {
Session::flash('error', trans('texts.invalid_counter'));
return Redirect::to('settings/' . ACCOUNT_INVOICE_SETTINGS)->withInput();

View File

@ -258,6 +258,13 @@ class ClientController extends BaseController
$client->payment_terms = Input::get('payment_terms') ?: 0;
$client->website = trim(Input::get('website'));
if (Input::has('invoice_number_counter')) {
$client->invoice_number_counter = (int) Input::get('invoice_number_counter');
}
if (Input::has('quote_number_counter')) {
$client->invoice_number_counter = (int) Input::get('quote_number_counter');
}
$client->save();
$data = json_decode(Input::get('data'));

View File

@ -59,16 +59,6 @@ class InvoiceApiController extends Controller
$data = Input::all();
$error = null;
// check if the invoice number is set and unique
if (!isset($data['invoice_number']) && !isset($data['id'])) {
$data['invoice_number'] = Auth::user()->account->getNextInvoiceNumber();
} else if (isset($data['invoice_number'])) {
$invoice = Invoice::scope()->where('invoice_number', '=', $data['invoice_number'])->first();
if ($invoice) {
$error = trans('validation.unique', ['attribute' => 'texts.invoice_number']);
}
}
if (isset($data['email'])) {
$client = Client::scope()->whereHas('contacts', function($query) use ($data) {
$query->where('email', '=', $data['email']);
@ -95,6 +85,16 @@ class InvoiceApiController extends Controller
$client = Client::scope($data['client_id'])->first();
}
// check if the invoice number is set and unique
if (!isset($data['invoice_number']) && !isset($data['id'])) {
$data['invoice_number'] = Auth::user()->account->getNextInvoiceNumber(false, '', $client);
} else if (isset($data['invoice_number'])) {
$invoice = Invoice::scope()->where('invoice_number', '=', $data['invoice_number'])->first();
if ($invoice) {
$error = trans('validation.unique', ['attribute' => 'texts.invoice_number']);
}
}
if (!$error) {
if (!isset($data['client_id']) && !isset($data['email'])) {
$error = trans('validation.', ['attribute' => 'client_id or email']);

View File

@ -235,6 +235,7 @@ class InvoiceController extends BaseController
public function edit($publicId, $clone = false)
{
$account = Auth::user()->account;
$invoice = Invoice::scope($publicId)->withTrashed()->with('invitations', 'account.country', 'client.contacts', 'client.country', 'invoice_items')->firstOrFail();
$entityType = $invoice->getEntityType();
@ -247,7 +248,7 @@ class InvoiceController extends BaseController
if ($clone) {
$invoice->id = null;
$invoice->invoice_number = Auth::user()->account->getNextInvoiceNumber($invoice->is_quote);
$invoice->invoice_number = $account->getNextInvoiceNumber($invoice->is_quote, '', $invoice->client);
$invoice->balance = $invoice->amount;
$invoice->invoice_status_id = 0;
$invoice->invoice_date = date_create()->format('Y-m-d');
@ -349,7 +350,7 @@ class InvoiceController extends BaseController
public function create($clientPublicId = 0, $isRecurring = false)
{
$client = null;
$invoiceNumber = $isRecurring ? microtime(true) : Auth::user()->account->getNextInvoiceNumber();
$invoiceNumber = $isRecurring ? microtime(true) : Auth::user()->account->getDefaultInvoiceNumber();
if ($clientPublicId) {
$client = Client::scope($clientPublicId)->firstOrFail();

View File

@ -82,7 +82,7 @@ class QuoteController extends BaseController
}
$client = null;
$invoiceNumber = Auth::user()->account->getNextInvoiceNumber(true);
$invoiceNumber = Auth::user()->account->getDefaultInvoiceNumber(true);
$account = Account::with('country')->findOrFail(Auth::user()->account_id);
if ($clientPublicId) {

View File

@ -408,6 +408,7 @@ if (!defined('CONTACT_EMAIL')) {
define('OUTDATE_BROWSER_URL', 'http://browsehappy.com/');
define('PDFMAKE_DOCS', 'http://pdfmake.org/playground.html');
define('PHANTOMJS_CLOUD', 'http://api.phantomjscloud.com/single/browser/v1/');
define('PHP_DATE_FORMATS', 'http://php.net/manual/en/function.date.php');
define('REFERRAL_PROGRAM_URL', false);
define('COUNT_FREE_DESIGNS', 4);

View File

@ -243,9 +243,88 @@ class Account extends Eloquent
return $height;
}
public function getNextInvoiceNumber($isQuote = false, $prefix = '')
public function hasNumberPattern($isQuote)
{
$counter = $isQuote && !$this->share_counter ? $this->quote_number_counter : $this->invoice_number_counter;
return $isQuote ? ($this->quote_number_pattern ? true : false) : ($this->invoice_number_pattern ? true : false);
}
public function getNumberPattern($isQuote, $client = null)
{
$pattern = $isQuote ? $this->quote_number_pattern : $this->invoice_number_pattern;
if (!$pattern) {
return false;
}
$search = ['{$year}'];
$replace = [date('Y')];
$search[] = '{$counter}';
$replace[] = str_pad($this->getCounter($isQuote), 4, '0', STR_PAD_LEFT);
$matches = false;
preg_match('/{\$date:(.*?)}/', $pattern, $matches);
if (count($matches) > 1) {
$format = $matches[1];
$search[] = $matches[0];
$replace[] = str_replace($format, date($format), $matches[1]);
}
$pattern = str_replace($search, $replace, $pattern);
if ($client) {
$pattern = $this->getClientInvoiceNumber($pattern, $isQuote, $client);
}
return $pattern;
}
private function getClientInvoiceNumber($pattern, $isQuote, $client)
{
if (!$client) {
return $pattern;
}
$search = [
//'{$clientId}',
'{$userId}',
'{$custom1}',
'{$custom2}',
];
$replace = [
//str_pad($client->public_id, 3, '0', STR_PAD_LEFT),
str_pad($client->user->public_id, 2, '0', STR_PAD_LEFT),
$client->custom_value1,
$client->custom_value2,
];
return str_replace($search, $replace, $pattern);
}
// if we're using a pattern we don't know the next number until a client
// is selected, to support this the default value is blank
public function getDefaultInvoiceNumber($isQuote = false, $prefix = '')
{
if ($this->getNumberPattern($isQuote)) {
return false;
}
return $this->getNextInvoiceNumber($isQuote = false, $prefix = '');
}
public function getCounter($isQuote)
{
return $isQuote && !$this->share_counter ? $this->quote_number_counter : $this->invoice_number_counter;
}
public function getNextInvoiceNumber($isQuote = false, $prefix = '', $client = null)
{
if ($this->hasNumberPattern($isQuote)) {
return $this->getNumberPattern($isQuote, $client);
}
$counter = $this->getCounter($isQuote);
$prefix .= $isQuote ? $this->quote_number_prefix : $this->invoice_number_prefix;
$counterOffset = 0;
@ -271,9 +350,9 @@ class Account extends Eloquent
return $number;
}
public function incrementCounter($isQuote = false)
public function incrementCounter($invoice)
{
if ($isQuote && !$this->share_counter) {
if ($invoice->is_quote && !$this->share_counter) {
$this->quote_number_counter += 1;
} else {
$this->invoice_number_counter += 1;

View File

@ -25,6 +25,11 @@ class Client extends EntityModel
return $this->belongsTo('App\Models\Account');
}
public function user()
{
return $this->belongsTo('App\Models\User');
}
public function invoices()
{
return $this->hasMany('App\Models\Invoice');
@ -168,6 +173,11 @@ class Client extends EntityModel
return $this->account->currency_id ?: DEFAULT_CURRENCY;
}
public function getCounter($isQuote)
{
return $isQuote ? $this->quote_number_counter : $this->invoice_number_counter;
}
}
/*

View File

@ -15,6 +15,16 @@ class Invoice extends EntityModel
'auto_bill' => 'boolean',
];
public static $patternFields = [
'counter',
'custom1',
'custom2',
'userId',
//'clientId', // need to update after saving
'year',
'date:',
];
public function account()
{
return $this->belongsTo('App\Models\Account');
@ -223,7 +233,7 @@ class Invoice extends EntityModel
}
$startDate = $this->getOriginal('last_sent_date') ?: $this->getOriginal('start_date');
$startDate .= ' ' . DEFAULT_SEND_RECURRING_HOUR . ':00:00';
$startDate .= ' ' . $this->account->recurring_hour . ':00:00';
$startDate = $this->account->getDateTime($startDate);
$endDate = $this->end_date ? $this->account->getDateTime($this->getOriginal('end_date')) : null;
$timezone = $this->account->getTimezone();
@ -249,7 +259,7 @@ class Invoice extends EntityModel
public function getNextSendDate()
{
if ($this->start_date && !$this->last_sent_date) {
$startDate = $this->getOriginal('start_date') . ' ' . DEFAULT_SEND_RECURRING_HOUR . ':00:00';
$startDate = $this->getOriginal('start_date') . ' ' . $this->account->recurring_hour . ':00:00';
return $this->account->getDateTime($startDate);
}
@ -432,7 +442,7 @@ class Invoice extends EntityModel
Invoice::creating(function ($invoice) {
if (!$invoice->is_recurring) {
$invoice->account->incrementCounter($invoice->is_quote);
$invoice->account->incrementCounter($invoice);
}
});

View File

@ -478,7 +478,7 @@ class InvoiceRepository
}
$clone->invoice_number = $account->invoice_number_prefix.$invoiceNumber;
} else {
$clone->invoice_number = $account->getNextInvoiceNumber();
$clone->invoice_number = $account->getNextInvoiceNumber($invoice->is_quote, '', $invoice->client);
}
foreach ([
@ -631,7 +631,7 @@ class InvoiceRepository
$invoice = Invoice::createNew($recurInvoice);
$invoice->client_id = $recurInvoice->client_id;
$invoice->recurring_invoice_id = $recurInvoice->id;
$invoice->invoice_number = $recurInvoice->account->getNextInvoiceNumber(false, 'R');
$invoice->invoice_number = $recurInvoice->account->getNextInvoiceNumber(false, 'R', $recurInvoice->client);
$invoice->amount = $recurInvoice->amount;
$invoice->balance = $recurInvoice->amount;
$invoice->invoice_date = date_create()->format('Y-m-d');

View File

@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
class AddInvoiceNumberPattern extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('accounts', function ($table) {
$table->string('invoice_number_pattern')->nullable();
$table->string('quote_number_pattern')->nullable();
});
Schema::table('clients', function ($table) {
$table->integer('invoice_number_counter')->default(1)->nullable();
$table->integer('quote_number_counter')->default(1)->nullable();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('accounts', function ($table) {
$table->dropColumn('invoice_number_pattern');
$table->dropColumn('quote_number_pattern');
});
Schema::table('accounts', function ($table) {
$table->dropColumn('invoice_number_counter');
$table->dropColumn('quote_number_counter');
});
}
}

View File

@ -32060,6 +32060,7 @@ NINJA.clientDetails = function(invoice) {
if (!client) {
return;
}
var account = invoice.account;
var contact = client.contacts[0];
var clientName = client.name || (contact.first_name || contact.last_name ? (contact.first_name + ' ' + contact.last_name) : contact.email);
var clientEmail = client.contacts[0].email == clientName ? '' : client.contacts[0].email;
@ -32070,6 +32071,11 @@ NINJA.clientDetails = function(invoice) {
cityStatePostal = formatAddress(client.city, client.state, client.postal_code, swap);
}
// if a custom field is used in the invoice/quote number then we'll hide it from the PDF
var pattern = invoice.is_quote ? account.quote_number_pattern : account.invoice_number_pattern;
var custom1InPattern = (pattern && pattern.indexOf('{$custom1}') >= 0);
var custom2InPattern = (pattern && pattern.indexOf('{$custom2}') >= 0);
data = [
{text:clientName || ' ', style: ['clientName']},
{text:client.id_number},
@ -32079,8 +32085,8 @@ NINJA.clientDetails = function(invoice) {
{text:cityStatePostal},
{text:client.country ? client.country.name : ''},
{text:clientEmail},
{text: invoice.client.custom_value1 ? invoice.account.custom_client_label1 + ' ' + invoice.client.custom_value1 : false},
{text: invoice.client.custom_value2 ? invoice.account.custom_client_label2 + ' ' + invoice.client.custom_value2 : false}
{text: client.custom_value1 && !custom1InPattern ? account.custom_client_label1 + ' ' + client.custom_value1 : false},
{text: client.custom_value2 && !custom2InPattern ? account.custom_client_label2 + ' ' + client.custom_value2 : false}
];
return NINJA.prepareDataList(data, 'clientDetails');

View File

@ -487,6 +487,7 @@ NINJA.clientDetails = function(invoice) {
if (!client) {
return;
}
var account = invoice.account;
var contact = client.contacts[0];
var clientName = client.name || (contact.first_name || contact.last_name ? (contact.first_name + ' ' + contact.last_name) : contact.email);
var clientEmail = client.contacts[0].email == clientName ? '' : client.contacts[0].email;
@ -497,6 +498,11 @@ NINJA.clientDetails = function(invoice) {
cityStatePostal = formatAddress(client.city, client.state, client.postal_code, swap);
}
// if a custom field is used in the invoice/quote number then we'll hide it from the PDF
var pattern = invoice.is_quote ? account.quote_number_pattern : account.invoice_number_pattern;
var custom1InPattern = (pattern && pattern.indexOf('{$custom1}') >= 0);
var custom2InPattern = (pattern && pattern.indexOf('{$custom2}') >= 0);
data = [
{text:clientName || ' ', style: ['clientName']},
{text:client.id_number},
@ -506,8 +512,8 @@ NINJA.clientDetails = function(invoice) {
{text:cityStatePostal},
{text:client.country ? client.country.name : ''},
{text:clientEmail},
{text: invoice.client.custom_value1 ? invoice.account.custom_client_label1 + ' ' + invoice.client.custom_value1 : false},
{text: invoice.client.custom_value2 ? invoice.account.custom_client_label2 + ' ' + invoice.client.custom_value2 : false}
{text: client.custom_value1 && !custom1InPattern ? account.custom_client_label1 + ' ' + client.custom_value1 : false},
{text: client.custom_value2 && !custom2InPattern ? account.custom_client_label2 + ' ' + client.custom_value2 : false}
];
return NINJA.prepareDataList(data, 'clientDetails');

View File

@ -840,6 +840,16 @@ return array(
'archived_tax_rate' => 'Successfully archived the tax rate',
'default_tax_rate_id' => 'Default Tax Rate',
'tax_rate' => 'Tax Rate',
'recurring_hour' => 'Recurring Hour',
'pattern' => 'Pattern',
'pattern_help_title' => 'Pattern Help',
'pattern_help_1' => 'Create custom invoice and quote numbers by specifying a pattern',
'pattern_help_2' => 'Available variables:',
'pattern_help_3' => 'For example, :example would be converted to :value',
'see_options' => 'See options',
'invoice_counter' => 'Invoice Counter',
'quote_counter' => 'Quote Counter',
'type' => 'Type',
);

View File

@ -10,7 +10,7 @@
.input-group-addon div.checkbox {
display: inline;
}
.tab-content span.input-group-addon {
.tab-content .pad-checkbox span.input-group-addon {
padding-right: 30px;
}
</style>
@ -20,7 +20,7 @@
@parent
@include('accounts.nav', ['selected' => ACCOUNT_INVOICE_SETTINGS, 'advanced' => true])
{!! Former::open()->addClass('warn-on-exit') !!}
{!! Former::open()->rules(['iframe_url' => 'url'])->addClass('warn-on-exit') !!}
{{ Former::populate($account) }}
{{ Former::populateField('custom_invoice_taxes1', intval($account->custom_invoice_taxes1)) }}
{{ Former::populateField('custom_invoice_taxes2', intval($account->custom_invoice_taxes2)) }}
@ -34,25 +34,28 @@
</div>
<div class="panel-body form-padding-right">
{!! Former::checkbox('pdf_email_attachment')->text(trans('texts.enable')) !!}
@if (Utils::isNinja())
{!! Former::inline_radios('custom_invoice_link')
->onchange('onCustomLinkChange()')
->radios([
trans('texts.subdomain') => ['value' => 'subdomain', 'name' => 'custom_link'],
trans('texts.website') => ['value' => 'website', 'name' => 'custom_link'],
])->check($account->iframe_url ? 'website' : 'subdomain') !!}
{{ Former::setOption('capitalize_translations', false) }}
{!! Former::text('subdomain')
->placeholder(trans('texts.www'))
->onchange('onSubdomainChange()')
->addGroupClass('subdomain')
->label(' ') !!}
{!! Former::text('iframe_url')
->placeholder('http://www.example.com/invoice')
->appendIcon('question-sign')
->addGroupClass('iframe_url')
->label(' ') !!}
@endif
{{-- Former::select('recurring_hour')->options($recurringHours) --}}
{!! Former::inline_radios('custom_invoice_link')
->onchange('onCustomLinkChange()')
->radios([
trans('texts.subdomain') => ['value' => 'subdomain', 'name' => 'custom_link'],
trans('texts.website') => ['value' => 'website', 'name' => 'custom_link'],
])->check($account->iframe_url ? 'website' : 'subdomain') !!}
{{ Former::setOption('capitalize_translations', false) }}
{!! Former::text('subdomain')
->placeholder(trans('texts.www'))
->onchange('onSubdomainChange()')
->addGroupClass('subdomain')
->label(' ') !!}
{!! Former::text('iframe_url')
->placeholder('http://www.example.com/invoice')
->appendIcon('question-sign')
->addGroupClass('iframe_url')
->label(' ') !!}
</div>
</div>
@ -61,6 +64,7 @@
<h3 class="panel-title">{!! trans('texts.invoice_quote_number') !!}</h3>
</div>
<div class="panel-body form-padding-right">
<div role="tabpanel">
<ul class="nav nav-tabs" role="tablist" style="border: none">
<li role="presentation" class="active"><a href="#invoiceNumber" aria-controls="invoiceNumber" role="tab" data-toggle="tab">{{ trans('texts.invoice_number') }}</a></li>
@ -70,18 +74,56 @@
<div class="tab-content">
<div role="tabpanel" class="tab-pane active" id="invoiceNumber">
<div class="panel-body">
{!! Former::text('invoice_number_prefix')->label(trans('texts.prefix')) !!}
{!! Former::text('invoice_number_counter')->label(trans('texts.counter')) !!}
{!! Former::inline_radios('invoice_number_type')
->onchange('onInvoiceNumberTypeChange()')
->label(trans('texts.type'))
->radios([
trans('texts.prefix') => ['value' => 'prefix', 'name' => 'invoice_number_type'],
trans('texts.pattern') => ['value' => 'pattern', 'name' => 'invoice_number_type'],
])->check($account->invoice_number_pattern ? 'pattern' : 'prefix') !!}
{!! Former::text('invoice_number_prefix')
->addGroupClass('invoice-prefix')
->label(' ') !!}
{!! Former::text('invoice_number_pattern')
->appendIcon('question-sign')
->addGroupClass('invoice-pattern')
->label(' ')
->addGroupClass('number-pattern') !!}
{!! Former::text('invoice_number_counter')
->label(trans('texts.counter')) !!}
</div>
</div>
<div role="tabpanel" class="tab-pane" id="quoteNumber">
<div class="panel-body">
{!! Former::text('quote_number_prefix')->label(trans('texts.prefix')) !!}
{!! Former::text('quote_number_counter')->label(trans('texts.counter'))
->append(Former::checkbox('share_counter')->raw()->onclick('setQuoteNumberEnabled()') . ' ' . trans('texts.share_invoice_counter')) !!}
{!! Former::inline_radios('quote_number_type')
->onchange('onQuoteNumberTypeChange()')
->label(trans('texts.type'))
->radios([
trans('texts.prefix') => ['value' => 'prefix', 'name' => 'quote_number_type'],
trans('texts.pattern') => ['value' => 'pattern', 'name' => 'quote_number_type'],
])->check($account->quote_number_pattern ? 'pattern' : 'prefix') !!}
{!! Former::text('quote_number_prefix')
->addGroupClass('quote-prefix')
->label(' ') !!}
{!! Former::text('quote_number_pattern')
->appendIcon('question-sign')
->addGroupClass('quote-pattern')
->addGroupClass('number-pattern')
->label(' ') !!}
{!! Former::text('quote_number_counter')
->label(trans('texts.counter'))
->addGroupClass('pad-checkbox')
->append(Former::checkbox('share_counter')->raw()
->onclick('setQuoteNumberEnabled()') . ' ' . trans('texts.share_invoice_counter')) !!}
</div>
</div>
</div>
</div>
</div>
@ -131,10 +173,16 @@
<div role="tabpanel" class="tab-pane" id="invoiceCharges">
<div class="panel-body">
{!! Former::text('custom_invoice_label1')->label(trans('texts.field_label'))
->append(Former::checkbox('custom_invoice_taxes1')->raw() . trans('texts.charge_taxes')) !!}
{!! Former::text('custom_invoice_label2')->label(trans('texts.field_label'))
->append(Former::checkbox('custom_invoice_taxes2')->raw() . trans('texts.charge_taxes')) !!}
{!! Former::text('custom_invoice_label1')
->label(trans('texts.field_label'))
->addGroupClass('pad-checkbox')
->append(Former::checkbox('custom_invoice_taxes1')
->raw() . trans('texts.charge_taxes')) !!}
{!! Former::text('custom_invoice_label2')
->label(trans('texts.field_label'))
->addGroupClass('pad-checkbox')
->append(Former::checkbox('custom_invoice_taxes2')
->raw() . trans('texts.charge_taxes')) !!}
</div>
</div>
@ -179,6 +227,40 @@
</div>
</div>
<div class="modal fade" id="patternHelpModal" tabindex="-1" role="dialog" aria-labelledby="patternHelpModalLabel" aria-hidden="true">
<div class="modal-dialog" style="min-width:150px">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title" id="patternHelpModalLabel">{{ trans('texts.pattern_help_title') }}</h4>
</div>
<div class="modal-body">
<p>{{ trans('texts.pattern_help_1') }}</p>
<p>{{ trans('texts.pattern_help_2') }}</p>
<ul>
@foreach (\App\Models\Invoice::$patternFields as $field)
@if ($field == 'date:')
<li>$date:format ({!! link_to(PHP_DATE_FORMATS, trans('texts.see_options'), ['target' => '_blank']) !!})</li>
@else
<li>${{ $field }}</li>
@endif
@endforeach
</ul>
<p>{{ trans('texts.pattern_help_3', [
'example' => '{$year}-{$counter}',
'value' => date('Y') . '-0001'
]) }}</p>
</div>
<div class="modal-footer" style="margin-top: 0px">
<button type="button" class="btn btn-primary" data-dismiss="modal">{{ trans('texts.close') }}</button>
</div>
</div>
</div>
</div>
{!! Former::close() !!}
@ -210,13 +292,41 @@
}
}
function onInvoiceNumberTypeChange() {
var val = $('input[name=invoice_number_type]:checked').val()
if (val == 'prefix') {
$('.invoice-prefix').show();
$('.invoice-pattern').hide();
} else {
$('.invoice-prefix').hide();
$('.invoice-pattern').show();
}
}
function onQuoteNumberTypeChange() {
var val = $('input[name=quote_number_type]:checked').val()
if (val == 'prefix') {
$('.quote-prefix').show();
$('.quote-pattern').hide();
} else {
$('.quote-prefix').hide();
$('.quote-pattern').show();
}
}
$('.iframe_url .input-group-addon').click(function() {
$('#iframeHelpModal').modal('show');
});
$('.number-pattern .input-group-addon').click(function() {
$('#patternHelpModal').modal('show');
});
$(function() {
setQuoteNumberEnabled();
onCustomLinkChange();
onInvoiceNumberTypeChange();
onQuoteNumberTypeChange();
$('#subdomain').change(function() {
$('#iframe_url').val('');

View File

@ -5,6 +5,7 @@
{!! Former::open_for_files()->addClass('warn-on-exit') !!}
{{ Former::populate($account) }}
{{ Former::populateField('military_time', intval($account->military_time)) }}
@include('accounts.nav', ['selected' => ACCOUNT_LOCALIZATION])

View File

@ -8,7 +8,6 @@
)) !!}
{{ Former::populate($account) }}
{{ Former::populateField('military_time', intval($account->military_time)) }}
{{ Former::populateField('first_name', $user->first_name) }}
{{ Former::populateField('last_name', $user->last_name) }}
{{ Former::populateField('email', $user->email) }}

View File

@ -1,3 +1,4 @@
@extends('header')
@ -115,6 +116,7 @@
{!! Former::select('industry_id')->addOption('','')
->fromQuery($industries, 'name', 'id') !!}
{!! Former::textarea('private_notes') !!}
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,797 @@
<script type="text/javascript">
function ViewModel(data) {
var self = this;
self.showMore = ko.observable(false);
//self.invoice = data ? false : new InvoiceModel();
self.invoice = ko.observable(data ? false : new InvoiceModel());
self.tax_rates = ko.observableArray();
self.tax_rates.push(new TaxRateModel()); // add blank row
self.loadClient = function(client) {
ko.mapping.fromJS(client, model.invoice().client().mapping, model.invoice().client);
@if (!$invoice)
self.setDueDate();
@endif
}
self.showMoreFields = function() {
self.showMore(!self.showMore());
}
self.setDueDate = function() {
@if ($entityType == ENTITY_INVOICE)
var paymentTerms = parseInt(self.invoice().client().payment_terms());
if (paymentTerms && paymentTerms != 0 && !self.invoice().due_date())
{
if (paymentTerms == -1) paymentTerms = 0;
var dueDate = $('#invoice_date').datepicker('getDate');
dueDate.setDate(dueDate.getDate() + paymentTerms);
self.invoice().due_date(dueDate);
// We're using the datepicker to handle the date formatting
self.invoice().due_date($('#due_date').val());
}
@endif
}
self.invoice_taxes = ko.observable({{ Auth::user()->account->invoice_taxes ? 'true' : 'false' }});
self.invoice_item_taxes = ko.observable({{ Auth::user()->account->invoice_item_taxes ? 'true' : 'false' }});
self.show_item_taxes = ko.observable({{ Auth::user()->account->show_item_taxes ? 'true' : 'false' }});
self.mapping = {
'invoice': {
create: function(options) {
return new InvoiceModel(options.data);
}
},
'tax_rates': {
create: function(options) {
return new TaxRateModel(options.data);
}
},
}
if (data) {
ko.mapping.fromJS(data, self.mapping, self);
}
self.invoice_taxes.show = ko.computed(function() {
if (self.invoice_taxes() && self.tax_rates().length > 1) {
return true;
}
if (self.invoice().tax_rate() > 0) {
return true;
}
return false;
});
self.invoice_item_taxes.show = ko.computed(function() {
if (self.invoice_item_taxes()) {
return true;
}
for (var i=0; i<self.invoice().invoice_items().length; i++) {
var item = self.invoice().invoice_items()[i];
if (item.tax_rate() > 0) {
return true;
}
}
return false;
});
self.addTaxRate = function(data) {
var itemModel = new TaxRateModel(data);
self.tax_rates.push(itemModel);
applyComboboxListeners();
}
self.getTaxRateById = function(id) {
for (var i=0; i<self.tax_rates().length; i++) {
var taxRate = self.tax_rates()[i];
if (taxRate.public_id() == id) {
return taxRate;
}
}
return false;
}
self.getTaxRate = function(name, rate) {
for (var i=0; i<self.tax_rates().length; i++) {
var taxRate = self.tax_rates()[i];
if (taxRate.name() == name && taxRate.rate() == parseFloat(rate)) {
return taxRate;
}
}
var taxRate = new TaxRateModel();
taxRate.name(name);
taxRate.rate(parseFloat(rate));
if (name) {
taxRate.is_deleted(true);
self.tax_rates.push(taxRate);
}
return taxRate;
}
self.showClientForm = function() {
trackEvent('/activity', '/view_client_form');
self.clientBackup = ko.mapping.toJS(self.invoice().client);
$('#emailError').css( "display", "none" );
$('#clientModal').modal('show');
}
self.clientFormComplete = function() {
trackEvent('/activity', '/save_client_form');
var email = $('#email0').val();
var firstName = $('#first_name').val();
var lastName = $('#last_name').val();
var name = $('#name').val();
if (name) {
//
} else if (firstName || lastName) {
name = firstName + ' ' + lastName;
} else {
name = email;
}
var isValid = true;
$("input[name='email']").each(function(item, value) {
var email = $(value).val();
if (!name && (!email || !isValidEmailAddress(email))) {
isValid = false;
}
});
if (!isValid) {
$('#emailError').css( "display", "inline" );
return;
}
if (self.invoice().client().public_id() == 0) {
self.invoice().client().public_id(-1);
self.invoice().client().invoice_number_counter = 1;
self.invoice().client().quote_number_counter = 1;
}
model.setDueDate();
setComboboxValue($('.client_select'), -1, name);
var client = $.parseJSON(ko.toJSON(self.invoice().client()));
setInvoiceNumber(client);
//$('.client_select select').combobox('setSelected');
//$('.client_select input.form-control').val(name);
//$('.client_select .combobox-container').addClass('combobox-selected');
$('#emailError').css( "display", "none" );
refreshPDF(true);
model.clientBackup = false;
$('#clientModal').modal('hide');
}
self.clientLinkText = ko.computed(function() {
if (self.invoice().client().public_id())
{
return "{{ trans('texts.edit_client') }}";
}
else
{
if (clients.length > {{ Auth::user()->getMaxNumClients() }})
{
return '';
}
else
{
return "{{ trans('texts.create_new_client') }}";
}
}
});
}
function InvoiceModel(data) {
var self = this;
this.client = ko.observable(data ? false : new ClientModel());
self.account = {!! $account !!};
self.id = ko.observable('');
self.discount = ko.observable('');
self.is_amount_discount = ko.observable(0);
self.frequency_id = ko.observable(4); // default to monthly
self.terms = ko.observable('');
self.default_terms = ko.observable(account.invoice_terms);
self.terms_placeholder = ko.observable({{ !$invoice && $account->invoice_terms ? 'account.invoice_terms' : false}});
self.set_default_terms = ko.observable(false);
self.invoice_footer = ko.observable('');
self.default_footer = ko.observable(account.invoice_footer);
self.footer_placeholder = ko.observable({{ !$invoice && $account->invoice_footer ? 'account.invoice_footer' : false}});
self.set_default_footer = ko.observable(false);
self.public_notes = ko.observable('');
self.po_number = ko.observable('');
self.invoice_date = ko.observable('{{ Utils::today() }}');
self.invoice_number = ko.observable('{{ isset($invoiceNumber) ? $invoiceNumber : '' }}');
self.due_date = ko.observable('');
self.start_date = ko.observable('{{ Utils::today() }}');
self.end_date = ko.observable('');
self.last_sent_date = ko.observable('');
self.tax_name = ko.observable();
self.tax_rate = ko.observable();
self.is_recurring = ko.observable({{ $isRecurring ? 'true' : 'false' }});
self.auto_bill = ko.observable();
self.invoice_status_id = ko.observable(0);
self.invoice_items = ko.observableArray();
self.amount = ko.observable(0);
self.balance = ko.observable(0);
self.invoice_design_id = ko.observable({{ $account->invoice_design_id }});
self.partial = ko.observable(0);
self.has_tasks = ko.observable(false);
self.custom_value1 = ko.observable(0);
self.custom_value2 = ko.observable(0);
self.custom_taxes1 = ko.observable(false);
self.custom_taxes2 = ko.observable(false);
self.custom_text_value1 = ko.observable();
self.custom_text_value2 = ko.observable();
self.mapping = {
'client': {
create: function(options) {
return new ClientModel(options.data);
}
},
'invoice_items': {
create: function(options) {
return new ItemModel(options.data);
}
},
'tax': {
create: function(options) {
return new TaxRateModel(options.data);
}
},
}
self.addItem = function() {
var itemModel = new ItemModel();
@if ($account->hide_quantity)
itemModel.qty(1);
@endif
self.invoice_items.push(itemModel);
applyComboboxListeners();
return itemModel;
}
if (data) {
ko.mapping.fromJS(data, self.mapping, self);
} else {
self.addItem();
}
self.productLabel = ko.computed(function() {
return self.has_tasks() ? invoiceLabels['date'] : invoiceLabels['item'];
}, this);
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);
self._tax = ko.observable();
this.tax = ko.computed({
read: function () {
return self._tax();
},
write: function(value) {
if (value) {
self._tax(value);
self.tax_name(value.name());
self.tax_rate(value.rate());
} else {
self._tax(false);
self.tax_name('');
self.tax_rate(0);
}
}
})
self.wrapped_terms = ko.computed({
read: function() {
return this.terms();
},
write: function(value) {
value = wordWrapText(value, 300);
self.terms(value);
},
owner: this
});
self.wrapped_notes = ko.computed({
read: function() {
return this.public_notes();
},
write: function(value) {
value = wordWrapText(value, 300);
self.public_notes(value);
},
owner: this
});
self.wrapped_footer = ko.computed({
read: function() {
return this.invoice_footer();
},
write: function(value) {
value = wordWrapText(value, 600);
self.invoice_footer(value);
},
owner: this
});
self.removeItem = function(item) {
self.invoice_items.remove(item);
refreshPDF(true);
}
self.totals = ko.observable();
self.totals.rawSubtotal = ko.computed(function() {
var total = 0;
for(var p=0; p < self.invoice_items().length; ++p) {
var item = self.invoice_items()[p];
total += item.totals.rawTotal();
}
return total;
});
self.totals.subtotal = ko.computed(function() {
var total = self.totals.rawSubtotal();
return total > 0 ? formatMoney(total, self.client().currency_id()) : '';
});
self.totals.rawDiscounted = ko.computed(function() {
if (parseInt(self.is_amount_discount())) {
return roundToTwo(self.discount());
} else {
return roundToTwo(self.totals.rawSubtotal() * (self.discount()/100));
}
});
self.totals.discounted = ko.computed(function() {
return formatMoney(self.totals.rawDiscounted(), self.client().currency_id());
});
self.totals.taxAmount = ko.computed(function() {
var total = self.totals.rawSubtotal();
var discount = self.totals.rawDiscounted();
total -= discount;
var customValue1 = roundToTwo(self.custom_value1());
var customValue2 = roundToTwo(self.custom_value2());
var customTaxes1 = self.custom_taxes1() == 1;
var customTaxes2 = self.custom_taxes2() == 1;
if (customValue1 && customTaxes1) {
total = NINJA.parseFloat(total) + customValue1;
}
if (customValue2 && customTaxes2) {
total = NINJA.parseFloat(total) + customValue2;
}
var taxRate = parseFloat(self.tax_rate());
if (taxRate > 0) {
var tax = roundToTwo(total * (taxRate/100));
return formatMoney(tax, self.client().currency_id());
} else {
return formatMoney(0, self.client().currency_id());
}
});
self.totals.itemTaxes = ko.computed(function() {
var taxes = {};
var total = self.totals.rawSubtotal();
for(var i=0; i<self.invoice_items().length; i++) {
var item = self.invoice_items()[i];
var lineTotal = item.totals.rawTotal();
if (self.discount()) {
if (parseInt(self.is_amount_discount())) {
lineTotal -= roundToTwo((lineTotal/total) * self.discount());
} else {
lineTotal -= roundToTwo(lineTotal * (self.discount()/100));
}
}
var taxAmount = roundToTwo(lineTotal * item.tax_rate() / 100);
if (taxAmount) {
var key = item.tax_name() + item.tax_rate();
if (taxes.hasOwnProperty(key)) {
taxes[key].amount += taxAmount;
} else {
taxes[key] = {name:item.tax_name(), rate:item.tax_rate(), amount:taxAmount};
}
}
}
return taxes;
});
self.totals.hasItemTaxes = ko.computed(function() {
var count = 0;
var taxes = self.totals.itemTaxes();
for (var key in taxes) {
if (taxes.hasOwnProperty(key)) {
count++;
}
}
return count > 0;
});
self.totals.itemTaxRates = ko.computed(function() {
var taxes = self.totals.itemTaxes();
var parts = [];
for (var key in taxes) {
if (taxes.hasOwnProperty(key)) {
parts.push(taxes[key].name + ' ' + (taxes[key].rate*1) + '%');
}
}
return parts.join('<br/>');
});
self.totals.itemTaxAmounts = ko.computed(function() {
var taxes = self.totals.itemTaxes();
var parts = [];
for (var key in taxes) {
if (taxes.hasOwnProperty(key)) {
parts.push(formatMoney(taxes[key].amount, self.client().currency_id()));
}
}
return parts.join('<br/>');
});
self.totals.rawPaidToDate = ko.computed(function() {
return accounting.toFixed(self.amount(),2) - accounting.toFixed(self.balance(),2);
});
self.totals.paidToDate = ko.computed(function() {
var total = self.totals.rawPaidToDate();
return formatMoney(total, self.client().currency_id());
});
self.totals.rawTotal = ko.computed(function() {
var total = accounting.toFixed(self.totals.rawSubtotal(),2);
var discount = self.totals.rawDiscounted();
total -= discount;
var customValue1 = roundToTwo(self.custom_value1());
var customValue2 = roundToTwo(self.custom_value2());
var customTaxes1 = self.custom_taxes1() == 1;
var customTaxes2 = self.custom_taxes2() == 1;
if (customValue1 && customTaxes1) {
total = NINJA.parseFloat(total) + customValue1;
}
if (customValue2 && customTaxes2) {
total = NINJA.parseFloat(total) + customValue2;
}
var taxRate = parseFloat(self.tax_rate());
if (taxRate > 0) {
total = NINJA.parseFloat(total) + roundToTwo((total * (taxRate/100)));
}
var taxes = self.totals.itemTaxes();
for (var key in taxes) {
if (taxes.hasOwnProperty(key)) {
total += taxes[key].amount;
}
}
if (customValue1 && !customTaxes1) {
total = NINJA.parseFloat(total) + customValue1;
}
if (customValue2 && !customTaxes2) {
total = NINJA.parseFloat(total) + customValue2;
}
var paid = self.totals.rawPaidToDate();
if (paid > 0) {
total -= paid;
}
return total;
});
self.totals.total = ko.computed(function() {
return formatMoney(self.partial() ? self.partial() : self.totals.rawTotal(), self.client().currency_id());
});
self.onDragged = function(item) {
refreshPDF(true);
}
}
function ClientModel(data) {
var self = this;
self.public_id = ko.observable(0);
self.name = ko.observable('');
self.id_number = ko.observable('');
self.vat_number = ko.observable('');
self.work_phone = ko.observable('');
self.custom_value1 = ko.observable('');
self.custom_value2 = ko.observable('');
self.private_notes = ko.observable('');
self.address1 = ko.observable('');
self.address2 = ko.observable('');
self.city = ko.observable('');
self.state = ko.observable('');
self.postal_code = ko.observable('');
self.country_id = ko.observable('');
self.size_id = ko.observable('');
self.industry_id = ko.observable('');
self.currency_id = ko.observable('');
self.language_id = ko.observable('');
self.website = ko.observable('');
self.payment_terms = ko.observable(0);
self.contacts = ko.observableArray();
self.mapping = {
'contacts': {
create: function(options) {
var model = new ContactModel(options.data);
model.send_invoice(options.data.send_invoice == '1');
return model;
}
}
}
self.showContact = function(elem) { if (elem.nodeType === 1) $(elem).hide().slideDown() }
self.hideContact = function(elem) { if (elem.nodeType === 1) $(elem).slideUp(function() { $(elem).remove(); }) }
self.addContact = function() {
var contact = new ContactModel();
contact.send_invoice(true);
self.contacts.push(contact);
return false;
}
self.removeContact = function() {
self.contacts.remove(this);
}
self.name.display = ko.computed(function() {
if (self.name()) {
return self.name();
}
if (self.contacts().length == 0) return;
var contact = self.contacts()[0];
if (contact.first_name() || contact.last_name()) {
return contact.first_name() + ' ' + contact.last_name();
} else {
return contact.email();
}
});
self.name.placeholder = ko.computed(function() {
if (self.contacts().length == 0) return '';
var contact = self.contacts()[0];
if (contact.first_name() || contact.last_name()) {
return contact.first_name() + ' ' + contact.last_name();
} else {
return contact.email();
}
});
if (data) {
ko.mapping.fromJS(data, {}, this);
} else {
self.addContact();
}
}
function ContactModel(data) {
var self = this;
self.public_id = ko.observable('');
self.first_name = ko.observable('');
self.last_name = ko.observable('');
self.email = ko.observable('');
self.phone = ko.observable('');
self.send_invoice = ko.observable(false);
self.invitation_link = ko.observable('');
self.invitation_status = ko.observable('');
self.invitation_viewed = ko.observable(false);
self.email_error = ko.observable('');
if (data) {
ko.mapping.fromJS(data, {}, this);
}
self.displayName = ko.computed(function() {
var str = '';
if (self.first_name() || self.last_name()) {
str += self.first_name() + ' ' + self.last_name() + '\n';
}
if (self.email()) {
str += self.email() + '\n';
}
return str;
});
self.email.display = ko.computed(function() {
var str = '';
if (self.first_name() || self.last_name()) {
str += self.first_name() + ' ' + self.last_name() + '<br/>';
}
if (self.email()) {
str += self.email() + '<br/>';
}
return str;
});
self.view_as_recipient = ko.computed(function() {
var str = '';
@if (Utils::isConfirmed())
if (self.invitation_link()) {
str += '<a href="' + self.invitation_link() + '" target="_blank">{{ trans('texts.view_as_recipient') }}</a>';
}
@endif
return str;
});
}
function TaxRateModel(data) {
var self = this;
self.public_id = ko.observable('');
self.rate = ko.observable(0);
self.name = ko.observable('');
self.is_deleted = ko.observable(false);
self.is_blank = ko.observable(false);
self.actionsVisible = ko.observable(false);
if (data) {
ko.mapping.fromJS(data, {}, this);
}
this.prettyRate = ko.computed({
read: function () {
return this.rate() ? roundToTwo(this.rate()) : '';
},
write: function (value) {
this.rate(value);
},
owner: this
});
self.displayName = ko.computed({
read: function () {
var name = self.name() ? self.name() : '';
var rate = self.rate() ? parseFloat(self.rate()) + '%' : '';
return name + ' ' + rate;
},
write: function (value) {
// do nothing
},
owner: this
});
self.hideActions = function() {
self.actionsVisible(false);
}
self.showActions = function() {
self.actionsVisible(true);
}
self.isEmpty = function() {
return !self.rate() && !self.name();
}
}
function ItemModel(data) {
var self = this;
self.product_key = ko.observable('');
self.notes = ko.observable('');
self.cost = ko.observable(0);
self.qty = ko.observable(0);
self.tax_name = ko.observable('');
self.tax_rate = ko.observable(0);
self.task_public_id = ko.observable('');
self.actionsVisible = ko.observable(false);
self._tax = ko.observable();
this.tax = ko.computed({
read: function () {
return self._tax();
},
write: function(value) {
self._tax(value);
self.tax_name(value.name());
self.tax_rate(value.rate());
}
})
this.prettyQty = ko.computed({
read: function () {
return NINJA.parseFloat(this.qty()) ? NINJA.parseFloat(this.qty()) : '';
},
write: function (value) {
this.qty(value);
},
owner: this
});
this.prettyCost = ko.computed({
read: function () {
return this.cost() ? this.cost() : '';
},
write: function (value) {
this.cost(value);
},
owner: this
});
self.mapping = {
'tax': {
create: function(options) {
return new TaxRateModel(options.data);
}
}
}
if (data) {
ko.mapping.fromJS(data, self.mapping, this);
}
self.wrapped_notes = ko.computed({
read: function() {
return this.notes();
},
write: function(value) {
value = wordWrapText(value, 235);
self.notes(value);
onItemChange();
},
owner: this
});
this.totals = ko.observable();
this.totals.rawTotal = ko.computed(function() {
var cost = roundToTwo(NINJA.parseFloat(self.cost()));
var qty = roundToTwo(NINJA.parseFloat(self.qty()));
var value = cost * qty;
return value ? roundToTwo(value) : 0;
});
this.totals.total = ko.computed(function() {
var total = self.totals.rawTotal();
if (window.hasOwnProperty('model') && model.invoice && model.invoice() && model.invoice().client()) {
return total ? formatMoney(total, model.invoice().client().currency_id()) : '';
} else {
return total ? formatMoney(total, 1) : '';
}
});
this.hideActions = function() {
this.actionsVisible(false);
}
this.showActions = function() {
this.actionsVisible(true);
}
this.isEmpty = function() {
return !self.product_key() && !self.notes() && !self.cost() && (!self.qty() || {{ $account->hide_quantity ? 'true' : 'false' }});
}
this.onSelect = function() {}
}
</script>

View File

@ -167,10 +167,13 @@
});
function setBulkActionsEnabled() {
var checked = $('tbody :checkbox:checked').length > 0;
$('button.archive, button.invoice').prop('disabled', !checked);
var buttonLabel = "{{ trans('texts.archive') }}";
var count = $('tbody :checkbox:checked').length;
$('button.archive, button.invoice').prop('disabled', !count);
if (count) {
buttonLabel += ' (' + count + ')';
}
$('button.archive').not('.dropdown-toggle').text(buttonLabel);
}
});