From 8fbaa234b4f0595365ccfa082d09a4fc002a44dd Mon Sep 17 00:00:00 2001 From: Noah Lancien Date: Mon, 24 Nov 2025 12:58:47 +0100 Subject: [PATCH 1/5] feat: many ui improvements for the claim part --- app/Enums/Claims/ClaimStatus.php | 3 + app/Http/Controllers/Bo/ClaimController.php | 42 +++++- .../Requests/Bo/Claims/ExportClaimRequest.php | 59 ++++++++ app/Jobs/ExportClaimsAsCsv.php | 134 ++++++++++++++++++ app/Mail/ExportClaims.php | 62 ++++++++ config/filesystems.php | 7 + resources/ts/bo/bo.ts | 1 + resources/ts/bo/pages/claims.ts | 17 +++ resources/ts/bo/pages/search-bar.ts | 2 + resources/ts/bo/utilities/fontawesome.ts | 2 + resources/views/bo/layouts/sidenav.blade.php | 31 +++- .../views/bo/modules/claim-tools.blade.php | 61 ++++++++ .../views/bo/modules/search-bar.blade.php | 2 +- .../views/bo/pages/claims/edit.blade.php | 2 +- .../bo/pages/claims/index-list.blade.php | 18 ++- .../views/bo/pages/claims/index.blade.php | 1 + .../views/mail/export-claims-plain.blade.php | 5 + resources/views/mail/export-claims.blade.php | 10 ++ routes/web-bo.php | 1 + 19 files changed, 451 insertions(+), 9 deletions(-) create mode 100644 app/Http/Requests/Bo/Claims/ExportClaimRequest.php create mode 100644 app/Jobs/ExportClaimsAsCsv.php create mode 100644 app/Mail/ExportClaims.php create mode 100644 resources/ts/bo/pages/claims.ts create mode 100644 resources/views/bo/modules/claim-tools.blade.php create mode 100644 resources/views/mail/export-claims-plain.blade.php create mode 100644 resources/views/mail/export-claims.blade.php diff --git a/app/Enums/Claims/ClaimStatus.php b/app/Enums/Claims/ClaimStatus.php index 53d2c64..afa8e99 100644 --- a/app/Enums/Claims/ClaimStatus.php +++ b/app/Enums/Claims/ClaimStatus.php @@ -12,6 +12,7 @@ enum ClaimStatus: int case sentDigital = 2; case sentMarketing = 3; case treated = 4; + case refused = 5; /** * Optionnal labels definition @@ -21,6 +22,7 @@ enum ClaimStatus: int self::sentDigital->name => 'Transférée au service Digital', self::sentMarketing->name => 'Retournée au Marketing', self::treated->name => 'Traitée', + self::refused->name => 'Refusé de répondre', ]; /** @@ -31,6 +33,7 @@ enum ClaimStatus: int self::sentDigital->name => '#0033c0', self::sentMarketing->name => '#69077c', self::treated->name => '#188618', + self::refused->name => '#dc143c', ]; /** diff --git a/app/Http/Controllers/Bo/ClaimController.php b/app/Http/Controllers/Bo/ClaimController.php index 1fcde8a..68e2caf 100644 --- a/app/Http/Controllers/Bo/ClaimController.php +++ b/app/Http/Controllers/Bo/ClaimController.php @@ -7,9 +7,11 @@ use App\Enums\Claims\ClaimStatus; use App\Enums\Users\RoleEnum; use App\Events\ClaimModelChanged; use App\Http\Controllers\Controller; +use App\Http\Requests\Bo\Claims\ExportClaimRequest; use App\Http\Requests\Bo\Claims\SendClaimAnswerRequest; use App\Http\Requests\Bo\Claims\UpdateClaimRequest; use App\Http\Requests\Bo\PaginateModelsRequest; +use App\Jobs\ExportClaimsAsCsv; use App\Mail\ClaimAnswerMail; use App\Models\Brand; use App\Models\Claim; @@ -54,8 +56,19 @@ class ClaimController extends Controller )->when( $userModel->role->equals(RoleEnum::marketing), fn($q) => $q->whereHas('brand', fn($q) => $q->whereIn('id', $userModel->brands->modelKeys())) + ) + ->when( + $request->filled('subject'), + fn($q) => $q->whereHas('claimSubject', fn($q) => $q->where('id', $request->subject)) + ) + ->when( + $request->filled('status'), + fn($q) => $q->whereIn('status', $request->status), + fn($q) => $q->whereNotIn('status', [ClaimStatus::treated, ClaimStatus::refused]) ); + // dd($query->toRawSql()); + /** @var string $search Search field */ $search = $request->search; if ($search) { @@ -82,7 +95,9 @@ class ClaimController extends Controller /** Custom pagination */ $claimModels = $query->simplePaginate($this->getPagination()); - return view('bo.pages.claims.index', compact('claimModels', 'search', 'searchFields')); + $claimSubjectModels = ClaimSubject::all(); + + return view('bo.pages.claims.index', compact('claimModels', 'search', 'searchFields', 'claimSubjectModels')); } /** @@ -204,4 +219,29 @@ class ClaimController extends Controller ); return \response()->json($models); } + + public function export(ExportClaimRequest $request) + { + $claims = Claim::query()->whereIn('id', $request->validated('claimList'))->get(); + $brandNames = Brand::query() + ->whereIn( + 'id', + \collect($claims) + ->pluck('brand_id') + ->unique() + )->pluck('name') + ->toArray(); + + // dd($brandNames); + \dispatch(new ExportClaimsAsCsv($request->validated('email'), $brandNames, $claims)); + + return \redirect() + ->back() + ->with('success', trans("Le traitement de l'export partiel a été lancé. Si vous n'avez pas recu de mail + cela signifie qu'une erreur est survenue et nous sommes au courant.")); + + } + + + } diff --git a/app/Http/Requests/Bo/Claims/ExportClaimRequest.php b/app/Http/Requests/Bo/Claims/ExportClaimRequest.php new file mode 100644 index 0000000..5cc3a5c --- /dev/null +++ b/app/Http/Requests/Bo/Claims/ExportClaimRequest.php @@ -0,0 +1,59 @@ +check(); + } + + public function prepareForValidation() + { + $this->merge([ + 'claimList' => json_decode($this->input('claimList'), true) + ]); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array + */ + public function rules(): array + { + return [ + 'email' => 'required|email', + 'claimList' => 'required|array', + 'claimList.*' => 'required|numeric|exists:claims,id', + ]; + } + + /** + * Get custom attributes for validator errors. + * + * @return array|null> + */ + public function attributes(): array + { + return [ + 'brand_id' => trans('models.classes.brands'), + 'subject_id' => trans('models.classes.claimsubjects'), + 'type' => trans('validation.custom.type'), + 'status' => trans('validation.custom.status'), + 'response_type' => trans('validation.custom.response_type'), + ]; + } +} diff --git a/app/Jobs/ExportClaimsAsCsv.php b/app/Jobs/ExportClaimsAsCsv.php new file mode 100644 index 0000000..0ce59dd --- /dev/null +++ b/app/Jobs/ExportClaimsAsCsv.php @@ -0,0 +1,134 @@ +deleteDirectory('export_claims'); + $disk->makeDirectory('export_claims'); + + // Insertion de la ligne d'en-têtes. + $csvlines[] = $this->header; + foreach ($this->claims as $inscription) { + $csvlines[] = $this->fillLine($inscription); + } + + $data = $this->array2csv($csvlines, ";"); + + $uuid = Str::uuid(); + $fileName = date('d-m-Y') . '-' . $uuid . '.csv'; + + $disk->put("export_claims/{$fileName}", $data); + + if (!$disk->exists("export_claims/{$fileName}")) { + throw new \RuntimeException( + "The CSV file 'export_claims/{$fileName}' has not been created." + ); + } + + $brandNamesString = implode(', ', $this->brandNames); + + Mail::to($this->recipient)->send(new ExportClaims($fileName, $brandNamesString)); + } + + /** + * fill one line for csv + * @param \App\Models\Claim $claim + * + * @return array + * @phpcs:disable Generic.Metrics.CyclomaticComplexity.TooHigh, Generic.Metrics.CyclomaticComplexity.MaxExceeded + */ + private function fillLine(Claim $claim) + { + // @phpcs:enable + $oneline = [ + sprintf('%s %s', $claim->first_name, Str::upper($claim->last_name)), + $claim->email, + $claim->address . "," . $claim->postcode . "," . $claim->city, + $claim->created_at->toDateTimeString(), + $claim->claimSubject->label, + $claim->status->label(), + $claim->message, + asset($claim->file), + ]; + return $oneline; + } + + /** + * converts an array to a csv stream + * + * @param array $data + * @param string $delimiter + * @param string $enclosure + * @param string $escape_char + * + * @return string + */ + private function array2csv( + array $data, + string $delimiter = ',', + string $enclosure = '"', + string $escape_char = "\\" + ) { + $f = fopen('php://memory', 'r+'); + + // UTF-8 BOM. + $BOM = "\xEF\xBB\xBF"; + fwrite($f, $BOM); + + foreach ($data as $item) { + fputcsv($f, $item, $delimiter, $enclosure, $escape_char); + } + rewind($f); + return stream_get_contents($f); + } +} diff --git a/app/Mail/ExportClaims.php b/app/Mail/ExportClaims.php new file mode 100644 index 0000000..f8f5cf4 --- /dev/null +++ b/app/Mail/ExportClaims.php @@ -0,0 +1,62 @@ +brandName), + ); + } + + /** + * Get the message content definition. + */ + public function content(): Content + { + return new Content( + markdown: 'mail.export-claims', + text: 'mail.export-claims-plain' + ); + } + + /** + * Get the attachments for the message. + * + * @return array + */ + public function attachments(): array + { + return [ + Attachment::fromStorageDisk('export', "export_claims/{$this->fileName}") + ->as(sprintf( + 'export-reclas-%s.csv', + now()->format('Y-m-d') + )) + ]; + } +} diff --git a/config/filesystems.php b/config/filesystems.php index e8833b8..9f594c1 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -68,6 +68,13 @@ return [ 'throw' => false, ], + 'export' => [ + 'driver' => 'local', + 'root' => storage_path('app/export'), + 'visibility' => 'private', + 'throw' => true, + ], + ], /* diff --git a/resources/ts/bo/bo.ts b/resources/ts/bo/bo.ts index 23b50f3..7325c36 100644 --- a/resources/ts/bo/bo.ts +++ b/resources/ts/bo/bo.ts @@ -14,6 +14,7 @@ import "./../modules/sweetalert"; import "./base/common"; // CUSTOM +import "./pages/claims"; import "./pages/customer.delete"; import "./pages/data.delete"; import "./pages/search-bar"; diff --git a/resources/ts/bo/pages/claims.ts b/resources/ts/bo/pages/claims.ts new file mode 100644 index 0000000..e3736e4 --- /dev/null +++ b/resources/ts/bo/pages/claims.ts @@ -0,0 +1,17 @@ + +document.addEventListener("DOMContentLoaded", function () { + const exportForm = document.getElementById("claimExportForm"); + const exportFormBtn = document.getElementById("claimExportFormBtn"); + const exportFormBtnInside = document.getElementById("claimExportFormBtnInside"); + + if (!exportForm || !exportFormBtn || !exportFormBtnInside || !(exportForm instanceof HTMLFormElement)) { + return; + } + + exportFormBtn.addEventListener("click", function (e) { + e.preventDefault(); + + exportFormBtnInside.click(); + }); +}); + diff --git a/resources/ts/bo/pages/search-bar.ts b/resources/ts/bo/pages/search-bar.ts index 4e9685c..a09f225 100644 --- a/resources/ts/bo/pages/search-bar.ts +++ b/resources/ts/bo/pages/search-bar.ts @@ -2,6 +2,7 @@ enum FilterKey { Brand = "brand", Question = "question", Answer = "answer", + Subject = "subject", } function setupFilterListener(selectId: string, paramKey: FilterKey): void { @@ -25,6 +26,7 @@ const filterMap: Record = { brandSelect: FilterKey.Brand, questionSelect: FilterKey.Question, answerSelect: FilterKey.Answer, + subjectSelect: FilterKey.Subject, }; Object.entries(filterMap).forEach(([selectId, paramKey]) => { diff --git a/resources/ts/bo/utilities/fontawesome.ts b/resources/ts/bo/utilities/fontawesome.ts index 324f67a..c8d371e 100644 --- a/resources/ts/bo/utilities/fontawesome.ts +++ b/resources/ts/bo/utilities/fontawesome.ts @@ -29,6 +29,7 @@ import { faEraser, faEye, faEyeSlash, + faFile, faFolderOpen, faListUl, faPaintRoller, @@ -57,6 +58,7 @@ library.add( faTrash, faEye, faEyeSlash, + faFile, faFolderOpen, faCrop, faQuestionCircle, diff --git a/resources/views/bo/layouts/sidenav.blade.php b/resources/views/bo/layouts/sidenav.blade.php index 59298d5..050610a 100644 --- a/resources/views/bo/layouts/sidenav.blade.php +++ b/resources/views/bo/layouts/sidenav.blade.php @@ -13,6 +13,20 @@ \App\Models\ClaimSubject::class, \App\Models\ClaimMailTemplate::class, ]; + $defaultFilters = [ + 'App\Models\Customer' => [], + 'App\Models\Brand' => [], + 'App\Models\Question' => [], + 'App\Models\Service' => [], + 'App\Models\SingleOptin' => [], + 'App\Models\Claim' => [ + 'sort_col' => 'created_at', + 'sort_way' => 'desc' + ], + 'App\Models\ClaimSubject' => [], + 'App\Models\ClaimMailTemplate' => [], + + ] @endphp @foreach ($modelsClasses as $crudModel) @can('viewAny', $crudModel) @@ -23,7 +37,10 @@ @if (!$loop->last)
diff --git a/resources/views/bo/modules/claim-tools.blade.php b/resources/views/bo/modules/claim-tools.blade.php new file mode 100644 index 0000000..53d2b71 --- /dev/null +++ b/resources/views/bo/modules/claim-tools.blade.php @@ -0,0 +1,61 @@ +@php + $selectedSubject = request()->subject ?? (isset($modelTable) ? Session::get("crud.$modelTable.subject") : null); +@endphp + +
+
+ +
+
+
+ {{-- SUBJECT SELECT --}} + +
+
+
+ +{{-- EXPORT MODAL --}} + diff --git a/resources/views/bo/modules/search-bar.blade.php b/resources/views/bo/modules/search-bar.blade.php index 7baf593..b63772b 100644 --- a/resources/views/bo/modules/search-bar.blade.php +++ b/resources/views/bo/modules/search-bar.blade.php @@ -67,7 +67,7 @@ @php $parameters = ['rstFltrs' => 'true', 'search' => null, 'page' => null]; if ($routes) { - $parameters = array_merge($parameters, ['brand' => null, 'question' => null, 'answer' => null]); + $parameters = array_merge($parameters, ['brand' => null, 'question' => null, 'answer' => null, 'subject' => null]); } @endphp @can('viewAny', $claimModel) diff --git a/resources/views/bo/pages/claims/index-list.blade.php b/resources/views/bo/pages/claims/index-list.blade.php index b1409f6..88809e4 100644 --- a/resources/views/bo/pages/claims/index-list.blade.php +++ b/resources/views/bo/pages/claims/index-list.blade.php @@ -2,11 +2,13 @@ @include('bo.modules.table-col-sorter', [ 'cols' => [ - 'reference' => str(__('validation.custom.reference'))->ucfirst(), - 'email' => str(__('models.classes.customers'))->ucfirst(), - 'brand_id' => str(__('models.classes.brands'))->singular()->ucfirst(), - 'type' => str(__('validation.custom.type'))->ucfirst(), - 'status' => str(__('validation.custom.status'))->ucfirst(), + 'reference' => str(__('validation.custom.reference'))->ucfirst(), + 'created_at' => str(__('validation.attributes.created_at'))->ucfirst(), + 'email' => str(__('models.classes.customers'))->ucfirst(), + 'message' => str(__('validation.attributes.message'))->ucfirst(), + 'brand_id' => str(__('models.classes.brands'))->singular()->ucfirst(), + 'type' => str(__('validation.custom.type'))->ucfirst(), + 'status' => str(__('validation.custom.status'))->ucfirst(), 'updated_at' => str(__('validation.attributes.updated_at'))->ucfirst(), ], ]) @@ -16,12 +18,18 @@ @foreach ($claimModels as $claimModel) {{ $claimModel->reference }} + + + {{ $claimModel->created_at->isoFormat('LLLL') }} + +

{{ sprintf('%s %s', $claimModel->first_name, $claimModel->last_name) }}

{{ e($claimModel->email) }}

+ {{ $claimModel->message }} {{ $claimModel->brand->name }} diff --git a/resources/views/bo/pages/claims/index.blade.php b/resources/views/bo/pages/claims/index.blade.php index 56563b1..82ea16e 100644 --- a/resources/views/bo/pages/claims/index.blade.php +++ b/resources/views/bo/pages/claims/index.blade.php @@ -16,6 +16,7 @@
+ @include('bo.modules.claim-tools') @include('bo.modules.search-bar')
diff --git a/resources/views/mail/export-claims-plain.blade.php b/resources/views/mail/export-claims-plain.blade.php new file mode 100644 index 0000000..604c9ad --- /dev/null +++ b/resources/views/mail/export-claims-plain.blade.php @@ -0,0 +1,5 @@ +Bonjour, + +Vous trouverez ci-joint l'export des réclamations du {{ now()->isoFormat('DD\/MM\/YYYY à HH:mm') }} + +Ceci est un message automatique, ne pas répondre. diff --git a/resources/views/mail/export-claims.blade.php b/resources/views/mail/export-claims.blade.php new file mode 100644 index 0000000..80d4762 --- /dev/null +++ b/resources/views/mail/export-claims.blade.php @@ -0,0 +1,10 @@ +{{-- blade-formatter-disable --}} +@component('mail::layout') + +# {{ __('Bonjour,') }} + +{{ __('Vous trouverez ci-joint l\'export des réclamations du') }} {{ now()->isoFormat('DD\/MM\/YYYY à HH:mm') }} + +{{ __('Ceci est un message automatique, ne pas répondre.') }} + +@endcomponent diff --git a/routes/web-bo.php b/routes/web-bo.php index 4c1d1c0..2c729c6 100644 --- a/routes/web-bo.php +++ b/routes/web-bo.php @@ -93,6 +93,7 @@ Route::middleware(['throttle:web', 'admin-secure'])->prefix('bo')->name('bo.')-> // * CLAIMS. Route::resource('claims', ClaimController::class)->only(['index', 'edit', 'update']); Route::post('claims/{claim}/answer', [ClaimController::class, 'answer'])->name('claims.answer'); + Route::post('claims/export', [ClaimController::class, 'export'])->name('claims.export'); // * CLAIM SUBJECTS. Route::get('/{brand}/claim_subjects/json/paginate', [ClaimController::class, 'jsonClaimPaginateModels']) From b7b40138979b949fe01c6d192e95ed0dcac4ff99 Mon Sep 17 00:00:00 2001 From: Noah Lancien Date: Tue, 25 Nov 2025 14:57:34 +0100 Subject: [PATCH 2/5] feat: remove export mail and replaced with .csv and .pdf downloads --- app/Enums/Claims/ClaimExportFormat.php | 39 ++ app/Http/Controllers/Bo/ClaimController.php | 104 ++++- .../Requests/Bo/Claims/ExportClaimRequest.php | 11 +- app/Jobs/ExportClaimsAsCsv.php | 134 ------- app/Mail/ExportClaims.php | 62 --- app/Policies/ClaimPolicy.php | 12 + composer.json | 1 + composer.lock | 367 +++++++++++++++++- config/filesystems.php | 8 - resources/lang/en/crud.php | 1 + resources/lang/fr/crud.php | 1 + resources/ts/bo/pages/claims.ts | 21 +- resources/ts/bo/utilities/fontawesome.ts | 6 + .../views/bo/modules/claim-tools.blade.php | 41 +- .../bo/pages/claims/index-list.blade.php | 10 + resources/views/bo/pages/claims/pdf.blade.php | 24 ++ .../views/mail/export-claims-plain.blade.php | 5 - resources/views/mail/export-claims.blade.php | 10 - routes/web-bo.php | 3 +- 19 files changed, 585 insertions(+), 275 deletions(-) create mode 100644 app/Enums/Claims/ClaimExportFormat.php delete mode 100644 app/Jobs/ExportClaimsAsCsv.php delete mode 100644 app/Mail/ExportClaims.php create mode 100644 resources/views/bo/pages/claims/pdf.blade.php delete mode 100644 resources/views/mail/export-claims-plain.blade.php delete mode 100644 resources/views/mail/export-claims.blade.php diff --git a/app/Enums/Claims/ClaimExportFormat.php b/app/Enums/Claims/ClaimExportFormat.php new file mode 100644 index 0000000..fae0a4b --- /dev/null +++ b/app/Enums/Claims/ClaimExportFormat.php @@ -0,0 +1,39 @@ +name => 'CSV', + self::pdf->name => 'PDF', + ]; + + public function class() + { + return match ($this) { + self::csv => 'success', + self::pdf => 'danger', + default => 'primary', + }; + } + + public function picto() + { + return match ($this) { + self::csv => 'fa-solid fa-file-csv', + self::pdf => 'fa-solid fa-file-pdf', + default => 'fa-solid fa-file', + }; + } +} diff --git a/app/Http/Controllers/Bo/ClaimController.php b/app/Http/Controllers/Bo/ClaimController.php index 68e2caf..e6847b8 100644 --- a/app/Http/Controllers/Bo/ClaimController.php +++ b/app/Http/Controllers/Bo/ClaimController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Bo; +use App\Enums\Claims\ClaimExportFormat; use App\Enums\Claims\ClaimResponseType; use App\Enums\Claims\ClaimStatus; use App\Enums\Users\RoleEnum; @@ -11,7 +12,6 @@ use App\Http\Requests\Bo\Claims\ExportClaimRequest; use App\Http\Requests\Bo\Claims\SendClaimAnswerRequest; use App\Http\Requests\Bo\Claims\UpdateClaimRequest; use App\Http\Requests\Bo\PaginateModelsRequest; -use App\Jobs\ExportClaimsAsCsv; use App\Mail\ClaimAnswerMail; use App\Models\Brand; use App\Models\Claim; @@ -20,9 +20,12 @@ use App\Models\ClaimMessage; use App\Models\ClaimSubject; use App\Models\CustomerInfo; use App\Traits\Controllers\UpdateModelPublished; +use Barryvdh\DomPDF\Facade\Pdf; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Str; +use Symfony\Component\HttpFoundation\StreamedResponse; use Uwil\LaravelCommon\Helpers\CollectionHelper; class ClaimController extends Controller @@ -67,8 +70,6 @@ class ClaimController extends Controller fn($q) => $q->whereNotIn('status', [ClaimStatus::treated, ClaimStatus::refused]) ); - // dd($query->toRawSql()); - /** @var string $search Search field */ $search = $request->search; if ($search) { @@ -95,7 +96,13 @@ class ClaimController extends Controller /** Custom pagination */ $claimModels = $query->simplePaginate($this->getPagination()); - $claimSubjectModels = ClaimSubject::all(); + $claimSubjectModels = ClaimSubject::query() + ->when( + $request->filled('brand'), + fn($q) => $q->whereHas('brand', fn($q) => $q->where('id', $request->brand)), + fn($q) => $q->whereHas('brand', fn($q) => $q->whereIn('id', $userModel->brands()->pluck('id'))) + ) + ->get(); return view('bo.pages.claims.index', compact('claimModels', 'search', 'searchFields', 'claimSubjectModels')); } @@ -220,28 +227,89 @@ class ClaimController extends Controller return \response()->json($models); } + + // * EXPORT + + /** + * CSV File headers + * + * @var array + */ + private $header = [ + "Prénom NOM", + "Email", + "Adresse", + "Date de création", + "Sujet", + "Statut", + "Message", + "Pièce jointe", + ]; + public function export(ExportClaimRequest $request) { $claims = Claim::query()->whereIn('id', $request->validated('claimList'))->get(); - $brandNames = Brand::query() - ->whereIn( - 'id', - \collect($claims) - ->pluck('brand_id') - ->unique() - )->pluck('name') - ->toArray(); + $format = ClaimExportFormat::from($request->validated('format')); - // dd($brandNames); - \dispatch(new ExportClaimsAsCsv($request->validated('email'), $brandNames, $claims)); + if ($format->equals(ClaimExportFormat::csv)) { + $csvlines[] = $this->header; + foreach ($claims as $claim) { + $csvlines[] = $this->fillLine($claim); + } - return \redirect() - ->back() - ->with('success', trans("Le traitement de l'export partiel a été lancé. Si vous n'avez pas recu de mail - cela signifie qu'une erreur est survenue et nous sommes au courant.")); + return new StreamedResponse(function() use ($csvlines) { + $this->array2csv($csvlines, ';'); + }, 200, [ + 'Content-Type' => 'text/csv', + 'Content-Disposition' => 'attachment; filename="export.csv"', + ]); + } elseif ($format->equals(ClaimExportFormat::pdf)) { + $pdf = Pdf::loadView('bo.pages.claims.pdf', [ + 'claimModels' => $claims + ]); + return $pdf->download('export.pdf'); + }; + return back()->with('error', __('Une erreur est survenue lors de l\'export. Veuillez réessayer en rafraîchissant la page.')); } + private function array2csv( + array $lines, + string $delimiter = ',', + string $enclosure = '"', + string $escape_char = "\\" + ){ + $f = fopen('php://output', 'w'); + // UTF-8 BOM. + $BOM = "\xEF\xBB\xBF"; + fwrite($f, $BOM); + foreach ($lines as $item) { + fputcsv($f, $item, $delimiter, $enclosure, $escape_char); + } + + fclose($f); + } + + /** + * fill one line for csv + * @param \App\Models\Claim $claim + * + * @return array + */ + private function fillLine(Claim $claim) + { + $oneline = [ + sprintf('%s %s', $claim->first_name, Str::upper($claim->last_name)), + $claim->email, + sprintf('%s, %s, %s', $claim->address, $claim->postcode, $claim->city), + $claim->created_at->toDateTimeString(), + $claim->claimSubject->label, + $claim->status->label(), + $claim->message, + asset($claim->file), + ]; + return $oneline; + } } diff --git a/app/Http/Requests/Bo/Claims/ExportClaimRequest.php b/app/Http/Requests/Bo/Claims/ExportClaimRequest.php index 5cc3a5c..37861a1 100644 --- a/app/Http/Requests/Bo/Claims/ExportClaimRequest.php +++ b/app/Http/Requests/Bo/Claims/ExportClaimRequest.php @@ -2,11 +2,9 @@ namespace App\Http\Requests\Bo\Claims; -use App\Enums\Claims\ClaimResponseType; -use App\Enums\Claims\ClaimStatus; -use App\Enums\Claims\ClaimType; +use App\Enums\Claims\ClaimExportFormat; use Illuminate\Foundation\Http\FormRequest; -use Illuminate\Validation\Rules\Enum; +use Illuminate\Validation\Rule; class ExportClaimRequest extends FormRequest { @@ -35,7 +33,10 @@ class ExportClaimRequest extends FormRequest public function rules(): array { return [ - 'email' => 'required|email', + 'format' => [ + 'required', + Rule::enum(ClaimExportFormat::class) + ], 'claimList' => 'required|array', 'claimList.*' => 'required|numeric|exists:claims,id', ]; diff --git a/app/Jobs/ExportClaimsAsCsv.php b/app/Jobs/ExportClaimsAsCsv.php deleted file mode 100644 index 0ce59dd..0000000 --- a/app/Jobs/ExportClaimsAsCsv.php +++ /dev/null @@ -1,134 +0,0 @@ -deleteDirectory('export_claims'); - $disk->makeDirectory('export_claims'); - - // Insertion de la ligne d'en-têtes. - $csvlines[] = $this->header; - foreach ($this->claims as $inscription) { - $csvlines[] = $this->fillLine($inscription); - } - - $data = $this->array2csv($csvlines, ";"); - - $uuid = Str::uuid(); - $fileName = date('d-m-Y') . '-' . $uuid . '.csv'; - - $disk->put("export_claims/{$fileName}", $data); - - if (!$disk->exists("export_claims/{$fileName}")) { - throw new \RuntimeException( - "The CSV file 'export_claims/{$fileName}' has not been created." - ); - } - - $brandNamesString = implode(', ', $this->brandNames); - - Mail::to($this->recipient)->send(new ExportClaims($fileName, $brandNamesString)); - } - - /** - * fill one line for csv - * @param \App\Models\Claim $claim - * - * @return array - * @phpcs:disable Generic.Metrics.CyclomaticComplexity.TooHigh, Generic.Metrics.CyclomaticComplexity.MaxExceeded - */ - private function fillLine(Claim $claim) - { - // @phpcs:enable - $oneline = [ - sprintf('%s %s', $claim->first_name, Str::upper($claim->last_name)), - $claim->email, - $claim->address . "," . $claim->postcode . "," . $claim->city, - $claim->created_at->toDateTimeString(), - $claim->claimSubject->label, - $claim->status->label(), - $claim->message, - asset($claim->file), - ]; - return $oneline; - } - - /** - * converts an array to a csv stream - * - * @param array $data - * @param string $delimiter - * @param string $enclosure - * @param string $escape_char - * - * @return string - */ - private function array2csv( - array $data, - string $delimiter = ',', - string $enclosure = '"', - string $escape_char = "\\" - ) { - $f = fopen('php://memory', 'r+'); - - // UTF-8 BOM. - $BOM = "\xEF\xBB\xBF"; - fwrite($f, $BOM); - - foreach ($data as $item) { - fputcsv($f, $item, $delimiter, $enclosure, $escape_char); - } - rewind($f); - return stream_get_contents($f); - } -} diff --git a/app/Mail/ExportClaims.php b/app/Mail/ExportClaims.php deleted file mode 100644 index f8f5cf4..0000000 --- a/app/Mail/ExportClaims.php +++ /dev/null @@ -1,62 +0,0 @@ -brandName), - ); - } - - /** - * Get the message content definition. - */ - public function content(): Content - { - return new Content( - markdown: 'mail.export-claims', - text: 'mail.export-claims-plain' - ); - } - - /** - * Get the attachments for the message. - * - * @return array - */ - public function attachments(): array - { - return [ - Attachment::fromStorageDisk('export', "export_claims/{$this->fileName}") - ->as(sprintf( - 'export-reclas-%s.csv', - now()->format('Y-m-d') - )) - ]; - } -} diff --git a/app/Policies/ClaimPolicy.php b/app/Policies/ClaimPolicy.php index a6ea601..438594c 100644 --- a/app/Policies/ClaimPolicy.php +++ b/app/Policies/ClaimPolicy.php @@ -106,6 +106,18 @@ class ClaimPolicy return $this->checkOnlyMinAdminOrInUserBrands($authUser, $claim); } + /** + * Export a Claim. + * + * @param \App\Models\User $authUser + * @param \App\Models\Claim $claim + * @return boolean + */ + public function export(User $authUser, Claim $claim): bool + { + return $this->checkOnlyMinAdminOrInUserBrands($authUser, $claim); + } + /** * Check if the user an access a specific resource. * diff --git a/composer.json b/composer.json index 4d1d30c..96e5378 100644 --- a/composer.json +++ b/composer.json @@ -28,6 +28,7 @@ "24slides/laravel-saml2": "^2.4", "arondeparon/laravel-request-sanitizer": "^5.0", "axlon/laravel-postal-code-validation": "^3.2", + "barryvdh/laravel-dompdf": "^3.1", "diglactic/laravel-breadcrumbs": "^9.0", "doctrine/dbal": "^3.0", "doctrine/inflector": "^2.0", diff --git a/composer.lock b/composer.lock index b076c98..b1e042f 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c078e9a537be5c78427890f22fc24612", + "content-hash": "f04361d9a00c60112340a92b68293a68", "packages": [ { "name": "24slides/laravel-saml2", @@ -210,6 +210,83 @@ }, "time": "2024-09-10T15:23:59+00:00" }, + { + "name": "barryvdh/laravel-dompdf", + "version": "v3.1.1", + "source": { + "type": "git", + "url": "https://github.com/barryvdh/laravel-dompdf.git", + "reference": "8e71b99fc53bb8eb77f316c3c452dd74ab7cb25d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/barryvdh/laravel-dompdf/zipball/8e71b99fc53bb8eb77f316c3c452dd74ab7cb25d", + "reference": "8e71b99fc53bb8eb77f316c3c452dd74ab7cb25d", + "shasum": "" + }, + "require": { + "dompdf/dompdf": "^3.0", + "illuminate/support": "^9|^10|^11|^12", + "php": "^8.1" + }, + "require-dev": { + "larastan/larastan": "^2.7|^3.0", + "orchestra/testbench": "^7|^8|^9|^10", + "phpro/grumphp": "^2.5", + "squizlabs/php_codesniffer": "^3.5" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "PDF": "Barryvdh\\DomPDF\\Facade\\Pdf", + "Pdf": "Barryvdh\\DomPDF\\Facade\\Pdf" + }, + "providers": [ + "Barryvdh\\DomPDF\\ServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "Barryvdh\\DomPDF\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Barry vd. Heuvel", + "email": "barryvdh@gmail.com" + } + ], + "description": "A DOMPDF Wrapper for Laravel", + "keywords": [ + "dompdf", + "laravel", + "pdf" + ], + "support": { + "issues": "https://github.com/barryvdh/laravel-dompdf/issues", + "source": "https://github.com/barryvdh/laravel-dompdf/tree/v3.1.1" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2025-02-13T15:07:54+00:00" + }, { "name": "brick/math", "version": "0.12.1", @@ -995,6 +1072,161 @@ ], "time": "2024-02-05T11:56:58+00:00" }, + { + "name": "dompdf/dompdf", + "version": "v3.1.4", + "source": { + "type": "git", + "url": "https://github.com/dompdf/dompdf.git", + "reference": "db712c90c5b9868df3600e64e68da62e78a34623" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/dompdf/zipball/db712c90c5b9868df3600e64e68da62e78a34623", + "reference": "db712c90c5b9868df3600e64e68da62e78a34623", + "shasum": "" + }, + "require": { + "dompdf/php-font-lib": "^1.0.0", + "dompdf/php-svg-lib": "^1.0.0", + "ext-dom": "*", + "ext-mbstring": "*", + "masterminds/html5": "^2.0", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "ext-gd": "*", + "ext-json": "*", + "ext-zip": "*", + "mockery/mockery": "^1.3", + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "^3.5", + "symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0" + }, + "suggest": { + "ext-gd": "Needed to process images", + "ext-gmagick": "Improves image processing performance", + "ext-imagick": "Improves image processing performance", + "ext-zlib": "Needed for pdf stream compression" + }, + "type": "library", + "autoload": { + "psr-4": { + "Dompdf\\": "src/" + }, + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1" + ], + "authors": [ + { + "name": "The Dompdf Community", + "homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md" + } + ], + "description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter", + "homepage": "https://github.com/dompdf/dompdf", + "support": { + "issues": "https://github.com/dompdf/dompdf/issues", + "source": "https://github.com/dompdf/dompdf/tree/v3.1.4" + }, + "time": "2025-10-29T12:43:30+00:00" + }, + { + "name": "dompdf/php-font-lib", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-font-lib.git", + "reference": "6137b7d4232b7f16c882c75e4ca3991dbcf6fe2d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/6137b7d4232b7f16c882c75e4ca3991dbcf6fe2d", + "reference": "6137b7d4232b7f16c882c75e4ca3991dbcf6fe2d", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "symfony/phpunit-bridge": "^3 || ^4 || ^5 || ^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "FontLib\\": "src/FontLib" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "The FontLib Community", + "homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse, export and make subsets of different types of font files.", + "homepage": "https://github.com/dompdf/php-font-lib", + "support": { + "issues": "https://github.com/dompdf/php-font-lib/issues", + "source": "https://github.com/dompdf/php-font-lib/tree/1.0.1" + }, + "time": "2024-12-02T14:37:59+00:00" + }, + { + "name": "dompdf/php-svg-lib", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-svg-lib.git", + "reference": "eb045e518185298eb6ff8d80d0d0c6b17aecd9af" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/eb045e518185298eb6ff8d80d0d0c6b17aecd9af", + "reference": "eb045e518185298eb6ff8d80d0d0c6b17aecd9af", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0", + "sabberworm/php-css-parser": "^8.4" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Svg\\": "src/Svg" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "The SvgLib Community", + "homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse and export to PDF SVG files.", + "homepage": "https://github.com/dompdf/php-svg-lib", + "support": { + "issues": "https://github.com/dompdf/php-svg-lib/issues", + "source": "https://github.com/dompdf/php-svg-lib/tree/1.0.0" + }, + "time": "2024-04-29T13:26:35+00:00" + }, { "name": "dragonmantank/cron-expression", "version": "v3.4.0", @@ -3330,6 +3562,73 @@ ], "time": "2024-12-08T08:18:47+00:00" }, + { + "name": "masterminds/html5", + "version": "2.10.0", + "source": { + "type": "git", + "url": "https://github.com/Masterminds/html5-php.git", + "reference": "fcf91eb64359852f00d921887b219479b4f21251" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251", + "reference": "fcf91eb64359852f00d921887b219479b4f21251", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Masterminds\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matt Butcher", + "email": "technosophos@gmail.com" + }, + { + "name": "Matt Farina", + "email": "matt@mattfarina.com" + }, + { + "name": "Asmir Mustafic", + "email": "goetas@gmail.com" + } + ], + "description": "An HTML5 parser and serializer.", + "homepage": "http://masterminds.github.io/html5-php", + "keywords": [ + "HTML5", + "dom", + "html", + "parser", + "querypath", + "serializer", + "xml" + ], + "support": { + "issues": "https://github.com/Masterminds/html5-php/issues", + "source": "https://github.com/Masterminds/html5-php/tree/2.10.0" + }, + "time": "2025-07-25T09:04:22+00:00" + }, { "name": "mews/purifier", "version": "3.4.2", @@ -5115,6 +5414,72 @@ }, "time": "2024-11-20T21:13:56+00:00" }, + { + "name": "sabberworm/php-css-parser", + "version": "v8.9.0", + "source": { + "type": "git", + "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git", + "reference": "d8e916507b88e389e26d4ab03c904a082aa66bb9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/d8e916507b88e389e26d4ab03c904a082aa66bb9", + "reference": "d8e916507b88e389e26d4ab03c904a082aa66bb9", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": "^5.6.20 || ^7.0.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0" + }, + "require-dev": { + "phpunit/phpunit": "5.7.27 || 6.5.14 || 7.5.20 || 8.5.41", + "rawr/cross-data-providers": "^2.0.0" + }, + "suggest": { + "ext-mbstring": "for parsing UTF-8 CSS" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Sabberworm\\CSS\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Raphael Schweikert" + }, + { + "name": "Oliver Klee", + "email": "github@oliverklee.de" + }, + { + "name": "Jake Hotson", + "email": "jake.github@qzdesign.co.uk" + } + ], + "description": "Parser for CSS Files written in PHP", + "homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser", + "keywords": [ + "css", + "parser", + "stylesheet" + ], + "support": { + "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", + "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v8.9.0" + }, + "time": "2025-07-11T13:20:48+00:00" + }, { "name": "sentry/sentry", "version": "4.10.0", diff --git a/config/filesystems.php b/config/filesystems.php index 9f594c1..521ca82 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -67,14 +67,6 @@ return [ 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), 'throw' => false, ], - - 'export' => [ - 'driver' => 'local', - 'root' => storage_path('app/export'), - 'visibility' => 'private', - 'throw' => true, - ], - ], /* diff --git a/resources/lang/en/crud.php b/resources/lang/en/crud.php index 1579b61..9c9ce57 100644 --- a/resources/lang/en/crud.php +++ b/resources/lang/en/crud.php @@ -10,6 +10,7 @@ return [ 'delete' => 'delete', 'save' => 'save', 'edit' => 'edit', + 'export' => 'export', ], 'helpers' => [ diff --git a/resources/lang/fr/crud.php b/resources/lang/fr/crud.php index 7e9529a..02d3b76 100644 --- a/resources/lang/fr/crud.php +++ b/resources/lang/fr/crud.php @@ -10,6 +10,7 @@ return [ 'delete' => 'supprimer', 'save' => 'sauvegarder', 'edit' => 'mettre à jour', + 'export' => 'exporter', ], 'helpers' => [ diff --git a/resources/ts/bo/pages/claims.ts b/resources/ts/bo/pages/claims.ts index e3736e4..7feb6db 100644 --- a/resources/ts/bo/pages/claims.ts +++ b/resources/ts/bo/pages/claims.ts @@ -1,17 +1,24 @@ +// Type for 'bootstrap modal show' event. Needed for 'relatedTarget'. +interface BootstrapModalShowEvent extends CustomEvent { + relatedTarget: HTMLElement; +} document.addEventListener("DOMContentLoaded", function () { - const exportForm = document.getElementById("claimExportForm"); - const exportFormBtn = document.getElementById("claimExportFormBtn"); - const exportFormBtnInside = document.getElementById("claimExportFormBtnInside"); + const exportFormModal = document.getElementById("exportModal"); + const claimListInputs = document.getElementsByClassName("claim-list-input"); - if (!exportForm || !exportFormBtn || !exportFormBtnInside || !(exportForm instanceof HTMLFormElement)) { + if (!exportFormModal || !claimListInputs) { return; } - exportFormBtn.addEventListener("click", function (e) { - e.preventDefault(); + exportFormModal.addEventListener("show.bs.modal", (event) => { + const customEvent = event as BootstrapModalShowEvent; + const button = customEvent.relatedTarget as HTMLElement; + const datas = button.getAttribute("data-bs-claim-list"); - exportFormBtnInside.click(); + for (const claimListInput of claimListInputs) { + (claimListInput as HTMLInputElement).value = String(datas); + } }); }); diff --git a/resources/ts/bo/utilities/fontawesome.ts b/resources/ts/bo/utilities/fontawesome.ts index c8d371e..9dc0c24 100644 --- a/resources/ts/bo/utilities/fontawesome.ts +++ b/resources/ts/bo/utilities/fontawesome.ts @@ -30,6 +30,9 @@ import { faEye, faEyeSlash, faFile, + faFileCsv, + faFileExport, + faFilePdf, faFolderOpen, faListUl, faPaintRoller, @@ -59,6 +62,9 @@ library.add( faEye, faEyeSlash, faFile, + faFileCsv, + faFileExport, + faFilePdf, faFolderOpen, faCrop, faQuestionCircle, diff --git a/resources/views/bo/modules/claim-tools.blade.php b/resources/views/bo/modules/claim-tools.blade.php index 53d2b71..91b5947 100644 --- a/resources/views/bo/modules/claim-tools.blade.php +++ b/resources/views/bo/modules/claim-tools.blade.php @@ -2,14 +2,14 @@ $selectedSubject = request()->subject ?? (isset($modelTable) ? Session::get("crud.$modelTable.subject") : null); @endphp -
-
-
-
+
{{-- SUBJECT SELECT --}} -
{{ __('L\'export sera envoyé en .csv à cette adresse mail.')}}
- - - -
+ @foreach(\App\Enums\Claims\ClaimExportFormat::cases() as $supportedFormat) +
+ @csrf + + + +
+ @endforeach
diff --git a/resources/views/bo/pages/claims/index-list.blade.php b/resources/views/bo/pages/claims/index-list.blade.php index 88809e4..fdc7228 100644 --- a/resources/views/bo/pages/claims/index-list.blade.php +++ b/resources/views/bo/pages/claims/index-list.blade.php @@ -60,6 +60,16 @@ @endcan + @can('export', $claimModel) + + + + @endcan
@else diff --git a/resources/views/bo/pages/claims/pdf.blade.php b/resources/views/bo/pages/claims/pdf.blade.php new file mode 100644 index 0000000..a3fe59a --- /dev/null +++ b/resources/views/bo/pages/claims/pdf.blade.php @@ -0,0 +1,24 @@ + + +@foreach($claimModels as $claimModel) +

{{ __('Référence') }} : {{ $claimModel->reference }}

+

{{ __('Date de la demande') }} : {{ $claimModel->created_at->toDateString() }}

+

{{ __('Prénom NOM') }} : {{ $claimModel->first_name }} {{ str($claimModel->last_name)->upper() }}

+

{{ __('Email') }} : {{ $claimModel->email }}

+

{{ __('Adresse') }} : {{ sprintf('%s, %s, %s', $claimModel->address, $claimModel->postcode, $claimModel->city) }}

+

{{ __('Statut de la réclamation') }} : {{ $claimModel->status->label() }}

+

{{ __('Sujet de la réclamation') }} : {{ $claimModel->claimSubject->label }}

+

{{ __('Type de demande') }} : {{ $claimModel->type->label() }}

+

{{ __('Marque concernée') }} : {{ $claimModel->brand->name }}

+

{{ __('Message') }} : {{ $claimModel->message }}

+

{{ __('Voir la pièce jointe') }} : {{ __('Cliquez ici') }}

+ +@if (!$loop->last) +
+@endif + +@endforeach diff --git a/resources/views/mail/export-claims-plain.blade.php b/resources/views/mail/export-claims-plain.blade.php deleted file mode 100644 index 604c9ad..0000000 --- a/resources/views/mail/export-claims-plain.blade.php +++ /dev/null @@ -1,5 +0,0 @@ -Bonjour, - -Vous trouverez ci-joint l'export des réclamations du {{ now()->isoFormat('DD\/MM\/YYYY à HH:mm') }} - -Ceci est un message automatique, ne pas répondre. diff --git a/resources/views/mail/export-claims.blade.php b/resources/views/mail/export-claims.blade.php deleted file mode 100644 index 80d4762..0000000 --- a/resources/views/mail/export-claims.blade.php +++ /dev/null @@ -1,10 +0,0 @@ -{{-- blade-formatter-disable --}} -@component('mail::layout') - -# {{ __('Bonjour,') }} - -{{ __('Vous trouverez ci-joint l\'export des réclamations du') }} {{ now()->isoFormat('DD\/MM\/YYYY à HH:mm') }} - -{{ __('Ceci est un message automatique, ne pas répondre.') }} - -@endcomponent diff --git a/routes/web-bo.php b/routes/web-bo.php index 2c729c6..68cb02b 100644 --- a/routes/web-bo.php +++ b/routes/web-bo.php @@ -93,7 +93,8 @@ Route::middleware(['throttle:web', 'admin-secure'])->prefix('bo')->name('bo.')-> // * CLAIMS. Route::resource('claims', ClaimController::class)->only(['index', 'edit', 'update']); Route::post('claims/{claim}/answer', [ClaimController::class, 'answer'])->name('claims.answer'); - Route::post('claims/export', [ClaimController::class, 'export'])->name('claims.export'); + Route::post('claims/export', [ClaimController::class, 'export']) + ->name('claims.export'); // * CLAIM SUBJECTS. Route::get('/{brand}/claim_subjects/json/paginate', [ClaimController::class, 'jsonClaimPaginateModels']) From 2b240ed4cad55581bbbca09ee99d3719c3080077 Mon Sep 17 00:00:00 2001 From: Noah Lancien Date: Thu, 27 Nov 2025 16:19:48 +0100 Subject: [PATCH 3/5] fix: move uploaded claims files to private storage + use .zip for some exports types --- app/Enums/Claims/ClaimExportFormat.php | 70 +++- .../Controllers/Api/V4/ClaimController.php | 2 +- app/Http/Controllers/Bo/ClaimController.php | 277 +++++++++++++--- .../Requests/Bo/Claims/ExportClaimRequest.php | 5 + app/Models/Claim.php | 32 ++ config/dompdf.php | 304 ++++++++++++++++++ .../views/bo/modules/claim-tools.blade.php | 2 +- resources/views/bo/pages/claims/pdf.blade.php | 62 ++-- 8 files changed, 682 insertions(+), 72 deletions(-) create mode 100644 config/dompdf.php diff --git a/app/Enums/Claims/ClaimExportFormat.php b/app/Enums/Claims/ClaimExportFormat.php index fae0a4b..c58db75 100644 --- a/app/Enums/Claims/ClaimExportFormat.php +++ b/app/Enums/Claims/ClaimExportFormat.php @@ -8,32 +8,78 @@ enum ClaimExportFormat: int { use BaseEnum; - case csv = 1; - case pdf = 2; + case csv = 1; + case csvWithoutAttachment = 2; + case pdf = 3; /** * Optionnal labels definition */ private const LABELS = [ - self::csv->name => 'CSV', - self::pdf->name => 'PDF', + self::csv->name => 'csv', + self::csvWithoutAttachment->name => 'csv sans pièces jointes', + self::pdf->name => 'pdf', ]; - public function class() + /** + * Bootstrap color class to use for the export buttons. + * + * @return string + */ + public function class(): string { return match ($this) { - self::csv => 'success', - self::pdf => 'danger', - default => 'primary', + self::csv, + self::csvWithoutAttachment => 'success', + self::pdf => 'danger', + default => 'primary', }; } - public function picto() + /** + * Font Awesome picto to use for the export buttons. + * + * @return string + */ + public function picto(): string { return match ($this) { - self::csv => 'fa-solid fa-file-csv', - self::pdf => 'fa-solid fa-file-pdf', - default => 'fa-solid fa-file', + self::csv, + self::csvWithoutAttachment => 'fa-solid fa-file-csv', + self::pdf => 'fa-solid fa-file-pdf', + default => 'fa-solid fa-file', + }; + } + + + /** + * File headers to use when the exported file is return with StreamedResponse. + * + * @return array + */ + public function fileHeaders(): array + { + return match ($this) { + self::csv, + self::csvWithoutAttachment => [ + 'Content-Type' => 'text/csv', + 'Content-Disposition' => 'attachment; filename="export.csv"', + ], + default => [], + }; + } + + /** + * Export file name. + * + * @return string + */ + public function fileName(): string + { + return match ($this) { + self::csv, + self::csvWithoutAttachment => 'export.csv', + self::pdf => 'export.pdf', }; } } diff --git a/app/Http/Controllers/Api/V4/ClaimController.php b/app/Http/Controllers/Api/V4/ClaimController.php index 784127c..f2e5fd2 100644 --- a/app/Http/Controllers/Api/V4/ClaimController.php +++ b/app/Http/Controllers/Api/V4/ClaimController.php @@ -24,7 +24,7 @@ class ClaimController extends Controller { $claimModel = (new Claim())->fill(Arr::except($request->validated(), 'file')); if ($request->filled('file')) { - $claimModel->file = FileHelper::storeBase64File('claim', $request->file); + $claimModel->file = FileHelper::storeBase64File('claim', $request->file, private: true); } if (!$claimModel->saveOrFail()) { diff --git a/app/Http/Controllers/Bo/ClaimController.php b/app/Http/Controllers/Bo/ClaimController.php index e6847b8..b426958 100644 --- a/app/Http/Controllers/Bo/ClaimController.php +++ b/app/Http/Controllers/Bo/ClaimController.php @@ -23,10 +23,13 @@ use App\Traits\Controllers\UpdateModelPublished; use Barryvdh\DomPDF\Facade\Pdf; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Symfony\Component\HttpFoundation\StreamedResponse; use Uwil\LaravelCommon\Helpers\CollectionHelper; +use ZipArchive; class ClaimController extends Controller { @@ -231,55 +234,219 @@ class ClaimController extends Controller // * EXPORT /** - * CSV File headers + * Export claim datas to a given format * - * @var array + * @param ExportClaimRequest $request + * @return \Illuminate\Http\Response|Symfony\Component\HttpFoundation\StreamedResponse|\Illuminate\Http\RedirectResponse */ - private $header = [ - "Prénom NOM", - "Email", - "Adresse", - "Date de création", - "Sujet", - "Statut", - "Message", - "Pièce jointe", - ]; - public function export(ExportClaimRequest $request) { + /** @var \Illuminate\Database\Eloquent\Collection $claims*/ $claims = Claim::query()->whereIn('id', $request->validated('claimList'))->get(); $format = ClaimExportFormat::from($request->validated('format')); - if ($format->equals(ClaimExportFormat::csv)) { - $csvlines[] = $this->header; - foreach ($claims as $claim) { - $csvlines[] = $this->fillLine($claim); + // * CSV + if ($format->equals(ClaimExportFormat::csvWithoutAttachment, ClaimExportFormat::csv)) { + $csv = $this->generateCsv($claims); + if (!$this->needsZip($format, $claims)) { + return new StreamedResponse(function () use ($format, $csv) { + echo $csv; + }, 200, $this->getFileHeaders($format)); } - - return new StreamedResponse(function() use ($csvlines) { - $this->array2csv($csvlines, ';'); - }, 200, [ - 'Content-Type' => 'text/csv', - 'Content-Disposition' => 'attachment; filename="export.csv"', - ]); + return $this->returnStreamedZip($format, $claims, $csv); + // * PDF } elseif ($format->equals(ClaimExportFormat::pdf)) { - $pdf = Pdf::loadView('bo.pages.claims.pdf', [ - 'claimModels' => $claims - ]); - return $pdf->download('export.pdf'); - }; + $pdf = $this->generatePdf($claims); - return back()->with('error', __('Une erreur est survenue lors de l\'export. Veuillez réessayer en rafraîchissant la page.')); + if (!$this->needsZip(ClaimExportFormat::pdf, $claims)) { + return $pdf->download('export.pdf'); + } + return $this->returnStreamedZip($format, $claims, $pdf->output()); + } else { + return back()->with( + 'error', + __('Une erreur est survenue lors de l\'export. Veuillez réessayer en rafraîchissant la page.') + ); + }; //end if } + /** + * Check if the export needs to be in a .zip file. + * + * @param ClaimExportFormat $format + * @param \Illuminate\Database\Eloquent\Collection $claims + * @return boolean + */ + private function needsZip(ClaimExportFormat $format, \Illuminate\Database\Eloquent\Collection $claims): bool + { + /** + * .zip is needed for : + * - csv with any files + * - pdf with files other than pictures + */ + if ($format->equals(ClaimExportFormat::csv)) { + return $claims->whereNotNull('file')->pluck('file')->isNotEmpty(); + } elseif ($format->equals(ClaimExportFormat::pdf)) { + return $claims + ->whereNotNull('file') + ->whereNotIn('file_extension', ['jpg', 'jpeg', 'png']) + ->isNotEmpty(); + } + + return false; + } + + /** + * Create the zip folder for the export with the main file inside. + * + * @param ZipArchive $zip + * @param string $file + * @param ClaimExportFormat $format + * @return string|false + * + * @throws \Exception If the zip opening failed. + */ + private function createExportZip(ZipArchive &$zip, string $file, ClaimExportFormat $format): string|false + { + $zipName = tempnam(Storage::disk('private')->path('export'), 'zip'); + if (!$zip->open($zipName, ZipArchive::OVERWRITE)) { + throw new \Exception(__('Couldn\'t open zip file.')); + }; + $zip->addFromString($format->fileName(), $file); + + return $zipName; + } + + /** + * Add attachments to zip + * + * @param ZipArchive $zip + * @param \Illuminate\Database\Eloquent\Collection $claims + * @param ClaimExportFormat $format + * @return void + */ + private function addAttachmentsToZip( + ZipArchive &$zip, + \Illuminate\Database\Eloquent\Collection $claims, + ClaimExportFormat $format + ) { + $claimsWithFilesToZip = $claims->whereNotNull('file') + ->when($format->equals(ClaimExportFormat::pdf), function ($collection) { + return $collection->whereNotIn('file_extension', ['jpg', 'jpeg', 'png']); + }); + foreach ($claimsWithFilesToZip as $claim) { + $filePath = $this->getPrivateFilePath($claim->file); + if (Storage::disk('private')->exists($filePath)) { + $zip->addFile( + Storage::disk('private')->path($filePath), + $claim->export_file_name + ); + } + } + } + + /** + * Return a StreamedResponse with a zip file. + * + * @param ClaimExportFormat $format + * @param \Illuminate\Database\Eloquent\Collection $claims + * @param string $file + * @return \Symfony\Component\HttpFoundation\StreamedResponse + */ + private function returnStreamedZip( + ClaimExportFormat $format, + \Illuminate\Database\Eloquent\Collection $claims, + string $file + ): \Symfony\Component\HttpFoundation\StreamedResponse { + return new StreamedResponse(function () use ($file, $claims, $format) { + // Create zip. + $zip = new ZipArchive(); + $zipName = $this->createExportZip($zip, $file, $format); + + // Add attachments. + $this->addAttachmentsToZip($zip, $claims, $format); + + $zip->close(); + readfile($zipName); + unlink($zipName); + }, 200, $this->getFileHeaders($format, true)); + } + + /** + * Generate PDF with claims. + * + * @param \Illuminate\Database\Eloquent\Collection $claims + * @return \Barryvdh\DomPDF\PDF + */ + private function generatePdf(\Illuminate\Database\Eloquent\Collection $claims): \Barryvdh\DomPDF\PDF + { + // Convert pictures to base64. + foreach ($claims->whereIn('file_extension', ['jpg', 'jpeg', 'png']) as $claim) { + $filePath = $this->getPrivateFilePath($claim->file); + $claim->fileBase64 = sprintf( + 'data:%s;base64,%s', + File::mimeType(Storage::disk('private')->path($filePath)), + base64_encode(Storage::disk('private')->get($filePath)) + ); + } + + // Create temp folder if it doesn't exists. Needed to allow base64 pictures to be displayed in pdf. + if (!Storage::disk('private')->exists('temp')) { + Storage::disk('private')->makeDirectory('temp'); + } + + return Pdf::setOptions(['tempDir' => Storage::disk('private')->path('temp')]) + ->loadView('bo.pages.claims.pdf', [ + 'claimModels' => $claims + ]); + } + + /** + * Generate claims CSV + * + * @param \Illuminate\Database\Eloquent\Collection $claims + * @return string|false + */ + private function generateCsv(\Illuminate\Database\Eloquent\Collection $claims) + { + // CSV Header. + $csvlines[] = [ + 'Référence', + 'Date de création', + 'Prénom NOM', + 'Email', + 'Adresse', + 'Statut', + 'Sujet', + 'Type', + 'Marque', + 'Message', + 'Pièce jointe', + ]; + + foreach ($claims as $claim) { + $csvlines[] = $this->fillLine($claim); + } + + return $this->array2csv($csvlines, ';'); + } + + /** + * Transform Array into CSV. + * + * @param array $lines + * @param string $delimiter + * @param string $enclosure + * @param string $escape_char + * @return string|false + */ private function array2csv( array $lines, string $delimiter = ',', string $enclosure = '"', string $escape_char = "\\" - ){ - $f = fopen('php://output', 'w'); + ) { + $f = fopen('php://temp', 'r+'); // UTF-8 BOM. $BOM = "\xEF\xBB\xBF"; @@ -289,27 +456,63 @@ class ClaimController extends Controller fputcsv($f, $item, $delimiter, $enclosure, $escape_char); } + rewind($f); + $csvContent = stream_get_contents($f); fclose($f); + + return $csvContent; } /** - * fill one line for csv - * @param \App\Models\Claim $claim + * Fill one line for csv. * + * @param \App\Models\Claim $claim * @return array */ private function fillLine(Claim $claim) { $oneline = [ + $claim->reference, + $claim->created_at->toDateTimeString(), sprintf('%s %s', $claim->first_name, Str::upper($claim->last_name)), $claim->email, sprintf('%s, %s, %s', $claim->address, $claim->postcode, $claim->city), - $claim->created_at->toDateTimeString(), - $claim->claimSubject->label, $claim->status->label(), + $claim->claimSubject->label, + $claim->type->label(), + $claim->brand->name, $claim->message, - asset($claim->file), + $claim->export_file_name, ]; return $oneline; } + + /** + * Get file path in private storage from stored path. + * + * @param string $basePath + * @return string + */ + private function getPrivateFilePath(string $basePath): string + { + return str($basePath)->replaceFirst('storage/app/private', ''); + } + + /** + * Get file headers to use with StreamedResponse. + * + * @param ClaimExportFormat $format + * @param boolean $isZip + * @return array + */ + private function getFileHeaders(ClaimExportFormat $format, bool $isZip = false): array + { + if ($isZip) { + return [ + 'Content-Type' => 'application/zip', + 'Content-Disposition' => 'attachment; filename="export.zip"', + ]; + }; + return $format->fileHeaders(); + } } diff --git a/app/Http/Requests/Bo/Claims/ExportClaimRequest.php b/app/Http/Requests/Bo/Claims/ExportClaimRequest.php index 37861a1..156674c 100644 --- a/app/Http/Requests/Bo/Claims/ExportClaimRequest.php +++ b/app/Http/Requests/Bo/Claims/ExportClaimRequest.php @@ -18,6 +18,11 @@ class ExportClaimRequest extends FormRequest return auth('backend')->check(); } + /** + * Prepare the data for validation. + * + * @return void + */ public function prepareForValidation() { $this->merge([ diff --git a/app/Models/Claim.php b/app/Models/Claim.php index 1a23fda..332aa2b 100644 --- a/app/Models/Claim.php +++ b/app/Models/Claim.php @@ -8,6 +8,7 @@ use App\Enums\Claims\ClaimType; use App\Events\ClaimModelChanged; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\File; use Illuminate\Support\Str; /** @@ -29,6 +30,9 @@ use Illuminate\Support\Str; * @property string|null $hashcode Claim associated Customer hashcode. * @property-read \Illuminate\Support\Carbon $created_at Claim created date. * @property-read \Illuminate\Support\Carbon $updated_at Claim updated date. + * + * @property-read string $file_extension Extension of the file. + * @property-read string $export_file_name Name of the exported file. */ class Claim extends Model { @@ -199,4 +203,32 @@ class Claim extends Model trim(implode(' ', array_filter([$this->postcode, $this->city]))) ])); } + + // * VIRTUAL ATTRIBUTES + + /** + * 'file_extension' virtual property. + * + * @return string + */ + public function getFileExtensionAttribute(): string + { + return File::extension($this->file); + } + + /** + * 'export_file_name' virtual property. + * + * @return string + */ + public function getExportFileNameAttribute(): string + { + return $this->file ? + sprintf( + '%s.%s', + $this->reference, + $this->file_extension, + ) : + __('Aucune pièce jointe'); + } } diff --git a/config/dompdf.php b/config/dompdf.php new file mode 100644 index 0000000..dcdf60c --- /dev/null +++ b/config/dompdf.php @@ -0,0 +1,304 @@ + true, + + // Override the public path if needed. + 'public_path' => null, + + // Dejavu Sans font is missing glyphs for converted entities, turn it off if you need to show € and £. + 'convert_entities' => true, + + 'options' => [ + /** + * The location of the DOMPDF font directory + * + * The location of the directory where DOMPDF will store fonts and font metrics + * Note: This directory must exist and be writable by the webserver process. + * *Please note the trailing slash.* + * + * Notes regarding fonts: + * Additional .afm font metrics can be added by executing load_font.php from command line. + * + * Only the original "Base 14 fonts" are present on all pdf viewers. Additional fonts must + * be embedded in the pdf file or the PDF may not display correctly. This can significantly + * increase file size unless font subsetting is enabled. Before embedding a font please + * review your rights under the font license. + * + * Any font specification in the source HTML is translated to the closest font available + * in the font directory. + * + * The pdf standard "Base 14 fonts" are: + * Courier, Courier-Bold, Courier-BoldOblique, Courier-Oblique, + * Helvetica, Helvetica-Bold, Helvetica-BoldOblique, Helvetica-Oblique, + * Times-Roman, Times-Bold, Times-BoldItalic, Times-Italic, + * Symbol, ZapfDingbats. + */ + + // Advised by dompdf (https://github.com/dompdf/dompdf/pull/782). + 'font_dir' => storage_path('fonts'), + + /** + * The location of the DOMPDF font cache directory + * + * This directory contains the cached font metrics for the fonts used by DOMPDF. + * This directory can be the same as DOMPDF_FONT_DIR + * + * Note: This directory must exist and be writable by the webserver process. + */ + 'font_cache' => storage_path('fonts'), + + /** + * The location of a temporary directory. + * + * The directory specified must be writeable by the webserver process. + * The temporary directory is required to download remote images and when + * using the PDFLib back end. + */ + 'temp_dir' => sys_get_temp_dir(), + + /** + * ==== IMPORTANT ==== + * + * dompdf's "chroot": Prevents dompdf from accessing system files or other + * files on the webserver. All local files opened by dompdf must be in a + * subdirectory of this directory. DO NOT set it to '/' since this could + * allow an attacker to use dompdf to read any files on the server. This + * should be an absolute path. + * This is only checked on command line call by dompdf.php, but not by + * direct class use like: + * $dompdf = new DOMPDF(); $dompdf->load_html($htmldata); $dompdf->render(); $pdfdata = $dompdf->output(); + */ + 'chroot' => realpath(base_path()), + + /** + * Protocol whitelist + * + * Protocols and PHP wrappers allowed in URIs, and the validation rules + * that determine if a resouce may be loaded. Full support is not guaranteed + * for the protocols/wrappers specified + * by this array. + * + * @var array + */ + 'allowed_protocols' => [ + 'data://' => ['rules' => []], + 'file://' => ['rules' => []], + 'http://' => ['rules' => []], + 'https://' => ['rules' => []], + ], + + /** + * Operational artifact (log files, temporary files) path validation + */ + 'artifactPathValidation' => null, + + /** + * @var string + */ + 'log_output_file' => null, + + /** + * Whether to enable font subsetting or not. + */ + 'enable_font_subsetting' => false, + + /** + * The PDF rendering backend to use + * + * Valid settings are 'PDFLib', 'CPDF' (the bundled R&OS PDF class), 'GD' and + * 'auto'. 'auto' will look for PDFLib and use it if found, or if not it will + * fall back on CPDF. 'GD' renders PDFs to graphic files. + * {@link * Canvas_Factory} ultimately determines which rendering class to + * instantiate based on this setting. + * + * Both PDFLib & CPDF rendering backends provide sufficient rendering + * capabilities for dompdf, however additional features (e.g. object, + * image and font support, etc.) differ between backends. Please see + * {@link PDFLib_Adapter} for more information on the PDFLib backend + * and {@link CPDF_Adapter} and lib/class.pdf.php for more information + * on CPDF. Also see the documentation for each backend at the links + * below. + * + * The GD rendering backend is a little different than PDFLib and + * CPDF. Several features of CPDF and PDFLib are not supported or do + * not make any sense when creating image files. For example, + * multiple pages are not supported, nor are PDF 'objects'. Have a + * look at {@link GD_Adapter} for more information. GD support is + * experimental, so use it at your own risk. + * + * @link http://www.pdflib.com + * @link http://www.ros.co.nz/pdf + * @link http://www.php.net/image + */ + 'pdf_backend' => 'CPDF', + + /** + * html target media view which should be rendered into pdf. + * List of types and parsing rules for future extensions: + * http://www.w3.org/TR/REC-html40/types.html + * screen, tty, tv, projection, handheld, print, braille, aural, all + * Note: aural is deprecated in CSS 2.1 because it is replaced by speech in CSS 3. + * Note, even though the generated pdf file is intended for print output, + * the desired content might be different (e.g. screen or projection view of html file). + * Therefore allow specification of content here. + */ + 'default_media_type' => 'screen', + + /** + * The default paper size. + * + * North America standard is "letter"; other countries generally "a4" + * + * @see CPDF_Adapter::PAPER_SIZES for valid sizes ('letter', 'legal', 'A4', etc.) + */ + 'default_paper_size' => 'a4', + + /** + * The default paper orientation. + * + * The orientation of the page (portrait or landscape). + * + * @var string + */ + 'default_paper_orientation' => 'portrait', + + /** + * The default font family + * + * Used if no suitable fonts can be found. This must exist in the font folder. + * + * @var string + */ + 'default_font' => 'serif', + + /** + * Image DPI setting + * + * This setting determines the default DPI setting for images and fonts. The + * DPI may be overridden for inline images by explictly setting the + * image's width & height style attributes (i.e. if the image's native + * width is 600 pixels and you specify the image's width as 72 points, + * the image will have a DPI of 600 in the rendered PDF. The DPI of + * background images can not be overridden and is controlled entirely + * via this parameter. + * + * For the purposes of DOMPDF, pixels per inch (PPI) = dots per inch (DPI). + * If a size in html is given as px (or without unit as image size), + * this tells the corresponding size in pt. + * This adjusts the relative sizes to be similar to the rendering of the + * html page in a reference browser. + * + * In pdf, always 1 pt = 1/72 inch + * + * Rendering resolution of various browsers in px per inch: + * Windows Firefox and Internet Explorer: + * SystemControl->Display properties->FontResolution: Default:96, largefonts:120, custom:? + * Linux Firefox: + * about:config *resolution: Default:96 + * (xorg screen dimension in mm and Desktop font dpi settings are ignored) + * + * Take care about extra font/image zoom factor of browser. + * + * In images, size in pixel attribute, img css style, are overriding + * the real image dimension in px for rendering. + * + * @var int + */ + 'dpi' => 96, + + /** + * Enable embedded PHP + * + * If this setting is set to true then DOMPDF will automatically evaluate embedded PHP contained + * within tags. + * + * ==== IMPORTANT ==== Enabling this for documents you do not trust (e.g. arbitrary remote html pages) + * is a security risk. + * Embedded scripts are run with the same level of system access available to dompdf. + * Set this option to false (recommended) if you wish to process untrusted documents. + * This setting may increase the risk of system exploit. + * Do not change this settings without understanding the consequences. + * Additional documentation is available on the dompdf wiki at: + * https://github.com/dompdf/dompdf/wiki + * + * @var bool + */ + 'enable_php' => false, + + /** + * Rnable inline JavaScript + * + * If this setting is set to true then DOMPDF will automatically insert JavaScript code contained + * within tags as written into the PDF. + * NOTE: This is PDF-based JavaScript to be executed by the PDF viewer, + * not browser-based JavaScript executed by Dompdf. + * + * @var bool + */ + 'enable_javascript' => true, + + /** + * Enable remote file access + * + * If this setting is set to true, DOMPDF will access remote sites for + * images and CSS files as required. + * + * ==== IMPORTANT ==== + * This can be a security risk, in particular in combination with isPhpEnabled and + * allowing remote html code to be passed to $dompdf = new DOMPDF(); $dompdf->load_html(...); + * This allows anonymous users to download legally doubtful internet content which on + * tracing back appears to being downloaded by your server, or allows malicious php code + * in remote html pages to be executed by your server with your account privileges. + * + * This setting may increase the risk of system exploit. Do not change + * this settings without understanding the consequences. Additional + * documentation is available on the dompdf wiki at: + * https://github.com/dompdf/dompdf/wiki + * + * @var bool + */ + 'enable_remote' => false, + + /** + * List of allowed remote hosts + * + * Each value of the array must be a valid hostname. + * + * This will be used to filter which resources can be loaded in combination with + * isRemoteEnabled. If enable_remote is FALSE, then this will have no effect. + * + * Leave to NULL to allow any remote host. + * + * @var array|null + */ + 'allowed_remote_hosts' => null, + + /** + * A ratio applied to the fonts height to be more like browsers' line height + */ + 'font_height_ratio' => 1.1, + + /** + * Use the HTML5 Lib parser + * + * @deprecated This feature is now always on in dompdf 2.x + * + * @var bool + */ + 'enable_html5_parser' => true, + ], + +]; diff --git a/resources/views/bo/modules/claim-tools.blade.php b/resources/views/bo/modules/claim-tools.blade.php index 91b5947..96d3777 100644 --- a/resources/views/bo/modules/claim-tools.blade.php +++ b/resources/views/bo/modules/claim-tools.blade.php @@ -41,7 +41,7 @@ @endforeach diff --git a/resources/views/bo/pages/claims/pdf.blade.php b/resources/views/bo/pages/claims/pdf.blade.php index a3fe59a..78efe28 100644 --- a/resources/views/bo/pages/claims/pdf.blade.php +++ b/resources/views/bo/pages/claims/pdf.blade.php @@ -1,24 +1,44 @@ - + + -@foreach($claimModels as $claimModel) -

{{ __('Référence') }} : {{ $claimModel->reference }}

-

{{ __('Date de la demande') }} : {{ $claimModel->created_at->toDateString() }}

-

{{ __('Prénom NOM') }} : {{ $claimModel->first_name }} {{ str($claimModel->last_name)->upper() }}

-

{{ __('Email') }} : {{ $claimModel->email }}

-

{{ __('Adresse') }} : {{ sprintf('%s, %s, %s', $claimModel->address, $claimModel->postcode, $claimModel->city) }}

-

{{ __('Statut de la réclamation') }} : {{ $claimModel->status->label() }}

-

{{ __('Sujet de la réclamation') }} : {{ $claimModel->claimSubject->label }}

-

{{ __('Type de demande') }} : {{ $claimModel->type->label() }}

-

{{ __('Marque concernée') }} : {{ $claimModel->brand->name }}

-

{{ __('Message') }} : {{ $claimModel->message }}

-

{{ __('Voir la pièce jointe') }} : {{ __('Cliquez ici') }}

+ + + + + -@if (!$loop->last) -
-@endif + + @foreach($claimModels as $claimModel) +

{{ __('Référence') }} : {{ $claimModel->reference }}

+

{{ __('Date de la demande') }} : {{ $claimModel->created_at->toDateString() }}

+

{{ __('Prénom NOM') }} : {{ $claimModel->first_name }} {{ str($claimModel->last_name)->upper() }}

+

{{ __('Email') }} : {{ $claimModel->email }}

+

{{ __('Adresse') }} : {{ sprintf('%s, %s, %s', $claimModel->address, $claimModel->postcode, $claimModel->city) }}

+

{{ __('Statut de la réclamation') }} : {{ $claimModel->status->label() }}

+

{{ __('Sujet de la réclamation') }} : {{ $claimModel->claimSubject->label }}

+

{{ __('Type de demande') }} : {{ $claimModel->type->label() }}

+

{{ __('Marque concernée') }} : {{ $claimModel->brand->name }}

+

{{ __('Message') }} : {{ $claimModel->message }}

+ {{-- ATTACHMENT --}} + @if ($claimModel->file_extension) + @if (in_array($claimModel->file_extension, ['jpg', 'jpeg', 'png'])) +

{{ __('Image') }} :

+ + @else +

{{ __('Nom de la pièce jointe') }} : {{ $claimModel->export_file_name }}

+ @endif + @else +

{{ __('Aucune pièce jointe.') }}

+ @endif -@endforeach + {{-- Page break between every claim. --}} + @if (!$loop->last) +
+ @endif + @endforeach + + From 0aea086f262ddb8c60fb2a22aca15be563830856 Mon Sep 17 00:00:00 2001 From: Noah Lancien Date: Mon, 1 Dec 2025 16:23:45 +0100 Subject: [PATCH 4/5] fix: make the export feature more generic --- app/Http/Controllers/Bo/ClaimController.php | 262 +----------------- app/Interfaces/IsExportable.php | 34 +++ app/Lib/Helpers/FileHelper.php | 17 ++ app/Models/Claim.php | 47 +++- app/Traits/Controllers/ExportDatas.php | 226 +++++++++++++++ config/dompdf.php | 2 +- resources/views/bo/pages/claims/pdf.blade.php | 44 --- .../views/export/claim_item_pdf.blade.php | 21 ++ resources/views/export/pdf.blade.php | 24 ++ 9 files changed, 373 insertions(+), 304 deletions(-) create mode 100644 app/Interfaces/IsExportable.php create mode 100644 app/Lib/Helpers/FileHelper.php create mode 100644 app/Traits/Controllers/ExportDatas.php delete mode 100644 resources/views/bo/pages/claims/pdf.blade.php create mode 100644 resources/views/export/claim_item_pdf.blade.php create mode 100644 resources/views/export/pdf.blade.php diff --git a/app/Http/Controllers/Bo/ClaimController.php b/app/Http/Controllers/Bo/ClaimController.php index b426958..c427e8a 100644 --- a/app/Http/Controllers/Bo/ClaimController.php +++ b/app/Http/Controllers/Bo/ClaimController.php @@ -19,21 +19,18 @@ use App\Models\ClaimMailTemplate; use App\Models\ClaimMessage; use App\Models\ClaimSubject; use App\Models\CustomerInfo; +use App\Traits\Controllers\ExportDatas; use App\Traits\Controllers\UpdateModelPublished; -use Barryvdh\DomPDF\Facade\Pdf; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Mail; -use Illuminate\Support\Facades\Storage; -use Illuminate\Support\Str; use Symfony\Component\HttpFoundation\StreamedResponse; use Uwil\LaravelCommon\Helpers\CollectionHelper; -use ZipArchive; class ClaimController extends Controller { use UpdateModelPublished; + use ExportDatas; /** * Create the controller instance. @@ -253,7 +250,7 @@ class ClaimController extends Controller echo $csv; }, 200, $this->getFileHeaders($format)); } - return $this->returnStreamedZip($format, $claims, $csv); + return $this->returnExportZip($format, $claims, $csv); // * PDF } elseif ($format->equals(ClaimExportFormat::pdf)) { $pdf = $this->generatePdf($claims); @@ -261,258 +258,7 @@ class ClaimController extends Controller if (!$this->needsZip(ClaimExportFormat::pdf, $claims)) { return $pdf->download('export.pdf'); } - return $this->returnStreamedZip($format, $claims, $pdf->output()); - } else { - return back()->with( - 'error', - __('Une erreur est survenue lors de l\'export. Veuillez réessayer en rafraîchissant la page.') - ); + return $this->returnExportZip($format, $claims, $pdf->output()); }; //end if } - - /** - * Check if the export needs to be in a .zip file. - * - * @param ClaimExportFormat $format - * @param \Illuminate\Database\Eloquent\Collection $claims - * @return boolean - */ - private function needsZip(ClaimExportFormat $format, \Illuminate\Database\Eloquent\Collection $claims): bool - { - /** - * .zip is needed for : - * - csv with any files - * - pdf with files other than pictures - */ - if ($format->equals(ClaimExportFormat::csv)) { - return $claims->whereNotNull('file')->pluck('file')->isNotEmpty(); - } elseif ($format->equals(ClaimExportFormat::pdf)) { - return $claims - ->whereNotNull('file') - ->whereNotIn('file_extension', ['jpg', 'jpeg', 'png']) - ->isNotEmpty(); - } - - return false; - } - - /** - * Create the zip folder for the export with the main file inside. - * - * @param ZipArchive $zip - * @param string $file - * @param ClaimExportFormat $format - * @return string|false - * - * @throws \Exception If the zip opening failed. - */ - private function createExportZip(ZipArchive &$zip, string $file, ClaimExportFormat $format): string|false - { - $zipName = tempnam(Storage::disk('private')->path('export'), 'zip'); - if (!$zip->open($zipName, ZipArchive::OVERWRITE)) { - throw new \Exception(__('Couldn\'t open zip file.')); - }; - $zip->addFromString($format->fileName(), $file); - - return $zipName; - } - - /** - * Add attachments to zip - * - * @param ZipArchive $zip - * @param \Illuminate\Database\Eloquent\Collection $claims - * @param ClaimExportFormat $format - * @return void - */ - private function addAttachmentsToZip( - ZipArchive &$zip, - \Illuminate\Database\Eloquent\Collection $claims, - ClaimExportFormat $format - ) { - $claimsWithFilesToZip = $claims->whereNotNull('file') - ->when($format->equals(ClaimExportFormat::pdf), function ($collection) { - return $collection->whereNotIn('file_extension', ['jpg', 'jpeg', 'png']); - }); - foreach ($claimsWithFilesToZip as $claim) { - $filePath = $this->getPrivateFilePath($claim->file); - if (Storage::disk('private')->exists($filePath)) { - $zip->addFile( - Storage::disk('private')->path($filePath), - $claim->export_file_name - ); - } - } - } - - /** - * Return a StreamedResponse with a zip file. - * - * @param ClaimExportFormat $format - * @param \Illuminate\Database\Eloquent\Collection $claims - * @param string $file - * @return \Symfony\Component\HttpFoundation\StreamedResponse - */ - private function returnStreamedZip( - ClaimExportFormat $format, - \Illuminate\Database\Eloquent\Collection $claims, - string $file - ): \Symfony\Component\HttpFoundation\StreamedResponse { - return new StreamedResponse(function () use ($file, $claims, $format) { - // Create zip. - $zip = new ZipArchive(); - $zipName = $this->createExportZip($zip, $file, $format); - - // Add attachments. - $this->addAttachmentsToZip($zip, $claims, $format); - - $zip->close(); - readfile($zipName); - unlink($zipName); - }, 200, $this->getFileHeaders($format, true)); - } - - /** - * Generate PDF with claims. - * - * @param \Illuminate\Database\Eloquent\Collection $claims - * @return \Barryvdh\DomPDF\PDF - */ - private function generatePdf(\Illuminate\Database\Eloquent\Collection $claims): \Barryvdh\DomPDF\PDF - { - // Convert pictures to base64. - foreach ($claims->whereIn('file_extension', ['jpg', 'jpeg', 'png']) as $claim) { - $filePath = $this->getPrivateFilePath($claim->file); - $claim->fileBase64 = sprintf( - 'data:%s;base64,%s', - File::mimeType(Storage::disk('private')->path($filePath)), - base64_encode(Storage::disk('private')->get($filePath)) - ); - } - - // Create temp folder if it doesn't exists. Needed to allow base64 pictures to be displayed in pdf. - if (!Storage::disk('private')->exists('temp')) { - Storage::disk('private')->makeDirectory('temp'); - } - - return Pdf::setOptions(['tempDir' => Storage::disk('private')->path('temp')]) - ->loadView('bo.pages.claims.pdf', [ - 'claimModels' => $claims - ]); - } - - /** - * Generate claims CSV - * - * @param \Illuminate\Database\Eloquent\Collection $claims - * @return string|false - */ - private function generateCsv(\Illuminate\Database\Eloquent\Collection $claims) - { - // CSV Header. - $csvlines[] = [ - 'Référence', - 'Date de création', - 'Prénom NOM', - 'Email', - 'Adresse', - 'Statut', - 'Sujet', - 'Type', - 'Marque', - 'Message', - 'Pièce jointe', - ]; - - foreach ($claims as $claim) { - $csvlines[] = $this->fillLine($claim); - } - - return $this->array2csv($csvlines, ';'); - } - - /** - * Transform Array into CSV. - * - * @param array $lines - * @param string $delimiter - * @param string $enclosure - * @param string $escape_char - * @return string|false - */ - private function array2csv( - array $lines, - string $delimiter = ',', - string $enclosure = '"', - string $escape_char = "\\" - ) { - $f = fopen('php://temp', 'r+'); - - // UTF-8 BOM. - $BOM = "\xEF\xBB\xBF"; - fwrite($f, $BOM); - - foreach ($lines as $item) { - fputcsv($f, $item, $delimiter, $enclosure, $escape_char); - } - - rewind($f); - $csvContent = stream_get_contents($f); - fclose($f); - - return $csvContent; - } - - /** - * Fill one line for csv. - * - * @param \App\Models\Claim $claim - * @return array - */ - private function fillLine(Claim $claim) - { - $oneline = [ - $claim->reference, - $claim->created_at->toDateTimeString(), - sprintf('%s %s', $claim->first_name, Str::upper($claim->last_name)), - $claim->email, - sprintf('%s, %s, %s', $claim->address, $claim->postcode, $claim->city), - $claim->status->label(), - $claim->claimSubject->label, - $claim->type->label(), - $claim->brand->name, - $claim->message, - $claim->export_file_name, - ]; - return $oneline; - } - - /** - * Get file path in private storage from stored path. - * - * @param string $basePath - * @return string - */ - private function getPrivateFilePath(string $basePath): string - { - return str($basePath)->replaceFirst('storage/app/private', ''); - } - - /** - * Get file headers to use with StreamedResponse. - * - * @param ClaimExportFormat $format - * @param boolean $isZip - * @return array - */ - private function getFileHeaders(ClaimExportFormat $format, bool $isZip = false): array - { - if ($isZip) { - return [ - 'Content-Type' => 'application/zip', - 'Content-Disposition' => 'attachment; filename="export.zip"', - ]; - }; - return $format->fileHeaders(); - } } diff --git a/app/Interfaces/IsExportable.php b/app/Interfaces/IsExportable.php new file mode 100644 index 0000000..ad6cba5 --- /dev/null +++ b/app/Interfaces/IsExportable.php @@ -0,0 +1,34 @@ +replaceFirst('storage/app/private', ''); + } +} diff --git a/app/Models/Claim.php b/app/Models/Claim.php index 332aa2b..eeb4559 100644 --- a/app/Models/Claim.php +++ b/app/Models/Claim.php @@ -6,6 +6,7 @@ use App\Enums\Claims\ClaimResponseType; use App\Enums\Claims\ClaimStatus; use App\Enums\Claims\ClaimType; use App\Events\ClaimModelChanged; +use App\Interfaces\IsExportable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\File; @@ -34,7 +35,7 @@ use Illuminate\Support\Str; * @property-read string $file_extension Extension of the file. * @property-read string $export_file_name Name of the exported file. */ -class Claim extends Model +class Claim extends Model implements IsExportable { use HasFactory; @@ -204,6 +205,50 @@ class Claim extends Model ])); } + /** + * Get the formmated csv line for the current model. + * + * @return array + */ + public function getFormattedCsvLine(): array + { + return [ + $this->reference, + $this->created_at->toDateTimeString(), + sprintf('%s %s', $this->first_name, Str::upper($this->last_name)), + $this->email, + sprintf('%s, %s, %s', $this->address, $this->postcode, $this->city), + $this->status->label(), + $this->claimSubject->label, + $this->type->label(), + $this->brand->name, + $this->message, + $this->export_file_name, + ]; + } + + /** + * Get the csv headers for this model. + * + * @return array + */ + public static function getCsvHeaders(): array + { + return [ + 'Référence', + 'Date de création', + 'Prénom NOM', + 'Email', + 'Adresse', + 'Statut', + 'Sujet', + 'Type', + 'Marque', + 'Message', + 'Pièce jointe', + ]; + } + // * VIRTUAL ATTRIBUTES /** diff --git a/app/Traits/Controllers/ExportDatas.php b/app/Traits/Controllers/ExportDatas.php new file mode 100644 index 0000000..c7767dc --- /dev/null +++ b/app/Traits/Controllers/ExportDatas.php @@ -0,0 +1,226 @@ +equals(ClaimExportFormat::csv)) { + return $collection->whereNotNull($fileFieldName)->pluck($fileFieldName)->isNotEmpty(); + } elseif ($format->equals(ClaimExportFormat::pdf)) { + return $collection + ->whereNotNull($fileFieldName) + ->whereNotIn('file_extension', ['jpg', 'jpeg', 'png']) + ->isNotEmpty(); + } + + return false; + } + + /** + * Return a StreamedResponse with a zip file. + * + * @param ClaimExportFormat $format + * @param \Illuminate\Database\Eloquent\Collection $claims + * @param string $file + * @return \Symfony\Component\HttpFoundation\StreamedResponse + */ + public function returnExportZip( + ClaimExportFormat $format, + \Illuminate\Database\Eloquent\Collection $claims, + string $file + ): \Symfony\Component\HttpFoundation\StreamedResponse { + return new StreamedResponse(function () use ($file, $claims, $format) { + // Create zip. + $zip = new ZipArchive(); + $zipName = $this->createExportZip($zip, $file, $format); + + // Add attachments. + $this->addAttachmentsToZip($zip, $claims, $format); + + $zip->close(); + readfile($zipName); + unlink($zipName); + }, 200, $this->getFileHeaders($format, true)); + } + + /** + * Create the zip folder for the export with the main file inside. + * + * @param ZipArchive $zip + * @param string $file + * @param ClaimExportFormat $format + * @return string|false + * + * @throws \Exception If the zip opening failed. + */ + private function createExportZip(ZipArchive &$zip, string $file, ClaimExportFormat $format): string|false + { + $zipName = tempnam(Storage::disk('private')->path('export'), 'zip'); + if (!$zip->open($zipName, ZipArchive::OVERWRITE)) { + throw new \Exception(__('Couldn\'t open zip file.')); + }; + $zip->addFromString($format->fileName(), $file); + + return $zipName; + } + + /** + * Add attachments to zip + * + * @param ZipArchive $zip + * @param \Illuminate\Database\Eloquent\Collection $claims + * @param ClaimExportFormat $format + * @param string $fileFieldName Name of the field containing the file. + * @return void + */ + private function addAttachmentsToZip( + ZipArchive &$zip, + \Illuminate\Database\Eloquent\Collection $claims, + ClaimExportFormat $format, + string $fileFieldName = 'file' + ) { + $claimsWithFilesToZip = $claims->whereNotNull($fileFieldName) + ->when($format->equals(ClaimExportFormat::pdf), function ($collection) { + return $collection->whereNotIn('file_extension', ['jpg', 'jpeg', 'png']); + }); + foreach ($claimsWithFilesToZip as $claim) { + $filePath = FileHelper::getPrivateFilePath($claim->file); + if (Storage::disk('private')->exists($filePath)) { + $zip->addFile( + Storage::disk('private')->path($filePath), + $claim->export_file_name + ); + } + } + } + + /** + * Get file headers to use with StreamedResponse. + * + * @param ClaimExportFormat $format + * @param boolean $isZip + * @return array + */ + public function getFileHeaders(ClaimExportFormat $format, bool $isZip = false): array + { + if ($isZip) { + return [ + 'Content-Type' => 'application/zip', + 'Content-Disposition' => 'attachment; filename="export.zip"', + ]; + }; + return $format->fileHeaders(); + } + + /** + * Transform Array into CSV. + * + * @param array $lines + * @param string $delimiter + * @param string $enclosure + * @param string $escape_char + * @return string|false + */ + private function array2csv( + array $lines, + string $delimiter = ',', + string $enclosure = '"', + string $escape_char = "\\" + ) { + $f = fopen('php://temp', 'r+'); + + // UTF-8 BOM. + $BOM = "\xEF\xBB\xBF"; + fwrite($f, $BOM); + + foreach ($lines as $item) { + fputcsv($f, $item, $delimiter, $enclosure, $escape_char); + } + + rewind($f); + $csvContent = stream_get_contents($f); + fclose($f); + + return $csvContent; + } + + /** + * Generate claims CSV + * + * @param \Illuminate\Database\Eloquent\Collection $models + * @return string|false + */ + private function generateCsv(\Illuminate\Database\Eloquent\Collection $models) + { + // CSV Header. + $class = get_class($models->first()); + $csvlines[] = $class::getCsvHeaders(); + + foreach ($models as $model) { + $csvlines[] = $model->getFormattedCsvLine(); + } + + return $this->array2csv($csvlines, ';'); + } + + /** + * Generate PDF with models datas. + * + * @param \Illuminate\Database\Eloquent\Collection $models + * @return \Barryvdh\DomPDF\PDF + */ + private function generatePdf(\Illuminate\Database\Eloquent\Collection $models): \Barryvdh\DomPDF\PDF + { + // Convert pictures to base64. + foreach ($models->whereIn('file_extension', ['jpg', 'jpeg', 'png']) as $claim) { + $filePath = FileHelper::getPrivateFilePath($claim->file); + $claim->fileBase64 = sprintf( + 'data:%s;base64,%s', + File::mimeType(Storage::disk('private')->path($filePath)), + base64_encode(Storage::disk('private')->get($filePath)) + ); + } + + // Create temp folder if it doesn't exists. Needed to allow base64 pictures to be displayed in pdf. + if (!Storage::disk('private')->exists('temp')) { + Storage::disk('private')->makeDirectory('temp'); + } + + $modelClassName = class_basename($models->first()); + return Pdf::setOptions(['tempDir' => Storage::disk('private')->path('temp')]) + ->loadView( + sprintf( + 'export.pdf', + str($modelClassName)->lower()->plural() + ), + [ + 'models' => $models, + 'modelName' => str($modelClassName)->lower() + ] + ); + } +} diff --git a/config/dompdf.php b/config/dompdf.php index dcdf60c..abe6f16 100644 --- a/config/dompdf.php +++ b/config/dompdf.php @@ -67,7 +67,7 @@ return [ * The temporary directory is required to download remote images and when * using the PDFLib back end. */ - 'temp_dir' => sys_get_temp_dir(), + 'temp_dir' => ini_get('upload_tmp_dir') ? ini_get('upload_tmp_dir') : sys_get_temp_dir(), /** * ==== IMPORTANT ==== diff --git a/resources/views/bo/pages/claims/pdf.blade.php b/resources/views/bo/pages/claims/pdf.blade.php deleted file mode 100644 index 78efe28..0000000 --- a/resources/views/bo/pages/claims/pdf.blade.php +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - - - - - @foreach($claimModels as $claimModel) -

{{ __('Référence') }} : {{ $claimModel->reference }}

-

{{ __('Date de la demande') }} : {{ $claimModel->created_at->toDateString() }}

-

{{ __('Prénom NOM') }} : {{ $claimModel->first_name }} {{ str($claimModel->last_name)->upper() }}

-

{{ __('Email') }} : {{ $claimModel->email }}

-

{{ __('Adresse') }} : {{ sprintf('%s, %s, %s', $claimModel->address, $claimModel->postcode, $claimModel->city) }}

-

{{ __('Statut de la réclamation') }} : {{ $claimModel->status->label() }}

-

{{ __('Sujet de la réclamation') }} : {{ $claimModel->claimSubject->label }}

-

{{ __('Type de demande') }} : {{ $claimModel->type->label() }}

-

{{ __('Marque concernée') }} : {{ $claimModel->brand->name }}

-

{{ __('Message') }} : {{ $claimModel->message }}

- {{-- ATTACHMENT --}} - @if ($claimModel->file_extension) - @if (in_array($claimModel->file_extension, ['jpg', 'jpeg', 'png'])) -

{{ __('Image') }} :

- - @else -

{{ __('Nom de la pièce jointe') }} : {{ $claimModel->export_file_name }}

- @endif - @else -

{{ __('Aucune pièce jointe.') }}

- @endif - - {{-- Page break between every claim. --}} - @if (!$loop->last) -
- @endif - @endforeach - - diff --git a/resources/views/export/claim_item_pdf.blade.php b/resources/views/export/claim_item_pdf.blade.php new file mode 100644 index 0000000..efcccd1 --- /dev/null +++ b/resources/views/export/claim_item_pdf.blade.php @@ -0,0 +1,21 @@ +

{{ __('Référence') }} : {{ $model->reference }}

+

{{ __('Date de la demande') }} : {{ $model->created_at->toDateString() }}

+

{{ __('Prénom NOM') }} : {{ $model->first_name }} {{ str($model->last_name)->upper() }}

+

{{ __('Email') }} : {{ $model->email }}

+

{{ __('Adresse') }} : {{ sprintf('%s, %s, %s', $model->address, $model->postcode, $model->city) }}

+

{{ __('Statut de la réclamation') }} : {{ $model->status->label() }}

+

{{ __('Sujet de la réclamation') }} : {{ $model->claimSubject->label }}

+

{{ __('Type de demande') }} : {{ $model->type->label() }}

+

{{ __('Marque concernée') }} : {{ $model->brand->name }}

+

{{ __('Message') }} : {{ $model->message }}

+{{-- ATTACHMENT --}} +@if ($model->file_extension) + @if (in_array($model->file_extension, ['jpg', 'jpeg', 'png'])) +

{{ __('Image') }} :

+ + @else +

{{ __('Nom de la pièce jointe') }} : {{ $model->export_file_name }}

+ @endif +@else +

{{ __('Aucune pièce jointe.') }}

+@endif diff --git a/resources/views/export/pdf.blade.php b/resources/views/export/pdf.blade.php new file mode 100644 index 0000000..1b8b7cf --- /dev/null +++ b/resources/views/export/pdf.blade.php @@ -0,0 +1,24 @@ + + + + + + + + + + + @foreach($models as $model) + @include('export.' . $modelName . '_item_pdf', ['model' => $model]) + + {{-- Page break between every claim. --}} + @if (!$loop->last) +
+ @endif + @endforeach + + From 647943da1147493e94a9e66fc6906a302a904dcf Mon Sep 17 00:00:00 2001 From: Noah Lancien Date: Tue, 2 Dec 2025 09:48:28 +0100 Subject: [PATCH 5/5] fix: make export more generic --- ...ClaimExportFormat.php => ExportFormat.php} | 4 +- app/Http/Controllers/Bo/ClaimController.php | 10 +-- .../Requests/Bo/Claims/ExportClaimRequest.php | 4 +- app/Traits/Controllers/ExportDatas.php | 79 ++++++++++--------- .../views/bo/modules/claim-tools.blade.php | 2 +- 5 files changed, 51 insertions(+), 48 deletions(-) rename app/Enums/{Claims/ClaimExportFormat.php => ExportFormat.php} (97%) diff --git a/app/Enums/Claims/ClaimExportFormat.php b/app/Enums/ExportFormat.php similarity index 97% rename from app/Enums/Claims/ClaimExportFormat.php rename to app/Enums/ExportFormat.php index c58db75..7d9731a 100644 --- a/app/Enums/Claims/ClaimExportFormat.php +++ b/app/Enums/ExportFormat.php @@ -1,10 +1,10 @@ $claims*/ $claims = Claim::query()->whereIn('id', $request->validated('claimList'))->get(); - $format = ClaimExportFormat::from($request->validated('format')); + $format = ExportFormat::from($request->validated('format')); // * CSV - if ($format->equals(ClaimExportFormat::csvWithoutAttachment, ClaimExportFormat::csv)) { + if ($format->equals(ExportFormat::csvWithoutAttachment, ExportFormat::csv)) { $csv = $this->generateCsv($claims); if (!$this->needsZip($format, $claims)) { return new StreamedResponse(function () use ($format, $csv) { @@ -252,10 +252,10 @@ class ClaimController extends Controller } return $this->returnExportZip($format, $claims, $csv); // * PDF - } elseif ($format->equals(ClaimExportFormat::pdf)) { + } elseif ($format->equals(ExportFormat::pdf)) { $pdf = $this->generatePdf($claims); - if (!$this->needsZip(ClaimExportFormat::pdf, $claims)) { + if (!$this->needsZip(ExportFormat::pdf, $claims)) { return $pdf->download('export.pdf'); } return $this->returnExportZip($format, $claims, $pdf->output()); diff --git a/app/Http/Requests/Bo/Claims/ExportClaimRequest.php b/app/Http/Requests/Bo/Claims/ExportClaimRequest.php index 156674c..908836f 100644 --- a/app/Http/Requests/Bo/Claims/ExportClaimRequest.php +++ b/app/Http/Requests/Bo/Claims/ExportClaimRequest.php @@ -2,7 +2,7 @@ namespace App\Http\Requests\Bo\Claims; -use App\Enums\Claims\ClaimExportFormat; +use App\Enums\ExportFormat; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -40,7 +40,7 @@ class ExportClaimRequest extends FormRequest return [ 'format' => [ 'required', - Rule::enum(ClaimExportFormat::class) + Rule::enum(ExportFormat::class) ], 'claimList' => 'required|array', 'claimList.*' => 'required|numeric|exists:claims,id', diff --git a/app/Traits/Controllers/ExportDatas.php b/app/Traits/Controllers/ExportDatas.php index c7767dc..82da1b5 100644 --- a/app/Traits/Controllers/ExportDatas.php +++ b/app/Traits/Controllers/ExportDatas.php @@ -2,7 +2,7 @@ namespace App\Traits\Controllers; -use App\Enums\Claims\ClaimExportFormat; +use App\Enums\ExportFormat; use App\Lib\Helpers\FileHelper; use Barryvdh\DomPDF\Facade\Pdf; use Illuminate\Support\Facades\File; @@ -18,20 +18,20 @@ trait ExportDatas * - csv with any files * - pdf with files other than pictures * - * @param ClaimExportFormat $format Requested format. - * @param \Illuminate\Database\Eloquent\Collection $collection Collection of models to export. + * @param ExportFormat $format Requested format. + * @param \Illuminate\Database\Eloquent\Collection $models Collection of models to export. * @param string $fileFieldName Name of the field containing the file. * @return boolean */ public function needsZip( - ClaimExportFormat $format, - \Illuminate\Database\Eloquent\Collection $collection, + ExportFormat $format, + \Illuminate\Database\Eloquent\Collection $models, string $fileFieldName = 'file' ): bool { - if ($format->equals(ClaimExportFormat::csv)) { - return $collection->whereNotNull($fileFieldName)->pluck($fileFieldName)->isNotEmpty(); - } elseif ($format->equals(ClaimExportFormat::pdf)) { - return $collection + if ($format->equals(ExportFormat::csv)) { + return $models->whereNotNull($fileFieldName)->pluck($fileFieldName)->isNotEmpty(); + } elseif ($format->equals(ExportFormat::pdf)) { + return $models ->whereNotNull($fileFieldName) ->whereNotIn('file_extension', ['jpg', 'jpeg', 'png']) ->isNotEmpty(); @@ -43,23 +43,23 @@ trait ExportDatas /** * Return a StreamedResponse with a zip file. * - * @param ClaimExportFormat $format - * @param \Illuminate\Database\Eloquent\Collection $claims + * @param ExportFormat $format + * @param \Illuminate\Database\Eloquent\Collection $models * @param string $file * @return \Symfony\Component\HttpFoundation\StreamedResponse */ public function returnExportZip( - ClaimExportFormat $format, - \Illuminate\Database\Eloquent\Collection $claims, + ExportFormat $format, + \Illuminate\Database\Eloquent\Collection $models, string $file ): \Symfony\Component\HttpFoundation\StreamedResponse { - return new StreamedResponse(function () use ($file, $claims, $format) { + return new StreamedResponse(function () use ($file, $models, $format) { // Create zip. $zip = new ZipArchive(); $zipName = $this->createExportZip($zip, $file, $format); // Add attachments. - $this->addAttachmentsToZip($zip, $claims, $format); + $this->addAttachmentsToZip($zip, $models, $format); $zip->close(); readfile($zipName); @@ -70,14 +70,14 @@ trait ExportDatas /** * Create the zip folder for the export with the main file inside. * - * @param ZipArchive $zip - * @param string $file - * @param ClaimExportFormat $format + * @param ZipArchive $zip + * @param string $file + * @param ExportFormat $format * @return string|false * * @throws \Exception If the zip opening failed. */ - private function createExportZip(ZipArchive &$zip, string $file, ClaimExportFormat $format): string|false + private function createExportZip(ZipArchive &$zip, string $file, ExportFormat $format): string|false { $zipName = tempnam(Storage::disk('private')->path('export'), 'zip'); if (!$zip->open($zipName, ZipArchive::OVERWRITE)) { @@ -92,27 +92,27 @@ trait ExportDatas * Add attachments to zip * * @param ZipArchive $zip - * @param \Illuminate\Database\Eloquent\Collection $claims - * @param ClaimExportFormat $format + * @param \Illuminate\Database\Eloquent\Collection $models + * @param ExportFormat $format * @param string $fileFieldName Name of the field containing the file. * @return void */ private function addAttachmentsToZip( ZipArchive &$zip, - \Illuminate\Database\Eloquent\Collection $claims, - ClaimExportFormat $format, + \Illuminate\Database\Eloquent\Collection $models, + ExportFormat $format, string $fileFieldName = 'file' ) { - $claimsWithFilesToZip = $claims->whereNotNull($fileFieldName) - ->when($format->equals(ClaimExportFormat::pdf), function ($collection) { - return $collection->whereNotIn('file_extension', ['jpg', 'jpeg', 'png']); + $modelsWithFilesToZip = $models->whereNotNull($fileFieldName) + ->when($format->equals(ExportFormat::pdf), function ($models) { + return $models->whereNotIn('file_extension', ['jpg', 'jpeg', 'png']); }); - foreach ($claimsWithFilesToZip as $claim) { - $filePath = FileHelper::getPrivateFilePath($claim->file); + foreach ($modelsWithFilesToZip as $model) { + $filePath = FileHelper::getPrivateFilePath($model->{$fileFieldName}); if (Storage::disk('private')->exists($filePath)) { $zip->addFile( Storage::disk('private')->path($filePath), - $claim->export_file_name + $model->export_file_name ); } } @@ -121,11 +121,11 @@ trait ExportDatas /** * Get file headers to use with StreamedResponse. * - * @param ClaimExportFormat $format - * @param boolean $isZip + * @param ExportFormat $format + * @param boolean $isZip * @return array */ - public function getFileHeaders(ClaimExportFormat $format, bool $isZip = false): array + public function getFileHeaders(ExportFormat $format, bool $isZip = false): array { if ($isZip) { return [ @@ -169,7 +169,7 @@ trait ExportDatas } /** - * Generate claims CSV + * Generate CSV with given models. * * @param \Illuminate\Database\Eloquent\Collection $models * @return string|false @@ -191,14 +191,17 @@ trait ExportDatas * Generate PDF with models datas. * * @param \Illuminate\Database\Eloquent\Collection $models + * @param string $fileFieldName * @return \Barryvdh\DomPDF\PDF */ - private function generatePdf(\Illuminate\Database\Eloquent\Collection $models): \Barryvdh\DomPDF\PDF - { + private function generatePdf( + \Illuminate\Database\Eloquent\Collection $models, + string $fileFieldName = 'file' + ): \Barryvdh\DomPDF\PDF { // Convert pictures to base64. - foreach ($models->whereIn('file_extension', ['jpg', 'jpeg', 'png']) as $claim) { - $filePath = FileHelper::getPrivateFilePath($claim->file); - $claim->fileBase64 = sprintf( + foreach ($models->whereIn('file_extension', ['jpg', 'jpeg', 'png']) as $model) { + $filePath = FileHelper::getPrivateFilePath($model->{$fileFieldName}); + $model->fileBase64 = sprintf( 'data:%s;base64,%s', File::mimeType(Storage::disk('private')->path($filePath)), base64_encode(Storage::disk('private')->get($filePath)) diff --git a/resources/views/bo/modules/claim-tools.blade.php b/resources/views/bo/modules/claim-tools.blade.php index 96d3777..4c4edfd 100644 --- a/resources/views/bo/modules/claim-tools.blade.php +++ b/resources/views/bo/modules/claim-tools.blade.php @@ -34,7 +34,7 @@