From b0bcc4f330e5b0eac49f0a645c982d9df156c909 Mon Sep 17 00:00:00 2001 From: Constantin Graf Date: Wed, 23 Oct 2024 13:21:20 +0200 Subject: [PATCH] Add pdf detailed report and placeholder for aggregate endpoint --- .env.example | 2 + .../PdfRendererIsNotConfiguredException.php | 10 + .../Api/V1/TimeEntryController.php | 121 ++++++++++-- .../TimeEntryAggregateExportRequest.php | 186 ++++++++++++++++++ app/Models/TimeEntry.php | 2 +- app/Service/ReportExport/CsvExport.php | 29 ++- .../TimeEntriesDetailedCsvExport.php | 44 +++-- .../TimeEntriesDetailedExport.php | 103 +++++++--- composer.json | 1 + composer.lock | 162 ++++++++++++++- config/services.php | 9 + docker-compose.yml | 7 + lang/en/exceptions.php | 2 + .../reports/time-entry-index-footer.blade.php | 15 ++ .../views/reports/time-entry-index.blade.php | 60 ++++++ routes/api.php | 1 + 16 files changed, 685 insertions(+), 69 deletions(-) create mode 100644 app/Exceptions/Api/PdfRendererIsNotConfiguredException.php create mode 100644 app/Http/Requests/V1/TimeEntry/TimeEntryAggregateExportRequest.php create mode 100644 config/services.php create mode 100644 resources/views/reports/time-entry-index-footer.blade.php create mode 100644 resources/views/reports/time-entry-index.blade.php diff --git a/.env.example b/.env.example index 265804e9..a5613f04 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,8 @@ QUEUE_CONNECTION=sync SESSION_DRIVER=database SESSION_LIFETIME=120 +GOTENBERG_URL=http://gotenberg:3000 + MEMCACHED_HOST=127.0.0.1 REDIS_HOST=127.0.0.1 diff --git a/app/Exceptions/Api/PdfRendererIsNotConfiguredException.php b/app/Exceptions/Api/PdfRendererIsNotConfiguredException.php new file mode 100644 index 00000000..0ba9b145 --- /dev/null +++ b/app/Exceptions/Api/PdfRendererIsNotConfiguredException.php @@ -0,0 +1,10 @@ +getTimeEntriesQuery($organization, $request, $member); $timeEntriesQuery->with([ 'task', - 'project' => [ - 'client', - ], + 'client', + 'project', 'user', 'tagsRelation', ]); $format = $request->getFormatValue(); + //$format = ExportFormat::PDF; $filename = 'time-entries-export-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension(); - $path = 'exports/'.$filename; + $folderPath = 'exports'; + $path = $folderPath.'/'.$filename; if ($format === ExportFormat::CSV) { - $export = new TimeEntriesDetailedCsvExport(config('filesystems.private'), $filename, $timeEntriesQuery, 1000); + $export = new TimeEntriesDetailedCsvExport(config('filesystems.private'), $folderPath, $filename, $timeEntriesQuery, 1000); $export->export(); + } elseif ($format === ExportFormat::PDF) { + if (config('services.gotenberg.url') === null) { + throw new PdfRendererIsNotConfiguredException; + } + $viewFile = file_get_contents(resource_path('views/reports/time-entry-index.blade.php')); + $html = Blade::render($viewFile, ['timeEntries' => $timeEntriesQuery->get()]); + $footerViewFile = file_get_contents(resource_path('views/reports/time-entry-index-footer.blade.php')); + $footerHtml = Blade::render($footerViewFile); + $request = Gotenberg::chromium(config('services.gotenberg.url')) + ->pdf() + ->pdfa('PDF/A-3b') + ->paperSize('8.27', '11.7') // A4 + ->footer(Stream::string('footer', $footerHtml)) + ->html(Stream::string('body', $html)); + $tempFolder = TemporaryDirectory::make(); + $filenameTemp = Gotenberg::save($request, $tempFolder->path()); + Storage::disk(config('filesystems.private')) + ->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename); } else { Excel::store( - new TimeEntriesDetailedExport($timeEntriesQuery), + new TimeEntriesDetailedExport($timeEntriesQuery, $format), $path, config('filesystems.private'), $format->getExportPackageType(), @@ -225,7 +253,7 @@ class TimeEntryController extends Controller * * @throws AuthorizationException */ - public function aggregate(Organization $organization, TimeEntryAggregateRequest $request, TimeEntryAggregationService $aggregationService): array + public function aggregate(Organization $organization, TimeEntryAggregateRequest $request): array { /** @var Member|null $member */ $member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null; @@ -235,6 +263,75 @@ class TimeEntryController extends Controller $this->checkPermission($organization, 'time-entries:view:all'); } + $aggregatedData = $this->getTimeEntriesAggregateQuery($organization, $request, $member); + + return [ + 'data' => $aggregatedData, + ]; + } + + /** + * Export aggregated time entries in organization + * + * @throws AuthorizationException + */ + public function aggregateExport(Organization $organization, TimeEntryAggregateExportRequest $request): JsonResponse + { + /** @var Member|null $member */ + $member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null; + if ($member !== null && $member->user_id === Auth::id()) { + $this->checkPermission($organization, 'time-entries:view:own'); + } else { + $this->checkPermission($organization, 'time-entries:view:all'); + } + + $aggregatedData = $this->getTimeEntriesAggregateQuery($organization, $request, $member); + + $format = $request->getFormatValue(); + //$format = ExportFormat::PDF; + $filename = 'time-entries-report-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension(); + $folderPath = 'exports'; + $path = $folderPath.'/'.$filename; + + if ($format === ExportFormat::CSV) { + // TODO + } elseif ($format === ExportFormat::PDF) { + if (config('services.gotenberg.url') === null) { + throw new PdfRendererIsNotConfiguredException; + } + // TODO + } else { + // TODO + } + + return response()->json([ + 'download_url' => Storage::disk(config('filesystems.private')) + ->temporaryUrl($path, now()->addMinutes(5)), + ]); + } + + /** + * @return array{ + * grouped_type: string|null, + * grouped_data: null|array + * }>, + * seconds: int, + * cost: int + * } + */ + private function getTimeEntriesAggregateQuery(Organization $organization, TimeEntryAggregateRequest|TimeEntryAggregateExportRequest $request, ?Member $member): array + { $timeEntriesQuery = TimeEntry::query() ->whereBelongsTo($organization, 'organization'); @@ -256,7 +353,7 @@ class TimeEntryController extends Controller $group1Type = $request->getGroup(); $group2Type = $request->getSubGroup(); - $aggregatedData = $aggregationService->getAggregatedTimeEntries( + $aggregatedData = app(TimeEntryAggregationService::class)->getAggregatedTimeEntries( $timeEntriesQuery, $group1Type, $group2Type, @@ -267,9 +364,7 @@ class TimeEntryController extends Controller $request->getEnd() ); - return [ - 'data' => $aggregatedData, - ]; + return $aggregatedData; } /** diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateExportRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateExportRequest.php new file mode 100644 index 00000000..fb400d20 --- /dev/null +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateExportRequest.php @@ -0,0 +1,186 @@ + [ + 'required', + 'string', + Rule::enum(ExportFormat::class), + ], + 'group' => [ + 'nullable', + 'required_with:group_2', + Rule::enum(TimeEntryAggregationType::class), + ], + + 'sub_group' => [ + 'nullable', + Rule::enum(TimeEntryAggregationType::class), + ], + // Filter by member ID + 'member_id' => [ + 'string', + ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + })->uuid(), + ], + // Filter by multiple member IDs, member IDs are OR combined, but AND combined with the member_id parameter + 'member_ids' => [ + 'array', + 'min:1', + ], + 'member_ids.*' => [ + 'string', + ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + })->uuid(), + ], + + // Filter by user ID + 'user_id' => [ + 'string', + ExistsEloquent::make(User::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->belongsToOrganization($this->organization); + })->uuid(), + ], + // Filter by project IDs, project IDs are OR combined + 'project_ids' => [ + 'array', + 'min:1', + ], + 'project_ids.*' => [ + 'string', + ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + })->uuid(), + ], + // Filter by client IDs, client IDs are OR combined + 'client_ids' => [ + 'array', + 'min:1', + ], + 'client_ids.*' => [ + 'string', + ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + })->uuid(), + ], + // Filter by tag IDs, tag IDs are AND combined + 'tag_ids' => [ + 'array', + 'min:1', + ], + 'tag_ids.*' => [ + 'string', + ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + })->uuid(), + ], + // Filter by task IDs, task IDs are OR combined + 'task_ids' => [ + 'array', + 'min:1', + ], + 'task_ids.*' => [ + 'string', + ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder { + return $builder->whereBelongsTo($this->organization, 'organization'); + })->uuid(), + ], + // Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z) + 'start' => [ + 'nullable', + 'string', + 'date_format:Y-m-d\TH:i:s\Z', + 'before:end', + ], + // Filter only time entries that have a start date before the given timestamp in UTC (example: 2021-01-01T00:00:00Z) + 'end' => [ + 'nullable', + 'string', + 'date_format:Y-m-d\TH:i:s\Z', + ], + // Filter by active status (active means has no end date, is still running) + 'active' => [ + 'string', + 'in:true,false', + ], + // Filter by billable status + 'billable' => [ + 'string', + 'in:true,false', + ], + 'fill_gaps_in_time_groups' => [ + 'string', + 'in:true,false', + ], + ]; + } + + public function getGroup(): ?TimeEntryAggregationType + { + return $this->input('group') !== null ? TimeEntryAggregationType::from($this->input('group')) : null; + } + + public function getSubGroup(): ?TimeEntryAggregationType + { + return $this->input('sub_group') !== null ? TimeEntryAggregationType::from($this->input('sub_group')) : null; + } + + public function getFillGapsInTimeGroups(): bool + { + return $this->has('fill_gaps_in_time_groups') && $this->input('fill_gaps_in_time_groups') === 'true'; + } + + public function getStart(): ?Carbon + { + return $this->input('start') !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('start'), 'UTC') : null; + } + + public function getEnd(): ?Carbon + { + return $this->input('end') !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC') : null; + } + + public function getFormatValue(): ExportFormat + { + return ExportFormat::from($this->validated('format')); + } +} diff --git a/app/Models/TimeEntry.php b/app/Models/TimeEntry.php index 31fc264e..16626eb4 100644 --- a/app/Models/TimeEntry.php +++ b/app/Models/TimeEntry.php @@ -54,11 +54,11 @@ class TimeEntry extends Model implements AuditableContract { use ComputedAttributes; use CustomAuditable; + /** @use HasFactory */ use HasFactory; use HasJsonRelationships; - use HasUuids; /** diff --git a/app/Service/ReportExport/CsvExport.php b/app/Service/ReportExport/CsvExport.php index b43ba04b..cd351bb3 100644 --- a/app/Service/ReportExport/CsvExport.php +++ b/app/Service/ReportExport/CsvExport.php @@ -5,10 +5,13 @@ declare(strict_types=1); namespace App\Service\ReportExport; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; +use Illuminate\Http\File; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Storage; use League\Csv\Writer; +use Spatie\TemporaryDirectory\TemporaryDirectory; /** * @template T of Model @@ -31,16 +34,19 @@ abstract class CsvExport */ private Builder $builder; + private string $folderPath; + /** * @param Builder $builder */ - public function __construct(string $disk, string $filename, Builder $builder, int $chunk) + public function __construct(string $disk, string $folderPath, string $filename, Builder $builder, int $chunk) { $this->disk = $disk; $this->filename = $filename; $this->chunk = $chunk; $this->builder = $builder; + $this->folderPath = $folderPath; } /** @@ -49,12 +55,21 @@ abstract class CsvExport */ abstract public function mapRow(Model $model): array; + /** + * @throws \League\Csv\CannotInsertRecord + * @throws \League\Csv\Exception + * @throws \League\Csv\UnavailableStream + */ public function export(): void { - $writer = Writer::createFromPath(Storage::disk($this->disk)->path($this->filename), 'w+'); + $tempDirectory = TemporaryDirectory::make(); + $writer = Writer::createFromPath($tempDirectory->path($this->filename), 'w+'); + $writer->setDelimiter(','); + $writer->setEnclosure('"'); + $writer->setEscape(''); $writer->insertOne(static::HEADER); - $this->builder->chunk($this->chunk, function ($models) use ($writer): void { + $this->builder->chunk($this->chunk, function (Collection $models) use ($writer): void { foreach ($models as $model) { $data = $this->mapRow($model); $row = $this->convertRow($data); @@ -63,6 +78,8 @@ abstract class CsvExport $writer->insertOne(array_values($row)); } }); + Storage::disk($this->disk)->putFileAs($this->folderPath, new File($tempDirectory->path($this->filename)), $this->filename); + $tempDirectory->delete(); } /** @@ -87,13 +104,11 @@ abstract class CsvExport /** * @param array $row - * - * @throws \Exception */ private function validateRow(array $row): void { - if (array_keys($row) !== self::HEADER) { - throw new \Exception('Invalid row'); + if (array_keys($row) !== static::HEADER) { + throw new \LogicException('Invalid row'); } } } diff --git a/app/Service/ReportExport/TimeEntriesDetailedCsvExport.php b/app/Service/ReportExport/TimeEntriesDetailedCsvExport.php index f4eab41e..921b196a 100644 --- a/app/Service/ReportExport/TimeEntriesDetailedCsvExport.php +++ b/app/Service/ReportExport/TimeEntriesDetailedCsvExport.php @@ -13,17 +13,17 @@ use Illuminate\Database\Eloquent\Model; class TimeEntriesDetailedCsvExport extends CsvExport { public const array HEADER = [ - 'id', - 'user_id', - 'project_id', - 'task_id', - 'start_time', - 'end_time', - 'duration', - 'description', - 'created_at', - 'updated_at', - 'deleted_at', + 'Description', + 'Task', + 'Project', + 'Client', + 'User', + 'Start', + 'End', + 'Duration', + 'Duration (decimal)', + 'Billable', + 'Tags', ]; /** @@ -31,16 +31,20 @@ class TimeEntriesDetailedCsvExport extends CsvExport */ public function mapRow(Model $model): array { + $duration = $model->getDuration(); + return [ - 'id' => $model->id, - 'user_id' => $model->user_id, - 'project_id' => $model->project_id, - 'task_id' => $model->task_id, - 'start_time' => $model->start, - 'end_time' => $model->end, - 'description' => $model->description, - 'created_at' => $model->created_at, - 'updated_at' => $model->updated_at, + 'Description' => $model->description, + 'Task' => $model->task?->name, + 'Project' => $model->project?->name, + 'Client' => $model->client?->name, + 'User' => $model->user->name, + 'Start' => $model->start->format('Y-m-d H:i:s'), + 'End' => $model->end?->format('Y-m-d H:i:s'), + 'Duration' => $duration !== null ? (int) floor($duration->totalHours).':'.$duration->format('%I:%S') : null, + 'Duration (decimal)' => $duration?->totalHours, + 'Billable' => $model->billable ? 'Yes' : 'No', + 'Tags' => $model->tagsRelation->pluck('name')->implode(', '), ]; } } diff --git a/app/Service/ReportExport/TimeEntriesDetailedExport.php b/app/Service/ReportExport/TimeEntriesDetailedExport.php index fda462f8..dfac7b18 100644 --- a/app/Service/ReportExport/TimeEntriesDetailedExport.php +++ b/app/Service/ReportExport/TimeEntriesDetailedExport.php @@ -4,18 +4,27 @@ declare(strict_types=1); namespace App\Service\ReportExport; +use App\Enums\ExportFormat; use App\Models\TimeEntry; use Illuminate\Database\Eloquent\Builder; +use LogicException; use Maatwebsite\Excel\Concerns\Exportable; use Maatwebsite\Excel\Concerns\FromQuery; -use Maatwebsite\Excel\Concerns\WithCustomCsvSettings; +use Maatwebsite\Excel\Concerns\ShouldAutoSize; +use Maatwebsite\Excel\Concerns\WithColumnFormatting; +use Maatwebsite\Excel\Concerns\WithDefaultStyles; use Maatwebsite\Excel\Concerns\WithHeadings; use Maatwebsite\Excel\Concerns\WithMapping; +use Maatwebsite\Excel\Concerns\WithStyles; +use PhpOffice\PhpSpreadsheet\Shared\Date; +use PhpOffice\PhpSpreadsheet\Style\NumberFormat; +use PhpOffice\PhpSpreadsheet\Style\Style; +use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; /** * @implements WithMapping */ -class TimeEntriesDetailedExport implements FromQuery, WithCustomCsvSettings, WithHeadings, WithMapping +class TimeEntriesDetailedExport implements FromQuery, ShouldAutoSize, WithColumnFormatting, WithDefaultStyles, WithHeadings, WithMapping, WithStyles { use Exportable; @@ -24,12 +33,15 @@ class TimeEntriesDetailedExport implements FromQuery, WithCustomCsvSettings, Wit */ private Builder $builder; + private ExportFormat $exportFormat; + /** * @param Builder $builder */ - public function __construct(Builder $builder) + public function __construct(Builder $builder, ExportFormat $exportFormat) { $this->builder = $builder; + $this->exportFormat = $exportFormat; } /** @@ -40,18 +52,41 @@ class TimeEntriesDetailedExport implements FromQuery, WithCustomCsvSettings, Wit return $this->builder; } + public function columnFormats(): array + { + if ($this->exportFormat === ExportFormat::XLSX) { + return [ + 'F' => 'yyyy-mm-dd hh:mm:ss', + 'G' => 'yyyy-mm-dd hh:mm:ss', + 'I' => NumberFormat::FORMAT_NUMBER_00, + ]; + } elseif ($this->exportFormat === ExportFormat::ODS) { + return [ + 'I' => NumberFormat::FORMAT_NUMBER_00, + ]; + } else { + throw new LogicException('Unsupported export format.'); + } + + } + /** - * @return array + * @return array>> */ - public function getCsvSettings(): array + public function styles(Worksheet $sheet): array { return [ - 'delimiter' => ',', - 'use_bom' => false, - 'output_encoding' => 'ISO-8859-1', + // Style the first row as bold text. + 1 => ['font' => ['bold' => true]], ]; } + public function defaultStyles(Style $defaultStyle) + { + // Configure the default styles + return $defaultStyle->getFill(); //->setFillType(Fill::FILL_SOLID); + } + /** * @return string[] */ @@ -63,10 +98,8 @@ class TimeEntriesDetailedExport implements FromQuery, WithCustomCsvSettings, Wit 'Project', 'Client', 'User', - 'Start date', - 'Start time', - 'End date', - 'End time', + 'Start', + 'End', 'Duration', 'Duration (decimal)', 'Billable', @@ -82,20 +115,36 @@ class TimeEntriesDetailedExport implements FromQuery, WithCustomCsvSettings, Wit { $duration = $model->getDuration(); - return [ - $model->description, - $model->task?->name, - $model->project?->name, - $model->project?->client?->name, - $model->user->name, - $model->start->format('Y-m-d'), - $model->start->format('H:i:s'), - $model->end?->format('Y-m-d'), - $model->end?->format('H:i:s'), - $duration !== null ? (int) floor($duration->totalHours).':'.$duration->format('%I:%S') : null, - $duration?->totalHours, - $model->billable ? 'Yes' : 'No', - $model->tagsRelation->pluck('name')->implode(', '), - ]; + if ($this->exportFormat === ExportFormat::XLSX) { + return [ + $model->description, + $model->task?->name, + $model->project?->name, + $model->client?->name, + $model->user->name, + Date::dateTimeToExcel($model->start), + $model->end !== null ? Date::dateTimeToExcel($model->end) : null, + $duration !== null ? (int) floor($duration->totalHours).':'.$duration->format('%I:%S') : null, + $duration?->totalHours, + $model->billable ? 'Yes' : 'No', + $model->tagsRelation->pluck('name')->implode(', '), + ]; + } elseif ($this->exportFormat === ExportFormat::ODS) { + return [ + $model->description, + $model->task?->name, + $model->project?->name, + $model->client?->name, + $model->user->name, + $model->start->format('Y-m-d H:i:s'), + $model->end?->format('Y-m-d H:i:s'), + $duration !== null ? (int) floor($duration->totalHours).':'.$duration->format('%I:%S') : null, + $duration?->totalHours, + $model->billable ? 'Yes' : 'No', + $model->tagsRelation->pluck('name')->implode(', '), + ]; + } else { + throw new LogicException('Unsupported export format.'); + } } } diff --git a/composer.json b/composer.json index 0b9888da..6bcd6e75 100644 --- a/composer.json +++ b/composer.json @@ -11,6 +11,7 @@ "dedoc/scramble": "dev-main", "filament/filament": "^3.2", "flowframe/laravel-trend": "^0.2.0", + "gotenberg/gotenberg-php": "^2.8", "guzzlehttp/guzzle": "^7.2", "inertiajs/inertia-laravel": "^1.0", "korridor/laravel-computed-attributes": "^3.1", diff --git a/composer.lock b/composer.lock index 4f0067e9..abbcab93 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": "7b901c08f4d2a3f90c4d667bd1470dcb", + "content-hash": "5634bfb04c10a875101045a25ac21f1a", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -2794,6 +2794,87 @@ ], "time": "2023-10-12T05:21:21+00:00" }, + { + "name": "gotenberg/gotenberg-php", + "version": "v2.8.0", + "source": { + "type": "git", + "url": "https://github.com/gotenberg/gotenberg-php.git", + "reference": "e8bc519812349f7bd57b294b4ea41dce53693b7c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/gotenberg/gotenberg-php/zipball/e8bc519812349f7bd57b294b4ea41dce53693b7c", + "reference": "e8bc519812349f7bd57b294b4ea41dce53693b7c", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "guzzlehttp/psr7": "^1 || ^2.1", + "php": "^8.1|^8.2|^8.3", + "php-http/discovery": "^1.14", + "psr/http-client": "^1.0", + "psr/http-message": "^1.0|^2.0" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0", + "pestphp/pest": "^2.28", + "phpstan/phpstan": "^1.12", + "squizlabs/php_codesniffer": "^3.10" + }, + "type": "library", + "autoload": { + "psr-4": { + "Gotenberg\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Julien Neuhart", + "email": "neuhart.julien@gmail.com", + "homepage": "https://github.com/gulien", + "role": "Developer" + } + ], + "description": "A PHP client for interacting with Gotenberg, a developer-friendly API for converting numerous document formats into PDF files, and more!", + "homepage": "https://github.com/gotenberg/gotenberg-php", + "keywords": [ + "Gotenberg", + "LibreOffice", + "chrome", + "chromium", + "convert", + "csv", + "docx", + "excel", + "html", + "markdown", + "pdf", + "pdftk", + "pptx", + "puppeteer", + "unoconv", + "wkhtmltopdf", + "word", + "xlsx" + ], + "support": { + "issues": "https://github.com/gotenberg/gotenberg-php/issues", + "source": "https://github.com/gotenberg/gotenberg-php/tree/v2.8.0" + }, + "funding": [ + { + "url": "https://github.com/gulien", + "type": "github" + } + ], + "time": "2024-09-29T17:23:42+00:00" + }, { "name": "graham-campbell/result-type", "version": "v1.1.3", @@ -6996,6 +7077,85 @@ }, "time": "2020-10-15T08:29:30+00:00" }, + { + "name": "php-http/discovery", + "version": "1.20.0", + "source": { + "type": "git", + "url": "https://github.com/php-http/discovery.git", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0|^2.0", + "php": "^7.1 || ^8.0" + }, + "conflict": { + "nyholm/psr7": "<1.0", + "zendframework/zend-diactoros": "*" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "*", + "psr/http-factory-implementation": "*", + "psr/http-message-implementation": "*" + }, + "require-dev": { + "composer/composer": "^1.0.2|^2.0", + "graham-campbell/phpspec-skip-example-extension": "^5.0", + "php-http/httplug": "^1.0 || ^2.0", + "php-http/message-factory": "^1.0", + "phpspec/phpspec": "^5.1 || ^6.1 || ^7.3", + "sebastian/comparator": "^3.0.5 || ^4.0.8", + "symfony/phpunit-bridge": "^6.4.4 || ^7.0.1" + }, + "type": "composer-plugin", + "extra": { + "class": "Http\\Discovery\\Composer\\Plugin", + "plugin-optional": true + }, + "autoload": { + "psr-4": { + "Http\\Discovery\\": "src/" + }, + "exclude-from-classmap": [ + "src/Composer/Plugin.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations", + "homepage": "http://php-http.org", + "keywords": [ + "adapter", + "client", + "discovery", + "factory", + "http", + "message", + "psr17", + "psr7" + ], + "support": { + "issues": "https://github.com/php-http/discovery/issues", + "source": "https://github.com/php-http/discovery/tree/1.20.0" + }, + "time": "2024-10-02T11:20:13+00:00" + }, { "name": "phpoffice/phpspreadsheet", "version": "1.29.2", diff --git a/config/services.php b/config/services.php new file mode 100644 index 00000000..b70a19ba --- /dev/null +++ b/config/services.php @@ -0,0 +1,9 @@ + [ + 'url' => env('GOTENBERG_URL'), + ], +]; diff --git a/docker-compose.yml b/docker-compose.yml index ffb3efd3..d2461aac 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -189,6 +189,13 @@ services: entrypoint: /etc/minio/create_bucket.sh extra_hosts: - "storage.${NGINX_HOST_NAME}:${REVERSE_PROXY_IP:-10.100.100.10}" + + gotenberg: + image: gotenberg/gotenberg:8 + networks: + - sail + healthcheck: + test: ["CMD", "curl", "--silent", "--fail", "http://localhost:3000/health"] networks: reverse-proxy: name: "${NETWORK_NAME}" diff --git a/lang/en/exceptions.php b/lang/en/exceptions.php index b2506647..28295e5b 100644 --- a/lang/en/exceptions.php +++ b/lang/en/exceptions.php @@ -10,6 +10,7 @@ use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException; use App\Exceptions\Api\OnlyOwnerCanChangeOwnership; use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException; use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner; +use App\Exceptions\Api\PdfRendererIsNotConfiguredException; use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException; use App\Exceptions\Api\TimeEntryStillRunningApiException; use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException; @@ -33,6 +34,7 @@ return [ ChangingRoleToPlaceholderIsNotAllowed::KEY => 'Changing role to placeholder is not allowed', ExportException::KEY => 'Export failed, please try again later or contact support', OrganizationHasNoSubscriptionButMultipleMembersException::KEY => 'Organization has no subscription but multiple members', + PdfRendererIsNotConfiguredException::KEY => 'PDF renderer is not configured', ], 'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.', ]; diff --git a/resources/views/reports/time-entry-index-footer.blade.php b/resources/views/reports/time-entry-index-footer.blade.php new file mode 100644 index 00000000..0a27ee97 --- /dev/null +++ b/resources/views/reports/time-entry-index-footer.blade.php @@ -0,0 +1,15 @@ + + + + + +

+ of +

+ + diff --git a/resources/views/reports/time-entry-index.blade.php b/resources/views/reports/time-entry-index.blade.php new file mode 100644 index 00000000..1a2aca7c --- /dev/null +++ b/resources/views/reports/time-entry-index.blade.php @@ -0,0 +1,60 @@ + + + + + Report + + + +

Report

+ +
+ 01.01.2020 - 01.01.2024 +
+ +
+ Duration: 20:10:10 +
+ +
+ + + + + + + + + + + + + + + @foreach($timeEntries as $timeEntry) + + + + + + + + + + + @endforeach + +
DescriptionTaskProjectClientUserDurationBillableTags
{{ $timeEntry->description }}{{ $timeEntry->task?->name ?? '-' }}{{ $timeEntry->project?->name ?? '-' }}{{ $timeEntry->client?->name ?? '-' }}{{ $timeEntry->user->name }} + 00:00:01 + {{ $timeEntry->start->format('Y-m-d H:i:s') }} - {{ $timeEntry->end->format('Y-m-d H:i:s') }} + {{ $timeEntry->billable ? 'Yes' : 'no' }}{{ $timeEntry->tagsRelation->implode('name', ', ') }}
+
+ + diff --git a/routes/api.php b/routes/api.php index 00dd005d..258590a4 100644 --- a/routes/api.php +++ b/routes/api.php @@ -89,6 +89,7 @@ Route::middleware([ Route::get('/organizations/{organization}/time-entries', [TimeEntryController::class, 'index'])->name('index'); Route::get('/organizations/{organization}/time-entries/export', [TimeEntryController::class, 'indexExport'])->name('index-export'); Route::get('/organizations/{organization}/time-entries/aggregate', [TimeEntryController::class, 'aggregate'])->name('aggregate'); + Route::get('/organizations/{organization}/time-entries/aggregate/export', [TimeEntryController::class, 'aggregateExport'])->name('aggregate-export'); Route::post('/organizations/{organization}/time-entries', [TimeEntryController::class, 'store'])->name('store')->middleware('check-organization-blocked'); Route::put('/organizations/{organization}/time-entries/{timeEntry}', [TimeEntryController::class, 'update'])->name('update')->middleware('check-organization-blocked'); Route::patch('/organizations/{organization}/time-entries', [TimeEntryController::class, 'updateMultiple'])->name('update-multiple')->middleware('check-organization-blocked');