Merge branch 'develop' of github.com:hillelcoren/invoice-ninja into develop

Conflicts:
	resources/views/invoices/knockout.blade.php
This commit is contained in:
Hillel Coren 2016-03-27 18:20:48 +03:00
commit d2d22fbb50
57 changed files with 2535 additions and 290 deletions

View File

@ -44,3 +44,27 @@ API_SECRET=password
#GOOGLE_OAUTH_REDIRECT=http://ninja.dev/auth/google
#GOOGLE_MAPS_API_KEY=
#S3_KEY=
#S3_SECRET=
#S3_REGION=
#S3_BUCKET=
#RACKSPACE_USERNAME=
#RACKSPACE_KEY=
#RACKSPACE_CONTAINER=
#RACKSPACE_REGION=
#RACKSPACE_TEMP_URL_SECRET=
# If this is set to anything, the URL secret will be set the next
# time a file is downloaded through the client portal.
# Only set this temporarily, as it slows things down.
#RACKSPACE_TEMP_URL_SECRET_SET=
#DOCUMENT_FILESYSTEM=
#MAX_DOCUMENT_SIZE # KB
#MAX_EMAIL_DOCUMENTS_SIZE # Total KB
#MAX_ZIP_DOCUMENTS_SIZE # Total KB (uncompressed)
#DOCUMENT_PREVIEW_SIZE # Pixels

View File

@ -95,6 +95,7 @@ module.exports = function(grunt) {
'public/vendor/bootstrap-datepicker/dist/locales/bootstrap-datepicker.no.min.js',
'public/vendor/bootstrap-datepicker/dist/locales/bootstrap-datepicker.es.min.js',
'public/vendor/bootstrap-datepicker/dist/locales/bootstrap-datepicker.sv.min.js',
'public/vendor/dropzone/dist/min/dropzone.min.js',
'public/vendor/typeahead.js/dist/typeahead.jquery.min.js',
'public/vendor/accounting/accounting.min.js',
'public/vendor/spectrum/spectrum.js',
@ -137,6 +138,7 @@ module.exports = function(grunt) {
'public/vendor/datatables-bootstrap3/BS3/assets/css/datatables.css',
'public/vendor/font-awesome/css/font-awesome.min.css',
'public/vendor/bootstrap-datepicker/dist/css/bootstrap-datepicker3.css',
'public/vendor/dropzone/dist/min/dropzone.min.css',
'public/vendor/spectrum/spectrum.css',
'public/css/bootstrap-combobox.css',
'public/css/typeahead.js-bootstrap.css',
@ -169,7 +171,7 @@ module.exports = function(grunt) {
'public/js/pdf_viewer.js',
'public/js/compatibility.js',
'public/js/pdfmake.min.js',
'public/js/vfs_fonts.js',
'public/js/vfs.js',
],
dest: 'public/pdf.built.js',
nonull: true

View File

@ -0,0 +1,41 @@
<?php namespace App\Console\Commands;
use DateTime;
use App\Models\Document;
use Illuminate\Console\Command;
class RemoveOrphanedDocuments extends Command
{
protected $name = 'ninja:remove-orphaned-documents';
protected $description = 'Removes old documents not associated with an expense or invoice';
public function fire()
{
$this->info(date('Y-m-d').' Running RemoveOrphanedDocuments...');
$documents = Document::whereRaw('invoice_id IS NULL AND expense_id IS NULL AND updated_at <= ?', array(new DateTime('-1 hour')))
->get();
$this->info(count($documents).' orphaned document(s) found');
foreach ($documents as $document) {
$document->delete();
}
$this->info('Done');
}
protected function getArguments()
{
return array(
//array('example', InputArgument::REQUIRED, 'An example argument.'),
);
}
protected function getOptions()
{
return array(
//array('example', null, InputOption::VALUE_OPTIONAL, 'An example option.', null),
);
}
}

View File

@ -13,6 +13,7 @@ class Kernel extends ConsoleKernel
*/
protected $commands = [
'App\Console\Commands\SendRecurringInvoices',
'App\Console\Commands\RemoveOrphanedDocuments',
'App\Console\Commands\ResetData',
'App\Console\Commands\CheckData',
'App\Console\Commands\SendRenewalInvoices',

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,140 @@
<?php namespace App\Http\Controllers;
use Datatable;
use Input;
use Redirect;
use Session;
use URL;
use Utils;
use View;
use Validator;
use Response;
use App\Models\Document;
use App\Ninja\Repositories\DocumentRepository;
class DocumentController extends BaseController
{
protected $documentRepo;
protected $model = 'App\Models\Document';
public function __construct(DocumentRepository $documentRepo)
{
// parent::__construct();
$this->documentRepo = $documentRepo;
}
public function get($publicId)
{
$document = Document::scope($publicId)
->firstOrFail();
if(!$this->checkViewPermission($document, $response)){
return $response;
}
return static::getDownloadResponse($document);
}
public static function getDownloadResponse($document){
$direct_url = $document->getDirectUrl();
if($direct_url){
return redirect($direct_url);
}
$stream = $document->getStream();
if($stream){
$headers = [
'Content-Type' => Document::$types[$document->type]['mime'],
'Content-Length' => $document->size,
];
$response = Response::stream(function() use ($stream) {
fpassthru($stream);
}, 200, $headers);
}
else{
$response = Response::make($document->getRaw(), 200);
$response->header('content-type', Document::$types[$document->type]['mime']);
}
return $response;
}
public function getPreview($publicId)
{
$document = Document::scope($publicId)
->firstOrFail();
if(!$this->checkViewPermission($document, $response)){
return $response;
}
if(empty($document->preview)){
return Response::view('error', array('error'=>'Preview does not exist!'), 404);
}
$direct_url = $document->getDirectPreviewUrl();
if($direct_url){
return redirect($direct_url);
}
$previewType = pathinfo($document->preview, PATHINFO_EXTENSION);
$response = Response::make($document->getRawPreview(), 200);
$response->header('content-type', Document::$types[$previewType]['mime']);
return $response;
}
public function getVFSJS($publicId, $name){
$document = Document::scope($publicId)
->firstOrFail();
if(substr($name, -3)=='.js'){
$name = substr($name, 0, -3);
}
if(!$this->checkViewPermission($document, $response)){
return $response;
}
if(!$document->isPDFEmbeddable()){
return Response::view('error', array('error'=>'Image does not exist!'), 404);
}
$content = $document->preview?$document->getRawPreview():$document->getRaw();
$content = 'ninjaAddVFSDoc('.json_encode(intval($publicId).'/'.strval($name)).',"'.base64_encode($content).'")';
$response = Response::make($content, 200);
$response->header('content-type', 'text/javascript');
$response->header('cache-control', 'max-age=31536000');
return $response;
}
public function postUpload()
{
if (!Utils::isPro()) {
return;
}
if(!$this->checkCreatePermission($response)){
return $response;
}
$result = $this->documentRepo->upload(Input::all()['file'], $doc_array);
if(is_string($result)){
return Response::json([
'error' => $result,
'code' => 400
], 400);
} else {
return Response::json([
'error' => false,
'document' => $doc_array,
'code' => 200
], 200);
}
}
}

View File

@ -99,7 +99,7 @@ class ExpenseController extends BaseController
public function edit($publicId)
{
$expense = Expense::scope($publicId)->firstOrFail();
$expense = Expense::scope($publicId)->with('documents')->firstOrFail();
if(!$this->checkEditPermission($expense, $response)){
return $response;
@ -163,7 +163,14 @@ class ExpenseController extends BaseController
*/
public function update(UpdateExpenseRequest $request)
{
$expense = $this->expenseService->save($request->input());
$data = $request->input();
$data['documents'] = $request->file('documents');
if(!$this->checkUpdatePermission($data, $response)){
return $response;
}
$expense = $this->expenseService->save($data, true);
Session::flash('message', trans('texts.updated_expense'));
@ -177,7 +184,14 @@ class ExpenseController extends BaseController
public function store(CreateExpenseRequest $request)
{
$expense = $this->expenseService->save($request->input());
$data = $request->input();
$data['documents'] = $request->file('documents');
if(!$this->checkUpdatePermission($data, $response)){
return $response;
}
$expense = $this->expenseService->save($data);
Session::flash('message', trans('texts.created_expense'));
@ -195,7 +209,6 @@ class ExpenseController extends BaseController
$expenses = Expense::scope($ids)->with('client')->get();
$clientPublicId = null;
$currencyId = null;
$data = [];
// Validate that either all expenses do not have a client or if there is a client, it is the same client
foreach ($expenses as $expense)
@ -220,19 +233,11 @@ class ExpenseController extends BaseController
Session::flash('error', trans('texts.expense_error_invoiced'));
return Redirect::to('expenses');
}
$account = Auth::user()->account;
$data[] = [
'publicId' => $expense->public_id,
'description' => $expense->public_notes,
'qty' => 1,
'cost' => $expense->present()->converted_amount,
];
}
return Redirect::to("invoices/create/{$clientPublicId}")
->with('expenseCurrencyId', $currencyId)
->with('expenses', $data);
->with('expenses', $ids);
break;
default:

View File

@ -17,12 +17,14 @@ use App\Models\Invoice;
use App\Models\Client;
use App\Models\Account;
use App\Models\Product;
use App\Models\Expense;
use App\Models\TaxRate;
use App\Models\InvoiceDesign;
use App\Models\Activity;
use App\Ninja\Mailers\ContactMailer as Mailer;
use App\Ninja\Repositories\InvoiceRepository;
use App\Ninja\Repositories\ClientRepository;
use App\Ninja\Repositories\DocumentRepository;
use App\Services\InvoiceService;
use App\Services\RecurringInvoiceService;
use App\Http\Requests\SaveInvoiceWithClientRequest;
@ -32,11 +34,12 @@ class InvoiceController extends BaseController
protected $mailer;
protected $invoiceRepo;
protected $clientRepo;
protected $documentRepo;
protected $invoiceService;
protected $recurringInvoiceService;
protected $model = 'App\Models\Invoice';
public function __construct(Mailer $mailer, InvoiceRepository $invoiceRepo, ClientRepository $clientRepo, InvoiceService $invoiceService, RecurringInvoiceService $recurringInvoiceService)
public function __construct(Mailer $mailer, InvoiceRepository $invoiceRepo, ClientRepository $clientRepo, InvoiceService $invoiceService, DocumentRepository $documentRepo, RecurringInvoiceService $recurringInvoiceService)
{
// parent::__construct();
@ -89,7 +92,7 @@ class InvoiceController extends BaseController
{
$account = Auth::user()->account;
$invoice = Invoice::scope($publicId)
->with('invitations', 'account.country', 'client.contacts', 'client.country', 'invoice_items', 'payments')
->with('invitations', 'account.country', 'client.contacts', 'client.country', 'invoice_items', 'documents', 'expenses', 'expenses.documents', 'payments')
->withTrashed()
->firstOrFail();
@ -240,6 +243,11 @@ class InvoiceController extends BaseController
$invoice = $account->createInvoice($entityType, $clientId);
$invoice->public_id = 0;
if(Session::get('expenses')){
$invoice->expenses = Expense::scope(Session::get('expenses'))->with('documents')->get();
}
$clients = Client::scope()->with('contacts', 'country')->orderBy('name');
if(!Auth::user()->hasPermission('view_all')){
$clients = $clients->where('clients.user_id', '=', Auth::user()->id);
@ -356,7 +364,6 @@ class InvoiceController extends BaseController
'recurringDueDateHelp' => $recurringDueDateHelp,
'invoiceLabels' => Auth::user()->account->getInvoiceLabels(),
'tasks' => Session::get('tasks') ? json_encode(Session::get('tasks')) : null,
'expenses' => Session::get('expenses') ? json_encode(Session::get('expenses')) : null,
'expenseCurrencyId' => Session::get('expenseCurrencyId') ?: null,
];
@ -370,6 +377,7 @@ class InvoiceController extends BaseController
public function store(SaveInvoiceWithClientRequest $request)
{
$data = $request->input();
$data['documents'] = $request->file('documents');
if(!$this->checkUpdatePermission($data, $response)){
return $response;
@ -410,6 +418,7 @@ class InvoiceController extends BaseController
public function update(SaveInvoiceWithClientRequest $request)
{
$data = $request->input();
$data['documents'] = $request->file('documents');
if(!$this->checkUpdatePermission($data, $response)){
return $response;
@ -540,7 +549,7 @@ class InvoiceController extends BaseController
public function invoiceHistory($publicId)
{
$invoice = Invoice::withTrashed()->scope($publicId)->firstOrFail();
$invoice->load('user', 'invoice_items', 'account.country', 'client.contacts', 'client.country');
$invoice->load('user', 'invoice_items', 'documents', 'expenses', 'expenses.documents', 'account.country', 'client.contacts', 'client.country');
$invoice->invoice_date = Utils::fromSqlDate($invoice->invoice_date);
$invoice->due_date = Utils::fromSqlDate($invoice->due_date);
$invoice->is_pro = Auth::user()->isPro();

View File

@ -7,27 +7,33 @@ use URL;
use Input;
use Utils;
use Request;
use Response;
use Session;
use Datatable;
use App\Models\Gateway;
use App\Models\Invitation;
use App\Models\Document;
use App\Ninja\Repositories\InvoiceRepository;
use App\Ninja\Repositories\PaymentRepository;
use App\Ninja\Repositories\ActivityRepository;
use App\Ninja\Repositories\DocumentRepository;
use App\Events\InvoiceInvitationWasViewed;
use App\Events\QuoteInvitationWasViewed;
use App\Services\PaymentService;
use Barracuda\ArchiveStream\ZipArchive;
class PublicClientController extends BaseController
{
private $invoiceRepo;
private $paymentRepo;
private $documentRepo;
public function __construct(InvoiceRepository $invoiceRepo, PaymentRepository $paymentRepo, ActivityRepository $activityRepo, PaymentService $paymentService)
public function __construct(InvoiceRepository $invoiceRepo, PaymentRepository $paymentRepo, ActivityRepository $activityRepo, DocumentRepository $documentRepo, PaymentService $paymentService)
{
$this->invoiceRepo = $invoiceRepo;
$this->paymentRepo = $paymentRepo;
$this->activityRepo = $activityRepo;
$this->documentRepo = $documentRepo;
$this->paymentService = $paymentService;
}
@ -119,6 +125,7 @@ class PublicClientController extends BaseController
'hideLogo' => $account->isWhiteLabel(),
'hideHeader' => $account->isNinjaAccount(),
'hideDashboard' => !$account->enable_client_portal,
'showDocuments' => $account->isPro(),
'clientViewCSS' => $account->clientViewCSS(),
'clientFontUrl' => $account->getFontsUrl(),
'invoice' => $invoice->hidePrivateFields(),
@ -133,6 +140,15 @@ class PublicClientController extends BaseController
'phantomjs' => Input::has('phantomjs'),
);
if($account->isPro() && $this->canCreateZip()){
$zipDocs = $this->getInvoiceZipDocuments($invoice, $size);
if(count($zipDocs) > 1){
$data['documentsZipURL'] = URL::to("client/documents/{$invitation->invitation_key}");
$data['documentsZipSize'] = $size;
}
}
return View::make('invoices.view', $data);
}
@ -252,6 +268,7 @@ class PublicClientController extends BaseController
'color' => $color,
'hideLogo' => $account->isWhiteLabel(),
'hideDashboard' => !$account->enable_client_portal,
'showDocuments' => $account->isPro(),
'clientViewCSS' => $account->clientViewCSS(),
'clientFontUrl' => $account->getFontsUrl(),
'title' => trans('texts.invoices'),
@ -284,6 +301,7 @@ class PublicClientController extends BaseController
'color' => $color,
'hideLogo' => $account->isWhiteLabel(),
'hideDashboard' => !$account->enable_client_portal,
'showDocuments' => $account->isPro(),
'clientViewCSS' => $account->clientViewCSS(),
'clientFontUrl' => $account->getFontsUrl(),
'entityType' => ENTITY_PAYMENT,
@ -322,6 +340,7 @@ class PublicClientController extends BaseController
'color' => $color,
'hideLogo' => $account->isWhiteLabel(),
'hideDashboard' => !$account->enable_client_portal,
'showDocuments' => $account->isPro(),
'clientViewCSS' => $account->clientViewCSS(),
'clientFontUrl' => $account->getFontsUrl(),
'title' => trans('texts.quotes'),
@ -342,6 +361,39 @@ class PublicClientController extends BaseController
return $this->invoiceRepo->getClientDatatable($invitation->contact_id, ENTITY_QUOTE, Input::get('sSearch'));
}
public function documentIndex()
{
if (!$invitation = $this->getInvitation()) {
return $this->returnError();
}
$account = $invitation->account;
$color = $account->primary_color ? $account->primary_color : '#0b4d78';
$data = [
'color' => $color,
'hideLogo' => $account->isWhiteLabel(),
'hideDashboard' => !$account->enable_client_portal,
'showDocuments' => $account->isPro(),
'clientViewCSS' => $account->clientViewCSS(),
'clientFontUrl' => $account->getFontsUrl(),
'title' => trans('texts.documents'),
'entityType' => ENTITY_DOCUMENT,
'columns' => Utils::trans(['invoice_number', 'name', 'document_date', 'document_size']),
];
return response()->view('public_list', $data);
}
public function documentDatatable()
{
if (!$invitation = $this->getInvitation()) {
return false;
}
return $this->documentRepo->getClientDatatable($invitation->contact_id, ENTITY_DOCUMENT, Input::get('sSearch'));
}
private function returnError($error = false)
{
return response()->view('error', [
@ -373,4 +425,147 @@ class PublicClientController extends BaseController
return $invitation;
}
public function getDocumentVFSJS($publicId, $name){
if (!$invitation = $this->getInvitation()) {
return $this->returnError();
}
$clientId = $invitation->invoice->client_id;
$document = Document::scope($publicId, $invitation->account_id)->first();
if(!$document->isPDFEmbeddable()){
return Response::view('error', array('error'=>'Image does not exist!'), 404);
}
$authorized = false;
if($document->expense && $document->expense->client_id == $invitation->invoice->client_id){
$authorized = true;
} else if($document->invoice && $document->invoice->client_id == $invitation->invoice->client_id){
$authorized = true;
}
if(!$authorized){
return Response::view('error', array('error'=>'Not authorized'), 403);
}
if(substr($name, -3)=='.js'){
$name = substr($name, 0, -3);
}
$content = $document->preview?$document->getRawPreview():$document->getRaw();
$content = 'ninjaAddVFSDoc('.json_encode(intval($publicId).'/'.strval($name)).',"'.base64_encode($content).'")';
$response = Response::make($content, 200);
$response->header('content-type', 'text/javascript');
$response->header('cache-control', 'max-age=31536000');
return $response;
}
protected function canCreateZip(){
return function_exists('gmp_init');
}
protected function getInvoiceZipDocuments($invoice, &$size=0){
$documents = $invoice->documents;
foreach($invoice->expenses as $expense){
$documents = $documents->merge($expense->documents);
}
$documents = $documents->sortBy('size');
$size = 0;
$maxSize = MAX_ZIP_DOCUMENTS_SIZE * 1000;
$toZip = array();
foreach($documents as $document){
if($size + $document->size > $maxSize)break;
if(!empty($toZip[$document->name])){
// This name is taken
if($toZip[$document->name]->hash != $document->hash){
// 2 different files with the same name
$nameInfo = pathinfo($document->name);
for($i = 1;; $i++){
$name = $nameInfo['filename'].' ('.$i.').'.$nameInfo['extension'];
if(empty($toZip[$name])){
$toZip[$name] = $document;
$size += $document->size;
break;
} else if ($toZip[$name]->hash == $document->hash){
// We're not adding this after all
break;
}
}
}
}
else{
$toZip[$document->name] = $document;
$size += $document->size;
}
}
return $toZip;
}
public function getInvoiceDocumentsZip($invitationKey){
if (!$invitation = $this->invoiceRepo->findInvoiceByInvitation($invitationKey)) {
return $this->returnError();
}
Session::put('invitation_key', $invitationKey); // track current invitation
$invoice = $invitation->invoice;
$toZip = $this->getInvoiceZipDocuments($invoice);
if(!count($toZip)){
return Response::view('error', array('error'=>'No documents small enough'), 404);
}
$zip = new ZipArchive($invitation->account->name.' Invoice '.$invoice->invoice_number.'.zip');
return Response::stream(function() use ($toZip, $zip) {
foreach($toZip as $name=>$document){
$fileStream = $document->getStream();
if($fileStream){
$zip->init_file_stream_transfer($name, $document->size, array('time'=>$document->created_at->timestamp));
while ($buffer = fread($fileStream, 256000))$zip->stream_file_part($buffer);
fclose($fileStream);
$zip->complete_file_stream();
}
else{
$zip->add_file($name, $document->getRaw());
}
}
$zip->finish();
}, 200);
}
public function getDocument($invitationKey, $publicId){
if (!$invitation = $this->invoiceRepo->findInvoiceByInvitation($invitationKey)) {
return $this->returnError();
}
Session::put('invitation_key', $invitationKey); // track current invitation
$clientId = $invitation->invoice->client_id;
$document = Document::scope($publicId, $invitation->account_id)->firstOrFail();
$authorized = false;
if($document->expense && $document->expense->client_id == $invitation->invoice->client_id){
$authorized = true;
} else if($document->invoice && $document->invoice->client_id == $invitation->invoice->client_id){
$authorized = true;
}
if(!$authorized){
return Response::view('error', array('error'=>'Not authorized'), 403);
}
return DocumentController::getDownloadResponse($document);
}
}

View File

@ -42,7 +42,7 @@ class Authenticate {
// Does this account require portal passwords?
$account = Account::whereId($account_id)->first();
if(!$account->enable_portal_password || !$account->isPro()){
if($account && (!$account->enable_portal_password || !$account->isPro())){
$authenticated = true;
}

View File

@ -45,14 +45,20 @@ Route::group(['middleware' => 'auth:client'], function() {
Route::get('complete', 'PaymentController@offsite_payment');
Route::get('client/quotes', 'PublicClientController@quoteIndex');
Route::get('client/invoices', 'PublicClientController@invoiceIndex');
Route::get('client/documents', 'PublicClientController@documentIndex');
Route::get('client/payments', 'PublicClientController@paymentIndex');
Route::get('client/dashboard', 'PublicClientController@dashboard');
Route::get('client/document/js/{public_id}/{filename}', 'PublicClientController@getDocumentVFSJS');
Route::get('client/document/{invitation_key}/{public_id}/{filename?}', 'PublicClientController@getDocument');
Route::get('client/documents/{invitation_key}/{filename?}', 'PublicClientController@getInvoiceDocumentsZip');
Route::get('api/client.quotes', array('as'=>'api.client.quotes', 'uses'=>'PublicClientController@quoteDatatable'));
Route::get('api/client.invoices', array('as'=>'api.client.invoices', 'uses'=>'PublicClientController@invoiceDatatable'));
Route::get('api/client.documents', array('as'=>'api.client.documents', 'uses'=>'PublicClientController@documentDatatable'));
Route::get('api/client.payments', array('as'=>'api.client.payments', 'uses'=>'PublicClientController@paymentDatatable'));
Route::get('api/client.activity', array('as'=>'api.client.activity', 'uses'=>'PublicClientController@activityDatatable'));
});
Route::get('api/client.quotes', array('as'=>'api.client.quotes', 'uses'=>'PublicClientController@quoteDatatable'));
Route::get('api/client.invoices', array('as'=>'api.client.invoices', 'uses'=>'PublicClientController@invoiceDatatable'));
Route::get('api/client.payments', array('as'=>'api.client.payments', 'uses'=>'PublicClientController@paymentDatatable'));
Route::get('api/client.activity', array('as'=>'api.client.activity', 'uses'=>'PublicClientController@activityDatatable'));
Route::get('license', 'PaymentController@show_license_payment');
Route::post('license', 'PaymentController@do_license_payment');
@ -132,6 +138,11 @@ Route::group(['middleware' => 'auth:user'], function() {
Route::post('invoices/bulk', 'InvoiceController@bulk');
Route::post('recurring_invoices/bulk', 'InvoiceController@bulk');
Route::get('document/{public_id}/{filename?}', 'DocumentController@get');
Route::get('document/js/{public_id}/{filename}', 'DocumentController@getVFSJS');
Route::get('document/preview/{public_id}/{filename?}', 'DocumentController@getPreview');
Route::post('document', 'DocumentController@postUpload');
Route::get('quotes/create/{client_id?}', 'QuoteController@create');
Route::get('quotes/{public_id}/clone', 'InvoiceController@cloneInvoice');
Route::get('quotes/{public_id}/edit', 'InvoiceController@edit');
@ -310,6 +321,7 @@ if (!defined('CONTACT_EMAIL')) {
define('ENTITY_CLIENT', 'client');
define('ENTITY_CONTACT', 'contact');
define('ENTITY_INVOICE', 'invoice');
define('ENTITY_DOCUMENT', 'document');
define('ENTITY_INVOICE_ITEMS', 'invoice_items');
define('ENTITY_INVITATION', 'invitation');
define('ENTITY_RECURRING_INVOICE', 'recurring_invoice');
@ -425,6 +437,10 @@ if (!defined('CONTACT_EMAIL')) {
define('MAX_IFRAME_URL_LENGTH', 250);
define('MAX_LOGO_FILE_SIZE', 200); // KB
define('MAX_FAILED_LOGINS', 10);
define('MAX_DOCUMENT_SIZE', env('MAX_DOCUMENT_SIZE', 10000));// KB
define('MAX_EMAIL_DOCUMENTS_SIZE', env('MAX_EMAIL_DOCUMENTS_SIZE', 10000));// Total KB
define('MAX_ZIP_DOCUMENTS_SIZE', env('MAX_EMAIL_DOCUMENTS_SIZE', 30000));// Total KB (uncompressed)
define('DOCUMENT_PREVIEW_SIZE', env('DOCUMENT_PREVIEW_SIZE', 300));// pixels
define('DEFAULT_FONT_SIZE', 9);
define('DEFAULT_HEADER_FONT', 1);// Roboto
define('DEFAULT_BODY_FONT', 1);// Roboto

View File

@ -6,9 +6,11 @@ use Session;
use DateTime;
use Event;
use Cache;
use Document;
use App;
use File;
use App\Events\UserSettingsChanged;
use Illuminate\Support\Facades\Storage;
use Illuminate\Database\Eloquent\SoftDeletes;
use Laracasts\Presenter\PresentableTrait;
@ -384,26 +386,64 @@ class Account extends Eloquent
public function hasLogo()
{
return file_exists($this->getLogoFullPath());
if($this->logo == ''){
$this->calculateLogoDetails();
}
public function getLogoPath()
{
$fileName = 'logo/' . $this->account_key;
return file_exists($fileName.'.png') ? $fileName.'.png' : $fileName.'.jpg';
return !empty($this->logo);
}
public function getLogoFullPath()
{
$fileName = public_path() . '/logo/' . $this->account_key;
public function getLogoDisk(){
return Storage::disk(env('LOGO_FILESYSTEM', 'logos'));
}
return file_exists($fileName.'.png') ? $fileName.'.png' : $fileName.'.jpg';
protected function calculateLogoDetails(){
$disk = $this->getLogoDisk();
if($disk->exists($this->account_key.'.png')){
$this->logo = $this->account_key.'.png';
} else if($disk->exists($this->account_key.'.jpg')) {
$this->logo = $this->account_key.'.jpg';
}
if(!empty($this->logo)){
$image = imagecreatefromstring($disk->get($this->logo));
$this->logo_width = imagesx($image);
$this->logo_height = imagesy($image);
$this->logo_size = $disk->size($this->logo);
} else {
$this->logo = null;
}
$this->save();
}
public function getLogoRaw(){
if(!$this->hasLogo()){
return null;
}
$disk = $this->getLogoDisk();
return $disk->get($this->logo);
}
public function getLogoURL()
{
return SITE_URL . '/' . $this->getLogoPath();
if(!$this->hasLogo()){
return null;
}
$disk = $this->getLogoDisk();
$adapter = $disk->getAdapter();
if($adapter instanceof \League\Flysystem\Adapter\Local) {
// Stored locally
$logo_url = str_replace(public_path(), url('/'), $adapter->applyPathPrefix($this->logo), $count);
if($count == 1){
return str_replace(DIRECTORY_SEPARATOR, '/', $logo_url);
}
}
Document::getDirectFileUrl($this->logo, $this->getDisk());
}
public function getToken($name)
@ -419,24 +459,20 @@ class Account extends Eloquent
public function getLogoWidth()
{
$path = $this->getLogoFullPath();
if (!file_exists($path)) {
return 0;
if(!$this->hasLogo()){
return null;
}
list($width, $height) = getimagesize($path);
return $width;
return $this->logo_width;
}
public function getLogoHeight()
{
$path = $this->getLogoFullPath();
if (!file_exists($path)) {
return 0;
if(!$this->hasLogo()){
return null;
}
list($width, $height) = getimagesize($path);
return $height;
return $this->logo_height;
}
public function createInvoice($entityType = ENTITY_INVOICE, $clientId = null)
@ -815,12 +851,11 @@ class Account extends Eloquent
public function getLogoSize()
{
if (!$this->hasLogo()) {
return 0;
if(!$this->hasLogo()){
return null;
}
$filename = $this->getLogoFullPath();
return round(File::size($filename) / 1000);
return round($this->logo_size / 1000);
}
public function isLogoTooLarge()

263
app/Models/Document.php Normal file
View File

@ -0,0 +1,263 @@
<?php namespace App\Models;
use Illuminate\Support\Facades\Storage;
use DB;
use Auth;
class Document extends EntityModel
{
public static $extraExtensions = array(
'jpg' => 'jpeg',
'tif' => 'tiff',
);
public static $allowedMimes = array(// Used by Dropzone.js; does not affect what the server accepts
'image/png', 'image/jpeg', 'image/tiff', 'application/pdf', 'image/gif', 'image/vnd.adobe.photoshop', 'text/plain',
'application/zip', 'application/msword',
'application/excel', 'application/vnd.ms-excel', 'application/x-excel', 'application/x-msexcel',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet','application/postscript', 'image/svg+xml',
'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'application/vnd.ms-powerpoint',
);
public static $types = array(
'png' => array(
'mime' => 'image/png',
),
'ai' => array(
'mime' => 'application/postscript',
),
'svg' => array(
'mime' => 'image/svg+xml',
),
'jpeg' => array(
'mime' => 'image/jpeg',
),
'tiff' => array(
'mime' => 'image/tiff',
),
'pdf' => array(
'mime' => 'application/pdf',
),
'gif' => array(
'mime' => 'image/gif',
),
'psd' => array(
'mime' => 'image/vnd.adobe.photoshop',
),
'txt' => array(
'mime' => 'text/plain',
),
'zip' => array(
'mime' => 'application/zip',
),
'doc' => array(
'mime' => 'application/msword',
),
'xls' => array(
'mime' => 'application/vnd.ms-excel',
),
'ppt' => array(
'mime' => 'application/vnd.ms-powerpoint',
),
'xlsx' => array(
'mime' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
),
'docx' => array(
'mime' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
),
'pptx' => array(
'mime' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
),
);
public function fill(array $attributes)
{
parent::fill($attributes);
if(empty($this->attributes['disk'])){
$this->attributes['disk'] = env('DOCUMENT_FILESYSTEM', 'documents');
}
return $this;
}
public function account()
{
return $this->belongsTo('App\Models\Account');
}
public function user()
{
return $this->belongsTo('App\Models\User');
}
public function expense()
{
return $this->belongsTo('App\Models\Expense')->withTrashed();
}
public function invoice()
{
return $this->belongsTo('App\Models\Invoice')->withTrashed();
}
public function getDisk(){
return Storage::disk(!empty($this->disk)?$this->disk:env('DOCUMENT_FILESYSTEM', 'documents'));
}
public function setDiskAttribute($value)
{
$this->attributes['disk'] = $value?$value:env('DOCUMENT_FILESYSTEM', 'documents');
}
public function getDirectUrl(){
return static::getDirectFileUrl($this->path, $this->getDisk());
}
public function getDirectPreviewUrl(){
return $this->preview?static::getDirectFileUrl($this->preview, $this->getDisk(), true):null;
}
public static function getDirectFileUrl($path, $disk, $prioritizeSpeed = false){
$adapter = $disk->getAdapter();
$fullPath = $adapter->applyPathPrefix($path);
if($adapter instanceof \League\Flysystem\AwsS3v3\AwsS3Adapter) {
$client = $adapter->getClient();
$command = $client->getCommand('GetObject', [
'Bucket' => $adapter->getBucket(),
'Key' => $fullPath
]);
return (string) $client->createPresignedRequest($command, '+10 minutes')->getUri();
} else if (!$prioritizeSpeed // Rackspace temp URLs are slow, so we don't use them for previews
&& $adapter instanceof \League\Flysystem\Rackspace\RackspaceAdapter) {
$secret = env('RACKSPACE_TEMP_URL_SECRET');
if($secret){
$object = $adapter->getContainer()->getObject($fullPath);
if(env('RACKSPACE_TEMP_URL_SECRET_SET')){
// Go ahead and set the secret too
$object->getService()->getAccount()->setTempUrlSecret($secret);
}
$url = $object->getUrl();
$expiry = strtotime('+10 minutes');
$urlPath = urldecode($url->getPath());
$body = sprintf("%s\n%d\n%s", 'GET', $expiry, $urlPath);
$hash = hash_hmac('sha1', $body, $secret);
return sprintf('%s?temp_url_sig=%s&temp_url_expires=%d', $url, $hash, $expiry);
}
}
return null;
}
public function getRaw(){
$disk = $this->getDisk();
return $disk->get($this->path);
}
public function getStream(){
$disk = $this->getDisk();
return $disk->readStream($this->path);
}
public function getRawPreview(){
$disk = $this->getDisk();
return $disk->get($this->preview);
}
public function getUrl(){
return url('document/'.$this->public_id.'/'.$this->name);
}
public function getClientUrl($invitation){
return url('client/document/'.$invitation->invitation_key.'/'.$this->public_id.'/'.$this->name);
}
public function isPDFEmbeddable(){
return $this->type == 'jpeg' || $this->type == 'png' || $this->preview;
}
public function getVFSJSUrl(){
if(!$this->isPDFEmbeddable())return null;
return url('document/js/'.$this->public_id.'/'.$this->name.'.js');
}
public function getClientVFSJSUrl(){
if(!$this->isPDFEmbeddable())return null;
return url('client/document/js/'.$this->public_id.'/'.$this->name.'.js');
}
public function getPreviewUrl(){
return $this->preview?url('document/preview/'.$this->public_id.'/'.$this->name.'.'.pathinfo($this->preview, PATHINFO_EXTENSION)):null;
}
public function toArray()
{
$array = parent::toArray();
if(empty($this->visible) || in_array('url', $this->visible))$array['url'] = $this->getUrl();
if(empty($this->visible) || in_array('preview_url', $this->visible))$array['preview_url'] = $this->getPreviewUrl();
return $array;
}
public function cloneDocument(){
$document = Document::createNew($this);
$document->path = $this->path;
$document->preview = $this->preview;
$document->name = $this->name;
$document->type = $this->type;
$document->disk = $this->disk;
$document->hash = $this->hash;
$document->size = $this->size;
$document->width = $this->width;
$document->height = $this->height;
return $document;
}
public static function canCreate(){
return true;
}
public static function canViewItem($document){
if(Auth::user()->hasPermission('view_all'))return true;
if($document->expense){
if($document->expense->invoice)return $document->expense->invoice->canView();
return $document->expense->canView();
}
if($document->invoice)return $document->invoice->canView();
return Auth::user()->id == $item->user_id;
}
}
Document::deleted(function ($document) {
$same_path_count = DB::table('documents')
->where('documents.account_id', '=', $document->account_id)
->where('documents.path', '=', $document->path)
->where('documents.disk', '=', $document->disk)
->count();
if(!$same_path_count){
$document->getDisk()->delete($document->path);
}
if($document->preview){
$same_preview_count = DB::table('documents')
->where('documents.account_id', '=', $document->account_id)
->where('documents.preview', '=', $document->preview)
->where('documents.disk', '=', $document->disk)
->count();
if(!$same_preview_count){
$document->getDisk()->delete($document->preview);
}
}
});

View File

@ -24,9 +24,14 @@ class EntityModel extends Eloquent
Utils::fatalError();
}
if(method_exists($className, 'withTrashed')){
$lastEntity = $className::withTrashed()
->scope(false, $entity->account_id)
->orderBy('public_id', 'DESC')
->scope(false, $entity->account_id);
} else {
$lastEntity = $className::scope(false, $entity->account_id);
}
$lastEntity = $lastEntity->orderBy('public_id', 'DESC')
->first();
if ($lastEntity) {

View File

@ -53,6 +53,11 @@ class Expense extends EntityModel
return $this->belongsTo('App\Models\Invoice')->withTrashed();
}
public function documents()
{
return $this->hasMany('App\Models\Document')->orderBy('id');
}
public function getName()
{
if($this->expense_number)
@ -80,6 +85,20 @@ class Expense extends EntityModel
{
return $this->invoice_currency_id != $this->expense_currency_id;
}
public function convertedAmount()
{
return round($this->amount * $this->exchange_rate, 2);
}
public function toArray()
{
$array = parent::toArray();
if(empty($this->visible) || in_array('converted_amount', $this->visible))$array['previewconverted_amount_url'] = $this->convertedAmount();
return $array;
}
}
Expense::creating(function ($expense) {

View File

@ -2,6 +2,7 @@
use Utils;
use DateTime;
use URL;
use Illuminate\Database\Eloquent\SoftDeletes;
use Laracasts\Presenter\PresentableTrait;
use App\Models\BalanceAffecting;
@ -175,6 +176,11 @@ class Invoice extends EntityModel implements BalanceAffecting
return $this->hasMany('App\Models\InvoiceItem')->orderBy('id');
}
public function documents()
{
return $this->hasMany('App\Models\Document')->orderBy('id');
}
public function invoice_status()
{
return $this->belongsTo('App\Models\InvoiceStatus');
@ -385,6 +391,8 @@ class Invoice extends EntityModel implements BalanceAffecting
'amount',
'balance',
'invoice_items',
'documents',
'expenses',
'client',
'tax_name',
'tax_rate',
@ -457,6 +465,7 @@ class Invoice extends EntityModel implements BalanceAffecting
'custom_invoice_text_label2',
'custom_invoice_item_label1',
'custom_invoice_item_label2',
'invoice_embed_documents'
]);
foreach ($this->invoice_items as $invoiceItem) {
@ -481,6 +490,26 @@ class Invoice extends EntityModel implements BalanceAffecting
]);
}
foreach ($this->documents as $document) {
$document->setVisible([
'public_id',
'name',
]);
}
foreach ($this->expenses as $expense) {
$expense->setVisible([
'documents',
]);
foreach ($expense->documents as $document) {
$document->setVisible([
'public_id',
'name',
]);
}
}
return $this;
}
@ -861,6 +890,18 @@ class Invoice extends EntityModel implements BalanceAffecting
return $taxes;
}
public function hasDocuments(){
if(count($this->documents))return true;
return $this->hasExpenseDocuments();
}
public function hasExpenseDocuments(){
foreach($this->expenses as $expense){
if(count($expense->documents))return true;
}
return false;
}
}
Invoice::creating(function ($invoice) {

View File

@ -1,6 +1,7 @@
<?php namespace App\Ninja\Mailers;
use Form;
use HTML;
use Utils;
use Event;
use URL;
@ -28,6 +29,7 @@ class ContactMailer extends Mailer
'invoice',
'quote',
'password',
'documents',
'viewLink',
'viewButton',
'paymentLink',
@ -60,8 +62,31 @@ class ContactMailer extends Mailer
$pdfString = $invoice->getPDFString();
}
$documentStrings = array();
if ($account->document_email_attachment && $invoice->hasDocuments()) {
$documents = $invoice->documents;
foreach($invoice->expenses as $expense){
$documents = $documents->merge($expense->documents);
}
$documents = $documents->sortBy('size');
$size = 0;
$maxSize = MAX_EMAIL_DOCUMENTS_SIZE * 1000;
foreach($documents as $document){
$size += $document->size;
if($size > $maxSize)break;
$documentStrings[] = array(
'name' => $document->name,
'data' => $document->getRaw(),
);
}
}
foreach ($invoice->invitations as $invitation) {
$response = $this->sendInvitation($invitation, $invoice, $emailTemplate, $emailSubject, $pdfString);
$response = $this->sendInvitation($invitation, $invoice, $emailTemplate, $emailSubject, $pdfString, $documentStrings);
if ($response === true) {
$sent = true;
}
@ -80,7 +105,7 @@ class ContactMailer extends Mailer
return $response;
}
private function sendInvitation($invitation, $invoice, $body, $subject, $pdfString)
private function sendInvitation($invitation, $invoice, $body, $subject, $pdfString, $documentStrings)
{
$client = $invoice->client;
$account = $invoice->account;
@ -127,6 +152,7 @@ class ContactMailer extends Mailer
'account' => $account,
'client' => $client,
'invoice' => $invoice,
'documents' => $documentStrings,
];
if ($account->attatchPDF()) {
@ -263,6 +289,20 @@ class ContactMailer extends Mailer
$invitation = $data['invitation'];
$invoice = $invitation->invoice;
$passwordHTML = isset($data['password'])?'<p>'.trans('texts.password').': '.$data['password'].'<p>':false;
$documentsHTML = '';
if($account->isPro() && $invoice->hasDocuments()){
$documentsHTML .= trans('texts.email_documents_header').'<ul>';
foreach($invoice->documents as $document){
$documentsHTML .= '<li><a href="'.HTML::entities($document->getClientUrl($invitation)).'">'.HTML::entities($document->name).'</a></li>';
}
foreach($invoice->expenses as $expense){
foreach($expense->documents as $document){
$documentsHTML .= '<li><a href="'.HTML::entities($document->getClientUrl($invitation)).'">'.HTML::entities($document->name).'</a></li>';
}
}
$documentsHTML .= '</ul>';
}
$variables = [
'$footer' => $account->getEmailFooter(),
@ -285,6 +325,7 @@ class ContactMailer extends Mailer
'$customClient2' => $account->custom_client_label2,
'$customInvoice1' => $account->custom_invoice_text_label1,
'$customInvoice2' => $account->custom_invoice_text_label2,
'$documents' => $documentsHTML,
];
// Add variables for available payment types

View File

@ -44,6 +44,13 @@ class Mailer
if (!empty($data['pdfString']) && !empty($data['pdfFileName'])) {
$message->attachData($data['pdfString'], $data['pdfFileName']);
}
// Attach documents to the email
if(!empty($data['documents'])){
foreach($data['documents'] as $document){
$message->attachData($document['data'], $document['name']);
}
}
});
return $this->handleSuccess($response, $data);

View File

@ -16,14 +16,9 @@ class ExpensePresenter extends Presenter {
return Utils::fromSqlDate($this->entity->expense_date);
}
public function converted_amount()
{
return round($this->entity->amount * $this->entity->exchange_rate, 2);
}
public function invoiced_amount()
{
return $this->entity->invoice_id ? $this->converted_amount() : 0;
return $this->entity->invoice_id ? $this->entity->convertedAmount() : 0;
}
public function link()

View File

@ -475,7 +475,7 @@ class AccountRepository
$item->account_id = $user->account->id;
$item->account_name = $user->account->getDisplayName();
$item->pro_plan_paid = $user->account->pro_plan_paid;
$item->logo_path = $user->account->hasLogo() ? $user->account->getLogoPath() : null;
$item->logo_url = $user->account->hasLogo() ? $user->account->getLogoUrl() : null;
$data[] = $item;
}

View File

@ -0,0 +1,231 @@
<?php namespace app\Ninja\Repositories;
use DB;
use Utils;
use Response;
use App\Models\Document;
use App\Ninja\Repositories\BaseRepository;
use Intervention\Image\ImageManager;
use Session;
use Form;
class DocumentRepository extends BaseRepository
{
// Expenses
public function getClassName()
{
return 'App\Models\Document';
}
public function all()
{
return Document::scope()
->with('user')
->get();
}
public function find()
{
$accountid = \Auth::user()->account_id;
$query = DB::table('clients')
->join('accounts', 'accounts.id', '=', 'clients.account_id')
->leftjoin('clients', 'clients.id', '=', 'clients.client_id')
/*->leftJoin('expenses', 'expenses.id', '=', 'clients.expense_id')
->leftJoin('invoices', 'invoices.id', '=', 'clients.invoice_id')*/
->where('documents.account_id', '=', $accountid)
/*->where('vendors.deleted_at', '=', null)
->where('clients.deleted_at', '=', null)*/
->select(
'documents.account_id',
'documents.path',
'documents.deleted_at',
'documents.size',
'documents.width',
'documents.height',
'documents.id',
'documents.is_deleted',
'documents.public_id',
'documents.invoice_id',
'documents.expense_id',
'documents.user_id',
'invoices.public_id as invoice_public_id',
'invoices.user_id as invoice_user_id',
'expenses.public_id as expense_public_id',
'expenses.user_id as expense_user_id'
);
return $query;
}
public function upload($uploaded, &$doc_array=null)
{
$extension = strtolower($uploaded->getClientOriginalExtension());
if(empty(Document::$types[$extension]) && !empty(Document::$extraExtensions[$extension])){
$documentType = Document::$extraExtensions[$extension];
}
else{
$documentType = $extension;
}
if(empty(Document::$types[$documentType])){
return 'Unsupported file type';
}
$documentTypeData = Document::$types[$documentType];
$filePath = $uploaded->path();
$name = $uploaded->getClientOriginalName();
$size = filesize($filePath);
if($size/1000 > MAX_DOCUMENT_SIZE){
return 'File too large';
}
$hash = sha1_file($filePath);
$filename = \Auth::user()->account->account_key.'/'.$hash.'.'.$documentType;
$document = Document::createNew();
$disk = $document->getDisk();
if(!$disk->exists($filename)){// Have we already stored the same file
$stream = fopen($filePath, 'r');
$disk->getDriver()->putStream($filename, $stream, ['mimetype'=>$documentTypeData['mime']]);
fclose($stream);
}
// This is an image; check if we need to create a preview
if(in_array($documentType, array('jpeg','png','gif','bmp','tiff','psd'))){
$makePreview = false;
$imageSize = getimagesize($filePath);
$width = $imageSize[0];
$height = $imageSize[1];
$imgManagerConfig = array();
if(in_array($documentType, array('gif','bmp','tiff','psd'))){
// Needs to be converted
$makePreview = true;
} else if($width > DOCUMENT_PREVIEW_SIZE || $height > DOCUMENT_PREVIEW_SIZE){
$makePreview = true;
}
if(in_array($documentType,array('bmp','tiff','psd'))){
if(!class_exists('Imagick')){
// Cant't read this
$makePreview = false;
} else {
$imgManagerConfig['driver'] = 'imagick';
}
}
if($makePreview){
$previewType = 'jpeg';
if(in_array($documentType, array('png','gif','tiff','psd'))){
// Has transparency
$previewType = 'png';
}
$document->preview = \Auth::user()->account->account_key.'/'.$hash.'.'.$documentType.'.x'.DOCUMENT_PREVIEW_SIZE.'.'.$previewType;
if(!$disk->exists($document->preview)){
// We haven't created a preview yet
$imgManager = new ImageManager($imgManagerConfig);
$img = $imgManager->make($filePath);
if($width <= DOCUMENT_PREVIEW_SIZE && $height <= DOCUMENT_PREVIEW_SIZE){
$previewWidth = $width;
$previewHeight = $height;
} else if($width > $height) {
$previewWidth = DOCUMENT_PREVIEW_SIZE;
$previewHeight = $height * DOCUMENT_PREVIEW_SIZE / $width;
} else {
$previewHeight = DOCUMENT_PREVIEW_SIZE;
$previewWidth = $width * DOCUMENT_PREVIEW_SIZE / $height;
}
$img->resize($previewWidth, $previewHeight);
$previewContent = (string) $img->encode($previewType);
$disk->put($document->preview, $previewContent);
$base64 = base64_encode($previewContent);
}
else{
$base64 = base64_encode($disk->get($document->preview));
}
}else{
$base64 = base64_encode(file_get_contents($filePath));
}
}
$document->path = $filename;
$document->type = $documentType;
$document->size = $size;
$document->hash = $hash;
$document->name = substr($name, -255);
if(!empty($imageSize)){
$document->width = $imageSize[0];
$document->height = $imageSize[1];
}
$document->save();
$doc_array = $document->toArray();
if(!empty($base64)){
$mime = Document::$types[!empty($previewType)?$previewType:$documentType]['mime'];
$doc_array['base64'] = 'data:'.$mime.';base64,'.$base64;
}
return $document;
}
public function getClientDatatable($contactId, $entityType, $search)
{
$query = DB::table('invitations')
->join('accounts', 'accounts.id', '=', 'invitations.account_id')
->join('invoices', 'invoices.id', '=', 'invitations.invoice_id')
->join('expenses', 'expenses.invoice_id', '=', 'invitations.invoice_id')
->join('documents', function($join){
$join->on('documents.invoice_id', '=', 'invitations.invoice_id')->orOn('documents.expense_id', '=', 'expenses.id');
})
->join('clients', 'clients.id', '=', 'invoices.client_id')
->where('invitations.contact_id', '=', $contactId)
->where('invitations.deleted_at', '=', null)
->where('invoices.is_deleted', '=', false)
->where('clients.deleted_at', '=', null)
->where('invoices.is_recurring', '=', false)
// This needs to be a setting to also hide the activity on the dashboard page
//->where('invoices.invoice_status_id', '>=', INVOICE_STATUS_SENT)
->select(
'invitations.invitation_key',
'invoices.invoice_number',
'documents.name',
'documents.public_id',
'documents.created_at',
'documents.size'
);
$table = \Datatable::query($query)
->addColumn('invoice_number', function ($model) {
return link_to(
'/view/'.$model->invitation_key,
$model->invoice_number
)->toHtml();
})
->addColumn('name', function ($model) {
return link_to(
'/client/document/'.$model->invitation_key.'/'.$model->public_id.'/'.$model->name,
$model->name,
['target'=>'_blank']
)->toHtml();
})
->addColumn('document_date', function ($model) {
return Utils::fromSqlDate($model->created_at);
})
->addColumn('document_size', function ($model) {
return Form::human_filesize($model->size);
});
return $table->make();
}
}

View File

@ -4,17 +4,25 @@ use DB;
use Utils;
use App\Models\Expense;
use App\Models\Vendor;
use App\Models\Document;
use App\Ninja\Repositories\BaseRepository;
use Session;
class ExpenseRepository extends BaseRepository
{
protected $documentRepo;
// Expenses
public function getClassName()
{
return 'App\Models\Expense';
}
public function __construct(DocumentRepository $documentRepo)
{
$this->documentRepo = $documentRepo;
}
public function all()
{
return Expense::scope()
@ -113,7 +121,7 @@ class ExpenseRepository extends BaseRepository
return $query;
}
public function save($input)
public function save($input, $checkSubPermissions=false)
{
$publicId = isset($input['public_id']) ? $input['public_id'] : false;
@ -147,6 +155,43 @@ class ExpenseRepository extends BaseRepository
$expense->save();
// Documents
$document_ids = !empty($input['document_ids'])?array_map('intval', $input['document_ids']):array();;
foreach ($document_ids as $document_id){
$document = Document::scope($document_id)->first();
if($document && !$checkSubPermissions || $document->canEdit()){
$document->invoice_id = null;
$document->expense_id = $expense->id;
$document->save();
}
}
if(!empty($input['documents']) && Document::canCreate()){
// Fallback upload
$doc_errors = array();
foreach($input['documents'] as $upload){
$result = $this->documentRepo->upload($upload);
if(is_string($result)){
$doc_errors[] = $result;
}
else{
$result->expense_id = $expense->id;
$result->save();
$document_ids[] = $result->public_id;
}
}
if(!empty($doc_errors)){
Session::flash('error', implode('<br>',array_map('htmlentities',$doc_errors)));
}
}
foreach ($expense->documents as $document){
if(!in_array($document->public_id, $document_ids)){
// Not checking permissions; deleting a document is just editing the invoice
$document->delete();
}
}
return $expense;
}

View File

@ -2,24 +2,29 @@
use DB;
use Utils;
use Session;
use App\Models\Invoice;
use App\Models\InvoiceItem;
use App\Models\Invitation;
use App\Models\Product;
use App\Models\Task;
use App\Models\Document;
use App\Models\Expense;
use App\Services\PaymentService;
use App\Ninja\Repositories\BaseRepository;
class InvoiceRepository extends BaseRepository
{
protected $documentRepo;
public function getClassName()
{
return 'App\Models\Invoice';
}
public function __construct(PaymentService $paymentService)
public function __construct(PaymentService $paymentService, DocumentRepository $documentRepo)
{
$this->documentRepo = $documentRepo;
$this->paymentService = $paymentService;
}
@ -398,6 +403,53 @@ class InvoiceRepository extends BaseRepository
$invoice->invoice_items()->forceDelete();
}
$document_ids = !empty($data['document_ids'])?array_map('intval', $data['document_ids']):array();;
foreach ($document_ids as $document_id){
$document = Document::scope($document_id)->first();
if($document && !$checkSubPermissions || $document->canEdit()){
if($document->invoice_id && $document->invoice_id != $invoice->id){
// From a clone
$document = $document->cloneDocument();
$document_ids[] = $document->public_id;// Don't remove this document
}
$document->invoice_id = $invoice->id;
$document->expense_id = null;
$document->save();
}
}
if(!empty($data['documents']) && Document::canCreate()){
// Fallback upload
$doc_errors = array();
foreach($data['documents'] as $upload){
$result = $this->documentRepo->upload($upload);
if(is_string($result)){
$doc_errors[] = $result;
}
else{
$result->invoice_id = $invoice->id;
$result->save();
$document_ids[] = $result->public_id;
}
}
if(!empty($doc_errors)){
Session::flash('error', implode('<br>',array_map('htmlentities',$doc_errors)));
}
}
foreach ($invoice->documents as $document){
if(!in_array($document->public_id, $document_ids)){
// Removed
// Not checking permissions; deleting a document is just editing the invoice
if($document->invoice_id == $invoice->id){
// Make sure the document isn't on a clone
$document->delete();
}
}
}
foreach ($data['invoice_items'] as $item) {
$item = (array) $item;
if (empty($item['cost']) && empty($item['product_key']) && empty($item['notes']) && empty($item['custom_value1']) && empty($item['custom_value2'])) {
@ -553,6 +605,11 @@ class InvoiceRepository extends BaseRepository
$clone->invoice_items()->save($cloneItem);
}
foreach ($invoice->documents as $document) {
$cloneDocument = $document->cloneDocument();
$invoice->documents()->save($cloneDocument);
}
foreach ($invoice->invitations as $invitation) {
$cloneInvitation = Invitation::createNew($invoice);
$cloneInvitation->contact_id = $invitation->contact_id;
@ -581,7 +638,7 @@ class InvoiceRepository extends BaseRepository
return false;
}
$invoice->load('user', 'invoice_items', 'invoice_design', 'account.country', 'client.contacts', 'client.country');
$invoice->load('user', 'invoice_items', 'documents', 'invoice_design', 'account.country', 'client.contacts', 'client.country');
$client = $invoice->client;
if (!$client || $client->is_deleted) {
@ -657,6 +714,11 @@ class InvoiceRepository extends BaseRepository
$invoice->invoice_items()->save($item);
}
foreach ($recurInvoice->documents as $recurDocument) {
$document = $recurDocument->cloneDocument();
$invoice->documents()->save($document);
}
foreach ($recurInvoice->invitations as $recurInvitation) {
$invitation = Invitation::createNew($recurInvitation);
$invitation->contact_id = $recurInvitation->contact_id;

View File

@ -22,8 +22,15 @@ class AppServiceProvider extends ServiceProvider {
*/
public function boot()
{
Form::macro('image_data', function($imagePath) {
return 'data:image/jpeg;base64,' . base64_encode(file_get_contents($imagePath));
Form::macro('image_data', function($image, $contents = false) {
if(!$contents){
$contents = file_get_contents($image);
}
else{
$contents = $image;
}
return 'data:image/jpeg;base64,' . base64_encode($contents);
});
Form::macro('nav_link', function($url, $text, $url2 = '', $extra = '') {
@ -152,6 +159,13 @@ class AppServiceProvider extends ServiceProvider {
return $str . '</ol>';
});
Form::macro('human_filesize', function($bytes, $decimals = 1) {
$size = array('B','kB','MB','GB','TB','PB','EB','ZB','YB');
$factor = floor((strlen($bytes) - 1) / 3);
if($factor == 0)$decimals=0;// There aren't fractional bytes
return sprintf("%.{$decimals}f", $bytes / pow(1024, $factor)) . ' ' . @$size[$factor];
});
Validator::extend('positive', function($attribute, $value, $parameters) {
return Utils::parseFloat($value) >= 0;
});

View File

@ -28,7 +28,7 @@ class ExpenseService extends BaseService
return $this->expenseRepo;
}
public function save($data)
public function save($data, $checkSubPermissions=false)
{
if (isset($data['client_id']) && $data['client_id']) {
$data['client_id'] = Client::getPrivateId($data['client_id']);
@ -38,7 +38,7 @@ class ExpenseService extends BaseService
$data['vendor_id'] = Vendor::getPrivateId($data['vendor_id']);
}
return $this->expenseRepo->save($data);
return $this->expenseRepo->save($data, $checkSubPermissions);
}
public function getDatatable($search)

View File

@ -26,7 +26,8 @@
"quill": "~0.20.0",
"datetimepicker": "~2.4.5",
"stacktrace-js": "~1.0.1",
"fuse.js": "~2.0.2"
"fuse.js": "~2.0.2",
"dropzone": "~4.3.0"
},
"resolutions": {
"jquery": "~1.11"

View File

@ -66,7 +66,10 @@
"maatwebsite/excel": "~2.0",
"ezyang/htmlpurifier": "~v4.7",
"cerdic/css-tidy": "~v1.5",
"asgrim/ofxparser": "^1.1"
"asgrim/ofxparser": "^1.1",
"league/flysystem-aws-s3-v3": "~1.0",
"league/flysystem-rackspace": "~1.0",
"barracudanetworks/archivestream-php": "^1.0"
},
"require-dev": {
"phpunit/phpunit": "~4.0",

359
composer.lock generated
View File

@ -4,8 +4,8 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file",
"This file is @generated automatically"
],
"hash": "e5e8524886bd38794a15e406acc3745a",
"content-hash": "6b3f343959ba3f330c425325574dfe28",
"hash": "5b6b0afc0a26fa2b1dc48241fd665c1e",
"content-hash": "33f39a8d05247b96374ed20e95499936",
"packages": [
{
"name": "agmscode/omnipay-agms",
@ -321,6 +321,126 @@
],
"time": "2015-12-11 11:08:57"
},
{
"name": "aws/aws-sdk-php",
"version": "3.17.1",
"source": {
"type": "git",
"url": "https://github.com/aws/aws-sdk-php.git",
"reference": "f8c0cc9357e10896a5c57104f2c79d1b727d97d0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/f8c0cc9357e10896a5c57104f2c79d1b727d97d0",
"reference": "f8c0cc9357e10896a5c57104f2c79d1b727d97d0",
"shasum": ""
},
"require": {
"guzzlehttp/guzzle": "~5.3|~6.0.1|~6.1",
"guzzlehttp/promises": "~1.0",
"guzzlehttp/psr7": "~1.0",
"mtdowling/jmespath.php": "~2.2",
"php": ">=5.5"
},
"require-dev": {
"andrewsville/php-token-reflection": "^1.4",
"aws/aws-php-sns-message-validator": "~1.0",
"behat/behat": "~3.0",
"doctrine/cache": "~1.4",
"ext-dom": "*",
"ext-json": "*",
"ext-openssl": "*",
"ext-pcre": "*",
"ext-simplexml": "*",
"ext-spl": "*",
"nette/neon": "^2.3",
"phpunit/phpunit": "~4.0|~5.0",
"psr/cache": "^1.0"
},
"suggest": {
"aws/aws-php-sns-message-validator": "To validate incoming SNS notifications",
"doctrine/cache": "To use the DoctrineCacheAdapter",
"ext-curl": "To send requests using cURL",
"ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.0-dev"
}
},
"autoload": {
"psr-4": {
"Aws\\": "src/"
},
"files": [
"src/functions.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "Amazon Web Services",
"homepage": "http://aws.amazon.com"
}
],
"description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project",
"homepage": "http://aws.amazon.com/sdkforphp",
"keywords": [
"amazon",
"aws",
"cloud",
"dynamodb",
"ec2",
"glacier",
"s3",
"sdk"
],
"time": "2016-03-22 19:19:22"
},
{
"name": "barracudanetworks/archivestream-php",
"version": "1.0.2",
"source": {
"type": "git",
"url": "https://github.com/barracudanetworks/ArchiveStream-php.git",
"reference": "9a81c7de7f0cd5ea2150fc3dc00f1c43178362b6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/barracudanetworks/ArchiveStream-php/zipball/9a81c7de7f0cd5ea2150fc3dc00f1c43178362b6",
"reference": "9a81c7de7f0cd5ea2150fc3dc00f1c43178362b6",
"shasum": ""
},
"require": {
"ext-gmp": "*",
"ext-mbstring": "*",
"php": ">=5.1.2"
},
"type": "library",
"autoload": {
"psr-4": {
"Barracuda\\ArchiveStream\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "A library for dynamically streaming dynamic tar or zip files without the need to have the complete file stored on the server.",
"homepage": "https://github.com/barracudanetworks/ArchiveStream-php",
"keywords": [
"archive",
"php",
"stream",
"tar",
"zip"
],
"time": "2016-01-07 06:02:26"
},
{
"name": "barryvdh/laravel-debugbar",
"version": "v2.2.0",
@ -579,7 +699,7 @@
"laravel"
],
"abandoned": "OpenSkill/Datatable",
"time": "2015-04-29 07:00:36"
"time": "2015-11-23 21:33:41"
},
{
"name": "classpreloader/classpreloader",
@ -3056,6 +3176,100 @@
],
"time": "2016-03-14 21:54:11"
},
{
"name": "league/flysystem-aws-s3-v3",
"version": "1.0.9",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git",
"reference": "595e24678bf78f8107ebc9355d8376ae0eb712c6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/595e24678bf78f8107ebc9355d8376ae0eb712c6",
"reference": "595e24678bf78f8107ebc9355d8376ae0eb712c6",
"shasum": ""
},
"require": {
"aws/aws-sdk-php": "^3.0.0",
"league/flysystem": "~1.0",
"php": ">=5.5.0"
},
"require-dev": {
"henrikbjorn/phpspec-code-coverage": "~1.0.1",
"phpspec/phpspec": "^2.0.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0-dev"
}
},
"autoload": {
"psr-4": {
"League\\Flysystem\\AwsS3v3\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Frank de Jonge",
"email": "info@frenky.net"
}
],
"description": "Flysystem adapter for the AWS S3 SDK v3.x",
"time": "2015-11-19 08:44:16"
},
{
"name": "league/flysystem-rackspace",
"version": "1.0.5",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/flysystem-rackspace.git",
"reference": "ba877e837f5dce60e78a0555de37eb9bfc7dd6b9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/flysystem-rackspace/zipball/ba877e837f5dce60e78a0555de37eb9bfc7dd6b9",
"reference": "ba877e837f5dce60e78a0555de37eb9bfc7dd6b9",
"shasum": ""
},
"require": {
"league/flysystem": "~1.0",
"php": ">=5.4.0",
"rackspace/php-opencloud": "~1.16"
},
"require-dev": {
"mockery/mockery": "0.9.*",
"phpunit/phpunit": "~4.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0-dev"
}
},
"autoload": {
"psr-4": {
"League\\Flysystem\\Rackspace\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Frank de Jonge",
"email": "info@frenky.net"
}
],
"description": "Flysystem adapter for Rackspace",
"time": "2016-03-11 12:13:42"
},
{
"name": "league/fractal",
"version": "0.13.0",
@ -3585,6 +3799,33 @@
],
"time": "2015-07-14 19:53:54"
},
{
"name": "mikemccabe/json-patch-php",
"version": "0.1.0",
"source": {
"type": "git",
"url": "https://github.com/mikemccabe/json-patch-php.git",
"reference": "b3af30a6aec7f6467c773cd49b2d974a70f7c0d4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/mikemccabe/json-patch-php/zipball/b3af30a6aec7f6467c773cd49b2d974a70f7c0d4",
"reference": "b3af30a6aec7f6467c773cd49b2d974a70f7c0d4",
"shasum": ""
},
"type": "library",
"autoload": {
"psr-4": {
"mikemccabe\\JsonPatch\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-3.0"
],
"description": "Produce and apply json-patch objects",
"time": "2015-01-05 21:19:54"
},
{
"name": "monolog/monolog",
"version": "1.18.1",
@ -3707,6 +3948,61 @@
],
"time": "2016-01-26 21:23:30"
},
{
"name": "mtdowling/jmespath.php",
"version": "2.3.0",
"source": {
"type": "git",
"url": "https://github.com/jmespath/jmespath.php.git",
"reference": "192f93e43c2c97acde7694993ab171b3de284093"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/192f93e43c2c97acde7694993ab171b3de284093",
"reference": "192f93e43c2c97acde7694993ab171b3de284093",
"shasum": ""
},
"require": {
"php": ">=5.4.0"
},
"require-dev": {
"phpunit/phpunit": "~4.0"
},
"bin": [
"bin/jp.php"
],
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.0-dev"
}
},
"autoload": {
"psr-4": {
"JmesPath\\": "src/"
},
"files": [
"src/JmesPath.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Michael Dowling",
"email": "mtdowling@gmail.com",
"homepage": "https://github.com/mtdowling"
}
],
"description": "Declaratively specify how to extract elements from a JSON document",
"keywords": [
"json",
"jsonpath"
],
"time": "2016-01-05 18:25:05"
},
{
"name": "nesbot/carbon",
"version": "1.21.0",
@ -5906,6 +6202,63 @@
],
"time": "2016-03-09 05:03:14"
},
{
"name": "rackspace/php-opencloud",
"version": "v1.16.0",
"source": {
"type": "git",
"url": "https://github.com/rackspace/php-opencloud.git",
"reference": "d6b71feed7f9e7a4b52e0240a79f06473ba69c8c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/rackspace/php-opencloud/zipball/d6b71feed7f9e7a4b52e0240a79f06473ba69c8c",
"reference": "d6b71feed7f9e7a4b52e0240a79f06473ba69c8c",
"shasum": ""
},
"require": {
"guzzle/guzzle": "~3.8",
"mikemccabe/json-patch-php": "~0.1",
"php": ">=5.4",
"psr/log": "~1.0"
},
"require-dev": {
"apigen/apigen": "~4.0",
"fabpot/php-cs-fixer": "1.0.*@dev",
"jakub-onderka/php-parallel-lint": "0.*",
"phpspec/prophecy": "~1.4",
"phpunit/phpunit": "4.3.*",
"satooshi/php-coveralls": "0.6.*@dev"
},
"type": "library",
"autoload": {
"psr-0": {
"OpenCloud": [
"lib/"
]
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "Jamie Hannaford",
"email": "jamie.hannaford@rackspace.com",
"homepage": "https://github.com/jamiehannaford"
}
],
"description": "PHP SDK for Rackspace/OpenStack APIs",
"keywords": [
"Openstack",
"nova",
"opencloud",
"rackspace",
"swift"
],
"time": "2016-01-29 10:34:57"
},
{
"name": "samvaughton/omnipay-barclays-epdq",
"version": "2.2.0",

View File

@ -48,22 +48,32 @@ return [
'root' => storage_path().'/app',
],
'logos' => [
'driver' => 'local',
'root' => env('LOGO_PATH', public_path().'/logo'),
],
'documents' => [
'driver' => 'local',
'root' => storage_path().'/documents',
],
's3' => [
'driver' => 's3',
'key' => 'your-key',
'secret' => 'your-secret',
'region' => 'your-region',
'bucket' => 'your-bucket',
'key' => env('S3_KEY', ''),
'secret' => env('S3_SECRET', ''),
'region' => env('S3_REGION', 'us-east-1'),
'bucket' => env('S3_BUCKET', ''),
],
'rackspace' => [
'driver' => 'rackspace',
'username' => 'your-username',
'key' => 'your-key',
'container' => 'your-container',
'endpoint' => 'https://identity.api.rackspacecloud.com/v2.0/',
'region' => 'IAD',
'url_type' => 'publicURL'
'username' => env('RACKSPACE_USERNAME', ''),
'key' => env('RACKSPACE_KEY', ''),
'container' => env('RACKSPACE_CONTAINER', ''),
'endpoint' => env('RACKSPACE_ENDPOINT', 'https://identity.api.rackspacecloud.com/v2.0/'),
'region' => env('RACKSPACE_REGION', 'IAD'),
'url_type' => env('RACKSPACE_URL_TYPE', 'publicURL')
],
],

View File

@ -0,0 +1,70 @@
<?php
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class AddDocuments extends Migration {
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('accounts', function($table) {
$table->string('logo')->nullable()->default(null);
$table->unsignedInteger('logo_width');
$table->unsignedInteger('logo_height');
$table->unsignedInteger('logo_size');
$table->boolean('invoice_embed_documents')->default(1);
$table->boolean('document_email_attachment')->default(1);
});
DB::table('accounts')->update(array('logo' => ''));
Schema::dropIfExists('documents');
Schema::create('documents', function($t)
{
$t->increments('id');
$t->unsignedInteger('public_id')->nullable();
$t->unsignedInteger('account_id');
$t->unsignedInteger('user_id');
$t->unsignedInteger('invoice_id')->nullable();
$t->unsignedInteger('expense_id')->nullable();
$t->string('path');
$t->string('preview');
$t->string('name');
$t->string('type');
$t->string('disk');
$t->string('hash', 40);
$t->unsignedInteger('size');
$t->unsignedInteger('width')->nullable();
$t->unsignedInteger('height')->nullable();
$t->timestamps();
$t->foreign('account_id')->references('id')->on('accounts');
$t->foreign('user_id')->references('id')->on('users');
$t->foreign('invoice_id')->references('id')->on('invoices');
$t->foreign('expense_id')->references('id')->on('expenses');
$t->unique( array('account_id','public_id') );
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('accounts', function($table) {
$table->dropColumn('logo');
$table->dropColumn('logo_width');
$table->dropColumn('logo_height');
$table->dropColumn('logo_size');
$table->dropColumn('invoice_embed_documents');
});
Schema::dropIfExists('documents');
}
}

File diff suppressed because one or more lines are too long

41
public/css/built.css vendored

File diff suppressed because one or more lines are too long

39
public/css/style.css vendored
View File

@ -1062,3 +1062,42 @@ td.right {
div.panel-body div.panel-body {
padding-bottom: 0px;
}
/* Attached Documents */
#document-upload {
border:1px solid #ebe7e7;
background:#f9f9f9 !important;
border-radius:3px;
padding:20px;
}
.invoice-table #document-upload{
max-width:560px;
}
#document-upload .dropzone{
background:none;
border:none;
padding:0;
}
.dropzone .dz-preview.dz-image-preview{
background:none;
}
.dropzone .dz-preview .dz-image{
border-radius:5px!important;
}
.dropzone .dz-preview.dz-image-preview .dz-image img{
object-fit: cover;
width: 100%;
height: 100%;
}
.dropzone .fallback-doc{
display:none;
}
.dropzone.dz-browser-not-supported .fallback-doc{
display:block;
}

View File

@ -109,11 +109,12 @@ function GetPdfMake(invoice, javascript, callback) {
function addFont(font){
if(window.ninjaFontVfs[font.folder]){
folder = 'fonts/'+font.folder;
pdfMake.fonts[font.name] = {
normal: font.folder+'/'+font.normal,
italics: font.folder+'/'+font.italics,
bold: font.folder+'/'+font.bold,
bolditalics: font.folder+'/'+font.bolditalics
normal: folder+'/'+font.normal,
italics: folder+'/'+font.italics,
bold: folder+'/'+font.bold,
bolditalics: folder+'/'+font.bolditalics
}
}
}
@ -144,6 +145,7 @@ NINJA.decodeJavascript = function(invoice, javascript)
'invoiceDetailsHeight': (NINJA.invoiceDetails(invoice).length * 16) + 16,
'invoiceLineItems': NINJA.invoiceLines(invoice),
'invoiceLineItemColumns': NINJA.invoiceColumns(invoice),
'invoiceDocuments' : NINJA.invoiceDocuments(invoice),
'quantityWidth': NINJA.quantityWidth(invoice),
'taxWidth': NINJA.taxWidth(invoice),
'clientDetails': NINJA.clientDetails(invoice),
@ -401,6 +403,39 @@ NINJA.invoiceLines = function(invoice) {
return NINJA.prepareDataTable(grid, 'invoiceItems');
}
NINJA.invoiceDocuments = function(invoice) {
if(!invoice.account.invoice_embed_documents)return[];
var stack = [];
var stackItem = null;
var j = 0;
for (var i = 0; i < invoice.documents.length; i++)addDoc(invoice.documents[i]);
if(invoice.expenses){
for (var i = 0; i < invoice.expenses.length; i++) {
var expense = invoice.expenses[i];
for (var i = 0; i < expense.documents.length; i++)addDoc(expense.documents[i]);
}
}
function addDoc(document){
var path = document.base64;
if(!path)path = 'docs/'+document.public_id+'/'+document.name;
if(path && (window.pdfMake.vfs[path] || document.base64)){
// Only embed if we actually have an image for it
if(j%3==0){
stackItem = {columns:[]};
stack.push(stackItem);
}
stackItem.columns.push({stack:[{image:path,style:'invoiceDocument',fit:[150,150]}], width:175})
j++;
}
}
return stack.length?{stack:stack}:[];
}
NINJA.subtotals = function(invoice, hideBalance)
{
if (!invoice) {

View File

@ -3,7 +3,12 @@ if(window.ninjaFontVfs)ninjaLoadFontVfs();
function ninjaLoadFontVfs(){
jQuery.each(window.ninjaFontVfs, function(font, files){
jQuery.each(files, function(filename, file){
window.pdfMake.vfs[font+'/'+filename] = file;
window.pdfMake.vfs['fonts/'+font+'/'+filename] = file;
});
})
}
function ninjaAddVFSDoc(name,content){
window.pdfMake.vfs['docs/'+name] = content;
if(window.refreshPDF)refreshPDF(true);
jQuery(document).trigger('ninjaVFSDocAdded');
}

View File

@ -7926,7 +7926,12 @@ if(window.ninjaFontVfs)ninjaLoadFontVfs();
function ninjaLoadFontVfs(){
jQuery.each(window.ninjaFontVfs, function(font, files){
jQuery.each(files, function(filename, file){
window.pdfMake.vfs[font+'/'+filename] = file;
window.pdfMake.vfs['fonts/'+font+'/'+filename] = file;
});
})
}
function ninjaAddVFSDoc(name,content){
window.pdfMake.vfs['docs/'+name] = content;
if(window.refreshPDF)refreshPDF(true);
jQuery(document).trigger('ninjaVFSDocAdded');
}

View File

@ -1097,6 +1097,32 @@ $LANG = array(
'november' => 'November',
'december' => 'December',
// Documents
'documents_header' => 'Documents:',
'email_documents_header' => 'Documents:',
'email_documents_example_1' => 'Widgets Receipt.pdf',
'email_documents_example_2' => 'Final Deliverable.zip',
'invoice_documents' => 'Attached Documents',
'expense_documents' => 'Attached Documents',
'invoice_embed_documents' => 'Embed Documents',
'invoice_embed_documents_help' => 'Include attached images in the invoice.',
'document_email_attachment' => 'Attach Documents',
'download_documents' => 'Download Documents (:size)',
'documents_from_expenses' => 'From Expenses:',
'dropzone' => array(// See http://www.dropzonejs.com/#config-dictDefaultMessage
'DefaultMessage' => 'Drop files or click to upload',
'FallbackMessage' => 'Your browser does not support drag\'n\'drop file uploads.',
'FallbackText' => 'Please use the fallback form below to upload your files like in the olden days.',
'FileTooBig' => 'File is too big ({{filesize}}MiB). Max filesize: {{maxFilesize}}MiB.',
'InvalidFileType' => 'You can\'t upload files of this type.',
'ResponseError' => 'Server responded with {{statusCode}} code.',
'CancelUpload' => 'Cancel upload',
'CancelUploadConfirmation' => 'Are you sure you want to cancel this upload?',
'RemoveFile' => 'Remove file',
),
'documents' => 'Documents',
'document_date' => 'Document Date',
'document_size' => 'Size',
);
return $LANG;

View File

@ -51,8 +51,8 @@
<div class="form-group">
<div class="col-lg-4 col-sm-4"></div>
<div class="col-lg-8 col-sm-8">
<a href="/{{ $account->getLogoPath().'?no_cache='.time() }}" target="_blank">
{!! HTML::image($account->getLogoPath().'?no_cache='.time(), 'Logo', ['max-width' => 200]) !!}
<a href="{{ $account->getLogoUrl().'?no_cache='.time() }}" target="_blank">
{!! HTML::image($account->getLogoUrl().'?no_cache='.time(), 'Logo', ['max-width' => 200]) !!}
</a> &nbsp;
<a href="#" onclick="deleteLogo()">{{ trans('texts.remove_logo') }}</a>
</div>

View File

@ -19,6 +19,7 @@
])->addClass('warn-on-exit') !!}
{{ Former::populate($account) }}
{{ Former::populateField('pdf_email_attachment', intval($account->pdf_email_attachment)) }}
{{ Former::populateField('document_email_attachment', intval($account->document_email_attachment)) }}
{{ Former::populateField('enable_email_markup', intval($account->enable_email_markup)) }}
<div class="panel panel-default">
@ -27,6 +28,7 @@
</div>
<div class="panel-body form-padding-right">
{!! Former::checkbox('pdf_email_attachment')->text(trans('texts.enable')) !!}
{!! Former::checkbox('document_email_attachment')->text(trans('texts.enable')) !!}
&nbsp;

View File

@ -48,6 +48,7 @@
function getPDFString(cb) {
invoice.is_pro = {!! Auth::user()->isPro() ? 'true' : 'false' !!};
invoice.account.hide_quantity = $('#hide_quantity').is(":checked");
invoice.account.invoice_embed_documents = $('#invoice_embed_documents').is(":checked");
invoice.account.hide_paid_to_date = $('#hide_paid_to_date').is(":checked");
invoice.invoice_design_id = $('#invoice_design_id').val();
@ -207,6 +208,7 @@
{!! Former::checkbox('hide_quantity')->text(trans('texts.hide_quantity_help')) !!}
{!! Former::checkbox('hide_paid_to_date')->text(trans('texts.hide_paid_to_date_help')) !!}
{!! Former::checkbox('invoice_embed_documents')->text(trans('texts.invoice_embed_documents_help')) !!}
</div>
</div>

View File

@ -201,6 +201,13 @@
var keys = {!! json_encode(\App\Ninja\Mailers\ContactMailer::$variableFields) !!};
var passwordHtml = "{!! $account->isPro() && $account->enable_portal_password && $account->send_portal_password?'<p>'.trans('texts.password').': 6h2NWNdw6<p>':'' !!}";
@if ($account->isPro())
var documentsHtml = "{!! trans('texts.email_documents_header').'<ul><li><a>'.trans('texts.email_documents_example_1').'</a></li><li><a>'.trans('texts.email_documents_example_2').'</a></li></ul>' !!}";
@else
var documentsHtml = "";
@endif
var vals = [
{!! json_encode($emailFooter) !!},
"{{ $account->getDisplayName() }}",
@ -213,6 +220,7 @@
"0001",
"0001",
passwordHtml,
documentsHtml,
"{{ URL::to('/view/...') }}$password",
'{!! Form::flatButton('view_invoice', '#0b4d78') !!}$password',
"{{ URL::to('/payment/...') }}$password",

View File

@ -3,7 +3,7 @@
<a href="{{ $account->website }}" style="color: #19BB40; text-decoration: underline;">
@endif
<img src="{{ $message->embed($account->getLogoFullPath()) }}" style="max-height:50px; max-width:140px; margin-left: 33px;" />
<img src="{{ $message->embed($account->getLogoURL()) }}" style="max-height:50px; max-width:140px; margin-left: 33px;" />
@if ($account->website)
</a>

View File

@ -105,6 +105,27 @@
</div>
</div>
@if ($account->isPro())
<div clas="row">
<div class="col-md-2 col-sm-4"><div class="control-label" style="margin-bottom:10px;">{{trans('texts.expense_documents')}}</div></div>
<div class="col-md-12 col-sm-8">
<div role="tabpanel" class="tab-pane" id="attached-documents" style="position:relative;z-index:9">
<div id="document-upload" class="dropzone">
<div class="fallback">
<input name="documents[]" type="file" multiple />
</div>
<div data-bind="foreach: documents">
<div class="fallback-doc">
<a href="#" class="fallback-doc-remove" data-bind="click: $parent.removeDocument"><i class="fa fa-close"></i></a>
<span data-bind="text:name"></span>
<input type="hidden" name="document_ids[]" data-bind="value: public_id"/>
</div>
</div>
</div>
</div>
</div>
</div>
@endif
</div>
</div>
@ -122,6 +143,7 @@
{!! Former::close() !!}
<script type="text/javascript">
Dropzone.autoDiscover = false;
var vendors = {!! $vendors !!};
var clients = {!! $clients !!};
@ -194,6 +216,56 @@
@else
$('#amount').focus();
@endif
@if (Auth::user()->account->isPro())
$('.main-form').submit(function(){
if($('#document-upload .fallback input').val())$(this).attr('enctype', 'multipart/form-data')
else $(this).removeAttr('enctype')
})
// Initialize document upload
dropzone = new Dropzone('#document-upload', {
url:{!! json_encode(url('document')) !!},
params:{
_token:"{{ Session::getToken() }}"
},
acceptedFiles:{!! json_encode(implode(',',\App\Models\Document::$allowedMimes)) !!},
addRemoveLinks:true,
@foreach(trans('texts.dropzone') as $key=>$text)
"dict{{strval($key)}}":"{{strval($text)}}",
@endforeach
maxFileSize:{{floatval(MAX_DOCUMENT_SIZE/1000)}},
});
if(dropzone instanceof Dropzone){
dropzone.on("addedfile",handleDocumentAdded);
dropzone.on("removedfile",handleDocumentRemoved);
dropzone.on("success",handleDocumentUploaded);
for (var i=0; i<model.documents().length; i++) {
var document = model.documents()[i];
var mockFile = {
name:document.name(),
size:document.size(),
type:document.type(),
public_id:document.public_id(),
status:Dropzone.SUCCESS,
accepted:true,
url:document.preview_url()||document.url(),
mock:true,
index:i
};
dropzone.emit('addedfile', mockFile);
dropzone.emit('complete', mockFile);
if(document.preview_url()){
dropzone.emit('thumbnail', mockFile, document.preview_url()||document.url());
}
else if(document.type()=='jpeg' || document.type()=='png' || document.type()=='svg'){
dropzone.emit('thumbnail', mockFile, document.url());
}
dropzone.files.push(mockFile);
}
}
@endif
});
var ViewModel = function(data) {
@ -201,13 +273,22 @@
self.expense_currency_id = ko.observable();
self.invoice_currency_id = ko.observable();
self.documents = ko.observableArray();
self.amount = ko.observable();
self.exchange_rate = ko.observable(1);
self.should_be_invoiced = ko.observable();
self.convert_currency = ko.observable(false);
self.mapping = {
'documents': {
create: function(options) {
return new DocumentModel(options.data);
}
}
}
if (data) {
ko.mapping.fromJS(data, {}, this);
ko.mapping.fromJS(data, self.mapping, this);
}
self.account_currency_id = ko.observable({{ $account->getCurrencyId() }});
@ -250,8 +331,57 @@
|| invoiceCurrencyId != self.account_currency_id()
|| expenseCurrencyId != self.account_currency_id();
})
};
self.addDocument = function() {
var documentModel = new DocumentModel();
self.documents.push(documentModel);
return documentModel;
}
self.removeDocument = function(doc) {
var public_id = doc.public_id?doc.public_id():doc;
self.documents.remove(function(document) {
return document.public_id() == public_id;
});
}
};
function DocumentModel(data) {
var self = this;
self.public_id = ko.observable(0);
self.size = ko.observable(0);
self.name = ko.observable('');
self.type = ko.observable('');
self.url = ko.observable('');
self.update = function(data){
ko.mapping.fromJS(data, {}, this);
}
if (data) {
self.update(data);
}
}
@if (Auth::user()->account->isPro())
function handleDocumentAdded(file){
if(file.mock)return;
file.index = model.documents().length;
model.addDocument({name:file.name, size:file.size, type:file.type});
}
function handleDocumentRemoved(file){
model.removeDocument(file.public_id);
}
function handleDocumentUploaded(file, response){
file.public_id = response.document.public_id
model.documents()[file.index].update(response.document);
if(response.document.preview_url){
dropzone.emit('thumbnail', file, response.document.preview_url);
}
}
@endif
</script>
@stop

View File

@ -448,7 +448,7 @@
'user_id' => $item->user_id,
'account_name' => $item->account_name,
'user_name' => $item->user_name,
'logo_path' => isset($item->logo_path) ? $item->logo_path : "",
'logo_url' => isset($item->logo_url) ? $item->logo_url : "",
'selected' => true,
])
@endif
@ -460,7 +460,7 @@
'user_id' => $item->user_id,
'account_name' => $item->account_name,
'user_name' => $item->user_name,
'logo_path' => isset($item->logo_path) ? $item->logo_path : "",
'logo_url' => isset($item->logo_url) ? $item->logo_url : "",
'selected' => false,
])
@endif
@ -469,7 +469,7 @@
@include('user_account', [
'account_name' => Auth::user()->account->name ?: trans('texts.untitled'),
'user_name' => Auth::user()->getDisplayName(),
'logo_path' => Auth::user()->account->getLogoPath(),
'logo_url' => Auth::user()->account->getLogoURL(),
'selected' => true,
])
@endif

View File

@ -95,7 +95,7 @@
<div class="row">
<div class="col-md-3 logo">
@if ($account->hasLogo())
{!! HTML::image($account->getLogoPath()) !!}
{!! HTML::image($account->getLogoURL()) !!}
@else
<h2>{{ $account->name}}</h2>
@endif

View File

@ -38,7 +38,7 @@
{!! Former::open($url)
->method($method)
->addClass('warn-on-exit')
->addClass('warn-on-exit main-form')
->autocomplete('off')
->onsubmit('return onFormSubmit(event)')
->rules(array(
@ -273,6 +273,9 @@
<li role="presentation" class="active"><a href="#notes" aria-controls="notes" role="tab" data-toggle="tab">{{ trans('texts.note_to_client') }}</a></li>
<li role="presentation"><a href="#terms" aria-controls="terms" role="tab" data-toggle="tab">{{ trans("texts.{$entityType}_terms") }}</a></li>
<li role="presentation"><a href="#footer" aria-controls="footer" role="tab" data-toggle="tab">{{ trans("texts.{$entityType}_footer") }}</a></li>
@if ($account->isPro())
<li role="presentation"><a href="#attached-documents" aria-controls="attached-documents" role="tab" data-toggle="tab">{{ trans("texts.invoice_documents") }}</a></li>
@endif
</ul>
<div class="tab-content">
@ -304,6 +307,32 @@
</div>
</div>') !!}
</div>
@if ($account->isPro())
<div role="tabpanel" class="tab-pane" id="attached-documents" style="position:relative;z-index:9">
<div id="document-upload">
<div class="dropzone">
<div class="fallback">
<input name="documents[]" type="file" multiple />
</div>
<div data-bind="foreach: documents">
<div class="fallback-doc">
<a href="#" class="fallback-doc-remove" data-bind="click: $parent.removeDocument"><i class="fa fa-close"></i></a>
<span data-bind="text:name"></span>
<input type="hidden" name="document_ids[]" data-bind="value: public_id"/>
</div>
</div>
</div>
@if ($invoice->hasExpenseDocuments())
<h4>{{trans('texts.documents_from_expenses')}}</h4>
@foreach($invoice->expenses as $expense)
@foreach($expense->documents as $document)
<div>{{$document->name}}</div>
@endforeach
@endforeach
@endif
</div>
</div>
@endif
</div>
</div>
@ -681,10 +710,12 @@
@include('invoices.knockout')
<script type="text/javascript">
Dropzone.autoDiscover = false;
var products = {!! $products !!};
var clients = {!! $clients !!};
var account = {!! Auth::user()->account !!};
var dropzone;
var clientMap = {};
var $clientSelect = $('select#client');
@ -757,24 +788,24 @@
model.invoice().has_tasks(true);
@endif
@if (isset($expenses) && $expenses)
if(model.invoice().expenses() && !model.invoice().public_id()){
model.expense_currency_id({{ $expenseCurrencyId }});
// move the blank invoice line item to the end
var blank = model.invoice().invoice_items.pop();
var expenses = {!! $expenses !!};
var expenses = model.invoice().expenses();
for (var i=0; i<expenses.length; i++) {
var expense = expenses[i];
var item = model.invoice().addItem();
item.notes(expense.description);
item.qty(expense.qty);
item.expense_public_id(expense.publicId);
item.cost(expense.cost);
item.notes(expense.public_notes());
item.qty(1);
item.expense_public_id(expense.public_id());
item.cost(expense.converted_amount());
}
model.invoice().invoice_items.push(blank);
model.invoice().has_expenses(true);
@endif
}
@endif
@ -899,6 +930,56 @@
@endif
applyComboboxListeners();
@if (Auth::user()->account->isPro())
$('.main-form').submit(function(){
if($('#document-upload .dropzone .fallback input').val())$(this).attr('enctype', 'multipart/form-data')
else $(this).removeAttr('enctype')
})
// Initialize document upload
dropzone = new Dropzone('#document-upload .dropzone', {
url:{!! json_encode(url('document')) !!},
params:{
_token:"{{ Session::getToken() }}"
},
acceptedFiles:{!! json_encode(implode(',',\App\Models\Document::$allowedMimes)) !!},
addRemoveLinks:true,
@foreach(trans('texts.dropzone') as $key=>$text)
"dict{{strval($key)}}":"{{strval($text)}}",
@endforeach
maxFileSize:{{floatval(MAX_DOCUMENT_SIZE/1000)}},
});
if(dropzone instanceof Dropzone){
dropzone.on("addedfile",handleDocumentAdded);
dropzone.on("removedfile",handleDocumentRemoved);
dropzone.on("success",handleDocumentUploaded);
for (var i=0; i<model.invoice().documents().length; i++) {
var document = model.invoice().documents()[i];
var mockFile = {
name:document.name(),
size:document.size(),
type:document.type(),
public_id:document.public_id(),
status:Dropzone.SUCCESS,
accepted:true,
url:document.preview_url()||document.url(),
mock:true,
index:i
};
dropzone.emit('addedfile', mockFile);
dropzone.emit('complete', mockFile);
if(document.preview_url()){
dropzone.emit('thumbnail', mockFile, document.preview_url()||document.url());
}
else if(document.type()=='jpeg' || document.type()=='png' || document.type()=='svg'){
dropzone.emit('thumbnail', mockFile, document.url());
}
dropzone.files.push(mockFile);
}
}
@endif
});
function onFrequencyChange(){
@ -943,7 +1024,9 @@
}
function createInvoiceModel() {
var invoice = ko.toJS(window.model).invoice;
var model = ko.toJS(window.model);
if(!model)return;
var invoice = model.invoice;
invoice.is_pro = {{ Auth::user()->isPro() ? 'true' : 'false' }};
invoice.is_quote = {{ $entityType == ENTITY_QUOTE ? 'true' : 'false' }};
invoice.contact = _.findWhere(invoice.client.contacts, {send_invoice: true});
@ -965,7 +1048,7 @@
@endif
@if ($account->hasLogo())
invoice.image = "{{ Form::image_data($account->getLogoPath()) }}";
invoice.image = "{{ Form::image_data($account->getLogoRaw(), true) }}";
invoice.imageWidth = {{ $account->getLogoWidth() }};
invoice.imageHeight = {{ $account->getLogoHeight() }};
@endif
@ -1257,6 +1340,43 @@
model.invoice().invoice_number(number);
}
@if ($account->isPro())
function handleDocumentAdded(file){
if(file.mock)return;
file.index = model.invoice().documents().length;
model.invoice().addDocument({name:file.name, size:file.size, type:file.type});
}
function handleDocumentRemoved(file){
model.invoice().removeDocument(file.public_id);
refreshPDF(true);
}
function handleDocumentUploaded(file, response){
file.public_id = response.document.public_id
model.invoice().documents()[file.index].update(response.document);
refreshPDF(true);
if(response.document.preview_url){
dropzone.emit('thumbnail', file, response.document.preview_url);
}
}
@endif
</script>
@if ($account->isPro() && $account->invoice_embed_documents)
@foreach ($invoice->documents as $document)
@if($document->isPDFEmbeddable())
<script src="{{ $document->getVFSJSUrl() }}" type="text/javascript" async></script>
@endif
@endforeach
@foreach ($invoice->expenses as $expense)
@foreach ($expense->documents as $document)
@if($document->isPDFEmbeddable())
<script src="{{ $document->getVFSJSUrl() }}" type="text/javascript" async></script>
@endif
@endforeach
@endforeach
@endif
@stop

View File

@ -57,4 +57,18 @@
@include('invoices.pdf', ['account' => Auth::user()->account, 'pdfHeight' => 800])
@if (Utils::isPro() && $invoice->account->invoice_embed_documents)
@foreach ($invoice->documents as $document)
@if($document->isPDFEmbeddable())
<script src="{{ $document->getVFSJSUrl() }}" type="text/javascript" async></script>
@endif
@endforeach
@foreach ($invoice->expenses as $expense)
@foreach ($expense->documents as $document)
@if($document->isPDFEmbeddable())
<script src="{{ $document->getVFSJSUrl() }}" type="text/javascript" async></script>
@endif
@endforeach
@endforeach
@endif
@stop

View File

@ -182,6 +182,8 @@ function InvoiceModel(data) {
self.auto_bill = ko.observable();
self.invoice_status_id = ko.observable(0);
self.invoice_items = ko.observableArray();
self.documents = ko.observableArray();
self.expenses = ko.observableArray();
self.amount = ko.observable(0);
self.balance = ko.observable(0);
self.invoice_design_id = ko.observable(1);
@ -207,6 +209,16 @@ function InvoiceModel(data) {
return new ItemModel(options.data);
}
},
'documents': {
create: function(options) {
return new DocumentModel(options.data);
}
},
'expenses': {
create: function(options) {
return new ExpenseModel(options.data);
}
},
}
self.addItem = function() {
@ -219,6 +231,19 @@ function InvoiceModel(data) {
return itemModel;
}
self.addDocument = function() {
var documentModel = new DocumentModel();
self.documents.push(documentModel);
return documentModel;
}
self.removeDocument = function(doc) {
var public_id = doc.public_id?doc.public_id():doc;
self.documents.remove(function(document) {
return document.public_id() == public_id;
});
}
if (data) {
ko.mapping.fromJS(data, self.mapping, self);
} else {
@ -699,6 +724,45 @@ function ItemModel(data) {
this.onSelect = function() {}
}
function DocumentModel(data) {
var self = this;
self.public_id = ko.observable(0);
self.size = ko.observable(0);
self.name = ko.observable('');
self.type = ko.observable('');
self.url = ko.observable('');
self.update = function(data){
ko.mapping.fromJS(data, {}, this);
}
if (data) {
self.update(data);
}
}
var ExpenseModel = function(data) {
var self = this;
self.mapping = {
'documents': {
create: function(options) {
return new DocumentModel(options.data);
}
}
}
self.description = ko.observable('');
self.qty = ko.observable(0);
self.public_id = ko.observable(0);
self.amount = ko.observable();
self.converted_amount = ko.observable();
if (data) {
ko.mapping.fromJS(data, self.mapping, this);
}
};
/* Custom binding for product key typeahead */
ko.bindingHandlers.typeahead = {
init: function (element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) {

View File

@ -75,7 +75,7 @@
logoImages.imageLogoHeight3 = 81/2;
@if ($account->hasLogo())
window.accountLogo = "{{ Form::image_data($account->getLogoPath()) }}";
window.accountLogo = "{{ Form::image_data($account->getLogoRaw(), true) }}";
if (window.invoice) {
invoice.image = window.accountLogo;
invoice.imageWidth = {{ $account->getLogoWidth() }};
@ -123,7 +123,7 @@
$('#theFrame').attr('src', string).show();
} else {
if (isRefreshing) {
//needsRefresh = true;
needsRefresh = true;
return;
}
isRefreshing = true;

View File

@ -24,7 +24,6 @@
@if ($checkoutComToken)
@include('partials.checkout_com_payment')
@else
<p>&nbsp;</p>
<div class="pull-right" style="text-align:right">
@if ($invoice->is_quote)
{!! Button::normal(trans('texts.download_pdf'))->withAttributes(['onclick' => 'onDownloadClick()'])->large() !!}&nbsp;&nbsp;
@ -39,13 +38,47 @@
<a href='{!! $paymentURL !!}' class="btn btn-success btn-lg">{{ trans('texts.pay_now') }}</a>
@endif
@else
{!! Button::normal('Download PDF')->withAttributes(['onclick' => 'onDownloadClick()'])->large() !!}
{!! Button::normal(trans('texts.download_pdf'))->withAttributes(['onclick' => 'onDownloadClick()'])->large() !!}
@endif
</div>
<div class="pull-left">
@if(!empty($documentsZipURL))
{!! Button::normal(trans('texts.download_documents', array('size'=>Form::human_filesize($documentsZipSize))))->asLinkTo($documentsZipURL)->large() !!}
@endif
</div>
@endif
<div class="clearfix"></div><p>&nbsp;</p>
@if ($account->isPro() && $invoice->hasDocuments())
<div class="invoice-documents">
<h3>{{ trans('texts.documents_header') }}</h3>
<ul>
@foreach ($invoice->documents as $document)
<li><a target="_blank" href="{{ $document->getClientUrl($invitation) }}">{{$document->name}} ({{Form::human_filesize($document->size)}})</a></li>
@endforeach
@foreach ($invoice->expenses as $expense)
@foreach ($expense->documents as $document)
<li><a target="_blank" href="{{ $document->getClientUrl($invitation) }}">{{$document->name}} ({{Form::human_filesize($document->size)}})</a></li>
@endforeach
@endforeach
</ul>
</div>
@endif
@if ($account->isPro() && $account->invoice_embed_documents)
@foreach ($invoice->documents as $document)
@if($document->isPDFEmbeddable())
<script src="{{ $document->getClientVFSJSUrl() }}" type="text/javascript" async></script>
@endif
@endforeach
@foreach ($invoice->expenses as $expense)
@foreach ($expense->documents as $document)
@if($document->isPDFEmbeddable())
<script src="{{ $document->getClientVFSJSUrl() }}" type="text/javascript" async></script>
@endif
@endforeach
@endforeach
@endif
<script type="text/javascript">
window.invoice = {!! $invoice->toJson() !!};
@ -91,5 +124,4 @@
<p>&nbsp;</p>
</div>
@stop

View File

@ -87,6 +87,11 @@
<li {{ Request::is('*client/invoices') ? 'class="active"' : '' }}>
{!! link_to('/client/invoices', trans('texts.invoices') ) !!}
</li>
@if (!empty($showDocuments))
<li {{ Request::is('*client/documents') ? 'class="active"' : '' }}>
{!! link_to('/client/documents', trans('texts.documents') ) !!}
</li>
@endif
<li {{ Request::is('*client/payments') ? 'class="active"' : '' }}>
{!! link_to('/client/payments', trans('texts.payments') ) !!}
</li>

View File

@ -9,9 +9,9 @@
<a href="{{ URL::to("/settings/user_details") }}">
@endif
@if (file_exists($logo_path))
@if (!empty($logo_url))
<div class="pull-left" style="height: 40px; margin-right: 16px;">
<img style="width: 40px; margin-top:6px" src="{{ asset($logo_path) }}"/>
<img style="width: 40px; margin-top:6px" src="{{ asset($logo_url) }}"/>
</div>
@else
<div class="pull-left" style="width: 40px; min-height: 40px; margin-right: 16px">&nbsp;</div>

View File

@ -24,8 +24,8 @@
@foreach (Session::get(SESSION_USER_ACCOUNTS) as $account)
<tr>
<td>
@if (isset($account->logo_path))
{!! HTML::image($account->logo_path.'?no_cache='.time(), 'Logo', ['width' => 100]) !!}
@if (isset($account->logo_url))
{!! HTML::image($account->logo_url.'?no_cache='.time(), 'Logo', ['width' => 100]) !!}
@endif
</td>
<td>

View File

@ -51,6 +51,12 @@
"paddingBottom": "$amount:14"
}
},
{
"stack": [
"$invoiceDocuments"
],
"style": "invoiceDocuments"
},
{
"columns": [
{
@ -254,6 +260,12 @@
"help": {
"fontSize": "$fontSizeSmaller",
"color": "#737373"
},
"invoiceDocuments": {
"margin": [47, 0, 47, 0]
},
"invoiceDocument": {
"margin": [0, 10, 0, 10]
}
},
"pageMargins": [0, 80, 0, 40]

View File

@ -69,6 +69,12 @@
"paddingBottom": "$amount:14"
}
},
{
"stack": [
"$invoiceDocuments"
],
"style": "invoiceDocuments"
},
{
"columns": [
"$notesAndTerms",
@ -200,6 +206,12 @@
"help": {
"fontSize": "$fontSizeSmaller",
"color": "#737373"
},
"invoiceDocuments": {
"margin": [7, 0, 7, 0]
},
"invoiceDocument": {
"margin": [0, 10, 0, 10]
}
},
"pageMargins": [40, 40, 40, 60]

View File

@ -85,7 +85,14 @@
"margin": [0, 16, 8, 0]
}
]
} ],
},
{
"stack": [
"$invoiceDocuments"
],
"style": "invoiceDocuments"
}
],
"footer": [
{
"canvas": [
@ -244,7 +251,13 @@
"help": {
"fontSize": "$fontSizeSmaller",
"color": "#737373"
},
"invoiceDocuments": {
"margin": [7, 0, 7, 0]
},
"invoiceDocument": {
"margin": [0, 10, 0, 10]
}
},
"pageMargins": [40, 120, 40, 50]
}
}

View File

@ -57,6 +57,12 @@
"paddingBottom": "$amount:8"
}
},
{
"stack": [
"$invoiceDocuments"
],
"style": "invoiceDocuments"
},
{
"columns": [
"$notesAndTerms",
@ -166,6 +172,12 @@
"help": {
"fontSize": "$fontSizeSmaller",
"color": "#737373"
},
"invoiceDocuments": {
"margin": [7, 0, 7, 0]
},
"invoiceDocument": {
"margin": [0, 10, 0, 10]
}
},
"pageMargins": [40, 40, 40, 60]