mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-04-24 13:44:03 +00:00
Feat(Gotenberg): Opt-in alternative pdf generation for modern CSS (#184)
* WIP(gotenberg): add pdf generation abstraction and UI * feat(pdf): settings validate(clien+server) & save * fix(gotenberg): Use correct default papersize chore(gotengberg): Remove unused GOTENBERG_MARGINS env from .env * style(gotenberg): fix linter/styling issues * fix(pdf): use pdf config policy * fix: revert accidental capitalization in mail config vue * Update composer, remove whitespace typo * Fix small typos * fix cookie/env issue * Add gotenberg to .dev, move admin menu item up
This commit is contained in:
@@ -63,6 +63,11 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- invoiceshelf-dev
|
- invoiceshelf-dev
|
||||||
|
|
||||||
|
pdf:
|
||||||
|
image: gotenberg/gotenberg:8
|
||||||
|
networks:
|
||||||
|
- invoiceshelf-dev
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
invoiceshelf-dev:
|
invoiceshelf-dev:
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,11 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- invoiceshelf-dev
|
- invoiceshelf-dev
|
||||||
|
|
||||||
|
pdf:
|
||||||
|
image: gotenberg/gotenberg:8
|
||||||
|
networks:
|
||||||
|
- invoiceshelf-dev
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
invoiceshelf-dev:
|
invoiceshelf-dev:
|
||||||
|
|
||||||
|
|||||||
@@ -50,5 +50,10 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- invoiceshelf-dev
|
- invoiceshelf-dev
|
||||||
|
|
||||||
|
pdf:
|
||||||
|
image: gotenberg/gotenberg:8
|
||||||
|
networks:
|
||||||
|
- invoiceshelf-dev
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
invoiceshelf-dev:
|
invoiceshelf-dev:
|
||||||
|
|||||||
@@ -54,3 +54,7 @@ TRUSTED_PROXIES="*"
|
|||||||
|
|
||||||
CRON_JOB_AUTH_TOKEN=""
|
CRON_JOB_AUTH_TOKEN=""
|
||||||
LOG_STACK=single
|
LOG_STACK=single
|
||||||
|
|
||||||
|
PDF_DRIVER=dompdf
|
||||||
|
GOTENBERG_HOST=
|
||||||
|
GOTENBERG_PAPERSIZE=
|
||||||
|
|||||||
16
app/Facades/PDF.php
Normal file
16
app/Facades/PDF.php
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Facades;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Facade;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @method static \Psr\Http\Message\ResponseInterface loadView(string $template)
|
||||||
|
*/
|
||||||
|
class PDF extends Facade
|
||||||
|
{
|
||||||
|
protected static function getFacadeAccessor()
|
||||||
|
{
|
||||||
|
return 'pdf.driver';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\V1\Admin\Settings;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\PDFConfigurationRequest;
|
||||||
|
use App\Space\EnvironmentManager;
|
||||||
|
|
||||||
|
class PDFConfigurationController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var EnvironmentManager
|
||||||
|
*/
|
||||||
|
protected $environmentManager;
|
||||||
|
|
||||||
|
public function __construct(EnvironmentManager $environmentManager)
|
||||||
|
{
|
||||||
|
$this->environmentManager = $environmentManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDrivers()
|
||||||
|
{
|
||||||
|
$this->authorize('manage pdf config');
|
||||||
|
|
||||||
|
$drivers = [
|
||||||
|
'dompdf',
|
||||||
|
'gotenberg',
|
||||||
|
];
|
||||||
|
|
||||||
|
return response()->json($drivers);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEnvironment()
|
||||||
|
{
|
||||||
|
$this->authorize('manage pdf config');
|
||||||
|
|
||||||
|
$config = [
|
||||||
|
'pdf_driver' => config('pdf.driver'),
|
||||||
|
'gotenberg_host' => config('pdf.gotenberg.host'),
|
||||||
|
'gotenberg_margins' => config('pdf.gotenberg.margins'),
|
||||||
|
'gotenberg_papersize' => config('pdf.gotenberg.papersize'),
|
||||||
|
];
|
||||||
|
|
||||||
|
return response()->json($config);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveEnvironment(PDFConfigurationRequest $request)
|
||||||
|
{
|
||||||
|
$this->authorize('manage pdf config');
|
||||||
|
$results = $this->environmentManager->savePDFVariables($request);
|
||||||
|
|
||||||
|
return response()->json($results);
|
||||||
|
}
|
||||||
|
}
|
||||||
57
app/Http/Requests/PDFConfigurationRequest.php
Normal file
57
app/Http/Requests/PDFConfigurationRequest.php
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class PDFConfigurationRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
switch ($this->get('pdf_driver')) {
|
||||||
|
case 'dompdf':
|
||||||
|
return [
|
||||||
|
'pdf_driver' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'gotenberg':
|
||||||
|
return [
|
||||||
|
'pdf_driver' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
],
|
||||||
|
'gotenberg_host' => [
|
||||||
|
'required',
|
||||||
|
'url',
|
||||||
|
],
|
||||||
|
'gotenberg_papersize' => [
|
||||||
|
function ($attribute, $value, $fail) {
|
||||||
|
($attribute); // unused
|
||||||
|
$reg = "/^\d+(pt|px|pc|mm|cm|in) \d+(pt|px|pc|mm|cm|in)$/";
|
||||||
|
if (! preg_match($reg, $value)) {
|
||||||
|
$fail('Invalid papersize, must be in format "210mm 297mm". Accepts: pt,px,pc,mm,cm,in');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
throw new \InvalidArgumentException('Invalid PDFDriver requested');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,9 +6,9 @@ use App;
|
|||||||
use App\Mail\SendEstimateMail;
|
use App\Mail\SendEstimateMail;
|
||||||
use App\Services\SerialNumberFormatter;
|
use App\Services\SerialNumberFormatter;
|
||||||
use App\Space\PdfTemplateUtils;
|
use App\Space\PdfTemplateUtils;
|
||||||
|
use App\Facades\PDF;
|
||||||
use App\Traits\GeneratesPdfTrait;
|
use App\Traits\GeneratesPdfTrait;
|
||||||
use App\Traits\HasCustomFieldsTrait;
|
use App\Traits\HasCustomFieldsTrait;
|
||||||
use Barryvdh\DomPDF\Facade\Pdf as PDF;
|
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|||||||
@@ -3,12 +3,12 @@
|
|||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App;
|
use App;
|
||||||
|
use App\Facades\PDF;
|
||||||
use App\Mail\SendInvoiceMail;
|
use App\Mail\SendInvoiceMail;
|
||||||
use App\Services\SerialNumberFormatter;
|
use App\Services\SerialNumberFormatter;
|
||||||
use App\Space\PdfTemplateUtils;
|
use App\Space\PdfTemplateUtils;
|
||||||
use App\Traits\GeneratesPdfTrait;
|
use App\Traits\GeneratesPdfTrait;
|
||||||
use App\Traits\HasCustomFieldsTrait;
|
use App\Traits\HasCustomFieldsTrait;
|
||||||
use Barryvdh\DomPDF\Facade\Pdf as PDF;
|
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|||||||
@@ -46,6 +46,15 @@ class SettingsPolicy
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function managePDFConfig(User $user)
|
||||||
|
{
|
||||||
|
if ($user->isOwner()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
public function manageSettings(User $user)
|
public function manageSettings(User $user)
|
||||||
{
|
{
|
||||||
if ($user->isOwner()) {
|
if ($user->isOwner()) {
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
Gate::define('manage backups', [SettingsPolicy::class, 'manageBackups']);
|
Gate::define('manage backups', [SettingsPolicy::class, 'manageBackups']);
|
||||||
Gate::define('manage file disk', [SettingsPolicy::class, 'manageFileDisk']);
|
Gate::define('manage file disk', [SettingsPolicy::class, 'manageFileDisk']);
|
||||||
Gate::define('manage email config', [SettingsPolicy::class, 'manageEmailConfig']);
|
Gate::define('manage email config', [SettingsPolicy::class, 'manageEmailConfig']);
|
||||||
|
Gate::define('manage pdf config', [SettingsPolicy::class, 'managePDFConfig']);
|
||||||
Gate::define('manage notes', [NotePolicy::class, 'manageNotes']);
|
Gate::define('manage notes', [NotePolicy::class, 'manageNotes']);
|
||||||
Gate::define('view notes', [NotePolicy::class, 'viewNotes']);
|
Gate::define('view notes', [NotePolicy::class, 'viewNotes']);
|
||||||
|
|
||||||
|
|||||||
13
app/Providers/PDFServiceProvider.php
Normal file
13
app/Providers/PDFServiceProvider.php
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use App\Services\PDFService;
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
|
class PDFServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
public $bindings = [
|
||||||
|
'pdf.driver' => PDFService::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
59
app/Services/PDFDrivers/GotenbergPDFDriver.php
Normal file
59
app/Services/PDFDrivers/GotenbergPDFDriver.php
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\PDFDrivers;
|
||||||
|
|
||||||
|
use Gotenberg\Gotenberg;
|
||||||
|
use Gotenberg\Stream;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
|
||||||
|
class GotenbergPDFResponse
|
||||||
|
{
|
||||||
|
/** @var \Psr\Http\Message\ResponseInterface */
|
||||||
|
protected $response;
|
||||||
|
|
||||||
|
public function __construct($stream)
|
||||||
|
{
|
||||||
|
$this->response = $stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stream(string $filename = 'document.pdf'): Response
|
||||||
|
{
|
||||||
|
$output = $this->response->getBody();
|
||||||
|
|
||||||
|
return new Response($output, 200, [
|
||||||
|
'Content-Type' => 'application/pdf',
|
||||||
|
'Content-Disposition' => 'inline; filename="'.$filename.'"',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function output(): string
|
||||||
|
{
|
||||||
|
return $this->response->getBody()->getContents();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class GotenbergPDFDriver
|
||||||
|
{
|
||||||
|
public function loadView(string $viewname): GotenbergPDFResponse
|
||||||
|
{
|
||||||
|
$papersize = explode(' ', config('pdf.gotenberg.papersize'));
|
||||||
|
if (count($papersize) != 2) {
|
||||||
|
throw new \InvalidArgumentException('Invalid Gotenberg Papersize specified');
|
||||||
|
}
|
||||||
|
|
||||||
|
$host = config('pdf.gotenberg.host');
|
||||||
|
$request = Gotenberg::chromium($host)
|
||||||
|
->pdf()
|
||||||
|
->margins(0, 0, 0, 0) // Margins can be set using CSS
|
||||||
|
->paperSize($papersize[0], $papersize[1])
|
||||||
|
->html(
|
||||||
|
Stream::string(
|
||||||
|
'document.html',
|
||||||
|
view($viewname)->render(),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
$result = Gotenberg::send($request);
|
||||||
|
|
||||||
|
return new GotenbergPDFResponse($result);
|
||||||
|
}
|
||||||
|
}
|
||||||
47
app/Services/PDFService.php
Normal file
47
app/Services/PDFService.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Two options:
|
||||||
|
* - Barryvdh\DomPDF\Facade\Pdf
|
||||||
|
* - Gotenberg
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App;
|
||||||
|
use App\Services\PDFDrivers\GotenbergPDFDriver;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
|
||||||
|
interface ResponseStream
|
||||||
|
{
|
||||||
|
public function stream(string $filename): Response;
|
||||||
|
|
||||||
|
public function output(): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PDFDriver
|
||||||
|
{
|
||||||
|
public function loadView(string $template): ResponseStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
class PDFDriverFactory
|
||||||
|
{
|
||||||
|
public static function create(string $driver)
|
||||||
|
{
|
||||||
|
return match ($driver) {
|
||||||
|
'dompdf' => App::make('dompdf.wrapper'),
|
||||||
|
'gotenberg' => new GotenbergPDFDriver,
|
||||||
|
default => throw new \InvalidArgumentException('Invalid PDFDriver requested')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PDFService
|
||||||
|
{
|
||||||
|
public static function loadView(string $template)
|
||||||
|
{
|
||||||
|
$driver = config('pdf.driver');
|
||||||
|
|
||||||
|
return PDFDriverFactory::create($driver)->loadView($template);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ use App\Http\Requests\DatabaseEnvironmentRequest;
|
|||||||
use App\Http\Requests\DiskEnvironmentRequest;
|
use App\Http\Requests\DiskEnvironmentRequest;
|
||||||
use App\Http\Requests\DomainEnvironmentRequest;
|
use App\Http\Requests\DomainEnvironmentRequest;
|
||||||
use App\Http\Requests\MailEnvironmentRequest;
|
use App\Http\Requests\MailEnvironmentRequest;
|
||||||
|
use App\Http\Requests\PDFConfigurationRequest;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Support\Facades\Artisan;
|
use Illuminate\Support\Facades\Artisan;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@@ -94,7 +95,6 @@ class EnvironmentManager
|
|||||||
}
|
}
|
||||||
|
|
||||||
return $str;
|
return $str;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -225,7 +225,6 @@ class EnvironmentManager
|
|||||||
try {
|
try {
|
||||||
|
|
||||||
$this->updateEnv($mailEnv);
|
$this->updateEnv($mailEnv);
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
return [
|
return [
|
||||||
'error' => 'mail_variables_save_error',
|
'error' => 'mail_variables_save_error',
|
||||||
@@ -237,6 +236,60 @@ class EnvironmentManager
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save the pdf generation content to the .env file.
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function savePDFVariables(PDFConfigurationRequest $request)
|
||||||
|
{
|
||||||
|
$pdfEnv = $this->getPDFConfiguration($request);
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
$this->updateEnv($pdfEnv);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
return [
|
||||||
|
'error' => 'pdf_variables_save_error',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => 'pdf_variables_save_successfully',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the pdf configuration
|
||||||
|
*
|
||||||
|
* @param PDFConfigurationRequest $request
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
private function getPDFConfiguration($request)
|
||||||
|
{
|
||||||
|
$pdfEnv = [];
|
||||||
|
|
||||||
|
$driver = $request->get('pdf_driver');
|
||||||
|
|
||||||
|
switch ($driver) {
|
||||||
|
case 'dompdf':
|
||||||
|
$pdfEnv = [
|
||||||
|
'PDF_DRIVER' => $request->get('pdf_driver'),
|
||||||
|
];
|
||||||
|
break;
|
||||||
|
case 'gotenberg':
|
||||||
|
$pdfEnv = [
|
||||||
|
'PDF_DRIVER' => $request->get('pdf_driver'),
|
||||||
|
'GOTENBERG_HOST' => $request->get('gotenberg_host'),
|
||||||
|
'GOTENBERG_MARGINS' => $request->get('gotenberg_margins'),
|
||||||
|
'GOTENBERG_PAPERSIZE' => $request->get('gotenberg_papersize'),
|
||||||
|
];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $pdfEnv;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the mail configuration
|
* Returns the mail configuration
|
||||||
*
|
*
|
||||||
@@ -316,7 +369,6 @@ class EnvironmentManager
|
|||||||
];
|
];
|
||||||
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $mailEnv;
|
return $mailEnv;
|
||||||
@@ -334,7 +386,6 @@ class EnvironmentManager
|
|||||||
try {
|
try {
|
||||||
|
|
||||||
$this->updateEnv($diskEnv);
|
$this->updateEnv($diskEnv);
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
return [
|
return [
|
||||||
'error' => 'disk_variables_save_error',
|
'error' => 'disk_variables_save_error',
|
||||||
@@ -450,7 +501,6 @@ class EnvironmentManager
|
|||||||
}
|
}
|
||||||
$formatted .= $current.$this->delimiter;
|
$formatted .= $current.$this->delimiter;
|
||||||
$previous = $current;
|
$previous = $current;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
file_put_contents($this->envPath, trim($formatted));
|
file_put_contents($this->envPath, trim($formatted));
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ return [
|
|||||||
App\Providers\RouteServiceProvider::class,
|
App\Providers\RouteServiceProvider::class,
|
||||||
App\Providers\DropboxServiceProvider::class,
|
App\Providers\DropboxServiceProvider::class,
|
||||||
App\Providers\ViewServiceProvider::class,
|
App\Providers\ViewServiceProvider::class,
|
||||||
|
App\Providers\PDFServiceProvider::class,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"doctrine/dbal": "^4.2",
|
"doctrine/dbal": "^4.2",
|
||||||
"dragonmantank/cron-expression": "^v3.4",
|
"dragonmantank/cron-expression": "^v3.4",
|
||||||
"guzzlehttp/guzzle": "^7.9",
|
"guzzlehttp/guzzle": "^7.9",
|
||||||
|
"gotenberg/gotenberg-php": "^2.8",
|
||||||
"invoiceshelf/modules": "^1.0.0",
|
"invoiceshelf/modules": "^1.0.0",
|
||||||
"jasonmccreary/laravel-test-assertions": "^v2.4",
|
"jasonmccreary/laravel-test-assertions": "^v2.4",
|
||||||
"laravel/framework": "^11.31",
|
"laravel/framework": "^11.31",
|
||||||
@@ -79,7 +80,8 @@
|
|||||||
"preferred-install": "dist",
|
"preferred-install": "dist",
|
||||||
"sort-packages": true,
|
"sort-packages": true,
|
||||||
"allow-plugins": {
|
"allow-plugins": {
|
||||||
"pestphp/pest-plugin": true
|
"pestphp/pest-plugin": true,
|
||||||
|
"php-http/discovery": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"extra": {
|
"extra": {
|
||||||
|
|||||||
1323
composer.lock
generated
1323
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -156,6 +156,16 @@ return [
|
|||||||
'ability' => '',
|
'ability' => '',
|
||||||
'model' => '',
|
'model' => '',
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'title' => 'settings.menu_title.pdf_generation',
|
||||||
|
'group' => '',
|
||||||
|
'name' => 'PDF Generation',
|
||||||
|
'link' => '/admin/settings/pdf-generation',
|
||||||
|
'icon' => 'DocumentIcon',
|
||||||
|
'owner_only' => true,
|
||||||
|
'ability' => '',
|
||||||
|
'model' => '',
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'title' => 'settings.roles.title',
|
'title' => 'settings.roles.title',
|
||||||
'group' => '',
|
'group' => '',
|
||||||
|
|||||||
14
config/pdf.php
Normal file
14
config/pdf.php
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
'driver' => env('PDF_DRIVER', 'gotenberg'),
|
||||||
|
|
||||||
|
'gotenberg' => [
|
||||||
|
'host' => env('GOTENBERG_HOST', 'http://pdf:3000'),
|
||||||
|
'papersize' => env('GOTENBERG_PAPERSIZE', '210mm 297mm'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'dompdf' => [],
|
||||||
|
|
||||||
|
];
|
||||||
13
lang/en.json
13
lang/en.json
@@ -852,7 +852,8 @@
|
|||||||
"payment_modes": "Payment Modes",
|
"payment_modes": "Payment Modes",
|
||||||
"notes": "Notes",
|
"notes": "Notes",
|
||||||
"exchange_rate": "Exchange Rate",
|
"exchange_rate": "Exchange Rate",
|
||||||
"address_information": "Address Information"
|
"address_information": "Address Information",
|
||||||
|
"pdf_generation": "PDF Generation"
|
||||||
},
|
},
|
||||||
"address_information": {
|
"address_information": {
|
||||||
"section_description": " You can update Your Address information using form below."
|
"section_description": " You can update Your Address information using form below."
|
||||||
@@ -905,7 +906,15 @@
|
|||||||
"pdf": {
|
"pdf": {
|
||||||
"title": "PDF Setting",
|
"title": "PDF Setting",
|
||||||
"footer_text": "Footer Text",
|
"footer_text": "Footer Text",
|
||||||
"pdf_layout": "PDF Layout"
|
"pdf_layout": "PDF Layout",
|
||||||
|
"pdf_configuration": "PDF Generation Settings",
|
||||||
|
"section_description": "Change the way PDFs are generated",
|
||||||
|
"driver": "PDF Driver to use",
|
||||||
|
"papersize": "Papersize",
|
||||||
|
"papersize_hint": "Papersize in width and height (ex. \"210mm 297mm\")",
|
||||||
|
"gotenberg_host": "Gotenberg service host",
|
||||||
|
"pdf_variables_save_successfully": "PDF configuration saved successfully",
|
||||||
|
"pdf_variables_save_error": "PDF configuration could not be saved"
|
||||||
},
|
},
|
||||||
"company_info": {
|
"company_info": {
|
||||||
"company_info": "Company info",
|
"company_info": "Company info",
|
||||||
|
|||||||
8
resources/scripts/admin/admin-router.js
vendored
8
resources/scripts/admin/admin-router.js
vendored
@@ -56,6 +56,8 @@ const UpdateApp = () =>
|
|||||||
import('@/scripts/admin/views/settings/UpdateAppSetting.vue')
|
import('@/scripts/admin/views/settings/UpdateAppSetting.vue')
|
||||||
const RolesSettings = () =>
|
const RolesSettings = () =>
|
||||||
import('@/scripts/admin/views/settings/RolesSettings.vue')
|
import('@/scripts/admin/views/settings/RolesSettings.vue')
|
||||||
|
const PDFGenerationSettings = () =>
|
||||||
|
import('@/scripts/admin/views/settings/PDFGenerationSetting.vue')
|
||||||
|
|
||||||
// Items
|
// Items
|
||||||
const ItemsIndex = () => import('@/scripts/admin/views/items/Index.vue')
|
const ItemsIndex = () => import('@/scripts/admin/views/items/Index.vue')
|
||||||
@@ -327,6 +329,12 @@ export default [
|
|||||||
meta: { isOwner: true },
|
meta: { isOwner: true },
|
||||||
component: UpdateApp,
|
component: UpdateApp,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'pdf-generation',
|
||||||
|
name: 'pdf.generation',
|
||||||
|
meta: { isOwner: true },
|
||||||
|
component: PDFGenerationSettings,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
70
resources/scripts/admin/stores/pdf-driver.js
vendored
Normal file
70
resources/scripts/admin/stores/pdf-driver.js
vendored
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
const { defineStore } = window.pinia
|
||||||
|
import { handleError } from '@/scripts/helpers/error-handling'
|
||||||
|
import { useNotificationStore } from '@/scripts/stores/notification'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
export const usePDFDriverStore = (useWindow = false) => {
|
||||||
|
const defineStoreFunc = useWindow ? window.pinia.defineStore : defineStore
|
||||||
|
const { global } = window.i18n
|
||||||
|
|
||||||
|
return defineStoreFunc({
|
||||||
|
id: 'pdf-driver',
|
||||||
|
|
||||||
|
state: () => ({
|
||||||
|
pdfDriverConfig: null,
|
||||||
|
pdf_driver: 'dompdf',
|
||||||
|
pdf_drivers: [],
|
||||||
|
|
||||||
|
dompdf: {
|
||||||
|
pdf_driver: '',
|
||||||
|
},
|
||||||
|
gotenberg: {
|
||||||
|
pdf_driver: '',
|
||||||
|
gotenberg_host: '',
|
||||||
|
gotenberg_papersize: ''
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
async fetchDrivers() {
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/v1/pdf/drivers')
|
||||||
|
this.pdf_drivers = response.data
|
||||||
|
} catch (err) {
|
||||||
|
handleError(err)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async fetchConfig() {
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/v1/pdf/config')
|
||||||
|
this.pdfDriverConfig = response.data
|
||||||
|
this.pdf_driver = response.data.pdf_driver
|
||||||
|
} catch (err) {
|
||||||
|
handleError(err)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async updateConfig(data) {
|
||||||
|
try {
|
||||||
|
const response = await axios.post('/api/v1/pdf/config', data)
|
||||||
|
const notificationStore = useNotificationStore()
|
||||||
|
if (response.data.success) {
|
||||||
|
notificationStore.showNotification({
|
||||||
|
type: 'success',
|
||||||
|
message: global.t('settings.pdf.' + response.data.success),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
notificationStore.showNotification({
|
||||||
|
type: 'error',
|
||||||
|
message: global.t('settings.pdf.' + response.data.error),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
handleError(err)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})()
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<template>
|
||||||
|
<BaseSettingCard
|
||||||
|
:title="$t('settings.pdf.pdf_configuration')"
|
||||||
|
:description="$t('settings.pdf.section_description')"
|
||||||
|
>
|
||||||
|
<div v-if="pdfDriverStore && pdfDriverStore.pdfDriverConfig" class="mt-14">
|
||||||
|
<component
|
||||||
|
:is="pdfDriver"
|
||||||
|
:config-data="pdfDriverStore.pdfDriverConfig"
|
||||||
|
:is-saving="isSaving"
|
||||||
|
:drivers="pdfDriverStore.pdf_drivers"
|
||||||
|
:is-fetching-initial-data="isFetchingInitialData"
|
||||||
|
@on-change-driver="(val) => changeDriver(val)"
|
||||||
|
@submit-data="saveConfig"
|
||||||
|
>
|
||||||
|
</component>
|
||||||
|
</div>
|
||||||
|
</BaseSettingCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import GotenbergDriver from '@/scripts/admin/views/settings/pdf-driver/GotenbergDriver.vue';
|
||||||
|
import DomPDFDriver from '@/scripts/admin/views/settings/pdf-driver/DomPDFDriver.vue';
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { usePDFDriverStore } from '@/scripts/admin/stores/pdf-driver'
|
||||||
|
const emit = defineEmits(['submit-data', 'on-change-driver'])
|
||||||
|
|
||||||
|
let isFetchingInitialData = ref(false)
|
||||||
|
let isSaving = ref(false)
|
||||||
|
|
||||||
|
const pdfDriverStore = usePDFDriverStore();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
function changeDriver(value) {
|
||||||
|
pdfDriverStore.pdf_driver = value
|
||||||
|
pdfDriverStore.pdfDriverConfig.pdf_driver = value
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
isFetchingInitialData.value = true
|
||||||
|
await Promise.all([
|
||||||
|
pdfDriverStore.fetchDrivers(),
|
||||||
|
pdfDriverStore.fetchConfig(),
|
||||||
|
])
|
||||||
|
isFetchingInitialData.value = false
|
||||||
|
}
|
||||||
|
loadData();
|
||||||
|
|
||||||
|
async function saveConfig(value) {
|
||||||
|
try {
|
||||||
|
isSaving.value = true
|
||||||
|
await pdfDriverStore.updateConfig(value)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pdfDriver = computed(() => {
|
||||||
|
switch (pdfDriverStore.pdf_driver) {
|
||||||
|
case 'dompdf':
|
||||||
|
return DomPDFDriver
|
||||||
|
case 'gotenberg':
|
||||||
|
return GotenbergDriver
|
||||||
|
default:
|
||||||
|
return DomPDFDriver
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<template>
|
||||||
|
<form @submit.prevent="saveConfig">
|
||||||
|
<BaseInputGrid>
|
||||||
|
<BaseInputGroup
|
||||||
|
:label="$t('settings.pdf.driver')"
|
||||||
|
:error="
|
||||||
|
v$.dompdf.pdf_driver.$error &&
|
||||||
|
v$.dompdf.pdf_driver.$errors[0].$message
|
||||||
|
"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<BaseMultiselect
|
||||||
|
v-model="pdfDriverStore.dompdf.pdf_driver"
|
||||||
|
:content-loading="isFetchingInitialData"
|
||||||
|
:options="drivers"
|
||||||
|
:can-deselect="false"
|
||||||
|
:invalid="v$.dompdf.pdf_driver.$error"
|
||||||
|
@update:modelValue="onChangeDriver"
|
||||||
|
/>
|
||||||
|
</BaseInputGroup>
|
||||||
|
</BaseInputGrid>
|
||||||
|
<div class="flex my-10">
|
||||||
|
<BaseButton
|
||||||
|
:disabled="isSaving"
|
||||||
|
:content-loading="isFetchingInitialData"
|
||||||
|
:loading="isSaving"
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
>
|
||||||
|
<template #left="slotProps">
|
||||||
|
<BaseIcon v-if="!isSaving" name="SaveIcon" :class="slotProps.class" />
|
||||||
|
</template>
|
||||||
|
{{ $t('general.save') }}
|
||||||
|
</BaseButton>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { usePDFDriverStore } from '@/scripts/admin/stores/pdf-driver'
|
||||||
|
import {required, email, helpers} from '@vuelidate/validators'
|
||||||
|
import useVuelidate from '@vuelidate/core'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
configData: {
|
||||||
|
type: Object,
|
||||||
|
require: true,
|
||||||
|
default: Object,
|
||||||
|
},
|
||||||
|
isSaving: {
|
||||||
|
type: Boolean,
|
||||||
|
require: true,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
isFetchingInitialData: {
|
||||||
|
type: Boolean,
|
||||||
|
require: true,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
drivers: {
|
||||||
|
type: Array,
|
||||||
|
require: true,
|
||||||
|
default: Array,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
const emit = defineEmits(['submit-data', 'on-change-driver'])
|
||||||
|
|
||||||
|
let isFetchingInitialData = ref(false)
|
||||||
|
|
||||||
|
const pdfDriverStore = usePDFDriverStore();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const rules = computed(() => {
|
||||||
|
return {
|
||||||
|
dompdf: {
|
||||||
|
pdf_driver: {
|
||||||
|
required: helpers.withMessage(t('validation.required'), required),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const v$ = useVuelidate(
|
||||||
|
rules,
|
||||||
|
computed(() => pdfDriverStore)
|
||||||
|
)
|
||||||
|
|
||||||
|
function onChangeDriver() {
|
||||||
|
// validation
|
||||||
|
v$.value.dompdf.pdf_driver.$touch()
|
||||||
|
emit('on-change-driver', pdfDriverStore.dompdf.pdf_driver)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveConfig() {
|
||||||
|
v$.value.dompdf.$touch()
|
||||||
|
if (!v$.value.dompdf.$invalid) {
|
||||||
|
emit('submit-data', pdfDriverStore.dompdf)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
for (const key in pdfDriverStore.dompdf) {
|
||||||
|
if (props.configData.hasOwnProperty(key)) {
|
||||||
|
pdfDriverStore.$patch((state) => {
|
||||||
|
state.dompdf[key] = props.configData[key]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<template>
|
||||||
|
<form @submit.prevent="saveConfig">
|
||||||
|
<BaseInputGrid>
|
||||||
|
<BaseInputGroup :label="$t('settings.pdf.driver')" :error="
|
||||||
|
v$.gotenberg.pdf_driver.$error &&
|
||||||
|
v$.gotenberg.pdf_driver.$errors[0].$message
|
||||||
|
" required>
|
||||||
|
<BaseMultiselect v-model="pdfDriverStore.gotenberg.pdf_driver" :content-loading="isFetchingInitialData"
|
||||||
|
:options="drivers" :can-deselect="false" @update:modelValue="onChangeDriver"
|
||||||
|
:invalid="v$.gotenberg.pdf_driver.$error" />
|
||||||
|
</BaseInputGroup>
|
||||||
|
<BaseInputGroup :label="$t('settings.pdf.gotenberg_host')" :content-loading="isFetchingInitialData"
|
||||||
|
:error="
|
||||||
|
v$.gotenberg.gotenberg_host.$error &&
|
||||||
|
v$.gotenberg.gotenberg_host.$errors[0].$message
|
||||||
|
" required>
|
||||||
|
<BaseInput v-model="pdfDriverStore.gotenberg.gotenberg_host" :content-loading="isFetchingInitialData"
|
||||||
|
:invalid="v$.gotenberg.gotenberg_host.$error" type="text" name="gotenberg_host" />
|
||||||
|
</BaseInputGroup>
|
||||||
|
<BaseInputGroup :label="$t('settings.pdf.papersize')" :content-loading="isFetchingInitialData" :error="
|
||||||
|
v$.gotenberg.gotenberg_papersize.$error &&
|
||||||
|
v$.gotenberg.gotenberg_papersize.$errors[0].$message
|
||||||
|
"
|
||||||
|
:help-text="$t('settings.pdf.papersize_hint')"
|
||||||
|
required>
|
||||||
|
<BaseInput v-model="pdfDriverStore.gotenberg.gotenberg_papersize" :content-loading="isFetchingInitialData"
|
||||||
|
:invalid="v$.gotenberg.gotenberg_papersize.$error" type="text" name="gotenberg_papersize" />
|
||||||
|
</BaseInputGroup>
|
||||||
|
</BaseInputGrid>
|
||||||
|
<div class="flex my-10">
|
||||||
|
<BaseButton :disabled="isSaving" :content-loading="isFetchingInitialData" :loading="isSaving" type="submit"
|
||||||
|
variant="primary">
|
||||||
|
<template #left="slotProps">
|
||||||
|
<BaseIcon v-if="!isSaving" name="SaveIcon" :class="slotProps.class" />
|
||||||
|
</template>
|
||||||
|
{{ $t('general.save') }}
|
||||||
|
</BaseButton>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import {ref, computed, onMounted} from 'vue'
|
||||||
|
import {useI18n} from 'vue-i18n'
|
||||||
|
import {usePDFDriverStore} from '@/scripts/admin/stores/pdf-driver'
|
||||||
|
import {required, email, helpers} from '@vuelidate/validators'
|
||||||
|
import useVuelidate from '@vuelidate/core'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
configData: {
|
||||||
|
type: Object,
|
||||||
|
require: true,
|
||||||
|
default: Object,
|
||||||
|
},
|
||||||
|
isSaving: {
|
||||||
|
type: Boolean,
|
||||||
|
require: true,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
isFetchingInitialData: {
|
||||||
|
type: Boolean,
|
||||||
|
require: true,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
drivers: {
|
||||||
|
type: Array,
|
||||||
|
require: true,
|
||||||
|
default: Array,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
const emit = defineEmits(['submit-data', 'on-change-driver'])
|
||||||
|
|
||||||
|
const pdfDriverStore = usePDFDriverStore();
|
||||||
|
const {t} = useI18n();
|
||||||
|
|
||||||
|
const rules = computed(() => {
|
||||||
|
return {
|
||||||
|
gotenberg: {
|
||||||
|
pdf_driver: {
|
||||||
|
required: helpers.withMessage(t('validation.required'), required),
|
||||||
|
},
|
||||||
|
gotenberg_host: {
|
||||||
|
required: helpers.withMessage(t('validation.required'), required),
|
||||||
|
},
|
||||||
|
gotenberg_papersize: {
|
||||||
|
required: helpers.withMessage(t('validation.required'), required),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const v$ = useVuelidate(
|
||||||
|
rules,
|
||||||
|
computed(() => pdfDriverStore)
|
||||||
|
)
|
||||||
|
|
||||||
|
function onChangeDriver() {
|
||||||
|
v$.value.gotenberg.pdf_driver.$touch()
|
||||||
|
emit('on-change-driver', pdfDriverStore.gotenberg.pdf_driver)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveConfig() {
|
||||||
|
v$.value.gotenberg.$touch()
|
||||||
|
if (!v$.value.gotenberg.$invalid) {
|
||||||
|
emit('submit-data', pdfDriverStore.gotenberg)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill pdfDriverStore.gotenbergConfig with data from config prop
|
||||||
|
onMounted(() => {
|
||||||
|
for (const key in pdfDriverStore.gotenberg) {
|
||||||
|
if (props.configData.hasOwnProperty(key)) {
|
||||||
|
pdfDriverStore.$patch((state) => {
|
||||||
|
state.gotenberg[key] = props.configData[key]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pdfDriverStore.gotenberg.gotenberg_papersize == '')
|
||||||
|
pdfDriverStore.$patch((state) => {
|
||||||
|
state.gotenberg.gotenberg_papersize = '210mm 297mm';
|
||||||
|
});
|
||||||
|
})
|
||||||
|
|
||||||
|
</script>
|
||||||
@@ -76,6 +76,7 @@ use App\Http\Controllers\V1\Admin\Settings\GetCompanySettingsController;
|
|||||||
use App\Http\Controllers\V1\Admin\Settings\GetSettingsController;
|
use App\Http\Controllers\V1\Admin\Settings\GetSettingsController;
|
||||||
use App\Http\Controllers\V1\Admin\Settings\GetUserSettingsController;
|
use App\Http\Controllers\V1\Admin\Settings\GetUserSettingsController;
|
||||||
use App\Http\Controllers\V1\Admin\Settings\MailConfigurationController;
|
use App\Http\Controllers\V1\Admin\Settings\MailConfigurationController;
|
||||||
|
use App\Http\Controllers\V1\Admin\Settings\PDFConfigurationController;
|
||||||
use App\Http\Controllers\V1\Admin\Settings\TaxTypesController;
|
use App\Http\Controllers\V1\Admin\Settings\TaxTypesController;
|
||||||
use App\Http\Controllers\V1\Admin\Settings\UpdateCompanySettingsController;
|
use App\Http\Controllers\V1\Admin\Settings\UpdateCompanySettingsController;
|
||||||
use App\Http\Controllers\V1\Admin\Settings\UpdateSettingsController;
|
use App\Http\Controllers\V1\Admin\Settings\UpdateSettingsController;
|
||||||
@@ -397,6 +398,15 @@ Route::prefix('/v1')->group(function () {
|
|||||||
|
|
||||||
Route::get('/company/mail/config', GetCompanyMailConfigurationController::class);
|
Route::get('/company/mail/config', GetCompanyMailConfigurationController::class);
|
||||||
|
|
||||||
|
// PDF Generation
|
||||||
|
//----------------------------------
|
||||||
|
|
||||||
|
Route::get('/pdf/drivers', [PDFConfigurationController::class, 'getDrivers']);
|
||||||
|
|
||||||
|
Route::get('/pdf/config', [PDFConfigurationController::class, 'getEnvironment']);
|
||||||
|
|
||||||
|
Route::post('/pdf/config', [PDFConfigurationController::class, 'saveEnvironment']);
|
||||||
|
|
||||||
Route::apiResource('notes', NotesController::class);
|
Route::apiResource('notes', NotesController::class);
|
||||||
|
|
||||||
// Tax Types
|
// Tax Types
|
||||||
|
|||||||
Reference in New Issue
Block a user