Updates for rejection

This commit is contained in:
David Bomba
2025-12-17 09:01:04 +11:00
parent 5f82a576b4
commit b689e8b6c2
18 changed files with 475 additions and 133 deletions

View File

@@ -90,6 +90,10 @@ class QuoteController extends Controller
return $this->approve((array) $transformed_ids, $request->has('process'));
}
if ($request->action == 'reject') {
return $this->reject((array) $transformed_ids, $request->has('process'));
}
return back();
}
@@ -171,6 +175,44 @@ class QuoteController extends Controller
}
}
protected function reject(array $ids, $process = false)
{
$quotes = Quote::query()
->whereIn('id', $ids)
->where('client_id', auth()->guard('contact')->user()->client_id)
->where('company_id', auth()->guard('contact')->user()->company_id)
->where('status_id', Quote::STATUS_SENT)
->withTrashed()
->get();
if (! $quotes || $quotes->count() == 0) {
return redirect()
->route('client.quotes.index')
->with('message', ctrans('texts.quotes_with_status_sent_can_be_rejected'));
}
if ($process) {
foreach ($quotes as $quote) {
$quote->service()->reject(auth()->guard('contact')->user(), request()->input('user_input', ''))->save();
}
return redirect()
->route('client.quotes.index')
->withSuccess('Quote(s) rejected successfully.');
}
$variables = false;
return $this->render('quotes.reject', [
'quotes' => $quotes,
'variables' => $variables,
]);
}
protected function approve(array $ids, $process = false)
{
$quotes = Quote::query()

View File

@@ -15,8 +15,6 @@ namespace App\Http\Requests\ClientPortal\Quotes;
use App\Http\ViewComposers\PortalComposer;
use Illuminate\Foundation\Http\FormRequest;
use function auth;
class ProcessQuotesInBulkRequest extends FormRequest
{
public function authorize()

View File

@@ -384,6 +384,8 @@ class Quote extends BaseModel
return '<h5><span class="badge badge-danger">'.ctrans('texts.expired').'</span></h5>';
case self::STATUS_CONVERTED:
return '<h5><span class="badge badge-light">'.ctrans('texts.converted').'</span></h5>';
case self::STATUS_REJECTED:
return '<h5><span class="badge badge-danger">'.ctrans('texts.rejected').'</span></h5>';
default:
return '<h5><span class="badge badge-light">'.ctrans('texts.draft').'</span></h5>';
}
@@ -402,6 +404,8 @@ class Quote extends BaseModel
return ctrans('texts.expired');
case self::STATUS_CONVERTED:
return ctrans('texts.converted');
case self::STATUS_REJECTED:
return ctrans('texts.rejected');
default:
return ctrans('texts.draft');
@@ -422,6 +426,15 @@ class Quote extends BaseModel
return false;
}
public function isRejected(): bool
{
if ($this->status_id === $this::STATUS_REJECTED) {
return true;
}
return false;
}
public function getValidUntilAttribute()
{
return $this->due_date;

View File

@@ -223,6 +223,7 @@ use App\Listeners\Invoice\InvoiceRestoredActivity;
use App\Listeners\Invoice\InvoiceReversedActivity;
use App\Listeners\Payment\PaymentRestoredActivity;
use App\Listeners\Quote\QuoteApprovedNotification;
use App\Listeners\Quote\QuoteRejectedNotification;
use SocialiteProviders\Apple\AppleExtendSocialite;
use SocialiteProviders\Manager\SocialiteWasCalled;
use App\Events\Subscription\SubscriptionWasCreated;
@@ -574,6 +575,7 @@ class EventServiceProvider extends ServiceProvider
],
QuoteWasRejected::class => [
QuoteRejectedActivity::class,
QuoteRejectedNotification::class,
],
QuoteWasUpdated::class => [
QuoteUpdatedActivity::class,

View File

@@ -19,6 +19,7 @@ use App\Utils\Traits\MakesHash;
use App\Exceptions\QuoteConversion;
use App\Repositories\QuoteRepository;
use App\Events\Quote\QuoteWasApproved;
use App\Events\Quote\QuoteWasRejected;
use App\Services\Invoice\LocationData;
use App\Services\Quote\UpdateReminder;
use App\Jobs\EDocument\CreateEDocument;
@@ -148,6 +149,24 @@ class QuoteService
}
public function reject($contact = null, ?string $notes = null): self
{
if($this->quote->status_id != Quote::STATUS_SENT) {
return $this;
}
$this->setStatus(Quote::STATUS_REJECTED)->save();
if (! $contact) {
$contact = $this->quote->invitations->first()->contact;
}
event(new QuoteWasRejected($contact, $this->quote, $this->quote->company, $notes ?? '', Ninja::eventVars()));
return $this;
}
public function approveWithNoCoversion($contact = null): self
{

View File

@@ -5635,7 +5635,6 @@ $lang = array(
'einvoice_received_subject' => 'E-Invoice/s Received',
'einvoice_received_body' => 'You have received :count new E-Invoice/s.<br><br>Login to view.',
'download_files_too_large' => 'Some files were too large to attach directly to the email. Please use the links below to download these individually.',
'restore_disabled_verifactu' => 'You cannot restore an invoice once it has been deleted',
'delete_disabled_verifactu' => 'You cannot delete an invoice once it has been cancelled or modified',
'rectify' => 'Rectificar',
@@ -5676,6 +5675,13 @@ $lang = array(
'activity_156' => 'Invoice cancellation for :invoice sent to AEAT successfully',
'activity_157' => 'Invoice cancellation for :invoice failed to send to AEAT :notes',
'activity_158' => 'Quote :quote was rejected by :client :notes',
'quotes_with_status_sent_can_be_rejected' => 'Only quotes with "Sent" status can be rejected.',
'reject' => 'Reject',
'rejected' => 'Rejected',
'reject_quote' => 'Reject Quote',
'reject_quote_confirmation' => 'Are you sure you want to reject this quote?',
'reason' => 'Reason',
'enter_reason' => 'Enter a reason...',
);
return $lang;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

104
public/build/assets/app-e5ec2fdc.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,9 @@
/**
* Invoice Ninja (https://invoiceninja.com)
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2021. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/class o{constructor(){}submitForm(){document.getElementById("reject-form").submit()}displayRejectModal(){let e=document.getElementById("displayRejectModal");e&&e.removeAttribute("style")}hideRejectModal(){let e=document.getElementById("displayRejectModal");e&&(e.style.display="none")}handle(){const e=document.getElementById("reject-button");if(!e)return;e.addEventListener("click",()=>{e.disabled=!0,setTimeout(()=>{e.disabled=!1},2e3),this.displayRejectModal()});const t=document.getElementById("reject-confirm-button");t&&t.addEventListener("click",()=>{const n=document.getElementById("reject_reason");if(n){const c=document.querySelector('#reject-form input[name="user_input"]');c&&(c.value=n.value)}this.hideRejectModal(),this.submitForm()});const d=document.getElementById("reject-close-button");d&&d.addEventListener("click",()=>{this.hideRejectModal()})}}new o().handle();

View File

@@ -12,7 +12,7 @@
"file": "assets/wait-8f4ae121.js"
},
"resources/js/app.js": {
"file": "assets/app-aa93be80.js",
"file": "assets/app-e5ec2fdc.js",
"imports": [
"_index-08e160a7.js",
"__commonjsHelpers-725317a4.js"
@@ -360,6 +360,11 @@
"isEntry": true,
"src": "resources/js/clients/quotes/approve.js"
},
"resources/js/clients/quotes/reject.js": {
"file": "assets/reject-dae85928.js",
"isEntry": true,
"src": "resources/js/clients/quotes/reject.js"
},
"resources/js/clients/shared/multiple-downloads.js": {
"file": "assets/multiple-downloads-2f8b7e95.js",
"isEntry": true,
@@ -385,7 +390,7 @@
"src": "resources/js/setup/setup.js"
},
"resources/sass/app.scss": {
"file": "assets/app-55cdafc9.css",
"file": "assets/app-2350ca5d.css",
"isEntry": true,
"src": "resources/sass/app.scss"
}

82
resources/js/clients/quotes/reject.js vendored Normal file
View File

@@ -0,0 +1,82 @@
/**
* Invoice Ninja (https://invoiceninja.com)
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2021. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
class Reject {
constructor() {
// Always show rejection modal for confirmation
// (Siempre mostrar modal de rechazo para confirmación)
}
submitForm() {
document.getElementById('reject-form').submit();
}
displayRejectModal() {
let displayRejectModal = document.getElementById("displayRejectModal");
if (displayRejectModal) {
displayRejectModal.removeAttribute("style");
}
}
hideRejectModal() {
let displayRejectModal = document.getElementById("displayRejectModal");
if (displayRejectModal) {
displayRejectModal.style.display = 'none';
}
}
handle() {
const rejectButton = document.getElementById('reject-button');
if (!rejectButton) return;
rejectButton.addEventListener('click', () => {
rejectButton.disabled = true;
// Re-enable the reject button after 2 seconds (Rehabilitar botón de rechazo después de 2 segundos)
setTimeout(() => {
rejectButton.disabled = false;
}, 2000);
// Always display the rejection modal for confirmation
// (Siempre mostrar el modal de rechazo para confirmación)
this.displayRejectModal();
});
// Handle confirm rejection button (Manejar botón de confirmar rechazo)
const rejectConfirmButton = document.getElementById('reject-confirm-button');
if (rejectConfirmButton) {
rejectConfirmButton.addEventListener('click', () => {
const rejectReason = document.getElementById('reject_reason');
// Set user input value if provided (optional)
// (Establecer valor de entrada del usuario si se proporciona - opcional)
if (rejectReason) {
const userInputField = document.querySelector('#reject-form input[name="user_input"]');
if (userInputField) {
userInputField.value = rejectReason.value;
}
}
this.hideRejectModal();
this.submitForm();
});
}
// Handle close/cancel button (Manejar botón de cerrar/cancelar)
const rejectCloseButton = document.getElementById('reject-close-button');
if (rejectCloseButton) {
rejectCloseButton.addEventListener('click', () => {
this.hideRejectModal();
});
}
}
}
new Reject().handle();

View File

@@ -1,33 +1,47 @@
<form action="{{ route('client.quotes.bulk') }}" method="post" id="approve-form">
@csrf
<input type="hidden" name="action" value="approve">
<input type="hidden" name="action" value="approve" id="quote-action">
<input type="hidden" name="process" value="true">
<input type="hidden" name="quotes[]" value="{{ $quote->hashed_id }}">
<input type="hidden" name="signature">
<input type="hidden" name="user_input" value="">
</form>
<form action="{{ route('client.quotes.bulk') }}" method="post" id="reject-form">
@csrf
<input type="hidden" name="action" value="reject">
<input type="hidden" name="process" value="true">
<input type="hidden" name="quotes[]" value="{{ $quote->hashed_id }}">
<input type="hidden" name="user_input" value="">
</form>
<div class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<div class="sm:flex sm:items-start sm:justify-between">
<div>
<h3 class="text-lg leading-6 font-medium text-gray-900">
{{ ctrans('texts.approve') }}
{{ ctrans('texts.approve') }} / {{ ctrans('texts.reject') }}
</h3>
</div>
<div class="mt-5 sm:mt-0 sm:ml-6 sm:flex-shrink-0 sm:flex sm:items-center">
@yield('quote-not-approved-right-side')
<div class="inline-flex rounded-md shadow-sm">
<input type="hidden" name="action" value="approve">
<button onclick="setTimeout(() => this.disabled = true, 0); return true;" type="button"
<div class="inline-flex rounded-md shadow-sm mr-2">
<button type="button"
onclick="document.querySelector('#approve-form [name=action]').value='approve'; setTimeout(() => this.disabled = true, 0); return true;"
class="button button-primary bg-primary"
id="approve-button">{{ ctrans('texts.approve') }}</button>
</div>
<div class="inline-flex rounded-md shadow-sm ">
<button type="button"
onclick="document.querySelector('#reject-form [name=action]').value='reject'; setTimeout(() => this.disabled = true, 0); return true;"
class="button button-secondary bg-red-500 text-white hover:bg-red-600"
id="reject-button">{{ ctrans('texts.reject') }}</button>
</div>
</div>
</div>
</div>
</div>
</form>

View File

@@ -0,0 +1,49 @@
{{-- Rejection Confirmation Modal (Modal de confirmación de rechazo) --}}
<div style="display: none;" id="displayRejectModal" class="fixed bottom-0 inset-x-0 px-4 pb-4 sm:inset-0 sm:flex sm:items-center sm:justify-center z-50">
<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 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" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
</div>
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
<h3 class="text-xl leading-6 font-medium text-gray-900">
{{ ctrans('texts.reject_quote') }}
</h3>
<p class="mt-2 text-sm text-gray-500">
{{ ctrans('texts.reject_quote_confirmation') }}
</p>
<div class="mt-4">
<label for="reject_reason" class="block text-sm font-medium text-gray-700 mb-1">
{{ ctrans('texts.reason') }} ({{ ctrans('texts.optional') }})
</label>
<textarea
name="reject_reason"
id="reject_reason"
rows="3"
class="block w-full rounded-md border-gray-300 bg-gray-100 focus:border-primary-300 focus:bg-white focus:ring focus:ring-primary-200 focus:ring-opacity-50"
placeholder="{{ ctrans('texts.enter_reason') }}"></textarea>
</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="reject-confirm-button" class="button button-danger bg-red-500 hover:bg-red-600 text-white w-full sm:w-auto">
{{ ctrans('texts.reject') }}
</button>
</div>
<div class="mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto">
<button type="button" class="button button-secondary w-full sm:w-auto" id="reject-close-button">
{{ ctrans('texts.cancel') }}
</button>
</div>
</div>
</div>
</div>

View File

@@ -22,6 +22,10 @@
<button type="submit" onclick="setTimeout(() => this.disabled = true, 0); return true;"
class="button button-primary bg-primary" name="action"
value="approve">{{ ctrans('texts.approve') }}</button>
<button type="submit" onclick="setTimeout(() => this.disabled = true, 0); return true;"
class="button button-primary bg-primary" name="action"
value="reject">{{ ctrans('texts.reject') }}</button>
</form>
</div>

View File

@@ -0,0 +1,90 @@
@extends('portal.ninja2020.layout.app')
@section('meta_title', ctrans('texts.reject'))
@push('head')
<meta name="accept-user-input" content="true">
@endpush
@section('body')
<form action="{{ route('client.quotes.bulk') }}" method="post" id="reject-form">
@csrf
<input type="hidden" name="action" value="reject">
<input type="hidden" name="process" value="true">
<input type="hidden" name="user_input" value="">
@foreach($quotes as $quote)
<input type="hidden" name="quotes[]" value="{{ $quote->hashed_id }}">
@endforeach
</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">
<div class="flex justify-end">
<div class="flex justify-end mb-2">
<div class="relative inline-block text-left">
<div>
<div class="rounded-md shadow-sm">
<button type="button" id="reject-button" onclick="setTimeout(() => this.disabled = true, 0); return true;"
class="inline-flex justify-center w-full rounded-md border border-gray-300 px-4 py-2 bg-white text-sm leading-5 font-medium text-gray-700 hover:text-gray-500 focus:outline-none focus:border-blue-300 focus:ring-blue active:bg-gray-50 active:text-gray-800 transition ease-in-out duration-150">
{{ ctrans('texts.reject') }}
</button>
</div>
</div>
</div>
</div>
</div>
@foreach($quotes as $quote)
<div class="bg-white shadow overflow-hidden sm:rounded-lg mb-4">
<div class="px-4 py-5 border-b border-gray-200 sm:px-6">
<h3 class="text-lg leading-6 font-medium text-gray-900">
{{ ctrans('texts.quote') }}
<a class="button-link text-primary" href="{{ route('client.quote.show', $quote->hashed_id) }}">
({{ $quote->number }})
</a>
</h3>
<p class="mt-1 max-w-2xl text-sm leading-5 text-gray-500" translate>
</p>
</div>
<div>
<dl>
<div class="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt class="text-sm leading-5 font-medium text-gray-500">
{{ ctrans('texts.quote_number') }}
</dt>
<dd class="mt-1 text-sm leading-5 text-gray-900 sm:mt-0 sm:col-span-2">
{{ $quote->number }}
</dd>
</div>
<div class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt class="text-sm leading-5 font-medium text-gray-500">
{{ ctrans('texts.quote_date') }}
</dt>
<dd class="mt-1 text-sm leading-5 text-gray-900 sm:mt-0 sm:col-span-2">
{{ $quote->translateDate($quote->date, $quote->client->date_format(), $quote->client->locale()) }}
</dd>
</div>
<div class="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt class="text-sm leading-5 font-medium text-gray-500">
{{ ctrans('texts.amount') }}
</dt>
<dd class="mt-1 text-sm leading-5 text-gray-900 sm:mt-0 sm:col-span-2">
{{ App\Utils\Number::formatMoney($quote->amount, $quote->client) }}
</dd>
</div>
</dl>
</div>
</div>
@endforeach
</div>
</div>
</div>
@endsection
@section('footer')
@include('portal.ninja2020.quotes.includes.user-input')
@endsection
@push('footer')
@vite('resources/js/clients/quotes/reject.js')
@endpush

View File

@@ -10,7 +10,7 @@
@section('body')
@if(!$quote->isApproved() && $client->getSetting('custom_message_unapproved_quote'))
@if(!$quote->isApproved() && !$quote->isRejected() && $client->getSetting('custom_message_unapproved_quote'))
@component('portal.ninja2020.components.message')
<pre>{{ $client->getSetting('custom_message_unapproved_quote') }}</pre>
@endcomponent
@@ -31,13 +31,13 @@
</h3>
</div>
@if($quote->invoice()->exists())
<div class="mt-5 sm:mt-0 sm:ml-6 flex justify-end">
<div class="inline-flex rounded-md shadow-sm">
<a class="button button-primary bg-primary" href="/client/invoices/{{ $quote->invoice->hashed_id }}">{{ ctrans('texts.view_invoice') }}</a>
</div>
</div>
@endif
@if($quote->invoice()->exists())
<div class="mt-5 sm:mt-0 sm:ml-6 flex justify-end">
<div class="inline-flex rounded-md shadow-sm">
<a class="button button-primary bg-primary" href="/client/invoices/{{ $quote->invoice->hashed_id }}">{{ ctrans('texts.view_invoice') }}</a>
</div>
</div>
@endif
</div>
</div>
</div>
@@ -55,7 +55,19 @@
</div>
</div>
</div>
@elseif($quote->status_id == \App\Models\Quote::STATUS_REJECTED)
<div class="bg-white shadow sm:rounded-lg mb-4">
<div class="px-4 py-5 sm:p-6">
<div class="sm:flex sm:items-start sm:justify-between">
<div>
<h3 class="text-lg leading-6 font-medium text-gray-900">
{{ ctrans('texts.rejected') }}
</h3>
</div>
</div>
</div>
</div>
@else
<div class="bg-white shadow sm:rounded-lg mb-4">
@@ -78,13 +90,14 @@
@section('footer')
@include('portal.ninja2020.quotes.includes.user-input')
@include('portal.ninja2020.quotes.includes.reject-input')
@include('portal.ninja2020.invoices.includes.terms', ['entities' => [$quote], 'variables' => $variables, 'entity_type' => ctrans('texts.quote')])
@include('portal.ninja2020.invoices.includes.signature')
@endsection
@push('head')
@vite('resources/js/clients/quotes/approve.js')
@vite('resources/js/clients/quotes/reject.js')
<script type="text/javascript" defer>
document.addEventListener('DOMContentLoaded', () => {

View File

@@ -25,6 +25,7 @@ export default defineConfig({
'resources/js/clients/payments/checkout-credit-card.js',
'resources/js/clients/quotes/action-selectors.js',
'resources/js/clients/quotes/approve.js',
'resources/js/clients/quotes/reject.js',
'resources/js/clients/payments/stripe-credit-card.js',
'resources/js/setup/setup.js',
'resources/js/clients/shared/pdf.js',