diff --git a/app/Enums/TimeEntryAggregationType.php b/app/Enums/TimeEntryAggregationType.php index 2c053416..aa36a7cd 100644 --- a/app/Enums/TimeEntryAggregationType.php +++ b/app/Enums/TimeEntryAggregationType.php @@ -4,8 +4,12 @@ declare(strict_types=1); namespace App\Enums; +use Datomatic\LaravelEnumHelper\LaravelEnumHelper; + enum TimeEntryAggregationType: string { + use LaravelEnumHelper; + case Day = 'day'; case Week = 'week'; case Month = 'month'; @@ -17,6 +21,16 @@ enum TimeEntryAggregationType: string case Billable = 'billable'; case Description = 'description'; + public static function fromInterval(TimeEntryAggregationTypeInterval $timeEntryAggregationTypeInterval): TimeEntryAggregationType + { + return match ($timeEntryAggregationTypeInterval) { + TimeEntryAggregationTypeInterval::Day => TimeEntryAggregationType::Day, + TimeEntryAggregationTypeInterval::Week => TimeEntryAggregationType::Week, + TimeEntryAggregationTypeInterval::Month => TimeEntryAggregationType::Month, + TimeEntryAggregationTypeInterval::Year => TimeEntryAggregationType::Year, + }; + } + public function toInterval(): ?TimeEntryAggregationTypeInterval { return match ($this) { diff --git a/app/Http/Controllers/Api/V1/TimeEntryController.php b/app/Http/Controllers/Api/V1/TimeEntryController.php index 6efc30b3..02e48486 100644 --- a/app/Http/Controllers/Api/V1/TimeEntryController.php +++ b/app/Http/Controllers/Api/V1/TimeEntryController.php @@ -27,9 +27,12 @@ use App\Models\Task; use App\Models\TimeEntry; use App\Service\ReportExport\TimeEntriesDetailedCsvExport; use App\Service\ReportExport\TimeEntriesDetailedExport; +use App\Service\ReportExport\TimeEntriesReportExport; use App\Service\TimeEntryAggregationService; use App\Service\TimeEntryFilter; use App\Service\TimezoneService; +use Gotenberg\Exceptions\GotenbergApiErrored; +use Gotenberg\Exceptions\NoOutputFileInResponse; use Gotenberg\Gotenberg; use Gotenberg\Stream; use Illuminate\Auth\Access\AuthorizationException; @@ -178,7 +181,6 @@ class TimeEntryController extends Controller 'tagsRelation', ]); $format = $request->getFormatValue(); - //$format = ExportFormat::PDF; $filename = 'time-entries-export-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension(); $folderPath = 'exports'; $path = $folderPath.'/'.$filename; @@ -190,8 +192,14 @@ class TimeEntryController extends Controller throw new PdfRendererIsNotConfiguredException; } $viewFile = file_get_contents(resource_path('views/reports/time-entry-index.blade.php')); + if ($viewFile === false) { + throw new \LogicException('View file not found'); + } $html = Blade::render($viewFile, ['timeEntries' => $timeEntriesQuery->get()]); $footerViewFile = file_get_contents(resource_path('views/reports/time-entry-index-footer.blade.php')); + if ($footerViewFile === false) { + throw new \LogicException('View file not found'); + } $footerHtml = Blade::render($footerViewFile); $request = Gotenberg::chromium(config('services.gotenberg.url')) ->pdf() @@ -253,7 +261,7 @@ class TimeEntryController extends Controller * * @throws AuthorizationException */ - public function aggregate(Organization $organization, TimeEntryAggregateRequest $request): array + public function aggregate(Organization $organization, TimeEntryAggregateRequest $request, TimeEntryAggregationService $timeEntryAggregationService): array { /** @var Member|null $member */ $member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null; @@ -262,8 +270,22 @@ class TimeEntryController extends Controller } else { $this->checkPermission($organization, 'time-entries:view:all'); } + $user = $this->user(); - $aggregatedData = $this->getTimeEntriesAggregateQuery($organization, $request, $member); + $group1Type = $request->getGroup(); + $group2Type = $request->getSubGroup(); + $timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member); + + $aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries( + $timeEntriesAggregateQuery, + $group1Type, + $group2Type, + $user->timezone, + $user->week_start, + $request->getFillGapsInTimeGroups(), + $request->getStart(), + $request->getEnd() + ); return [ 'data' => $aggregatedData, @@ -274,10 +296,13 @@ class TimeEntryController extends Controller * Export aggregated time entries in organization * * @operationId exportAggregatedTimeEntries - * @throws AuthorizationException * + * @throws AuthorizationException + * @throws PdfRendererIsNotConfiguredException + * @throws GotenbergApiErrored + * @throws NoOutputFileInResponse */ - public function aggregateExport(Organization $organization, TimeEntryAggregateExportRequest $request): JsonResponse + public function aggregateExport(Organization $organization, TimeEntryAggregateExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse { /** @var Member|null $member */ $member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null; @@ -286,24 +311,82 @@ class TimeEntryController extends Controller } else { $this->checkPermission($organization, 'time-entries:view:all'); } + $user = $this->user(); - $aggregatedData = $this->getTimeEntriesAggregateQuery($organization, $request, $member); + $group = $request->getGroup(); + $subGroup = $request->getSubGroup(); + $timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member); + + $aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions( + $timeEntriesAggregateQuery->clone(), + $group, + $subGroup, + $user->timezone, + $user->week_start, + false, + $request->getStart(), + $request->getEnd() + ); + $dataHistoryChart = $timeEntryAggregationService->getAggregatedTimeEntries( + $timeEntriesAggregateQuery->clone(), + $request->getHistoryGroup(), + null, + $user->timezone, + $user->week_start, + true, + $request->getStart(), + $request->getEnd() + ); + $currency = $organization->currency; + $timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user()); $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 ($format === ExportFormat::PDF) { if (config('services.gotenberg.url') === null) { throw new PdfRendererIsNotConfiguredException; } - // TODO + $viewFile = file_get_contents(resource_path('views/reports/time-entry-aggregate-index.blade.php')); + if ($viewFile === false) { + throw new \LogicException('View file not found'); + } + $html = Blade::render($viewFile, [ + 'aggregatedData' => $aggregatedData, + 'dataHistoryChart' => $dataHistoryChart, + 'currency' => $currency, + 'group' => $group, + 'subGroup' => $subGroup, + 'start' => $request->getStart()->timezone($timezone), + 'end' => $request->getEnd()->timezone($timezone), + ]); + $footerViewFile = file_get_contents(resource_path('views/reports/time-entry-index-footer.blade.php')); + if ($footerViewFile === false) { + throw new \LogicException('View file not found'); + } + $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 { - // TODO + Excel::store( + new TimeEntriesReportExport($aggregatedData, $format, $currency, $group, $subGroup), + $path, + config('filesystems.private'), + $format->getExportPackageType(), + [ + 'visibility' => 'private', + ] + ); } return response()->json([ @@ -313,26 +396,9 @@ class TimeEntryController extends Controller } /** - * @return array{ - * grouped_type: string|null, - * grouped_data: null|array - * }>, - * seconds: int, - * cost: int - * } + * @return Builder */ - private function getTimeEntriesAggregateQuery(Organization $organization, TimeEntryAggregateRequest|TimeEntryAggregateExportRequest $request, ?Member $member): array + private function getTimeEntriesAggregateQuery(Organization $organization, TimeEntryAggregateRequest|TimeEntryAggregateExportRequest $request, ?Member $member): Builder { $timeEntriesQuery = TimeEntry::query() ->whereBelongsTo($organization, 'organization'); @@ -348,25 +414,8 @@ class TimeEntryController extends Controller $filter->addTaskIdsFilter($request->input('task_ids')); $filter->addClientIdsFilter($request->input('client_ids')); $filter->addBillableFilter($request->input('billable')); - $timeEntriesQuery = $filter->get(); - $user = $this->user(); - - $group1Type = $request->getGroup(); - $group2Type = $request->getSubGroup(); - - $aggregatedData = app(TimeEntryAggregationService::class)->getAggregatedTimeEntries( - $timeEntriesQuery, - $group1Type, - $group2Type, - $user->timezone, - $user->week_start, - $request->getFillGapsInTimeGroups(), - $request->getStart(), - $request->getEnd() - ); - - return $aggregatedData; + return $filter->get(); } /** diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateExportRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateExportRequest.php index fb400d20..0b5cc34b 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateExportRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateExportRequest.php @@ -6,6 +6,7 @@ namespace App\Http\Requests\V1\TimeEntry; use App\Enums\ExportFormat; use App\Enums\TimeEntryAggregationType; +use App\Enums\TimeEntryAggregationTypeInterval; use App\Models\Client; use App\Models\Member; use App\Models\Organization; @@ -28,7 +29,7 @@ class TimeEntryAggregateExportRequest extends FormRequest /** * Get the validation rules that apply to the request. * - * @return ValidationRule + * @return array> */ public function rules(): array { @@ -39,15 +40,21 @@ class TimeEntryAggregateExportRequest extends FormRequest Rule::enum(ExportFormat::class), ], 'group' => [ - 'nullable', - 'required_with:group_2', + 'required', Rule::enum(TimeEntryAggregationType::class), ], 'sub_group' => [ - 'nullable', + 'required', Rule::enum(TimeEntryAggregationType::class), ], + + 'history_group' => [ + 'required', + 'nullable', + Rule::enum(TimeEntryAggregationTypeInterval::class), + ], + // Filter by member ID 'member_id' => [ 'string', @@ -126,14 +133,14 @@ class TimeEntryAggregateExportRequest extends FormRequest ], // Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z) 'start' => [ - 'nullable', + 'required', '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', + 'required', 'string', 'date_format:Y-m-d\TH:i:s\Z', ], @@ -154,29 +161,29 @@ class TimeEntryAggregateExportRequest extends FormRequest ]; } - public function getGroup(): ?TimeEntryAggregationType + public function getGroup(): TimeEntryAggregationType { - return $this->input('group') !== null ? TimeEntryAggregationType::from($this->input('group')) : null; + return TimeEntryAggregationType::from($this->input('group')); } - public function getSubGroup(): ?TimeEntryAggregationType + public function getSubGroup(): TimeEntryAggregationType { - return $this->input('sub_group') !== null ? TimeEntryAggregationType::from($this->input('sub_group')) : null; + return TimeEntryAggregationType::from($this->input('sub_group')); } - public function getFillGapsInTimeGroups(): bool + public function getHistoryGroup(): TimeEntryAggregationType { - return $this->has('fill_gaps_in_time_groups') && $this->input('fill_gaps_in_time_groups') === 'true'; + return TimeEntryAggregationType::fromInterval(TimeEntryAggregationTypeInterval::from($this->input('history_group'))); } - public function getStart(): ?Carbon + public function getStart(): Carbon { - return $this->input('start') !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('start'), 'UTC') : null; + return Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('start'), 'UTC'); } - public function getEnd(): ?Carbon + public function getEnd(): Carbon { - return $this->input('end') !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC') : null; + return Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC'); } public function getFormatValue(): ExportFormat diff --git a/app/Service/IntervalService.php b/app/Service/IntervalService.php new file mode 100644 index 00000000..78ff0add --- /dev/null +++ b/app/Service/IntervalService.php @@ -0,0 +1,17 @@ +cascade(); + + return ((int) floor($interval->totalHours)).':'.$interval->format('%I:%S'); + } +} diff --git a/app/Service/ReportExport/CsvExport.php b/app/Service/ReportExport/CsvExport.php index cd351bb3..8708cb3c 100644 --- a/app/Service/ReportExport/CsvExport.php +++ b/app/Service/ReportExport/CsvExport.php @@ -36,6 +36,8 @@ abstract class CsvExport private string $folderPath; + protected const string CARBON_FORMAT = 'Y-m-d\TH:i:sP'; + /** * @param Builder $builder */ @@ -51,7 +53,7 @@ abstract class CsvExport /** * @param T $model - * @return array + * @return array */ abstract public function mapRow(Model $model): array; @@ -83,7 +85,7 @@ abstract class CsvExport } /** - * @param array $data + * @param array $data * @return array */ private function convertRow(array $data): array @@ -91,7 +93,9 @@ abstract class CsvExport $convertedRow = []; foreach ($data as $key => $value) { if ($value instanceof Carbon) { - $convertedRow[$key] = $value->toIso8601String(); + $convertedRow[$key] = $value->format(static::CARBON_FORMAT); + } elseif (is_float($value)) { + $convertedRow[$key] = (string) $value; } elseif ($value === null) { $convertedRow[$key] = ''; } else { diff --git a/app/Service/ReportExport/TimeEntriesDetailedCsvExport.php b/app/Service/ReportExport/TimeEntriesDetailedCsvExport.php index 921b196a..5a9cdf14 100644 --- a/app/Service/ReportExport/TimeEntriesDetailedCsvExport.php +++ b/app/Service/ReportExport/TimeEntriesDetailedCsvExport.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Service\ReportExport; use App\Models\TimeEntry; +use App\Service\IntervalService; use Illuminate\Database\Eloquent\Model; /** @@ -26,11 +27,14 @@ class TimeEntriesDetailedCsvExport extends CsvExport 'Tags', ]; + protected const string CARBON_FORMAT = 'Y-m-d H:i:s'; + /** * @param TimeEntry $model */ public function mapRow(Model $model): array { + $interval = app(IntervalService::class); $duration = $model->getDuration(); return [ @@ -39,9 +43,9 @@ class TimeEntriesDetailedCsvExport extends CsvExport '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, + 'Start' => $model->start, + 'End' => $model->end, + 'Duration' => $duration !== null ? $interval->format($model->getDuration()) : 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 dfac7b18..8126faed 100644 --- a/app/Service/ReportExport/TimeEntriesDetailedExport.php +++ b/app/Service/ReportExport/TimeEntriesDetailedExport.php @@ -6,13 +6,13 @@ namespace App\Service\ReportExport; use App\Enums\ExportFormat; use App\Models\TimeEntry; +use App\Service\IntervalService; use Illuminate\Database\Eloquent\Builder; use LogicException; use Maatwebsite\Excel\Concerns\Exportable; use Maatwebsite\Excel\Concerns\FromQuery; 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; @@ -24,7 +24,7 @@ use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; /** * @implements WithMapping */ -class TimeEntriesDetailedExport implements FromQuery, ShouldAutoSize, WithColumnFormatting, WithDefaultStyles, WithHeadings, WithMapping, WithStyles +class TimeEntriesDetailedExport implements FromQuery, ShouldAutoSize, WithColumnFormatting, WithHeadings, WithMapping, WithStyles { use Exportable; @@ -52,6 +52,9 @@ class TimeEntriesDetailedExport implements FromQuery, ShouldAutoSize, WithColumn return $this->builder; } + /** + * @return array + */ public function columnFormats(): array { if ($this->exportFormat === ExportFormat::XLSX) { @@ -81,12 +84,6 @@ class TimeEntriesDetailedExport implements FromQuery, ShouldAutoSize, WithColumn ]; } - public function defaultStyles(Style $defaultStyle) - { - // Configure the default styles - return $defaultStyle->getFill(); //->setFillType(Fill::FILL_SOLID); - } - /** * @return string[] */ @@ -113,6 +110,7 @@ class TimeEntriesDetailedExport implements FromQuery, ShouldAutoSize, WithColumn */ public function map($model): array { + $interval = app(IntervalService::class); $duration = $model->getDuration(); if ($this->exportFormat === ExportFormat::XLSX) { @@ -124,7 +122,7 @@ class TimeEntriesDetailedExport implements FromQuery, ShouldAutoSize, WithColumn $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 !== null ? $interval->format($duration) : null, $duration?->totalHours, $model->billable ? 'Yes' : 'No', $model->tagsRelation->pluck('name')->implode(', '), diff --git a/app/Service/ReportExport/TimeEntriesReportExport.php b/app/Service/ReportExport/TimeEntriesReportExport.php new file mode 100644 index 00000000..25fe7cb2 --- /dev/null +++ b/app/Service/ReportExport/TimeEntriesReportExport.php @@ -0,0 +1,100 @@ + + * }>, + * seconds: int, + * cost: int + * } + */ + private array $data; + + private ExportFormat $exportFormat; + + private string $currency; + + private TimeEntryAggregationType $group; + + private TimeEntryAggregationType $subGroup; + + /** + * @param array{ + * grouped_type: string|null, + * grouped_data: null|array + * }>, + * seconds: int, + * cost: int + * } $data + */ + public function __construct(array $data, ExportFormat $exportFormat, string $currency, TimeEntryAggregationType $group, TimeEntryAggregationType $subGroup) + { + $this->data = $data; + $this->exportFormat = $exportFormat; + $this->currency = $currency; + $this->group = $group; + $this->subGroup = $subGroup; + } + + public function view(): View + { + return view('reports.time-entry-aggregate-index-excel', [ + 'data' => $this->data, + 'currency' => $this->currency, + 'group' => $this->group, + 'subGroup' => $this->subGroup, + 'exportFormat' => $this->exportFormat, + ]); + } + + /** + * @return array + */ + public function getCsvSettings(): array + { + return [ + 'delimiter' => ',', + 'enclosure' => '"', + 'escape_character' => '', + ]; + } +} diff --git a/app/Service/TimeEntryAggregationService.php b/app/Service/TimeEntryAggregationService.php index 56dfeb44..01635bd8 100644 --- a/app/Service/TimeEntryAggregationService.php +++ b/app/Service/TimeEntryAggregationService.php @@ -7,7 +7,11 @@ namespace App\Service; use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationTypeInterval; use App\Enums\Weekday; +use App\Models\Client; +use App\Models\Project; +use App\Models\Task; use App\Models\TimeEntry; +use App\Models\User; use Carbon\CarbonTimeZone; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Carbon; @@ -135,6 +139,109 @@ class TimeEntryAggregationService ]; } + /** + * @param Builder $timeEntriesQuery + * @return array{ + * grouped_type: string|null, + * grouped_data: null|array + * }>, + * seconds: int, + * cost: int + * } + */ + public function getAggregatedTimeEntriesWithDescriptions(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end): array + { + $aggregatedTimeEntries = $this->getAggregatedTimeEntries($timeEntriesQuery, $group1Type, $group2Type, $timezone, $startOfWeek, $fillGapsInTimeGroups, $start, $end); + + $keysGroup1 = []; + $keysGroup2 = []; + + if ($aggregatedTimeEntries['grouped_data'] !== null) { + foreach ($aggregatedTimeEntries['grouped_data'] as $group1) { + $keysGroup1[] = $group1['key']; + if ($group1['grouped_data'] !== null) { + foreach ($group1['grouped_data'] as $group2) { + $keysGroup2[] = $group2['key']; + } + } + } + } + + $descriptionMapGroup1 = $group1Type !== null ? $this->loadDescriptionMap($keysGroup1, $group1Type) : []; + $descriptionMapGroup2 = $group2Type !== null ? $this->loadDescriptionMap($keysGroup2, $group2Type) : []; + + if ($aggregatedTimeEntries['grouped_data'] !== null) { + /* + $aggregatedTimeEntries['grouped_data'] = array_map(function (array $value) use ($descriptionMapGroup1, $descriptionMapGroup2): array { + $value['description'] = $value['key'] !== null ? ($descriptionMapGroup1[$value['key']] ?? null) : null; + if ($value['grouped_data'] !== null) { + $value['grouped_data'] = array_map(function (array $value) use ($descriptionMapGroup2): array { + $value['description'] = $value['key'] !== null ? ($descriptionMapGroup2[$value['key']] ?? null) : null; + + return $value; + }, $value['grouped_data']); + } + + return $value; + }, $aggregatedTimeEntries['grouped_data']); + */ + foreach ($aggregatedTimeEntries['grouped_data'] as $keyGroup1 => $group1) { + $aggregatedTimeEntries['grouped_data'][$keyGroup1]['description'] = $group1['key'] !== null ? ($descriptionMapGroup1[$group1['key']] ?? null) : null; + if ($aggregatedTimeEntries['grouped_data'][$keyGroup1]['grouped_data'] !== null) { + foreach ($aggregatedTimeEntries['grouped_data'][$keyGroup1]['grouped_data'] as $keyGroup2 => $group2) { + $aggregatedTimeEntries['grouped_data'][$keyGroup1]['grouped_data'][$keyGroup2]['description'] = $group2['key'] !== null ? ($descriptionMapGroup2[$group2['key']] ?? null) : null; + } + } + } + } + + return $aggregatedTimeEntries; + } + + /** + * @param array $keys + * @return array + */ + private function loadDescriptionMap(array $keys, TimeEntryAggregationType $type): array + { + if ($type === TimeEntryAggregationType::Client) { + return Client::query() + ->whereIn('id', $keys) + ->pluck('name', 'id') + ->toArray(); + } elseif ($type === TimeEntryAggregationType::User) { + return User::query() + ->whereIn('id', $keys) + ->pluck('name', 'id') + ->toArray(); + } elseif ($type === TimeEntryAggregationType::Project) { + return Project::query() + ->whereIn('id', $keys) + ->pluck('name', 'id') + ->toArray(); + } elseif ($type === TimeEntryAggregationType::Task) { + return Task::query() + ->whereIn('id', $keys) + ->pluck('name', 'id') + ->toArray(); + } else { + return []; + } + } + /** * @param array=7.4" + }, + "require-dev": { + "behat/behat": "^3.0", + "phpstan/phpstan": "^1.0", + "phpunit/phpunit": "^9.0", + "vimeo/psalm": "^4.0" + }, + "suggest": { + "pds/skeleton": "PHP Package Development Standards", + "phing/phing": "PHP Build Tool" + }, + "type": "library", + "autoload": { + "psr-4": { + "Jawira\\CaseConverter\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jawira Portugal", + "email": "dev@tugal.be" + } + ], + "description": "Convert strings between 13 naming conventions: Snake case, Camel case, Pascal case, Kebab case, Ada case, Train case, Cobol case, Macro case, Upper case, Lower case, Sentence case, Title case and Dot notation.", + "homepage": "https://jawira.github.io/case-converter/", + "keywords": [ + "Ada case", + "Cobol case", + "Macro case", + "Train case", + "camel case", + "dot notation", + "kebab case", + "lower case", + "pascal case", + "sentence case", + "snake case", + "title case", + "upper case" + ], + "support": { + "issues": "https://github.com/jawira/case-converter/issues", + "source": "https://github.com/jawira/case-converter/tree/v3.5.1" + }, + "time": "2022-08-14T11:40:18+00:00" + }, { "name": "justinrainbow/json-schema", "version": "5.3.0", @@ -3751,6 +3925,69 @@ }, "time": "2024-03-11T14:26:14+00:00" }, + { + "name": "laminas/laminas-code", + "version": "4.14.0", + "source": { + "type": "git", + "url": "https://github.com/laminas/laminas-code.git", + "reference": "562e02b7d85cb9142b5116cc76c4c7c162a11a1c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laminas/laminas-code/zipball/562e02b7d85cb9142b5116cc76c4c7c162a11a1c", + "reference": "562e02b7d85cb9142b5116cc76c4c7c162a11a1c", + "shasum": "" + }, + "require": { + "php": "~8.1.0 || ~8.2.0 || ~8.3.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0.1", + "ext-phar": "*", + "laminas/laminas-coding-standard": "^2.5.0", + "laminas/laminas-stdlib": "^3.17.0", + "phpunit/phpunit": "^10.3.3", + "psalm/plugin-phpunit": "^0.19.0", + "vimeo/psalm": "^5.15.0" + }, + "suggest": { + "doctrine/annotations": "Doctrine\\Common\\Annotations >=1.0 for annotation features", + "laminas/laminas-stdlib": "Laminas\\Stdlib component" + }, + "type": "library", + "autoload": { + "psr-4": { + "Laminas\\Code\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "Extensions to the PHP Reflection API, static code scanning, and code generation", + "homepage": "https://laminas.dev", + "keywords": [ + "code", + "laminas", + "laminasframework" + ], + "support": { + "chat": "https://laminas.dev/chat", + "docs": "https://docs.laminas.dev/laminas-code/", + "forum": "https://discourse.laminas.dev", + "issues": "https://github.com/laminas/laminas-code/issues", + "rss": "https://github.com/laminas/laminas-code/releases.atom", + "source": "https://github.com/laminas/laminas-code" + }, + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], + "time": "2024-06-17T08:50:25+00:00" + }, { "name": "laminas/laminas-diactoros", "version": "3.4.0", diff --git a/resources/views/reports/time-entry-aggregate-index-excel.blade.php b/resources/views/reports/time-entry-aggregate-index-excel.blade.php new file mode 100644 index 00000000..180c24a9 --- /dev/null +++ b/resources/views/reports/time-entry-aggregate-index-excel.blade.php @@ -0,0 +1,130 @@ +@use('App\Enums\ExportFormat') +@use('Brick\Math\BigDecimal') +@use('PhpOffice\PhpSpreadsheet\Cell\DataType') +@use('PhpOffice\PhpSpreadsheet\Style\NumberFormat') +@use('Carbon\CarbonInterval') +@use('App\Enums\TimeEntryAggregationType') +@inject('interval', 'App\Service\IntervalService') + + + + + + + + + + + + @php + $counter = 1; + $totalDuration = 0; + $totalCost = 0; + @endphp + @foreach($data['grouped_data'] as $group1Entry) + @foreach($group1Entry['grouped_data'] as $group2Entry) + @php + $duration = CarbonInterval::seconds($group2Entry['seconds']); + @endphp + + + @if($exportFormat === ExportFormat::ODS || $exportFormat === ExportFormat::CSV) + + + + + @else + @if ($subGroup === TimeEntryAggregationType::Billable) + + @else + + @endif + + + + + @endif + + @php + ++$counter; + $totalDuration += $group2Entry['seconds']; + $totalCost += $group2Entry['cost']; + @endphp + @endforeach + @endforeach + @php + $totalDurationInterval = CarbonInterval::seconds($totalDuration); + @endphp + + + + @if($exportFormat === ExportFormat::ODS || $exportFormat === ExportFormat::CSV) + + + + @else + + + + @endif + + +
+ {{ $group->description() }} + + {{ $subGroup->description() }} + + Duration + + Duration (decimal) + + Amount ({{ Str::upper($currency) }}) +
+ @if ($group === TimeEntryAggregationType::Billable) + {{ $group1Entry['key'] ? 'Yes' : 'No' }} + @else + {{ $group1Entry['description'] ?? $group1Entry['key'] ?? '-' }} + @endif + + @if ($subGroup === TimeEntryAggregationType::Billable) + {{ $group2Entry['key'] ? 'Yes' : 'No' }} + @else + {{ $group2Entry['description'] ?? $group2Entry['key'] ?? '-' }} + @endif + + {{ $interval->format($duration) }} + + {{ round($duration->totalHours, 2) }} + + {{ round(BigDecimal::ofUnscaledValue($group2Entry['cost'], 2)->toFloat(), 2) }} + + {{ $group1Entry['key'] ? 'Yes' : 'No' }} + + {{ $group2Entry['description'] ?? $group2Entry['key'] ?? '-' }} + + {{ $duration->totalDays }} + + {{ $duration->totalHours }} + + {{ BigDecimal::ofUnscaledValue($group2Entry['cost'], 2)->__toString() }} +
+ Total + + {{ $interval->format($totalDurationInterval) }} + + {{ round($totalDurationInterval->totalHours, 2) }} + + {{ round(BigDecimal::ofUnscaledValue($totalCost, 2)->toFloat(), 2) }} + + =SUM(C2:C{{ $counter }}) + + =SUM(D2:D{{ $counter }}) + + =SUM(E2:E{{ $counter }}) +
diff --git a/resources/views/reports/time-entry-aggregate-index.blade.php b/resources/views/reports/time-entry-aggregate-index.blade.php new file mode 100644 index 00000000..6df248d7 --- /dev/null +++ b/resources/views/reports/time-entry-aggregate-index.blade.php @@ -0,0 +1,127 @@ +@use('Brick\Math\BigDecimal') +@use('PhpOffice\PhpSpreadsheet\Cell\DataType') +@use('Carbon\CarbonInterval') +@inject('interval', 'App\Service\IntervalService') + + + + + Report + + + + +

Report

+ +
+ {{ $start->format('Y-m-d') }} - {{ $end->format('Y-m-d') }}

+
+ +
+ Duration: {{ $interval->format(CarbonInterval::seconds($aggregatedData['seconds'])) }}
+ Total cost: {{ round(BigDecimal::ofUnscaledValue($aggregatedData['cost'], 2)->toFloat(), 2) }}
+
+ +
+ + @foreach($aggregatedData['grouped_data'] as $group1Entry) +

{{ $group->description() }}: {{ $group1Entry['description'] ?? $group1Entry['key'] ?? '-' }}

+ + + + + + + + + + + + @php + $counter = 1; + $totalDuration = 0; + $totalCost = 0; + @endphp + @foreach($group1Entry['grouped_data'] as $group2Entry) + @php + $duration = CarbonInterval::seconds($group2Entry['seconds']); + @endphp + + + + + + + @php + $totalDuration += $group2Entry['seconds']; + $totalCost += $group2Entry['cost']; + @endphp + @endforeach + +
+ {{ $subGroup->description() }} + + Duration + + Duration (decimal) + + Amount ({{ Str::upper($currency) }}) +
+ {{ $group2Entry['description'] ?? $group2Entry['key'] ?? '-' }} + + {{ $interval->format($duration) }} + + {{ round($duration->totalHours, 2) }} + + {{ round(BigDecimal::ofUnscaledValue($group2Entry['cost'], 2)->toFloat(), 2) }} +
+ @endforeach + + + + diff --git a/resources/views/reports/time-entry-index.blade.php b/resources/views/reports/time-entry-index.blade.php index 1a2aca7c..18b97956 100644 --- a/resources/views/reports/time-entry-index.blade.php +++ b/resources/views/reports/time-entry-index.blade.php @@ -13,7 +13,7 @@ -

Report

+

Detailed Report

01.01.2020 - 01.01.2024 diff --git a/routes/web.php b/routes/web.php index b5c59cca..a0dc6a2f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,8 +2,12 @@ declare(strict_types=1); +use App\Exceptions\Api\PdfRendererIsNotConfiguredException; use App\Http\Controllers\Web\DashboardController; use App\Http\Controllers\Web\HomeController; +use Gotenberg\Gotenberg; +use Gotenberg\Stream; +use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Route; use Inertia\Inertia; use Laravel\Jetstream\Jetstream; @@ -66,4 +70,22 @@ Route::middleware([ return Inertia::render('Import'); })->name('import'); + Route::get('/pdf-test', function () { + if (config('services.gotenberg.url') === null) { + throw new PdfRendererIsNotConfiguredException; + } + $viewFile = file_get_contents(resource_path('views/reports/time-entry-aggregate-index.blade.php')); + $html = Blade::render($viewFile, ['aggregatedData' => []]); + $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)); + + return Gotenberg::send($request); + }); + }); diff --git a/tests/Unit/Service/IntervalServiceTest.php b/tests/Unit/Service/IntervalServiceTest.php new file mode 100644 index 00000000..c57def28 --- /dev/null +++ b/tests/Unit/Service/IntervalServiceTest.php @@ -0,0 +1,25 @@ +format($interval); + + // Assert + $this->assertEquals('34293552:32:03', $result); + } +}