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

Add support for invoice attachments

This commit is contained in:
Joshua Dwire 2016-03-22 22:23:45 -04:00
parent 5640c74f35
commit 88808d44bf
17 changed files with 547 additions and 44 deletions

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',

View File

@ -0,0 +1,60 @@
<?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)
->withTrashed()
->firstOrFail();
if(!$this->checkViewPermission($document, $response)){
return $response;
}
$public_url = $document->getPublicUrl();
if($public_url){
return redirect($public_url);
}
$response = Response::make($document->getRaw(), 200);
$response->header('content-type', $document->type);
return $response;
}
public function postUpload()
{
if(!$this->checkCreatePermission($response)){
return $response;
}
$document = Input::all();
$response = $this->documentRepo->upload($document);
return $response;
}
}

View File

@ -23,6 +23,7 @@ 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 +33,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 +91,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', 'payments')
->withTrashed()
->firstOrFail();
@ -229,7 +231,7 @@ class InvoiceController extends BaseController
return $response;
}
$account = Auth::user()->account;
$account = Auth::user()->account;
$entityType = $isRecurring ? ENTITY_RECURRING_INVOICE : ENTITY_INVOICE;
$clientId = null;

View File

@ -132,6 +132,9 @@ 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::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');
@ -425,6 +428,7 @@ if (!defined('CONTACT_EMAIL')) {
define('MAX_IFRAME_URL_LENGTH', 250);
define('MAX_LOGO_FILE_SIZE', 200); // KB
define('MAX_FAILED_LOGINS', 10);
define('DEFAULT_MAX_DOCUMENT_SIZE', 10000);// KB
define('DEFAULT_FONT_SIZE', 9);
define('DEFAULT_HEADER_FONT', 1);// Roboto
define('DEFAULT_BODY_FONT', 1);// Roboto

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

@ -0,0 +1,102 @@
<?php namespace App\Models;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Storage;
class Document extends EntityModel
{
public static $extensions = array(
'png' => 'image/png',
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'pdf' => 'application/pdf',
'gif' => 'image/gif'
);
public static $types = array(
'image/png' => array(
'extension' => 'png',
'image' => true,
),
'image/jpeg' => array(
'extension' => 'jpeg',
'image' => true,
),
'image/gif' => array(
'extension' => 'gif',
'image' => true,
),
'application/pdf' => array(
'extension' => 'pdf',
),
);
// Expenses
use SoftDeletes;
protected $dates = ['deleted_at'];
public function fill(array $attributes)
{
parent::fill($attributes);
if(empty($this->attributes['disk'])){
$this->attributes['disk'] = env('LOGO_DISK', '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('LOGO_DISK', 'documents'));
}
public function setDiskAttribute($value)
{
$this->attributes['disk'] = $value?$value:env('LOGO_DISK', 'documents');
}
public function getPublicUrl(){
$disk = $this->getDisk();
$adapter = $disk->getAdapter();
return null;
}
public function getRaw(){
$disk = $this->getDisk();
return $disk->get($this->path);
}
public function getUrl(){
return url('document/'.$this->public_id.'/'.$this->name);
}
public function toArray()
{
$array = parent::toArray();
$array['url'] = $this->getUrl();
return $array;
}
}

View File

@ -175,6 +175,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 +390,7 @@ class Invoice extends EntityModel implements BalanceAffecting
'amount',
'balance',
'invoice_items',
'documents',
'client',
'tax_name',
'tax_rate',

View File

@ -0,0 +1,128 @@
<?php namespace app\Ninja\Repositories;
use DB;
use Utils;
use Response;
use App\Models\Document;
use App\Ninja\Repositories\BaseRepository;
use Intervention\Image\Facades\Image;
use Session;
class DocumentRepository extends BaseRepository
{
// Expenses
public function getClassName()
{
return 'App\Models\Document';
}
public function all()
{
return Document::scope()
->with('user')
->withTrashed()
->where('is_deleted', '=', false)
->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($input)
{
$uploaded = $input['file'];
$extension = strtolower($uploaded->extension());
if(empty(Document::$extensions[$extension])){
return Response::json([
'error' => 'Unsupported extension',
'code' => 400
], 400);
}
$documentType = Document::$extensions[$extension];
$filePath = $uploaded->path();
$fileContents = null;
$name = $uploaded->getClientOriginalName();
if(filesize($filePath)/1000 > env('MAX_DOCUMENT_SIZE', DEFAULT_MAX_DOCUMENT_SIZE)){
return Response::json([
'error' => 'File too large',
'code' => 400
], 400);
}
if($documentType == 'image/gif'){
// Convert gif to png
$img = Image::make($filePath);
$fileContents = (string) $img->encode('png');
$documentType = 'image/png';
$name = pathinfo($name)['filename'].'.png';
}
$documentTypeData = Document::$types[$documentType];
$hash = $fileContents?sha1($fileContents):sha1_file($filePath);
$filename = \Auth::user()->account->account_key.'/'.$hash.'.'.$documentTypeData['extension'];
$document = Document::createNew();
$disk = $document->getDisk();
if(!$disk->exists($filename)){// Have we already stored the same file
$disk->put($filename, $fileContents?$fileContents:file_get_contents($filePath));
}
$document->path = $filename;
$document->type = $documentType;
$document->size = $fileContents?strlen($fileContents):filesize($filePath);
$document->name = substr($name, -255);
if(!empty($documentTypeData['image'])){
$imageSize = getimagesize($filePath);
if($imageSize){
$document->width = $imageSize[0];
$document->height = $imageSize[1];
}
}
$document->save();
return Response::json([
'error' => false,
'document' => $document,
'code' => 200
], 200);
}
}

View File

@ -7,6 +7,7 @@ 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;
@ -397,6 +398,23 @@ class InvoiceRepository extends BaseRepository
if ($publicId) {
$invoice->invoice_items()->forceDelete();
}
foreach ($data['documents'] as $document_id){
$document = Document::scope($document_id)->first();
if($document && !$checkSubPermissions || $document->canEdit()){
$document->invoice_id = $invoice->id;
$document->save();
}
}
foreach ($invoice->documents as $document){
if(!in_array($document->id, $data['documents'])){
// Removed
if(!$checkSubPermissions || $document->canEdit()){
$document->delete();
}
}
}
foreach ($data['invoice_items'] as $item) {
$item = (array) $item;

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

@ -0,0 +1,66 @@
<?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');
});
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('name');
$t->string('type');
$t->string('disk');
$t->unsignedInteger('size');
$t->unsignedInteger('width')->nullable();
$t->unsignedInteger('height')->nullable();
$t->timestamps();
$t->softDeletes();
$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');
});
Schema::dropIfExists('documents');
}
}

View File

@ -1,37 +0,0 @@
<?php
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
use DB;
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');
});*/
DB::table('accounts')->update(array('logo' => ''));
}
/**
* 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');
});
}
}

File diff suppressed because one or more lines are too long

25
public/css/built.css vendored

File diff suppressed because one or more lines are too long

23
public/css/style.css vendored
View File

@ -1061,4 +1061,27 @@ td.right {
div.panel-body div.panel-body {
padding-bottom: 0px;
}
/* Attached Documents */
.dropzone {
border:1px solid #ebe7e7;
background:#f9f9f9 !important;
border-radius:3px;
}
.dropzone .dz-preview.dz-image-preview{
background:none;
}
.dropzone .dz-preview .dz-image{
width:119px;
height:119px;
border-radius:5px!important;
}
.dropzone .dz-preview.dz-image-preview .dz-image img{
object-fit: cover;
width: 100%;
height: 100%;
}

View File

@ -1097,6 +1097,10 @@ $LANG = array(
'november' => 'November',
'december' => 'December',
// Documents
'invoice_documents' => 'Attached Documents',
'document_upload_message' => 'Drop files here or click to upload.'
);
return $LANG;

View File

@ -270,6 +270,7 @@
<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>
<li role="presentation"><a href="#attached-documents" aria-controls="attached-documents" role="tab" data-toggle="tab">{{ trans("texts.{$entityType}_documents") }}</a></li>
</ul>
<div class="tab-content">
@ -301,6 +302,16 @@
</div>
</div>') !!}
</div>
<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="file" type="file" multiple />
</div>
</div>
<div data-bind="foreach: documents">
<input type="hidden" name="documents[]" data-bind="value: public_id">
</div>
</div>
</div>
</div>
@ -675,10 +686,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');
@ -905,6 +918,43 @@
@endif
applyComboboxListeners();
// Initialize document upload
dropzone = new Dropzone('#document-upload', {
url:{!! json_encode(url('document')) !!},
params:{
_token:"{{ Session::getToken() }}"
},
acceptedFiles:{!! json_encode(implode(',',array_keys(\App\Models\Document::$types))) !!},
addRemoveLinks:true,
maxFileSize:{{floatval(env('MAX_DOCUMENT_SIZE', DEFAULT_MAX_DOCUMENT_SIZE)/1000)}},
dictDefaultMessage:{!! json_encode(trans('texts.document_upload_message')) !!}
});
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.url(),
mock:true,
index:i
};
dropzone.emit('addedfile', mockFile);
dropzone.emit('complete', mockFile);
if(document.type().match(/image.*/)){
dropzone.emit('thumbnail', mockFile, document.url());
}
dropzone.files.push(mockFile);
}
});
function onFrequencyChange(){
@ -1262,6 +1312,21 @@
number = number.replace('{$custom2}', client.custom_value2 ? client.custom_value1 : '');
model.invoice().invoice_number(number);
}
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);
}
function handleDocumentUploaded(file, response){
file.public_id = response.document.public_id
model.invoice().documents()[file.index].update(response.document);
}
</script>

View File

@ -226,6 +226,7 @@ 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.amount = ko.observable(0);
self.balance = ko.observable(0);
self.invoice_design_id = ko.observable(1);
@ -251,6 +252,11 @@ function InvoiceModel(data) {
return new ItemModel(options.data);
}
},
'documents': {
create: function(options) {
return new DocumentModel(options.data);
}
},
'tax': {
create: function(options) {
return new TaxRateModel(options.data);
@ -267,6 +273,18 @@ function InvoiceModel(data) {
applyComboboxListeners();
return itemModel;
}
self.addDocument = function() {
var documentModel = new DocumentModel();
self.documents.push(documentModel);
return documentModel;
}
self.removeDocument = function(public_id) {
self.documents.remove(function(document) {
return document.public_id() == public_id;
});
}
if (data) {
ko.mapping.fromJS(data, self.mapping, self);
@ -810,6 +828,23 @@ 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);
}
}
/* Custom binding for product key typeahead */
ko.bindingHandlers.typeahead = {