diff --git a/app/Http/Controllers/Auth/VendorContactLoginController.php b/app/Http/Controllers/Auth/VendorContactLoginController.php new file mode 100644 index 000000000000..c9cc34802d7f --- /dev/null +++ b/app/Http/Controllers/Auth/VendorContactLoginController.php @@ -0,0 +1,54 @@ +middleware('guest:vendor', ['except' => ['logout']]); + } + + public function catch() + { + + } + + public function logout() + { + Auth::guard('vendor')->logout(); + request()->session()->invalidate(); + + return redirect('/vendor'); + } + + +} diff --git a/app/Http/Controllers/BaseController.php b/app/Http/Controllers/BaseController.php index e048291d7230..e1bfbf1af4f5 100644 --- a/app/Http/Controllers/BaseController.php +++ b/app/Http/Controllers/BaseController.php @@ -84,6 +84,7 @@ class BaseController extends Controller 'company.products.documents', 'company.payments.paymentables', 'company.payments.documents', + 'company.purchase_orders.documents', 'company.payment_terms.company', 'company.projects.documents', 'company.recurring_expenses', @@ -296,6 +297,13 @@ class BaseController extends Controller if(!$user->hasPermission('view_project')) $query->where('projects.user_id', $user->id)->orWhere('projects.assigned_user_id', $user->id); + }, + 'company.purchase_orders'=> function ($query) use ($updated_at, $user) { + $query->where('updated_at', '>=', $updated_at)->with('documents'); + + if(!$user->hasPermission('view_purchase_order')) + $query->where('purchase_orders.user_id', $user->id)->orWhere('purchase_orders.assigned_user_id', $user->id); + }, 'company.quotes'=> function ($query) use ($updated_at, $user) { $query->where('updated_at', '>=', $updated_at)->with('invitations', 'documents'); @@ -533,6 +541,13 @@ class BaseController extends Controller if(!$user->hasPermission('view_project')) $query->where('projects.user_id', $user->id)->orWhere('projects.assigned_user_id', $user->id); + }, + 'company.purchase_orders'=> function ($query) use ($created_at, $user) { + $query->where('created_at', '>=', $created_at)->with('documents'); + + if(!$user->hasPermission('view_purchase_order')) + $query->where('purchase_orders.user_id', $user->id)->orWhere('purchase_orders.assigned_user_id', $user->id); + }, 'company.quotes'=> function ($query) use ($created_at, $user) { $query->where('created_at', '>=', $created_at)->with('invitations', 'documents'); diff --git a/app/Http/Controllers/SubdomainController.php b/app/Http/Controllers/SubdomainController.php index 6ad54f007535..63b4812e3ba9 100644 --- a/app/Http/Controllers/SubdomainController.php +++ b/app/Http/Controllers/SubdomainController.php @@ -35,6 +35,7 @@ class SubdomainController extends BaseController 'html', 'lb', 'shopify', + 'beta', ]; public function __construct() diff --git a/app/Http/Controllers/VendorPortal/PurchaseOrderController.php b/app/Http/Controllers/VendorPortal/PurchaseOrderController.php index 4742fcb4d2e9..bbc03e8b7867 100644 --- a/app/Http/Controllers/VendorPortal/PurchaseOrderController.php +++ b/app/Http/Controllers/VendorPortal/PurchaseOrderController.php @@ -14,6 +14,7 @@ namespace App\Http\Controllers\VendorPortal; use App\Events\Misc\InvitationWasViewed; use App\Events\PurchaseOrder\PurchaseOrderWasViewed; use App\Http\Controllers\Controller; +use App\Http\Requests\VendorPortal\PurchaseOrders\ProcessPurchaseOrdersInBulkRequest; use App\Http\Requests\VendorPortal\PurchaseOrders\ShowPurchaseOrderRequest; use App\Http\Requests\VendorPortal\PurchaseOrders\ShowPurchaseOrdersRequest; use App\Models\PurchaseOrder; @@ -115,4 +116,73 @@ class PurchaseOrderController extends Controller return $data; } + public function bulk(ProcessPurchaseOrdersInBulkRequest $request) + { + $transformed_ids = $this->transformKeys($request->purchase_orders); + + if ($request->input('action') == 'download') { + return $this->downloadInvoices((array) $transformed_ids); + } + + return redirect() + ->back() + ->with('message', ctrans('texts.no_action_provided')); + } + + public function downloadInvoices($ids) + { + + $purchase_orders = PurchaseOrder::whereIn('id', $ids) + ->where('vendor_id', auth()->guard('vendor')->user()->vendor_id) + ->withTrashed() + ->get(); + + if(count($purchase_orders) == 0) + return back()->with(['message' => ctrans('texts.no_items_selected')]); + + + if(count($purchase_orders) == 1){ + + $purchase_order = $purchase_orders->first(); + + $file = $purchase_order->service()->getPurchaseOrderPdf(auth()->guard('vendor')->user()); + + return response()->streamDownload(function () use($file) { + echo Storage::get($file); + }, basename($file), ['Content-Type' => 'application/pdf']); + } + + return $this->buildZip($purchase_orders); + } + + + private function buildZip($purchase_orders) + { + // create new archive + $zipFile = new \PhpZip\ZipFile(); + try{ + + foreach ($purchase_orders as $purchase_order) { + + #add it to the zip + $zipFile->addFromString(basename($purchase_order->pdf_file_path()), file_get_contents($purchase_order->pdf_file_path(null, 'url', true))); + + } + + $filename = date('Y-m-d').'_'.str_replace(' ', '_', trans('texts.purchase_orders')).'.zip'; + $filepath = sys_get_temp_dir() . '/' . $filename; + + $zipFile->saveAsFile($filepath) // save the archive to a file + ->close(); // close archive + + return response()->download($filepath, $filename)->deleteFileAfterSend(true); + + } + catch(\PhpZip\Exception\ZipException $e){ + // handle exception + } + finally{ + $zipFile->close(); + } + } } diff --git a/app/Http/Requests/VendorPortal/PurchaseOrders/ProcessPurchaseOrdersInBulkRequest.php b/app/Http/Requests/VendorPortal/PurchaseOrders/ProcessPurchaseOrdersInBulkRequest.php new file mode 100644 index 000000000000..88244f1edc6e --- /dev/null +++ b/app/Http/Requests/VendorPortal/PurchaseOrders/ProcessPurchaseOrdersInBulkRequest.php @@ -0,0 +1,31 @@ +guard('vendor')->user()->vendor->company->enabled_modules & PortalComposer::MODULE_PURCHASE_ORDERS; + } + + public function rules() + { + return [ + 'purchase_orders' => ['array'], + ]; + } +} diff --git a/app/Jobs/Vendor/CreatePurchaseOrderPdf.php b/app/Jobs/Vendor/CreatePurchaseOrderPdf.php index 2b601a8d1fd5..fde78e220368 100644 --- a/app/Jobs/Vendor/CreatePurchaseOrderPdf.php +++ b/app/Jobs/Vendor/CreatePurchaseOrderPdf.php @@ -70,7 +70,7 @@ class CreatePurchaseOrderPdf implements ShouldQueue * * @param $invitation */ - public function __construct($invitation, $disk = 'public') + public function __construct($invitation, $disk = null) { $this->invitation = $invitation; $this->company = $invitation->company; @@ -83,7 +83,7 @@ class CreatePurchaseOrderPdf implements ShouldQueue $this->vendor = $invitation->contact->vendor; $this->vendor->load('company'); - $this->disk = Ninja::isHosted() ? config('filesystems.default') : $disk; + $this->disk = $disk ?? config('filesystems.default'); } diff --git a/app/Models/Company.php b/app/Models/Company.php index 4c02942c854e..10977900fe42 100644 --- a/app/Models/Company.php +++ b/app/Models/Company.php @@ -14,6 +14,7 @@ namespace App\Models; use App\DataMapper\CompanySettings; use App\Models\Language; use App\Models\Presenters\CompanyPresenter; +use App\Models\PurchaseOrder; use App\Models\User; use App\Services\Notification\NotificationService; use App\Utils\Ninja; @@ -192,6 +193,11 @@ class Company extends BaseModel return $this->hasMany(Subscription::class)->withTrashed(); } + public function purchase_orders() + { + return $this->hasMany(PurchaseOrder::class)->withTrashed(); + } + public function task_statuses() { return $this->hasMany(TaskStatus::class)->withTrashed(); diff --git a/app/Models/PurchaseOrder.php b/app/Models/PurchaseOrder.php index 326f8b9791f5..70419412e960 100644 --- a/app/Models/PurchaseOrder.php +++ b/app/Models/PurchaseOrder.php @@ -101,7 +101,7 @@ class PurchaseOrder extends BaseModel const STATUS_DRAFT = 1; const STATUS_SENT = 2; - const STATUS_APPROVED = 3; + const STATUS_ACCEPTED = 3; const STATUS_CANCELLED = 4; public static function stringStatus(int $status) @@ -113,8 +113,8 @@ class PurchaseOrder extends BaseModel case self::STATUS_SENT: return ctrans('texts.sent'); break; - case self::STATUS_APPROVED: - return ctrans('texts.approved'); + case self::STATUS_ACCEPTED: + return ctrans('texts.accepted'); break; case self::STATUS_CANCELLED: return ctrans('texts.cancelled'); @@ -134,8 +134,8 @@ class PurchaseOrder extends BaseModel case self::STATUS_SENT: return '
'.ctrans('texts.sent').'
'; break; - case self::STATUS_APPROVED: - return '
'.ctrans('texts.approved').'
'; + case self::STATUS_ACCEPTED: + return '
'.ctrans('texts.accepted').'
'; break; case self::STATUS_CANCELLED: return '
'.ctrans('texts.cancelled').'
'; diff --git a/app/Services/Invoice/InvoiceService.php b/app/Services/Invoice/InvoiceService.php index a0a90deb0363..234392313b31 100644 --- a/app/Services/Invoice/InvoiceService.php +++ b/app/Services/Invoice/InvoiceService.php @@ -341,7 +341,7 @@ class InvoiceService if(Storage::disk(config('filesystems.default'))->exists($this->invoice->client->invoice_filepath($invitation) . $this->invoice->numberFormatter().'.pdf')) Storage::disk(config('filesystems.default'))->delete($this->invoice->client->invoice_filepath($invitation) . $this->invoice->numberFormatter().'.pdf'); - if(Ninja::isHosted() && Storage::disk(config('filesystems.default'))->exists($this->invoice->client->invoice_filepath($invitation) . $this->invoice->numberFormatter().'.pdf')) { + if(Ninja::isHosted() && Storage::disk('public')->exists($this->invoice->client->invoice_filepath($invitation) . $this->invoice->numberFormatter().'.pdf')) { Storage::disk('public')->delete($this->invoice->client->invoice_filepath($invitation) . $this->invoice->numberFormatter().'.pdf'); } diff --git a/app/Transformers/CompanyTransformer.php b/app/Transformers/CompanyTransformer.php index e1a9ee0d13eb..554e33ab27f2 100644 --- a/app/Transformers/CompanyTransformer.php +++ b/app/Transformers/CompanyTransformer.php @@ -29,6 +29,7 @@ use App\Models\Payment; use App\Models\PaymentTerm; use App\Models\Product; use App\Models\Project; +use App\Models\PurchaseOrder; use App\Models\Quote; use App\Models\RecurringExpense; use App\Models\RecurringInvoice; @@ -39,6 +40,7 @@ use App\Models\TaskStatus; use App\Models\TaxRate; use App\Models\User; use App\Models\Webhook; +use App\Transformers\PurchaseOrderTransformer; use App\Transformers\RecurringExpenseTransformer; use App\Utils\Traits\MakesHash; use stdClass; @@ -95,6 +97,7 @@ class CompanyTransformer extends EntityTransformer 'task_statuses', 'subscriptions', 'recurring_expenses', + 'purchase_orders', ]; /** @@ -391,4 +394,11 @@ class CompanyTransformer extends EntityTransformer return $this->includeCollection($company->subscriptions, $transformer, Subscription::class); } + + public function includePurchaseOrders(Company $company) + { + $transformer = new PurchaseOrderTransformer($this->serializer); + + return $this->includeCollection($company->purchase_orders, $transformer, PurchaseOrder::class); + } } diff --git a/public/js/clients/purchase_orders/action-selectors.js b/public/js/clients/purchase_orders/action-selectors.js new file mode 100644 index 000000000000..afc05b746e16 --- /dev/null +++ b/public/js/clients/purchase_orders/action-selectors.js @@ -0,0 +1,2 @@ +/*! For license information please see action-selectors.js.LICENSE.txt */ +(()=>{function e(e,n){var r="undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(!r){if(Array.isArray(e)||(r=function(e,n){if(!e)return;if("string"==typeof e)return t(e,n);var r=Object.prototype.toString.call(e).slice(8,-1);"Object"===r&&e.constructor&&(r=e.constructor.name);if("Map"===r||"Set"===r)return Array.from(e);if("Arguments"===r||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(r))return t(e,n)}(e))||n&&e&&"number"==typeof e.length){r&&(e=r);var o=0,c=function(){};return{s:c,n:function(){return o>=e.length?{done:!0}:{done:!1,value:e[o++]}},e:function(e){throw e},f:c}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var i,a=!0,l=!1;return{s:function(){r=r.call(e)},n:function(){var e=r.next();return a=e.done,e},e:function(e){l=!0,i=e},f:function(){try{a||null==r.return||r.return()}finally{if(l)throw i}}}}function t(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=new Array(t);n2&&void 0!==arguments[2]?arguments[2]:{};if(r.hasOwnProperty("single")&&document.querySelectorAll(".child-hidden-input").forEach((function(e){return e.remove()})),!1!==t.checked){var o=document.createElement("INPUT");o.setAttribute("name","purchase_orders[]"),o.setAttribute("value",t.dataset.value),o.setAttribute("class","child-hidden-input"),o.hidden=!0,n.append(o)}else{var c,i=document.querySelectorAll("input.child-hidden-input"),a=e(i);try{for(a.s();!(c=a.n()).done;){var l=c.value;l.value==t.dataset.value&&l.remove()}}catch(e){a.e(e)}finally{a.f()}}}},{key:"handle",value:function(){var t=this;this.parentElement.addEventListener("click",(function(){t.watchCheckboxes(t.parentElement)}));var n,r=e(document.querySelectorAll(".form-check-child"));try{var o=function(){var e=n.value;e.addEventListener("click",(function(){t.processChildItem(e,t.parentForm)}))};for(r.s();!(n=r.n()).done;)o()}catch(e){r.e(e)}finally{r.f()}}}],o&&n(r.prototype,o),c&&n(r,c),Object.defineProperty(r,"prototype",{writable:!1}),t}())).handle()})(); \ No newline at end of file diff --git a/public/js/clients/purchase_orders/action-selectors.js.LICENSE.txt b/public/js/clients/purchase_orders/action-selectors.js.LICENSE.txt new file mode 100644 index 000000000000..82efe1d67ef8 --- /dev/null +++ b/public/js/clients/purchase_orders/action-selectors.js.LICENSE.txt @@ -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 + */ diff --git a/public/mix-manifest.json b/public/mix-manifest.json index 8f526ac40699..f97b196ea88a 100755 --- a/public/mix-manifest.json +++ b/public/mix-manifest.json @@ -4,6 +4,7 @@ "/js/clients/payments/authorize-credit-card-payment.js": "/js/clients/payments/authorize-credit-card-payment.js?id=803182f668c39d631ca5c55437876da4", "/js/clients/payments/stripe-ach.js": "/js/clients/payments/stripe-ach.js?id=7bed15f51bca764378d9a3aa605b8664", "/js/clients/invoices/action-selectors.js": "/js/clients/invoices/action-selectors.js?id=d4f86ddee4e8a1d6e9719010aa0fe62b", + "/js/clients/purchase_orders/action-selectors.js": "/js/clients/purchase_orders/action-selectors.js?id=160b8161599fc2429b449b0970d3ba6c", "/js/clients/invoices/payment.js": "/js/clients/invoices/payment.js?id=b88ad7c8881cc87df07b129c5a7c76df", "/js/clients/payments/stripe-sofort.js": "/js/clients/payments/stripe-sofort.js?id=1c5493a4c53a5b862d07ee1818179ea9", "/js/clients/payments/stripe-alipay.js": "/js/clients/payments/stripe-alipay.js?id=0274ab4f8d2b411f2a2fe5142301e7af", diff --git a/resources/js/clients/purchase_orders/action-selectors.js b/resources/js/clients/purchase_orders/action-selectors.js new file mode 100644 index 000000000000..360261e92b91 --- /dev/null +++ b/resources/js/clients/purchase_orders/action-selectors.js @@ -0,0 +1,79 @@ +/** + * 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 ActionSelectors { + constructor() { + this.parentElement = document.querySelector('.form-check-parent'); + this.parentForm = document.getElementById('bulkActions'); + } + + watchCheckboxes(parentElement) { + document + .querySelectorAll('.child-hidden-input') + .forEach((element) => element.remove()); + + document.querySelectorAll('.form-check-child').forEach((child) => { + if (parentElement.checked) { + child.checked = parentElement.checked; + this.processChildItem( + child, + document.getElementById('bulkActions') + ); + } else { + child.checked = false; + document + .querySelectorAll('.child-hidden-input') + .forEach((element) => element.remove()); + } + }); + } + + processChildItem(element, parent, options = {}) { + if (options.hasOwnProperty('single')) { + document + .querySelectorAll('.child-hidden-input') + .forEach((element) => element.remove()); + } + + if (element.checked === false) { + let inputs = document.querySelectorAll('input.child-hidden-input'); + + for (let i of inputs) { + if (i.value == element.dataset.value) i.remove(); + } + + return; + } + + let _temp = document.createElement('INPUT'); + + _temp.setAttribute('name', 'purchase_orders[]'); + _temp.setAttribute('value', element.dataset.value); + _temp.setAttribute('class', 'child-hidden-input'); + _temp.hidden = true; + + parent.append(_temp); + } + + handle() { + this.parentElement.addEventListener('click', () => { + this.watchCheckboxes(this.parentElement); + }); + + for (let child of document.querySelectorAll('.form-check-child')) { + child.addEventListener('click', () => { + this.processChildItem(child, this.parentForm); + }); + } + } +} + +/** @handle **/ +new ActionSelectors().handle(); diff --git a/resources/lang/en/texts.php b/resources/lang/en/texts.php index 64cc99d50e0c..00a578f1470d 100644 --- a/resources/lang/en/texts.php +++ b/resources/lang/en/texts.php @@ -4628,7 +4628,7 @@ $LANG = array( 'purchase_order_date' => 'Purchase Order Date', 'purchase_orders' => 'Purchase Orders', 'purchase_order_number_placeholder' => 'Purchase Order # :purchase_order', - + 'accepted' => 'Accepted', ); return $LANG; diff --git a/resources/views/portal/ninja2020/components/livewire/purchase-orders-table.blade.php b/resources/views/portal/ninja2020/components/livewire/purchase-orders-table.blade.php index 8907f454e83d..237003fcbff7 100644 --- a/resources/views/portal/ninja2020/components/livewire/purchase-orders-table.blade.php +++ b/resources/views/portal/ninja2020/components/livewire/purchase-orders-table.blade.php @@ -121,5 +121,5 @@ @push('footer') - + @endpush diff --git a/resources/views/portal/ninja2020/purchase_orders/catch.blade.php b/resources/views/portal/ninja2020/purchase_orders/catch.blade.php new file mode 100644 index 000000000000..f3a8543df73a --- /dev/null +++ b/resources/views/portal/ninja2020/purchase_orders/catch.blade.php @@ -0,0 +1,18 @@ +@extends('portal.ninja2020.layout.vendor_app') +@section('meta_title', ctrans('texts.purchase_orders')) + +@section('header') + @if($errors->any()) +
+ @foreach($errors->all() as $error) +

{{ $error }}

+ @endforeach +
+ @endif +@endsection + +@section('body') +
+

Vendor Portal

+
+@endsection diff --git a/routes/vendor.php b/routes/vendor.php index 575daaeea9d0..22a5235e173c 100644 --- a/routes/vendor.php +++ b/routes/vendor.php @@ -9,10 +9,13 @@ | is assigned the "api" middleware group. Enjoy building your API! | */ +use App\Http\Controllers\Auth\VendorContactLoginController; use App\Http\Controllers\VendorPortal\InvitationController; use App\Http\Controllers\VendorPortal\PurchaseOrderController; use Illuminate\Support\Facades\Route; +Route::get('vendor', [VendorContactLoginController::class, 'catch'])->name('vendor.catchall')->middleware(['domain_db', 'contact_account','vendor_locale']); //catch all + Route::group(['middleware' => ['invite_db'], 'prefix' => 'vendor', 'as' => 'vendor.'], function () { /*Invitation catches*/ Route::get('purchase_order/{invitation_key}', [InvitationController::class, 'purchaseOrder']); @@ -30,5 +33,6 @@ Route::group(['middleware' => ['auth:vendor', 'vendor_locale', 'domain_db'], 'pr Route::get('profile/{vendor_contact}/edit', [PurchaseOrderController::class, 'index'])->name('profile.edit'); Route::post('invoices/payment', [PurchaseOrderController::class, 'bulk'])->name('purchase_orders.bulk'); + Route::get('logout', [VendorContactLoginController::class, 'logout'])->name('logout'); }); \ No newline at end of file diff --git a/webpack.mix.js b/webpack.mix.js index 9f0c809188b5..a6e0ebb55a36 100644 --- a/webpack.mix.js +++ b/webpack.mix.js @@ -18,6 +18,10 @@ mix.js("resources/js/app.js", "public/js") "resources/js/clients/invoices/action-selectors.js", "public/js/clients/invoices/action-selectors.js" ) + .js( + "resources/js/clients/purchase_orders/action-selectors.js", + "public/js/clients/purchase_orders/action-selectors.js" + ) .js( "resources/js/clients/invoices/payment.js", "public/js/clients/invoices/payment.js"