mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 00: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,
|
||||
|
||||
@@ -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
239
composer.lock
generated
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
127
resources/views/reports/time-entry-aggregate-index.blade.php
Normal file
127
resources/views/reports/time-entry-aggregate-index.blade.php
Normal 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>
|
||||
@@ -13,7 +13,7 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Report</h1>
|
||||
<h1>Detailed Report</h1>
|
||||
|
||||
<div>
|
||||
<span>01.01.2020 - 01.01.2024</span>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
25
tests/Unit/Service/IntervalServiceTest.php
Normal file
25
tests/Unit/Service/IntervalServiceTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user