Add report exports

This commit is contained in:
Constantin Graf
2024-10-25 16:37:40 +02:00
committed by Constantin Graf
parent 7c1fe35754
commit 8712cfb9dc
16 changed files with 924 additions and 82 deletions

View File

@@ -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) {

View File

@@ -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<array{
* key: string|null,
* seconds: int,
* cost: int,
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* seconds: int,
* cost: int,
* grouped_type: null,
* grouped_data: null
* }>
* }>,
* seconds: int,
* cost: int
* }
* @return Builder<TimeEntry>
*/
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();
}
/**

View File

@@ -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<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
*/
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

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Service;
use Carbon\CarbonInterval;
class IntervalService
{
public function format(CarbonInterval $interval): string
{
$interval->cascade();
return ((int) floor($interval->totalHours)).':'.$interval->format('%I:%S');
}
}

View File

@@ -36,6 +36,8 @@ abstract class CsvExport
private string $folderPath;
protected const string CARBON_FORMAT = 'Y-m-d\TH:i:sP';
/**
* @param Builder<T> $builder
*/
@@ -51,7 +53,7 @@ abstract class CsvExport
/**
* @param T $model
* @return array<string, string|Carbon|null>
* @return array<string, string|float|Carbon|null>
*/
abstract public function mapRow(Model $model): array;
@@ -83,7 +85,7 @@ abstract class CsvExport
}
/**
* @param array<string, string|Carbon|null> $data
* @param array<string, string|float|Carbon|null> $data
* @return array<string, string>
*/
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 {

View File

@@ -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(', '),

View File

@@ -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<TimeEntry>
*/
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<string, string>
*/
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(', '),

View File

@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Service\ReportExport;
use App\Enums\ExportFormat;
use App\Enums\TimeEntryAggregationType;
use Illuminate\View\View;
use Maatwebsite\Excel\Concerns\Exportable;
use Maatwebsite\Excel\Concerns\FromView;
use Maatwebsite\Excel\Concerns\ShouldAutoSize;
use Maatwebsite\Excel\Concerns\WithCustomCsvSettings;
class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsvSettings
{
use Exportable;
/**
* @var array{
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* seconds: int,
* cost: int,
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* seconds: int,
* cost: int,
* grouped_type: null,
* grouped_data: null
* }>
* }>,
* 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<array{
* key: string|null,
* seconds: int,
* cost: int,
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* seconds: int,
* cost: int,
* grouped_type: null,
* grouped_data: null
* }>
* }>,
* 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<string, string>
*/
public function getCsvSettings(): array
{
return [
'delimiter' => ',',
'enclosure' => '"',
'escape_character' => '',
];
}
}

View File

@@ -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<TimeEntry> $timeEntriesQuery
* @return array{
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* description: string|null,
* seconds: int,
* cost: int,
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* description: string|null,
* seconds: int,
* cost: int,
* grouped_type: null,
* grouped_data: null
* }>
* }>,
* 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<int, string> $keys
* @return array<string, string>
*/
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<array{
* key: string|null,

View File

@@ -8,6 +8,7 @@
"php": "8.3.*",
"ext-zip": "*",
"brick/money": "^0.9.0",
"datomatic/laravel-enum-helper": "^1.1",
"dedoc/scramble": "dev-main",
"filament/filament": "^3.2",
"flowframe/laravel-trend": "^0.2.0",

239
composer.lock generated
View File

@@ -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": "5634bfb04c10a875101045a25ac21f1a",
"content-hash": "0ae21e521922b7431905a0764dfc0245",
"packages": [
{
"name": "anourvalar/eloquent-serialize",
@@ -1408,6 +1408,113 @@
},
"time": "2024-08-09T14:30:48+00:00"
},
{
"name": "datomatic/enum-helper",
"version": "v1.1.0",
"source": {
"type": "git",
"url": "https://github.com/datomatic/enum-helper.git",
"reference": "a31986b4a2876e5d942da2816f760d829af52664"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/datomatic/enum-helper/zipball/a31986b4a2876e5d942da2816f760d829af52664",
"reference": "a31986b4a2876e5d942da2816f760d829af52664",
"shasum": ""
},
"require": {
"ext-ctype": "*",
"ext-mbstring": "*",
"php": "^8.1"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.8",
"pestphp/pest": "^1.21",
"phpstan/phpstan": "^1.7"
},
"type": "library",
"autoload": {
"psr-4": {
"Datomatic\\EnumHelper\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Alberto Peripolli",
"email": "info@albertoperipolli.com"
}
],
"description": "Simple opinionated framework agnostic PHP 8.1 enum helper",
"support": {
"issues": "https://github.com/datomatic/enum-helper/issues",
"source": "https://github.com/datomatic/enum-helper/tree/v1.1.0"
},
"time": "2022-10-15T11:27:54+00:00"
},
{
"name": "datomatic/laravel-enum-helper",
"version": "v1.1.1",
"source": {
"type": "git",
"url": "https://github.com/datomatic/laravel-enum-helper.git",
"reference": "293fd7d454b3718b5046a08913e4b121552124a9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/datomatic/laravel-enum-helper/zipball/293fd7d454b3718b5046a08913e4b121552124a9",
"reference": "293fd7d454b3718b5046a08913e4b121552124a9",
"shasum": ""
},
"require": {
"composer/class-map-generator": "^1.0",
"datomatic/enum-helper": "^1.0",
"illuminate/support": "^8.0|^9.0|^10.0|^11.0",
"illuminate/translation": "^8.0|^9.0|^10.0|^11.0",
"jawira/case-converter": "^3.5",
"laminas/laminas-code": "^4.0",
"php": "^8.1"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.8",
"orchestra/testbench": "^6.23|^7.0|^9.0",
"pestphp/pest": "^1.21|^2.34",
"pestphp/pest-plugin-laravel": "^1.2|^2.3",
"phpstan/phpstan": "^1.7"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Datomatic\\LaravelEnumHelper\\LaravelEnumHelperServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Datomatic\\LaravelEnumHelper\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Alberto Peripolli",
"email": "info@albertoperipolli.com"
}
],
"description": "Simple opinionated framework agnostic PHP 8.1 enum helper for Laravel",
"support": {
"issues": "https://github.com/datomatic/laravel-enum-helper/issues",
"source": "https://github.com/datomatic/laravel-enum-helper/tree/v1.1.1"
},
"time": "2024-03-14T14:36:39+00:00"
},
{
"name": "dedoc/scramble",
"version": "dev-main",
@@ -3424,6 +3531,73 @@
],
"time": "2024-06-13T01:25:09+00:00"
},
{
"name": "jawira/case-converter",
"version": "v3.5.1",
"source": {
"type": "git",
"url": "https://github.com/jawira/case-converter.git",
"reference": "2be05b98dcb743bef60ab6f849145bd3434ed003"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/jawira/case-converter/zipball/2be05b98dcb743bef60ab6f849145bd3434ed003",
"reference": "2be05b98dcb743bef60ab6f849145bd3434ed003",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": ">=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",

View File

@@ -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')
<table>
<thead>
<tr>
<th style="border: 1px solid black; font-weight: bold;" data-type="{{ DataType::TYPE_STRING }}">
{{ $group->description() }}
</th>
<th style="border: 1px solid black; font-weight: bold;" data-type="{{ DataType::TYPE_STRING }}">
{{ $subGroup->description() }}
</th>
<th style="border: 1px solid black; font-weight: bold;" data-type="{{ DataType::TYPE_STRING }}">
Duration
</th>
<th style="border: 1px solid black; font-weight: bold;" data-type="{{ DataType::TYPE_STRING }}">
Duration (decimal)
</th>
<th style="border: 1px solid black; font-weight: bold;" data-type="{{ DataType::TYPE_STRING }}">
Amount ({{ Str::upper($currency) }})
</th>
</tr>
</thead>
<tbody>
@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
<tr>
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}">
@if ($group === TimeEntryAggregationType::Billable)
{{ $group1Entry['key'] ? 'Yes' : 'No' }}
@else
{{ $group1Entry['description'] ?? $group1Entry['key'] ?? '-' }}
@endif
</td>
@if($exportFormat === ExportFormat::ODS || $exportFormat === ExportFormat::CSV)
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}">
@if ($subGroup === TimeEntryAggregationType::Billable)
{{ $group2Entry['key'] ? 'Yes' : 'No' }}
@else
{{ $group2Entry['description'] ?? $group2Entry['key'] ?? '-' }}
@endif
</td>
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}">
{{ $interval->format($duration) }}
</td>
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}">
{{ round($duration->totalHours, 2) }}
</td>
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}">
{{ round(BigDecimal::ofUnscaledValue($group2Entry['cost'], 2)->toFloat(), 2) }}
</td>
@else
@if ($subGroup === TimeEntryAggregationType::Billable)
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}">
{{ $group1Entry['key'] ? 'Yes' : 'No' }}
</td>
@else
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}">
{{ $group2Entry['description'] ?? $group2Entry['key'] ?? '-' }}
</td>
@endif
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_NUMERIC }}"
data-format="[hh]:mm:ss">
{{ $duration->totalDays }}
</td>
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_NUMERIC }}"
data-format="{{ NumberFormat::FORMAT_NUMBER_00 }}">
{{ $duration->totalHours }}
</td>
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_NUMERIC }}"
data-format="{{ NumberFormat::FORMAT_NUMBER_COMMA_SEPARATED1 }}">
{{ BigDecimal::ofUnscaledValue($group2Entry['cost'], 2)->__toString() }}
</td>
@endif
</tr>
@php
++$counter;
$totalDuration += $group2Entry['seconds'];
$totalCost += $group2Entry['cost'];
@endphp
@endforeach
@endforeach
@php
$totalDurationInterval = CarbonInterval::seconds($totalDuration);
@endphp
<tr style="border: 1px solid black;">
<td style="border: 1px solid black; font-weight: bold;" data-type="{{ DataType::TYPE_STRING }}"></td>
<td style="border: 1px solid black; font-weight: bold;" data-type="{{ DataType::TYPE_STRING }}">
Total
</td>
@if($exportFormat === ExportFormat::ODS || $exportFormat === ExportFormat::CSV)
<td style="border: 1px solid black; font-weight: bold;" data-type="{{ DataType::TYPE_STRING }}">
{{ $interval->format($totalDurationInterval) }}
</td>
<td style="border: 1px solid black; font-weight: bold;" data-type="{{ DataType::TYPE_STRING }}">
{{ round($totalDurationInterval->totalHours, 2) }}
</td>
<td style="border: 1px solid black; font-weight: bold;" data-type="{{ DataType::TYPE_STRING }}">
{{ round(BigDecimal::ofUnscaledValue($totalCost, 2)->toFloat(), 2) }}
</td>
@else
<td style="border: 1px solid black; font-weight: bold;" data-type="{{ DataType::TYPE_FORMULA }}"
data-format="[hh]:mm:ss">
=SUM(C2:C{{ $counter }})
</td>
<td style="border: 1px solid black; font-weight: bold;" data-type="{{ DataType::TYPE_FORMULA }}"
data-format="{{ NumberFormat::FORMAT_NUMBER_00 }}">
=SUM(D2:D{{ $counter }})
</td>
<td style="border: 1px solid black; font-weight: bold;" data-type="{{ DataType::TYPE_FORMULA }}"
data-format="{{ NumberFormat::FORMAT_NUMBER_COMMA_SEPARATED1 }}">
=SUM(E2:E{{ $counter }})
</td>
@endif
</tr>
</tbody>
</table>

View File

@@ -0,0 +1,127 @@
@use('Brick\Math\BigDecimal')
@use('PhpOffice\PhpSpreadsheet\Cell\DataType')
@use('Carbon\CarbonInterval')
@inject('interval', 'App\Service\IntervalService')
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Report</title>
<style>
body {
font-family: "Open Sans", sans-serif;
}
table {
font-size: 10px;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.1/dist/echarts.min.js"></script>
</head>
<body>
<h1>Report</h1>
<div>
<span>{{ $start->format('Y-m-d') }} - {{ $end->format('Y-m-d') }}</span><br><br>
</div>
<div>
<span>Duration: {{ $interval->format(CarbonInterval::seconds($aggregatedData['seconds'])) }}</span><br>
<span>Total cost: {{ round(BigDecimal::ofUnscaledValue($aggregatedData['cost'], 2)->toFloat(), 2) }}</span><br>
</div>
<div id="main-chart" style="width: 800px; height:400px;"></div>
@foreach($aggregatedData['grouped_data'] as $group1Entry)
<h2>{{ $group->description() }}: {{ $group1Entry['description'] ?? $group1Entry['key'] ?? '-' }}</h2>
<table>
<thead>
<tr>
<th>
{{ $subGroup->description() }}
</th>
<th>
Duration
</th>
<th>
Duration (decimal)
</th>
<th>
Amount ({{ Str::upper($currency) }})
</th>
</tr>
</thead>
<tbody>
@php
$counter = 1;
$totalDuration = 0;
$totalCost = 0;
@endphp
@foreach($group1Entry['grouped_data'] as $group2Entry)
@php
$duration = CarbonInterval::seconds($group2Entry['seconds']);
@endphp
<tr>
<td>
{{ $group2Entry['description'] ?? $group2Entry['key'] ?? '-' }}
</td>
<td>
{{ $interval->format($duration) }}
</td>
<td>
{{ round($duration->totalHours, 2) }}
</td>
<td>
{{ round(BigDecimal::ofUnscaledValue($group2Entry['cost'], 2)->toFloat(), 2) }}
</td>
</tr>
@php
$totalDuration += $group2Entry['seconds'];
$totalCost += $group2Entry['cost'];
@endphp
@endforeach
</tbody>
</table>
@endforeach
<script>
// Initialize the echarts instance based on the prepared dom
let element = document.getElementById('main-chart');
let myChart = echarts.init(element, null, {
renderer: 'svg'
});
// Specify the configuration items and data for the chart
let option = {
tooltip: {},
xAxis: {
data: ['{!! collect($dataHistoryChart['grouped_data'])->pluck('key')->implode("', '") !!}'],
rotate: 0
},
yAxis: {
minInterval: 1,
axisLabel: {
formatter: function (value, index) {
let totalSeconds = value;
let hours = Math.floor(totalSeconds / 3600);
totalSeconds %= 3600;
let minutes = Math.floor(totalSeconds / 60);
let seconds = totalSeconds % 60;
return hours + ':' + minutes + ':' + seconds;
}
}
},
series: [
{
name: 'time',
type: 'bar',
data: [{!! collect($dataHistoryChart['grouped_data'])->pluck('seconds')->implode(', ') !!}],
}
]
};
// Display the chart using the configuration items and data just specified.
myChart.setOption(option);
</script>
</body>
</html>

View File

@@ -13,7 +13,7 @@
</style>
</head>
<body>
<h1>Report</h1>
<h1>Detailed Report</h1>
<div>
<span>01.01.2020 - 01.01.2024</span>

View File

@@ -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);
});
});

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Service;
use App\Service\IntervalService;
use Carbon\CarbonInterval;
use Tests\TestCase;
class IntervalServiceTest extends TestCase
{
public function test_format_returns_correctly_formatted_interval(): void
{
// Arrange
$intervalService = app(IntervalService::class);
$interval = CarbonInterval::seconds(123456789123);
// Act
$result = $intervalService->format($interval);
// Assert
$this->assertEquals('34293552:32:03', $result);
}
}