mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-14 03:02:15 +01:00
Add report exports
This commit is contained in:
committed by
Constantin Graf
parent
7c1fe35754
commit
8712cfb9dc
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
17
app/Service/IntervalService.php
Normal file
17
app/Service/IntervalService.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(', '),
|
||||
|
||||
@@ -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(', '),
|
||||
|
||||
100
app/Service/ReportExport/TimeEntriesReportExport.php
Normal file
100
app/Service/ReportExport/TimeEntriesReportExport.php
Normal 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' => '',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user