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

Improvements to reports

This commit is contained in:
Hillel Coren 2017-01-22 12:09:29 +02:00
parent faa1889d19
commit a63f694d09
30 changed files with 958 additions and 498 deletions

View File

@ -12,7 +12,7 @@
Watch this [YouTube Video](https://www.youtube.com/watch?v=xHGKvadapbA) for an overview of the app's features.
All Pro and Enterprise features from the hosted app are included in the open-source code.
All Pro and Enterprise features from the hosted app are included in the open-source code. We offer a $20 per year white-label license to remove our branding.
The [self-host zip](https://www.invoiceninja.com/self-host/) includes all third party libraries whereas downloading the code from GitHub requires using Composer to install the dependencies.

View File

@ -6,12 +6,9 @@ use Input;
use Utils;
use DB;
use Session;
use Str;
use View;
use App\Models\Account;
use App\Models\Client;
use App\Models\Payment;
use App\Models\Expense;
use App\Models\Task;
/**
* Class ReportController
@ -67,19 +64,22 @@ class ReportController extends BaseController
}
$reportTypes = [
ENTITY_CLIENT => trans('texts.client'),
ENTITY_INVOICE => trans('texts.invoice'),
ENTITY_PRODUCT => trans('texts.product'),
ENTITY_PAYMENT => trans('texts.payment'),
ENTITY_EXPENSE => trans('texts.expense'),
ENTITY_TASK => trans('texts.task'),
ENTITY_TAX_RATE => trans('texts.tax'),
'client',
'product',
'invoice',
'invoice_details',
'aging',
'profit_and_loss',
'payment',
'expense',
'task',
'tax_rate',
];
$params = [
'startDate' => $startDate->format('Y-m-d'),
'endDate' => $endDate->format('Y-m-d'),
'reportTypes' => $reportTypes,
'reportTypes' => array_combine($reportTypes, Utils::trans($reportTypes)),
'reportType' => $reportType,
'title' => trans('texts.charts_and_reports'),
'account' => Auth::user()->account,
@ -87,8 +87,18 @@ class ReportController extends BaseController
if (Auth::user()->account->hasFeature(FEATURE_REPORTS)) {
$isExport = $action == 'export';
$params = array_merge($params, self::generateReport($reportType, $startDate, $endDate, $dateField, $isExport));
$reportClass = '\\App\\Ninja\\Reports\\' . Str::studly($reportType) . 'Report';
$options = [
'date_field' => $dateField,
'invoice_status' => request()->invoice_status,
'group_dates_by' => request()->group_dates_by,
];
$report = new $reportClass($startDate, $endDate, $isExport, $options);
if (Input::get('report_type')) {
$report->run();
}
$params['report'] = $report;
$params = array_merge($params, $report->results());
if ($isExport) {
self::export($reportType, $params['displayData'], $params['columns'], $params['reportTotals']);
}
@ -96,427 +106,12 @@ class ReportController extends BaseController
$params['columns'] = [];
$params['displayData'] = [];
$params['reportTotals'] = [];
$params['report'] = false;
}
return View::make('reports.chart_builder', $params);
}
/**
* @param $reportType
* @param $startDate
* @param $endDate
* @param $dateField
* @param $isExport
* @return array
*/
private function generateReport($reportType, $startDate, $endDate, $dateField, $isExport)
{
if ($reportType == ENTITY_CLIENT) {
return $this->generateClientReport($startDate, $endDate, $isExport);
} elseif ($reportType == ENTITY_INVOICE) {
return $this->generateInvoiceReport($startDate, $endDate, $isExport);
} elseif ($reportType == ENTITY_PRODUCT) {
return $this->generateProductReport($startDate, $endDate, $isExport);
} elseif ($reportType == ENTITY_PAYMENT) {
return $this->generatePaymentReport($startDate, $endDate, $isExport);
} elseif ($reportType == ENTITY_TAX_RATE) {
return $this->generateTaxRateReport($startDate, $endDate, $dateField, $isExport);
} elseif ($reportType == ENTITY_EXPENSE) {
return $this->generateExpenseReport($startDate, $endDate, $isExport);
} elseif ($reportType == ENTITY_TASK) {
return $this->generateTaskReport($startDate, $endDate, $isExport);
}
}
private function generateTaskReport($startDate, $endDate, $isExport)
{
$columns = ['client', 'date', 'description', 'duration'];
$displayData = [];
$tasks = Task::scope()
->with('client.contacts')
->withArchived()
->dateRange($startDate, $endDate);
foreach ($tasks->get() as $task) {
$displayData[] = [
$task->client ? ($isExport ? $task->client->getDisplayName() : $task->client->present()->link) : trans('texts.unassigned'),
link_to($task->present()->url, $task->getStartTime()),
$task->present()->description,
Utils::formatTime($task->getDuration()),
];
}
return [
'columns' => $columns,
'displayData' => $displayData,
'reportTotals' => [],
];
}
/**
* @param $startDate
* @param $endDate
* @param $dateField
* @param $isExport
* @return array
*/
private function generateTaxRateReport($startDate, $endDate, $dateField, $isExport)
{
$columns = ['tax_name', 'tax_rate', 'amount', 'paid'];
$account = Auth::user()->account;
$displayData = [];
$reportTotals = [];
$clients = Client::scope()
->withArchived()
->with('contacts')
->with(['invoices' => function($query) use ($startDate, $endDate, $dateField) {
$query->with('invoice_items')->withArchived();
if ($dateField == FILTER_INVOICE_DATE) {
$query->where('invoice_date', '>=', $startDate)
->where('invoice_date', '<=', $endDate)
->with('payments');
} else {
$query->whereHas('payments', function($query) use ($startDate, $endDate) {
$query->where('payment_date', '>=', $startDate)
->where('payment_date', '<=', $endDate)
->withArchived();
})
->with(['payments' => function($query) use ($startDate, $endDate) {
$query->where('payment_date', '>=', $startDate)
->where('payment_date', '<=', $endDate)
->withArchived();
}]);
}
}]);
foreach ($clients->get() as $client) {
$currencyId = $client->currency_id ?: Auth::user()->account->getCurrencyId();
$amount = 0;
$paid = 0;
$taxTotals = [];
foreach ($client->invoices as $invoice) {
foreach ($invoice->getTaxes(true) as $key => $tax) {
if ( ! isset($taxTotals[$currencyId])) {
$taxTotals[$currencyId] = [];
}
if (isset($taxTotals[$currencyId][$key])) {
$taxTotals[$currencyId][$key]['amount'] += $tax['amount'];
$taxTotals[$currencyId][$key]['paid'] += $tax['paid'];
} else {
$taxTotals[$currencyId][$key] = $tax;
}
}
$amount += $invoice->amount;
$paid += $invoice->getAmountPaid();
}
foreach ($taxTotals as $currencyId => $taxes) {
foreach ($taxes as $tax) {
$displayData[] = [
$tax['name'],
$tax['rate'] . '%',
$account->formatMoney($tax['amount'], $client),
$account->formatMoney($tax['paid'], $client)
];
}
$reportTotals = $this->addToTotals($reportTotals, $client->currency_id, 'amount', $tax['amount']);
$reportTotals = $this->addToTotals($reportTotals, $client->currency_id, 'paid', $tax['paid']);
}
}
return [
'columns' => $columns,
'displayData' => $displayData,
'reportTotals' => $reportTotals,
];
}
/**
* @param $startDate
* @param $endDate
* @param $isExport
* @return array
*/
private function generatePaymentReport($startDate, $endDate, $isExport)
{
$columns = ['client', 'invoice_number', 'invoice_date', 'amount', 'payment_date', 'paid', 'method'];
$account = Auth::user()->account;
$displayData = [];
$reportTotals = [];
$payments = Payment::scope()
->withArchived()
->excludeFailed()
->whereHas('client', function($query) {
$query->where('is_deleted', '=', false);
})
->whereHas('invoice', function($query) {
$query->where('is_deleted', '=', false);
})
->with('client.contacts', 'invoice', 'payment_type', 'account_gateway.gateway')
->where('payment_date', '>=', $startDate)
->where('payment_date', '<=', $endDate);
foreach ($payments->get() as $payment) {
$invoice = $payment->invoice;
$client = $payment->client;
$displayData[] = [
$isExport ? $client->getDisplayName() : $client->present()->link,
$isExport ? $invoice->invoice_number : $invoice->present()->link,
$invoice->present()->invoice_date,
$account->formatMoney($invoice->amount, $client),
$payment->present()->payment_date,
$account->formatMoney($payment->getCompletedAmount(), $client),
$payment->present()->method,
];
$reportTotals = $this->addToTotals($reportTotals, $client->currency_id, 'amount', $invoice->amount);
$reportTotals = $this->addToTotals($reportTotals, $client->currency_id, 'paid', $payment->getCompletedAmount());
}
return [
'columns' => $columns,
'displayData' => $displayData,
'reportTotals' => $reportTotals,
];
}
/**
* @param $startDate
* @param $endDate
* @param $isExport
* @return array
*/
private function generateInvoiceReport($startDate, $endDate, $isExport)
{
$columns = ['client', 'invoice_number', 'invoice_date', 'amount', 'payment_date', 'paid', 'method'];
$account = Auth::user()->account;
$displayData = [];
$reportTotals = [];
$clients = Client::scope()
->withTrashed()
->with('contacts')
->where('is_deleted', '=', false)
->with(['invoices' => function($query) use ($startDate, $endDate) {
$query->invoices()
->withArchived()
->where('invoice_date', '>=', $startDate)
->where('invoice_date', '<=', $endDate)
->with(['payments' => function($query) {
$query->withArchived()
->excludeFailed()
->with('payment_type', 'account_gateway.gateway');
}, 'invoice_items'])
->withTrashed();
}]);
foreach ($clients->get() as $client) {
foreach ($client->invoices as $invoice) {
$payments = count($invoice->payments) ? $invoice->payments : [false];
foreach ($payments as $payment) {
$displayData[] = [
$isExport ? $client->getDisplayName() : $client->present()->link,
$isExport ? $invoice->invoice_number : $invoice->present()->link,
$invoice->present()->invoice_date,
$account->formatMoney($invoice->amount, $client),
$payment ? $payment->present()->payment_date : '',
$payment ? $account->formatMoney($payment->getCompletedAmount(), $client) : '',
$payment ? $payment->present()->method : '',
];
$reportTotals = $this->addToTotals($reportTotals, $client->currency_id, 'paid', $payment ? $payment->getCompletedAmount() : 0);
}
$reportTotals = $this->addToTotals($reportTotals, $client->currency_id, 'amount', $invoice->amount);
$reportTotals = $this->addToTotals($reportTotals, $client->currency_id, 'balance', $invoice->balance);
}
}
return [
'columns' => $columns,
'displayData' => $displayData,
'reportTotals' => $reportTotals,
];
}
/**
* @param $startDate
* @param $endDate
* @param $isExport
* @return array
*/
private function generateProductReport($startDate, $endDate, $isExport)
{
$columns = ['client', 'invoice_number', 'invoice_date', 'quantity', 'product'];
$account = Auth::user()->account;
$displayData = [];
$reportTotals = [];
$clients = Client::scope()
->withTrashed()
->with('contacts')
->where('is_deleted', '=', false)
->with(['invoices' => function($query) use ($startDate, $endDate) {
$query->where('invoice_date', '>=', $startDate)
->where('invoice_date', '<=', $endDate)
->where('is_deleted', '=', false)
->where('is_recurring', '=', false)
->where('invoice_type_id', '=', INVOICE_TYPE_STANDARD)
->with(['invoice_items'])
->withTrashed();
}]);
foreach ($clients->get() as $client) {
foreach ($client->invoices as $invoice) {
foreach ($invoice->invoice_items as $invoiceItem) {
$displayData[] = [
$isExport ? $client->getDisplayName() : $client->present()->link,
$isExport ? $invoice->invoice_number : $invoice->present()->link,
$invoice->present()->invoice_date,
round($invoiceItem->qty, 2),
$invoiceItem->product_key,
];
//$reportTotals = $this->addToTotals($reportTotals, $client->currency_id, 'paid', $payment ? $payment->amount : 0);
}
//$reportTotals = $this->addToTotals($reportTotals, $client->currency_id, 'amount', $invoice->amount);
//$reportTotals = $this->addToTotals($reportTotals, $client->currency_id, 'balance', $invoice->balance);
}
}
return [
'columns' => $columns,
'displayData' => $displayData,
'reportTotals' => [],
];
}
/**
* @param $startDate
* @param $endDate
* @param $isExport
* @return array
*/
private function generateClientReport($startDate, $endDate, $isExport)
{
$columns = ['client', 'amount', 'paid', 'balance'];
$account = Auth::user()->account;
$displayData = [];
$reportTotals = [];
$clients = Client::scope()
->withArchived()
->with('contacts')
->with(['invoices' => function($query) use ($startDate, $endDate) {
$query->where('invoice_date', '>=', $startDate)
->where('invoice_date', '<=', $endDate)
->where('invoice_type_id', '=', INVOICE_TYPE_STANDARD)
->where('is_recurring', '=', false)
->withArchived();
}]);
foreach ($clients->get() as $client) {
$amount = 0;
$paid = 0;
foreach ($client->invoices as $invoice) {
$amount += $invoice->amount;
$paid += $invoice->getAmountPaid();
}
$displayData[] = [
$isExport ? $client->getDisplayName() : $client->present()->link,
$account->formatMoney($amount, $client),
$account->formatMoney($paid, $client),
$account->formatMoney($amount - $paid, $client)
];
$reportTotals = $this->addToTotals($reportTotals, $client->currency_id, 'amount', $amount);
$reportTotals = $this->addToTotals($reportTotals, $client->currency_id, 'paid', $paid);
$reportTotals = $this->addToTotals($reportTotals, $client->currency_id, 'balance', $amount - $paid);
}
return [
'columns' => $columns,
'displayData' => $displayData,
'reportTotals' => $reportTotals,
];
}
/**
* @param $startDate
* @param $endDate
* @param $isExport
* @return array
*/
private function generateExpenseReport($startDate, $endDate, $isExport)
{
$columns = ['vendor', 'client', 'date', 'expense_amount'];
$account = Auth::user()->account;
$displayData = [];
$reportTotals = [];
$expenses = Expense::scope()
->withArchived()
->with('client.contacts', 'vendor')
->where('expense_date', '>=', $startDate)
->where('expense_date', '<=', $endDate);
foreach ($expenses->get() as $expense) {
$amount = $expense->amountWithTax();
$displayData[] = [
$expense->vendor ? ($isExport ? $expense->vendor->name : $expense->vendor->present()->link) : '',
$expense->client ? ($isExport ? $expense->client->getDisplayName() : $expense->client->present()->link) : '',
$expense->present()->expense_date,
Utils::formatMoney($amount, $expense->currency_id),
];
$reportTotals = $this->addToTotals($reportTotals, $expense->expense_currency_id, 'amount', $amount);
$reportTotals = $this->addToTotals($reportTotals, $expense->invoice_currency_id, 'amount', 0);
}
return [
'columns' => $columns,
'displayData' => $displayData,
'reportTotals' => $reportTotals,
];
}
/**
* @param $data
* @param $currencyId
* @param $field
* @param $value
* @return mixed
*/
private function addToTotals($data, $currencyId, $field, $value) {
$currencyId = $currencyId ?: Auth::user()->account->getCurrencyId();
if (!isset($data[$currencyId][$field])) {
$data[$currencyId][$field] = 0;
}
$data[$currencyId][$field] += $value;
return $data;
}
/**
* @param $reportType
* @param $data
@ -534,6 +129,7 @@ class ReportController extends BaseController
Utils::exportData($output, $data, Utils::trans($columns));
/*
fwrite($output, trans('texts.totals'));
foreach ($totals as $currencyId => $fields) {
foreach ($fields as $key => $value) {
@ -550,6 +146,7 @@ class ReportController extends BaseController
}
fwrite($output, $csv."\n");
}
*/
fclose($output);
exit;

View File

@ -247,8 +247,8 @@ Route::group([
Route::post('settings/email_settings', 'AccountController@saveEmailSettings');
Route::get('company/{section}/{subSection?}', 'AccountController@redirectLegacy');
Route::get('settings/data_visualizations', 'ReportController@d3');
Route::get('settings/reports', 'ReportController@showReports');
Route::post('settings/reports', 'ReportController@showReports');
Route::get('reports', 'ReportController@showReports');
Route::post('reports', 'ReportController@showReports');
Route::post('settings/change_plan', 'AccountController@changePlan');
Route::post('settings/cancel_account', 'AccountController@cancelAccount');

View File

@ -117,7 +117,7 @@ class Account extends Eloquent
ACCOUNT_EMAIL_SETTINGS,
ACCOUNT_TEMPLATES_AND_REMINDERS,
ACCOUNT_BANKS,
ACCOUNT_REPORTS,
//ACCOUNT_REPORTS,
ACCOUNT_DATA_VISUALIZATIONS,
ACCOUNT_API_TOKENS,
ACCOUNT_USER_MANAGEMENT,

View File

@ -289,6 +289,7 @@ class EntityModel extends Eloquent
'vendors' => 'building',
'settings' => 'cog',
'self-update' => 'download',
'reports' => 'th-list',
];
return array_get($icons, $entityType);

View File

@ -1,6 +1,7 @@
<?php namespace App\Ninja\Presenters;
use Utils;
use Carbon;
/**
* Class ExpensePresenter
@ -24,6 +25,11 @@ class ExpensePresenter extends EntityPresenter
return Utils::fromSqlDate($this->entity->expense_date);
}
public function month()
{
return Carbon::parse($this->entity->payment_date)->format('Y m');
}
public function amount()
{
return Utils::formatMoney($this->entity->amount, $this->entity->expense_currency_id);

View File

@ -1,5 +1,6 @@
<?php namespace App\Ninja\Presenters;
use Carbon;
use stdClass;
use Utils;
use DropdownButton;
@ -44,6 +45,38 @@ class InvoicePresenter extends EntityPresenter {
}
}
public function age()
{
if ( ! $this->entity->due_date || $this->entity->date_date == '0000-00-00') {
return 0;
}
$date = Carbon::parse($this->entity->due_date);
if ($date->isFuture()) {
return 0;
}
return $date->diffInDays();
}
public function ageGroup()
{
$age = $this->age();
if ($age > 120) {
return 'age_group_120';
} elseif ($age > 90) {
return 'age_group_90';
} elseif ($age > 60) {
return 'age_group_60';
} elseif ($age > 30) {
return 'age_group_30';
} else {
return 'age_group_0';
}
}
public function dueDateLabel()
{
if ($this->entity->isType(INVOICE_TYPE_STANDARD)) {

View File

@ -1,5 +1,6 @@
<?php namespace App\Ninja\Presenters;
use Carbon;
use Utils;
class PaymentPresenter extends EntityPresenter {
@ -19,6 +20,11 @@ class PaymentPresenter extends EntityPresenter {
return Utils::fromSqlDate($this->entity->payment_date);
}
public function month()
{
return Carbon::parse($this->entity->payment_date)->format('Y m');
}
public function method()
{
if ($this->entity->account_gateway) {

View File

@ -0,0 +1,84 @@
<?php
namespace App\Ninja\Reports;
use Auth;
class AbstractReport
{
public $startDate;
public $endDate;
public $isExport;
public $options;
public $totals = [];
public $columns = [];
public $data = [];
public function __construct($startDate, $endDate, $isExport, $options = false)
{
$this->startDate = $startDate;
$this->endDate = $endDate;
$this->isExport = $isExport;
$this->options = $options;
}
public function run()
{
}
public function results()
{
return [
'columns' => $this->columns,
'displayData' => $this->data,
'reportTotals' => $this->totals,
];
}
protected function addToTotals($currencyId, $field, $value, $dimension = false)
{
$currencyId = $currencyId ?: Auth::user()->account->getCurrencyId();
if ( ! isset($this->totals[$currencyId][$dimension])) {
$this->totals[$currencyId][$dimension] = [];
}
if ( ! isset($this->totals[$currencyId][$dimension][$field])) {
$this->totals[$currencyId][$dimension][$field] = 0;
}
$this->totals[$currencyId][$dimension][$field] += $value;
}
public function tableHeader()
{
$str = '';
foreach ($this->columns as $key => $val) {
if (is_array($val)) {
$field = $key;
$class = $val;
} else {
$field = $val;
$class = [];
}
if (strpos($field, 'date') !== false) {
//$class[] = 'group-date-monthyear';
$class[] = 'group-date-' . (isset($this->options['group_dates_by']) ? $this->options['group_dates_by'] : 'monthyear');
} elseif (in_array($field, ['client', 'method'])) {
$class[] = 'group-letter-100';
} elseif (in_array($field, ['amount', 'paid', 'balance'])) {
$class[] = 'group-number-50';
}
$class = count($class) ? implode(' ', $class) : 'group-false';
$label = trans("texts.{$field}");
$str .= "<th class=\"{$class}\">{$label}</th>";
}
return $str;
}
}

View File

@ -0,0 +1,58 @@
<?php
namespace App\Ninja\Reports;
use Auth;
use App\Models\Client;
class AgingReport extends AbstractReport
{
public $columns = [
'client',
'invoice_number',
'invoice_date',
'due_date',
'age' => ['group-number-30'],
'amount',
'balance',
];
public function run()
{
$account = Auth::user()->account;
$clients = Client::scope()
->withArchived()
->with('contacts')
->with(['invoices' => function($query) {
$query->invoices()
->whereIsPublic(true)
->withArchived()
->where('balance', '>', 0)
->where('invoice_date', '>=', $this->startDate)
->where('invoice_date', '<=', $this->endDate)
->with(['invoice_items']);
}]);
foreach ($clients->get() as $client) {
foreach ($client->invoices as $invoice) {
$this->data[] = [
$this->isExport ? $client->getDisplayName() : $client->present()->link,
$this->isExport ? $invoice->invoice_number : $invoice->present()->link,
$invoice->present()->invoice_date,
$invoice->present()->due_date,
$invoice->present()->age,
$account->formatMoney($invoice->amount, $client),
$account->formatMoney($invoice->balance, $client),
];
$this->addToTotals($client->currency_id, $invoice->present()->ageGroup, $invoice->balance);
//$this->addToTotals($client->currency_id, 'paid', $payment ? $payment->getCompletedAmount() : 0);
//$this->addToTotals($client->currency_id, 'amount', $invoice->amount);
//$this->addToTotals($client->currency_id, 'balance', $invoice->balance);
}
}
}
}

View File

@ -0,0 +1,53 @@
<?php
namespace App\Ninja\Reports;
use Auth;
use App\Models\Client;
class ClientReport extends AbstractReport
{
public $columns = [
'client',
'amount',
'paid',
'balance',
];
public function run()
{
$account = Auth::user()->account;
$clients = Client::scope()
->withArchived()
->with('contacts')
->with(['invoices' => function($query) {
$query->where('invoice_date', '>=', $this->startDate)
->where('invoice_date', '<=', $this->endDate)
->where('invoice_type_id', '=', INVOICE_TYPE_STANDARD)
->where('is_recurring', '=', false)
->withArchived();
}]);
foreach ($clients->get() as $client) {
$amount = 0;
$paid = 0;
foreach ($client->invoices as $invoice) {
$amount += $invoice->amount;
$paid += $invoice->getAmountPaid();
}
$this->data[] = [
$this->isExport ? $client->getDisplayName() : $client->present()->link,
$account->formatMoney($amount, $client),
$account->formatMoney($paid, $client),
$account->formatMoney($amount - $paid, $client)
];
$this->addToTotals($client->currency_id, 'amount', $amount);
$this->addToTotals($client->currency_id, 'paid', $paid);
$this->addToTotals($client->currency_id, 'balance', $amount - $paid);
}
}
}

View File

@ -0,0 +1,44 @@
<?php
namespace App\Ninja\Reports;
use Auth;
use Utils;
use App\Models\Expense;
class ExpenseReport extends AbstractReport
{
public $columns = [
'vendor',
'client',
'date',
'category',
'expense_amount',
];
public function run()
{
$account = Auth::user()->account;
$expenses = Expense::scope()
->withArchived()
->with('client.contacts', 'vendor')
->where('expense_date', '>=', $this->startDate)
->where('expense_date', '<=', $this->endDate);
foreach ($expenses->get() as $expense) {
$amount = $expense->amountWithTax();
$this->data[] = [
$expense->vendor ? ($this->isExport ? $expense->vendor->name : $expense->vendor->present()->link) : '',
$expense->client ? ($this->isExport ? $expense->client->getDisplayName() : $expense->client->present()->link) : '',
$expense->present()->expense_date,
$expense->present()->category,
Utils::formatMoney($amount, $expense->currency_id),
];
$this->addToTotals($expense->expense_currency_id, 'amount', $amount);
$this->addToTotals($expense->invoice_currency_id, 'amount', 0);
}
}
}

View File

@ -0,0 +1,61 @@
<?php
namespace App\Ninja\Reports;
use Auth;
use App\Models\Client;
class InvoiceDetailsReport extends AbstractReport
{
public $columns = [
'client',
'invoice_number',
'invoice_date',
'product',
'qty',
'cost',
//'tax_rate1',
//'tax_rate2',
];
public function run()
{
$account = Auth::user()->account;
$status = $this->options['invoice_status'];
$clients = Client::scope()
->withArchived()
->with('contacts')
->with(['invoices' => function($query) use ($status) {
if ($status == 'draft') {
$query->whereIsPublic(false);
} elseif ($status == 'unpaid' || $status == 'paid') {
$query->whereIsPublic(true);
}
$query->invoices()
->withArchived()
->where('invoice_date', '>=', $this->startDate)
->where('invoice_date', '<=', $this->endDate)
->with(['invoice_items']);
}]);
foreach ($clients->get() as $client) {
foreach ($client->invoices as $invoice) {
foreach ($invoice->invoice_items as $item) {
$this->data[] = [
$this->isExport ? $client->getDisplayName() : $client->present()->link,
$this->isExport ? $invoice->invoice_number : $invoice->present()->link,
$invoice->present()->invoice_date,
$item->product_key,
$item->qty,
$account->formatMoney($item->cost, $client),
];
}
//$this->addToTotals($client->currency_id, 'paid', $payment ? $payment->getCompletedAmount() : 0);
//$this->addToTotals($client->currency_id, 'amount', $invoice->amount);
//$this->addToTotals($client->currency_id, 'balance', $invoice->balance);
}
}
}
}

View File

@ -0,0 +1,73 @@
<?php
namespace App\Ninja\Reports;
use Auth;
use App\Models\Client;
class InvoiceReport extends AbstractReport
{
public $columns = [
'client',
'invoice_number',
'invoice_date',
'amount',
'payment_date',
'paid',
'method'
];
public function run()
{
$account = Auth::user()->account;
$status = $this->options['invoice_status'];
$clients = Client::scope()
->withArchived()
->with('contacts')
->with(['invoices' => function($query) use ($status) {
if ($status == 'draft') {
$query->whereIsPublic(false);
} elseif ($status == 'unpaid' || $status == 'paid') {
$query->whereIsPublic(true);
}
$query->invoices()
->withArchived()
->where('invoice_date', '>=', $this->startDate)
->where('invoice_date', '<=', $this->endDate)
->with(['payments' => function($query) {
$query->withArchived()
->excludeFailed()
->with('payment_type', 'account_gateway.gateway');
}, 'invoice_items']);
}]);
foreach ($clients->get() as $client) {
foreach ($client->invoices as $invoice) {
$payments = count($invoice->payments) ? $invoice->payments : [false];
foreach ($payments as $payment) {
if ( ! $payment && $status == 'paid') {
continue;
} elseif ($payment && $status == 'unpaid') {
continue;
}
$this->data[] = [
$this->isExport ? $client->getDisplayName() : $client->present()->link,
$this->isExport ? $invoice->invoice_number : $invoice->present()->link,
$invoice->present()->invoice_date,
$account->formatMoney($invoice->amount, $client),
$payment ? $payment->present()->payment_date : '',
$payment ? $account->formatMoney($payment->getCompletedAmount(), $client) : '',
$payment ? $payment->present()->method : '',
];
$this->addToTotals($client->currency_id, 'paid', $payment ? $payment->getCompletedAmount() : 0);
}
$this->addToTotals($client->currency_id, 'amount', $invoice->amount);
$this->addToTotals($client->currency_id, 'balance', $invoice->balance);
}
}
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Ninja\Reports;
use Auth;
use App\Models\Payment;
class PaymentReport extends AbstractReport
{
public $columns = [
'client',
'invoice_number',
'invoice_date',
'amount',
'payment_date',
'paid',
'method',
];
public function run()
{
$account = Auth::user()->account;
$payments = Payment::scope()
->withArchived()
->excludeFailed()
->whereHas('client', function($query) {
$query->where('is_deleted', '=', false);
})
->whereHas('invoice', function($query) {
$query->where('is_deleted', '=', false);
})
->with('client.contacts', 'invoice', 'payment_type', 'account_gateway.gateway')
->where('payment_date', '>=', $this->startDate)
->where('payment_date', '<=', $this->endDate);
foreach ($payments->get() as $payment) {
$invoice = $payment->invoice;
$client = $payment->client;
$this->data[] = [
$this->isExport ? $client->getDisplayName() : $client->present()->link,
$this->isExport ? $invoice->invoice_number : $invoice->present()->link,
$invoice->present()->invoice_date,
$account->formatMoney($invoice->amount, $client),
$payment->present()->payment_date,
$account->formatMoney($payment->getCompletedAmount(), $client),
$payment->present()->method,
];
$this->addToTotals($client->currency_id, 'amount', $invoice->amount);
$this->addToTotals($client->currency_id, 'paid', $payment->getCompletedAmount());
}
}
}

View File

@ -0,0 +1,55 @@
<?php
namespace App\Ninja\Reports;
use Auth;
use App\Models\Client;
class ProductReport extends AbstractReport
{
public $columns = [
'client',
'invoice_number',
'invoice_date',
'quantity',
'product',
];
public function run()
{
$account = Auth::user()->account;
$clients = Client::scope()
->withTrashed()
->with('contacts')
->where('is_deleted', '=', false)
->with(['invoices' => function($query) {
$query->where('invoice_date', '>=', $this->startDate)
->where('invoice_date', '<=', $this->endDate)
->where('is_deleted', '=', false)
->where('is_recurring', '=', false)
->where('invoice_type_id', '=', INVOICE_TYPE_STANDARD)
->with(['invoice_items'])
->withTrashed();
}]);
foreach ($clients->get() as $client) {
foreach ($client->invoices as $invoice) {
foreach ($invoice->invoice_items as $invoiceItem) {
$this->data[] = [
$this->isExport ? $client->getDisplayName() : $client->present()->link,
$this->isExport ? $invoice->invoice_number : $invoice->present()->link,
$invoice->present()->invoice_date,
round($invoiceItem->qty, 2),
$invoiceItem->product_key,
];
//$reportTotals = $this->addToTotals($reportTotals, $client->currency_id, 'paid', $payment ? $payment->amount : 0);
}
//$reportTotals = $this->addToTotals($reportTotals, $client->currency_id, 'amount', $invoice->amount);
//$reportTotals = $this->addToTotals($reportTotals, $client->currency_id, 'balance', $invoice->balance);
}
}
}
}

View File

@ -0,0 +1,68 @@
<?php
namespace App\Ninja\Reports;
use Auth;
use App\Models\Payment;
use App\Models\Expense;
class ProfitAndLossReport extends AbstractReport
{
public $columns = [
'type',
'client',
'amount',
'date',
'notes',
];
public function run()
{
$account = Auth::user()->account;
$payments = Payment::scope()
->with('client.contacts')
->withArchived()
->excludeFailed();
foreach ($payments->get() as $payment) {
$client = $payment->client;
$this->data[] = [
trans('texts.payment'),
$client ? ($this->isExport ? $client->getDisplayName() : $client->present()->link) : '',
$account->formatMoney($payment->getCompletedAmount(), $client),
$payment->present()->payment_date,
$payment->present()->method,
];
$this->addToTotals($client->currency_id, 'revenue', $payment->getCompletedAmount(), $payment->present()->month);
$this->addToTotals($client->currency_id, 'expenses', 0, $payment->present()->month);
$this->addToTotals($client->currency_id, 'profit', $payment->getCompletedAmount(), $payment->present()->month);
}
$expenses = Expense::scope()
->with('client.contacts')
->withArchived();
foreach ($expenses->get() as $expense) {
$client = $expense->client;
$this->data[] = [
trans('texts.expense'),
$client ? ($this->isExport ? $client->getDisplayName() : $client->present()->link) : '',
$expense->present()->amount,
$expense->present()->expense_date,
$expense->present()->category,
];
$this->addToTotals($client->currency_id, 'revenue', 0, $expense->present()->month);
$this->addToTotals($client->currency_id, 'expenses', $expense->amount, $expense->present()->month);
$this->addToTotals($client->currency_id, 'profit', $expense->amount * -1, $expense->present()->month);
}
//$this->addToTotals($client->currency_id, 'paid', $payment ? $payment->getCompletedAmount() : 0);
//$this->addToTotals($client->currency_id, 'amount', $invoice->amount);
//$this->addToTotals($client->currency_id, 'balance', $invoice->balance);
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Ninja\Reports;
use Auth;
use Utils;
use App\Models\Task;
class TaskReport extends AbstractReport
{
public $columns = [
'client',
'date',
'project',
'description',
'duration',
];
public function run()
{
$tasks = Task::scope()
->with('client.contacts')
->withArchived()
->dateRange($this->startDate, $this->endDate);
foreach ($tasks->get() as $task) {
$this->data[] = [
$task->client ? ($this->isExport ? $task->client->getDisplayName() : $task->client->present()->link) : trans('texts.unassigned'),
link_to($task->present()->url, $task->getStartTime()),
$task->present()->project,
$task->present()->description,
Utils::formatTime($task->getDuration()),
];
}
}
}

View File

@ -0,0 +1,82 @@
<?php
namespace App\Ninja\Reports;
use Auth;
use App\Models\Client;
class TaxRateReport extends AbstractReport
{
public $columns = [
'tax_name',
'tax_rate',
'amount',
'paid',
];
public function run()
{
$account = Auth::user()->account;
$clients = Client::scope()
->withArchived()
->with('contacts')
->with(['invoices' => function($query) {
$query->with('invoice_items')->withArchived();
if ($this->options['date_field'] == FILTER_INVOICE_DATE) {
$query->where('invoice_date', '>=', $this->startDate)
->where('invoice_date', '<=', $this->endDate)
->with('payments');
} else {
$query->whereHas('payments', function($query) {
$query->where('payment_date', '>=', $this->startDate)
->where('payment_date', '<=', $this->endDate)
->withArchived();
})
->with(['payments' => function($query) {
$query->where('payment_date', '>=', $this->startDate)
->where('payment_date', '<=', $this->endDate)
->withArchived();
}]);
}
}]);
foreach ($clients->get() as $client) {
$currencyId = $client->currency_id ?: Auth::user()->account->getCurrencyId();
$amount = 0;
$paid = 0;
$taxTotals = [];
foreach ($client->invoices as $invoice) {
foreach ($invoice->getTaxes(true) as $key => $tax) {
if ( ! isset($taxTotals[$currencyId])) {
$taxTotals[$currencyId] = [];
}
if (isset($taxTotals[$currencyId][$key])) {
$taxTotals[$currencyId][$key]['amount'] += $tax['amount'];
$taxTotals[$currencyId][$key]['paid'] += $tax['paid'];
} else {
$taxTotals[$currencyId][$key] = $tax;
}
}
$amount += $invoice->amount;
$paid += $invoice->getAmountPaid();
}
foreach ($taxTotals as $currencyId => $taxes) {
foreach ($taxes as $tax) {
$this->data[] = [
$tax['name'],
$tax['rate'] . '%',
$account->formatMoney($tax['amount'], $client),
$account->formatMoney($tax['paid'], $client)
];
}
$this->addToTotals($client->currency_id, 'amount', $tax['amount']);
$this->addToTotals($client->currency_id, 'paid', $tax['paid']);
}
}
}
}

View File

@ -195,6 +195,7 @@ class AccountRepository
$features = array_merge($features, [
['dashboard', '/dashboard'],
['reports', '/reports'],
['customize_design', '/settings/customize_design'],
['new_tax_rate', '/tax_rates/create'],
['new_product', '/products/create'],

View File

@ -772,7 +772,7 @@ class InvoiceRepository extends BaseRepository
*/
public function markPaid(Invoice $invoice)
{
if (floatval($invoice->balance) <= 0) {
if ( ! $invoice->canBePaid()) {
return;
}
@ -822,15 +822,16 @@ class InvoiceRepository extends BaseRepository
public function findOpenInvoices($clientId, $entityType = false)
{
$query = Invoice::scope()
->invoiceType(INVOICE_TYPE_STANDARD)
->whereClientId($clientId)
->whereIsRecurring(false)
->whereDeletedAt(null);
->invoiceType(INVOICE_TYPE_STANDARD)
->whereClientId($clientId)
->whereIsRecurring(false)
->whereDeletedAt(null)
->where('balance', '>', 0);
if ($entityType == ENTITY_TASK) {
$query->whereHasTasks(true);
} elseif ($entityType == ENTITY_EXPENSE) {
$query->whereHasExpenses(true);
$query->whereHasTasks(false);
}
return $query->where('invoice_status_id', '<', 5)

View File

@ -34,7 +34,8 @@
"sweetalert2": "^5.3.8",
"jSignature": "brinley/jSignature#^2.1.0",
"select2": "select2-dist#^4.0.3",
"mousetrap": "^1.6.0"
"mousetrap": "^1.6.0",
"tablesorter": "jquery.tablesorter#^2.28.4"
},
"resolutions": {
"jquery": "~1.11"

View File

@ -66,6 +66,12 @@ elixir(function(mix) {
bowerDir + '/select2/dist/css/select2.css'
], 'public/css/select2.css');
mix.styles([
bowerDir + '/tablesorter/dist/css/theme.bootstrap_3.min.css',
bowerDir + '/tablesorter/dist/css/theme.bootstrap.min.css',
bowerDir + '/tablesorter/dist/css/widget.grouping.min.css'
], 'public/css/tablesorter.css');
/**
* JS configuration
@ -84,6 +90,13 @@ elixir(function(mix) {
bowerDir + '/bootstrap-daterangepicker/daterangepicker.js'
], 'public/js/daterangepicker.min.js');
mix.scripts([
bowerDir + '/tablesorter/dist/js/jquery.tablesorter.combined.js',
bowerDir + '/tablesorter/dist/js/widgets/widget-grouping.min.js',
bowerDir + '/tablesorter/dist/js/widgets/widget-uitheme.min.js',
bowerDir + '/tablesorter/dist/js/widgets/widget-filter.min.js',
], 'public/js/tablesorter.min.js');
mix.scripts([
bowerDir + '/select2/dist/js/select2.js',
'resources/assets/js/maximize-select2-height.js',

2
public/css/tablesorter.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

6
public/js/tablesorter.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -2318,7 +2318,30 @@ $LANG = array(
'domain_help_website' => 'Used when sending emails.',
'preview' => 'Preview',
'import_invoices' => 'Import Invoices',
'new_report' => 'New Report',
'edit_report' => 'Edit Report',
'columns' => 'Columns',
'filters' => 'Filters',
'sort_by' => 'Sort By',
'draft' => 'Draft',
'unpaid' => 'Unpaid',
'aging' => 'Aging',
'age' => 'Age',
'days' => 'Days',
'age_group_0' => '0 - 30 Days',
'age_group_30' => '30 - 60 Days',
'age_group_60' => '60 - 90 Days',
'age_group_90' => '90 - 120 Days',
'age_group_120' => '120+ Days',
'invoice_details' => 'Invoice Details',
'qty' => 'Quantity',
'profit_and_loss' => 'Profit and Loss',
'revenue' => 'Revenue',
'profit' => 'Profit',
'group_when_sorted' => 'Group When Sorted',
'group_dates_by' => 'Group Dates By',
'year' => 'Year',
);
return $LANG;

View File

@ -473,6 +473,7 @@
'tasks' => false,
'expenses' => false,
'vendors' => false,
'reports' => false,
'settings' => false,
] as $key => $value)
{!! Form::nav_link($key, $value ?: $key) !!}
@ -514,6 +515,7 @@
])
@endforeach
@endif
@include('partials.navigation_option', ['option' => 'reports'])
@include('partials.navigation_option', ['option' => 'settings'])
<li style="width:100%;">
<div class="nav-footer">

View File

@ -6,11 +6,20 @@
<script src="{{ asset('js/daterangepicker.min.js') }}" type="text/javascript"></script>
<link href="{{ asset('css/daterangepicker.css') }}" rel="stylesheet" type="text/css"/>
<link href="{{ asset('css/tablesorter.css') }}" rel="stylesheet" type="text/css"/>
<script src="{{ asset('js/tablesorter.min.js') }}" type="text/javascript"></script>
<style type="text/css">
table.tablesorter th {
color: white;
background-color: #777 !important;
}
</style>
@stop
@section('content')
@parent
@include('accounts.nav', ['selected' => ACCOUNT_REPORTS, 'advanced' => true])
<script type="text/javascript">
@ -58,6 +67,11 @@
{!! Former::populateField('start_date', $startDate) !!}
{!! Former::populateField('end_date', $endDate) !!}
@if ( ! request()->report_type)
{!! Former::populateField('group_when_sorted', 1) !!}
{!! Former::populateField('group_dates_by', 'monthyear') !!}
@endif
<div class="row">
<div class="col-lg-12">
<div class="panel panel-default">
@ -68,12 +82,14 @@
<div class="row">
<div class="col-md-6">
<div class="form-group">
{!! Former::select('report_type')->options($reportTypes, $reportType)->label(trans('texts.type')) !!}
<div class="form-group">
<label for="reportrange" class="control-label col-lg-4 col-sm-4">
{{ trans('texts.date_range') }}
</label>
<div class="col-lg-8 col-sm-8">
<div id="reportrange" style="background: #f9f9f9; cursor: pointer; padding: 9px 14px; border: 1px solid #dfe0e1; margin-top: 0px; margin-left:18px">
<div id="reportrange" style="background: #f9f9f9; cursor: pointer; padding: 9px 14px; border: 1px solid #dfe0e1; margin-top: 0px;">
<i class="glyphicon glyphicon-calendar fa fa-calendar"></i>&nbsp;
<span></span> <b class="caret"></b>
</div>
@ -85,48 +101,109 @@
</div>
</div>
<div id="statusField" style="display:{{ in_array($reportType, ['invoice', 'invoice_details']) ? 'block' : 'none' }}">
{!! Former::select('invoice_status')->label('status')
->addOption(trans('texts.all'), 'all')
->addOption(trans('texts.draft'), 'draft')
->addOption(trans('texts.unpaid'), 'unpaid')
->addOption(trans('texts.paid'), 'paid') !!}
</div>
<p>&nbsp;</p>
<p>&nbsp;</p>
{!! Former::actions(
Button::primary(trans('texts.export'))->withAttributes(array('onclick' => 'onExportClick()'))->appendIcon(Icon::create('export')),
Button::success(trans('texts.run'))->withAttributes(array('id' => 'submitButton'))->submit()->appendIcon(Icon::create('play'))
) !!}
@if (!Auth::user()->hasFeature(FEATURE_REPORTS))
<script>
$(function() {
$('form.warn-on-exit').find('input, button').prop('disabled', true);
});
</script>
@endif
</div>
<div class="col-md-6">
{!! Former::select('report_type')->options($reportTypes, $reportType)->label(trans('texts.type')) !!}
<div id="dateField" style="display:{{ $reportType == ENTITY_TAX_RATE ? 'block' : 'none' }}">
<div id="dateField" style="display:{{ $reportType == ENTITY_TAX_RATE ? 'block' : 'none' }}">
{!! Former::select('date_field')->label(trans('texts.filter'))
->addOption(trans('texts.invoice_date'), FILTER_INVOICE_DATE)
->addOption(trans('texts.payment_date'), FILTER_PAYMENT_DATE) !!}
</div>
{!! Former::close() !!}
</div>
</div>
</div>
<div class="col-md-6">
{!! Former::checkbox('group_when_sorted')->text('enable') !!}
{!! Former::select('group_dates_by')
->addOption(trans('texts.day'), 'day')
->addOption(trans('texts.month'), 'monthyear')
->addOption(trans('texts.year'), 'year') !!}
</div>
</div>
</div>
</div>
@if (!Auth::user()->hasFeature(FEATURE_REPORTS))
<script>
$(function() {
$('form.warn-on-exit').find('input, button').prop('disabled', true);
});
</script>
@endif
<center>
{!! Button::primary(trans('texts.export'))
->withAttributes(array('onclick' => 'onExportClick()'))
->appendIcon(Icon::create('export'))
->large() !!}
{!! Button::success(trans('texts.run'))
->withAttributes(array('id' => 'submitButton'))
->submit()
->appendIcon(Icon::create('play'))
->large() !!}
</center><br/>
{!! Former::close() !!}
@if (request()->report_type)
<div class="panel panel-default">
<div class="panel-body">
<table class="table table-striped invoice-table">
@if (count(array_values($reportTotals)))
<table class="tablesorter tablesorter-totals" style="display:none">
<thead>
<tr>
@foreach ($columns as $column)
<th>{{ trans("texts.{$column}") }}</th>
<th>{{ trans("texts.totals") }}</th>
@foreach (array_values(array_values($reportTotals)[0])[0] as $key => $val)
<th>{{ trans("texts.{$key}") }}</th>
@endforeach
</tr>
</thead>
<tbody>
@foreach ($reportTotals as $currencyId => $each)
@foreach ($each as $dimension => $val)
<tr>
<td>{!! Utils::getFromCache($currencyId, 'currencies')->name !!}
@if ($dimension)
- {{ $dimension }}
@endif
</td>
@foreach ($val as $id => $field)
<td>{!! Utils::formatMoney($field, $currencyId) !!}</td>
@endforeach
</tr>
@endforeach
@endforeach
</tbody>
</table>
<p>&nbsp;</p>
@endif
<!--
<div class="columnSelectorWrapper">
<input id="colSelect1" type="checkbox" class="hidden">
<label class="columnSelectorButton" for="colSelect1">Column</label>
<div id="columnSelector" class="columnSelector">
</div>
</div>
-->
<table class="tablesorter tablesorter-data" style="display:none">
<thead>
<tr>
{!! $report->tableHeader() !!}
</tr>
</thead>
<tbody>
@if (count($displayData))
@foreach ($displayData as $record)
@ -144,36 +221,13 @@
</tbody>
</table>
<p>&nbsp;</p>
@if (count(array_values($reportTotals)))
<table class="table table-striped invoice-table">
<thead>
<tr>
<th>{{ trans("texts.totals") }}</th>
@foreach (array_values($reportTotals)[0] as $key => $val)
<th>{{ trans("texts.{$key}") }}</th>
@endforeach
</tr>
</thead>
<tbody>
@foreach ($reportTotals as $currencyId => $val)
<tr>
<td>{!! Utils::getFromCache($currencyId, 'currencies')->name !!}</td>
@foreach ($val as $id => $field)
<td>{!! Utils::formatMoney($field, $currencyId) !!}</td>
@endforeach
</tr>
@endforeach
</tbody>
</table>
@endif
</div>
</div>
</div>
@endif
<script type="text/javascript">
function onExportClick() {
@ -182,6 +236,11 @@
$('#action').val('');
}
var sumColumns = [];
@foreach ($columns as $column)
sumColumns.push("{{ in_array($column, ['amount', 'paid', 'balance']) ? trans("texts.{$column}") : false }}");
@endforeach
$(function() {
$('.start_date .input-group-addon').click(function() {
toggleDatePicker('start_date');
@ -192,12 +251,51 @@
$('#report_type').change(function() {
var val = $('#report_type').val();
if (val == '{{ ENTITY_TAX_RATE }}') {
if (val == '{{ ENTITY_TAX_RATE }}') {
$('#dateField').fadeIn();
} else {
$('#dateField').fadeOut();
}
if (val == '{{ ENTITY_INVOICE }}' || val == 'invoice_details') {
$('#statusField').fadeIn();
} else {
$('#statusField').fadeOut();
}
});
$(function(){
$(".tablesorter-data").tablesorter({
theme: 'bootstrap',
widgets: ['zebra', 'uitheme', 'filter'{!! request()->group_when_sorted ? ", 'group'" : "" !!}, 'columnSelector'],
headerTemplate : '{content} {icon}',
widgetOptions : {
columnSelector_container : $('#columnSelector'),
filter_cssFilter: 'form-control',
group_collapsed: true,
group_saveGroups: false,
//group_formatter : function(txt, col, table, c, wo, data) {},
group_callback: function ($cell, $rows, column, table) {
for (var i=0; i<sumColumns.length; i++) {
var label = sumColumns[i];
if (!label) {
continue;
}
var subtotal = 0;
$rows.each(function() {
var txt = $(this).find("td").eq(i).text().replace(/[$,]/g, '');
subtotal += parseFloat(txt || 0);
});
$cell.find(".group-count").append(' - ' + label + ': ' + roundToTwo(subtotal));
}
},
}
}).show();
$(".tablesorter-totals").tablesorter({
theme: 'bootstrap',
widgets: ['zebra', 'uitheme'],
}).show();
});
})