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

Merge pull request #4322 from beganovich/v5-display-signature-and-terms-on-quotes

(v5) Display signatures & accepting terms for invoices & quotes
This commit is contained in:
Benjamin Beganović 2020-11-17 17:00:59 +01:00 committed by GitHub
commit f81526af2d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 125 additions and 77 deletions

View File

@ -170,8 +170,8 @@ class PaymentController extends Controller
});
if ((bool) $request->signature) {
$invoices->each(function ($invoice) {
if (request()->has('signature') && !is_null(request()->signature) && !empty(request()->signature)) {
$invoices->each(function ($invoice) use ($request) {
InjectSignature::dispatch($invoice, $request->signature);
});
}

View File

@ -6,6 +6,7 @@ use App\Events\Quote\QuoteWasApproved;
use App\Http\Controllers\Controller;
use App\Http\Requests\ClientPortal\ProcessQuotesInBulkRequest;
use App\Http\Requests\ClientPortal\ShowQuoteRequest;
use App\Jobs\Invoice\InjectSignature;
use App\Models\Company;
use App\Models\Quote;
use App\Utils\Ninja;
@ -111,6 +112,10 @@ class QuoteController extends Controller
foreach ($quotes as $quote) {
$quote->service()->approve(auth()->user())->save();
event(new QuoteWasApproved(auth('contact')->user(), $quote, $quote->company, Ninja::eventVars()));
if (request()->has('signature') && !is_null(request()->signature) && !empty(request()->signature)) {
InjectSignature::dispatch($quote, request()->signature);
}
}
return redirect()

View File

@ -15,9 +15,9 @@ class InjectSignature implements ShouldQueue
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* @var App\Models\Invoice
* @var App\Models\Invoice|App\Models\Quote
*/
public $invoice;
public $entity;
/**
* @var string
@ -27,12 +27,12 @@ class InjectSignature implements ShouldQueue
/**
* Create a new job instance.
*
* @param Invoice $invoice
* @param $entity
* @param string $signature
*/
public function __construct(Invoice $invoice, string $signature)
public function __construct($entity, string $signature)
{
$this->invoice = $invoice;
$this->entity = $entity;
$this->signature = $signature;
}
@ -44,7 +44,7 @@ class InjectSignature implements ShouldQueue
*/
public function handle()
{
$invitation = $this->invoice->invitations->whereNotNull('signature_base64')->first();
$invitation = $this->entity->invitations->whereNotNull('signature_base64')->first();
if (! $invitation) {
return;

View File

@ -1,2 +1,2 @@
/*! For license information please see approve.js.LICENSE.txt */
!function(e){var t={};function n(r){if(t[r])return t[r].exports;var o=t[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,n),o.l=!0,o.exports}n.m=e,n.c=t,n.d=function(e,t,r){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:r})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(n.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)n.d(r,o,function(t){return e[t]}.bind(null,o));return r},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="/",n(n.s=10)}({10:function(e,t,n){e.exports=n("WuMn")},WuMn:function(e,t){function n(e,t){for(var n=0;n<t.length;n++){var r=t[n];r.enumerable=r.enumerable||!1,r.configurable=!0,"value"in r&&(r.writable=!0),Object.defineProperty(e,r.key,r)}}var r=function(){function e(t){!function(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}(this,e),this.shouldDisplaySignature=t}var t,r,o;return t=e,(r=[{key:"submitForm",value:function(){document.getElementById("approve-form").submit()}},{key:"displaySignature",value:function(){document.getElementById("displaySignatureModal").removeAttribute("style"),new SignaturePad(document.getElementById("signature-pad"),{backgroundColor:"rgb(240,240,240)",penColor:"rgb(0, 0, 0)"})}},{key:"handle",value:function(){var e=this;document.getElementById("approve-button").addEventListener("click",(function(){e.shouldDisplaySignature&&(e.displaySignature(),document.getElementById("signature-next-step").addEventListener("click",(function(){e.submitForm()}))),e.shouldDisplaySignature||e.submitForm()}))}}])&&n(t.prototype,r),o&&n(t,o),e}(),o=document.querySelector('meta[name="require-quote-signature"]').content;new r(Boolean(+o)).handle()}});
!function(e){var t={};function n(r){if(t[r])return t[r].exports;var u=t[r]={i:r,l:!1,exports:{}};return e[r].call(u.exports,u,u.exports,n),u.l=!0,u.exports}n.m=e,n.c=t,n.d=function(e,t,r){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:r})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(n.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var u in e)n.d(r,u,function(t){return e[t]}.bind(null,u));return r},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="/",n(n.s=10)}({10:function(e,t,n){e.exports=n("WuMn")},WuMn:function(e,t){function n(e,t){for(var n=0;n<t.length;n++){var r=t[n];r.enumerable=r.enumerable||!1,r.configurable=!0,"value"in r&&(r.writable=!0),Object.defineProperty(e,r.key,r)}}var r=function(){function e(t,n){!function(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}(this,e),this.shouldDisplaySignature=t,this.shouldDisplayTerms=n,this.termsAccepted=!1}var t,r,u;return t=e,(r=[{key:"submitForm",value:function(){document.getElementById("approve-form").submit()}},{key:"displaySignature",value:function(){document.getElementById("displaySignatureModal").removeAttribute("style");var e=new SignaturePad(document.getElementById("signature-pad"),{penColor:"rgb(0, 0, 0)"});this.signaturePad=e}},{key:"displayTerms",value:function(){document.getElementById("displayTermsModal").removeAttribute("style")}},{key:"handle",value:function(){var e=this;document.getElementById("approve-button").addEventListener("click",(function(){e.shouldDisplaySignature&&e.shouldDisplayTerms&&(e.displaySignature(),document.getElementById("signature-next-step").addEventListener("click",(function(){e.displayTerms(),document.getElementById("accept-terms-button").addEventListener("click",(function(){document.querySelector('input[name="signature"').value=e.signaturePad.toDataURL(),e.termsAccepted=!0,e.submitForm()}))}))),e.shouldDisplaySignature&&!e.shouldDisplayTerms&&(e.displaySignature(),document.getElementById("signature-next-step").addEventListener("click",(function(){document.querySelector('input[name="signature"').value=e.signaturePad.toDataURL(),e.submitForm()}))),!e.shouldDisplaySignature&&e.shouldDisplayTerms&&(e.displayTerms(),document.getElementById("accept-terms-button").addEventListener("click",(function(){e.termsAccepted=!0,e.submitForm()}))),e.shouldDisplaySignature||e.shouldDisplayTerms||e.submitForm()}))}}])&&n(t.prototype,r),u&&n(t,u),e}(),u=document.querySelector('meta[name="require-quote-signature"]').content,o=document.querySelector('meta[name="show-quote-terms"]').content;new r(Boolean(+u),Boolean(+o)).handle()}});

View File

@ -12,7 +12,7 @@
"/js/clients/payments/stripe-credit-card.js": "/js/clients/payments/stripe-credit-card.js?id=f4659d26a26d2f408397",
"/js/clients/payments/stripe-sofort.js": "/js/clients/payments/stripe-sofort.js?id=7f5b13e34c75e63c015e",
"/js/clients/quotes/action-selectors.js": "/js/clients/quotes/action-selectors.js?id=1b8f9325aa6e8595e7fa",
"/js/clients/quotes/approve.js": "/js/clients/quotes/approve.js?id=9cdbe50bab63dc1dd520",
"/js/clients/quotes/approve.js": "/js/clients/quotes/approve.js?id=85bcae0a646882e56b12",
"/js/clients/shared/multiple-downloads.js": "/js/clients/shared/multiple-downloads.js?id=5c35d28cf0a3286e7c45",
"/js/clients/shared/pdf.js": "/js/clients/shared/pdf.js?id=fa54bb4229aba6b0817c",
"/js/setup/setup.js": "/js/setup/setup.js?id=f42b2dee6575623822c2",

View File

@ -9,8 +9,10 @@
*/
class Approve {
constructor(displaySignature) {
constructor(displaySignature, displayTerms) {
this.shouldDisplaySignature = displaySignature;
this.shouldDisplayTerms = displayTerms;
this.termsAccepted = false;
}
submitForm() {
@ -18,34 +20,84 @@ class Approve {
}
displaySignature() {
let displaySignatureModal = document.getElementById('displaySignatureModal');
let displaySignatureModal = document.getElementById(
'displaySignatureModal'
);
displaySignatureModal.removeAttribute('style');
const signaturePad = new SignaturePad(document.getElementById('signature-pad'), {
backgroundColor: 'rgb(240,240,240)',
penColor: 'rgb(0, 0, 0)'
});
const signaturePad = new SignaturePad(
document.getElementById('signature-pad'),
{
penColor: 'rgb(0, 0, 0)',
}
);
this.signaturePad = signaturePad;
}
displayTerms() {
let displayTermsModal = document.getElementById("displayTermsModal");
displayTermsModal.removeAttribute("style");
}
handle() {
document.getElementById('approve-button').addEventListener('click', () => {
if (this.shouldDisplaySignature) {
this.displaySignature();
document
.getElementById('approve-button')
.addEventListener('click', () => {
if (this.shouldDisplaySignature && this.shouldDisplayTerms) {
this.displaySignature();
document.getElementById('signature-next-step').addEventListener('click', () => {
document
.getElementById('signature-next-step')
.addEventListener('click', () => {
this.displayTerms();
document
.getElementById('accept-terms-button')
.addEventListener('click', () => {
document.querySelector(
'input[name="signature"'
).value = this.signaturePad.toDataURL();
this.termsAccepted = true;
this.submitForm();
});
});
}
if (this.shouldDisplaySignature && !this.shouldDisplayTerms) {
this.displaySignature();
document
.getElementById('signature-next-step')
.addEventListener('click', () => {
document.querySelector(
'input[name="signature"'
).value = this.signaturePad.toDataURL();
this.submitForm();
});
}
if (!this.shouldDisplaySignature && this.shouldDisplayTerms) {
this.displayTerms();
document
.getElementById('accept-terms-button')
.addEventListener('click', () => {
this.termsAccepted = true;
this.submitForm();
});
}
if (!this.shouldDisplaySignature && !this.shouldDisplayTerms) {
this.submitForm();
});
}
if (!this.shouldDisplaySignature) this.submitForm();
})
}
});
}
}
const signature = document.querySelector(
'meta[name="require-quote-signature"]'
).content;
new Approve(Boolean(+signature)).handle();
const signature = document.querySelector('meta[name="require-quote-signature"]')
.content;
const terms = document.querySelector('meta[name="show-quote-terms"]').content;
new Approve(Boolean(+signature), Boolean(+terms)).handle();

View File

@ -3303,4 +3303,6 @@ return [
'activity_65' => ':user emailed reminder 3 for invoice :invoice to :contact',
'activity_66' => ':user emailed reminder endless for invoice :invoice to :contact',
'by_clicking_next_you_accept_terms' => 'By clicking "Next step" you accept terms.',
'not_specified' => 'Not specified',
];

View File

@ -1,24 +1,13 @@
<div style="display: none;" id="displaySignatureModal"
class="fixed bottom-0 inset-x-0 px-4 pb-4 sm:inset-0 sm:flex sm:items-center sm:justify-center">
<div x-show="open" x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
class="fixed inset-0 transition-opacity">
<div style="display: none;" id="displaySignatureModal" class="fixed bottom-0 inset-x-0 px-4 pb-4 sm:inset-0 sm:flex sm:items-center sm:justify-center">
<div x-show="open" x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-200" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" class="fixed inset-0 transition-opacity">
<div class="absolute inset-0 bg-gray-500 opacity-75"></div>
</div>
<div x-show="open" x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100" x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
class="bg-white rounded-lg px-4 pt-5 pb-4 overflow-hidden shadow-xl transform transition-all sm:max-w-lg sm:w-full sm:p-6">
<div x-show="open" x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100" x-transition:leave="ease-in duration-200" x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100" x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" class="bg-white rounded-lg px-4 pt-5 pb-4 overflow-hidden shadow-xl transform transition-all sm:max-w-lg sm:w-full sm:p-6">
<div class="sm:flex sm:items-start">
<div
class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
<div class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
<svg class="h-6 w-6 text-red-600" stroke="currentColor" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
@ -34,11 +23,15 @@
</div>
<div class="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<div class="flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto">
<button type="button" class="button button-primary bg-primary" id="signature-next-step"
@click="document.getElementById('displaySignatureModal').style.display = 'none';">
<button type="button" id="signature-next-step" class="button button-primary bg-primary" @click="document.getElementById('displaySignatureModal').style.display = 'none';">
{{ ctrans('texts.next_step') }}
</button>
</div>
<div class="mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto">
<button @click="document.getElementById('displaySignatureModal').style.display = 'none';" type="button" class="button button-secondary">
{{ ctrans('texts.close') }}
</button>
</div>
</div>
</div>
</div>

View File

@ -1,46 +1,38 @@
<div style="display: none;" id="displayTermsModal"
class="fixed bottom-0 inset-x-0 px-4 pb-4 sm:inset-0 sm:flex sm:items-center sm:justify-center">
<div x-show="open" x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
class="fixed inset-0 transition-opacity">
<div style="display: none;" id="displayTermsModal" class="fixed bottom-0 inset-x-0 px-4 pb-4 sm:inset-0 sm:flex sm:items-center sm:justify-center">
<div x-show="open" x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-200" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" class="fixed inset-0 transition-opacity">
<div class="absolute inset-0 bg-gray-500 opacity-75"></div>
</div>
<div x-show="open" x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100" x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
class="bg-white rounded-lg px-4 pt-5 pb-4 overflow-hidden shadow-xl transform transition-all sm:max-w-lg sm:w-full sm:p-6">
<div x-show="open" x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100" x-transition:leave="ease-in duration-200" x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100" x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" class="bg-white rounded-lg px-4 pt-5 pb-4 overflow-hidden shadow-xl transform transition-all sm:max-w-lg sm:w-full sm:p-6">
<div class="sm:flex sm:items-start">
<div
class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
<svg class="h-6 w-6 text-red-600" stroke="currentColor" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
</div>
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 class="text-lg leading-6 font-medium text-gray-900">
<h3 class="text-xl leading-6 font-medium text-gray-900">
{{ ctrans('texts.terms') }}
</h3>
<div class="mt-2">
<p class="text-sm leading-5 text-gray-500">
{!! $invoice->terms !!}
</p>
<div class="mt-4">
@foreach($entities as $entity)
<div class="mb-4">
<h4 class="leading-6 font-medium text-gray-900">{{ $entity_type }} {{ $entity->number }}:</h4>
@if($entity->terms)
<p class="text-sm leading-5 text-gray-500">{!! $entity->terms !!}</p>
@else
<i class="text-sm leading-5 text-gray-500">{{ ctrans('texts.not_specified') }}</i>
@endif
</div>
@endforeach
<p class="mt-4 block text-sm text-gray-900">{{ ctrans('texts.by_clicking_next_you_accept_terms') }}</p>
</div>
</div>
</div>
<div class="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<div class="flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto">
<button type="button" id="accept-terms-button" class="button button-primary bg-primary">
{{ ctrans('texts.agree_to_terms', ['terms' => trans('texts.invoice_terms')]) }}
{{ ctrans('texts.next_step') }}
</button>
</div>
<div class="mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto">
<button @click="document.getElementById('displayTermsModal').style.display = 'none';" type="button"
class="button button-secondary">
<button @click="document.getElementById('displayTermsModal').style.display = 'none';" type="button" class="button button-secondary">
{{ ctrans('texts.close') }}
</button>
</div>

View File

@ -143,7 +143,7 @@
</div>
</form>
@include('portal.ninja2020.invoices.includes.terms')
@include('portal.ninja2020.invoices.includes.terms', ['entities' => $invoices, 'entity_type' => ctrans('texts.invoice')])
@include('portal.ninja2020.invoices.includes.signature')
@endsection

View File

@ -2,7 +2,8 @@
@section('meta_title', ctrans('texts.approve'))
@push('head')
<meta name="require-quote-signature" content="{{ $settings->require_invoice_signature ? true : false }}">
<meta name="show-quote-terms" content="{{ $settings->show_accept_quote_terms ? true : false }}">
<meta name="require-quote-signature" content="{{ $settings->require_quote_signature ? true : false }}">
<script src="https://cdn.jsdelivr.net/npm/signature_pad@2.3.2/dist/signature_pad.min.js"></script>
@endpush
@ -14,7 +15,9 @@
@foreach($quotes as $quote)
<input type="hidden" name="quotes[]" value="{{ $quote->hashed_id }}">
@endforeach
<input type="hidden" name="signature">
</form>
<div class="container mx-auto">
<div class="grid grid-cols-6 gap-4">
<div class="col-span-6 md:col-start-2 md:col-span-4">
@ -78,6 +81,7 @@
</div>
</div>
@include('portal.ninja2020.invoices.includes.terms', ['entities' => $quotes, 'entity_type' => ctrans('texts.quote')])
@include('portal.ninja2020.invoices.includes.signature')
@endsection