Updated PDF reports

This commit is contained in:
Constantin Graf
2024-11-28 14:51:02 +01:00
committed by Constantin Graf
parent 0860aa9d24
commit a4d8a02b80
14 changed files with 432 additions and 233 deletions

View File

@@ -164,7 +164,7 @@ class TimeEntryController extends Controller
*
* @operationId exportTimeEntries
*/
public function indexExport(Organization $organization, TimeEntryIndexExportRequest $request): JsonResponse
public function indexExport(Organization $organization, TimeEntryIndexExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
{
/** @var Member|null $member */
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
@@ -198,12 +198,29 @@ class TimeEntryController extends Controller
if (config('services.gotenberg.url') === null) {
throw new PdfRendererIsNotConfiguredException;
}
$viewFile = file_get_contents(resource_path('views/reports/time-entry-index.blade.php'));
$viewFile = file_get_contents(resource_path('views/reports/time-entry-index/pdf.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'));
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
$timeEntriesQuery->clone()->reorder()->withOnly([]),
null,
null,
$user->timezone,
$user->week_start,
false,
null,
null
);
$html = Blade::render($viewFile, [
'timeEntries' => $timeEntriesQuery->get(),
'aggregatedData' => $aggregatedData,
'timezone' => $timezone,
'currency' => $organization->currency,
'start' => $request->getStart()->timezone($timezone),
'end' => $request->getEnd()->timezone($timezone),
]);
$footerViewFile = file_get_contents(resource_path('views/reports/time-entry-index/pdf-footer.blade.php'));
if ($footerViewFile === false) {
throw new \LogicException('View file not found');
}
@@ -372,7 +389,7 @@ class TimeEntryController extends Controller
config('services.gotenberg.basic_auth_password'),
] : null,
]);
$viewFile = file_get_contents(resource_path('views/reports/time-entry-aggregate-index.blade.php'));
$viewFile = file_get_contents(resource_path('views/reports/time-entry-aggregate/pdf.blade.php'));
if ($viewFile === false) {
throw new \LogicException('View file not found');
}
@@ -385,7 +402,7 @@ class TimeEntryController extends Controller
'start' => $request->getStart()->timezone($timezone),
'end' => $request->getEnd()->timezone($timezone),
]);
$footerViewFile = file_get_contents(resource_path('views/reports/time-entry-index-footer.blade.php'));
$footerViewFile = file_get_contents(resource_path('views/reports/time-entry-aggregate/pdf-footer.blade.php'));
if ($footerViewFile === false) {
throw new \LogicException('View file not found');
}
@@ -395,6 +412,7 @@ class TimeEntryController extends Controller
->pdfa('PDF/A-3b')
->paperSize('8.27', '11.7') // A4
->footer(Stream::string('footer', $footerHtml))
->assets(Stream::path(resource_path('pdf-js/echarts.min.js'), 'echarts.min.js'))
->html(Stream::string('body', $html));
$tempFolder = TemporaryDirectory::make();
$filenameTemp = Gotenberg::save($request, $tempFolder->path(), $client);

View File

@@ -12,6 +12,7 @@ use App\Models\Tag;
use App\Models\Task;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
@@ -96,14 +97,14 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
],
// 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',
],
@@ -131,6 +132,26 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
];
}
public function getStart(): Carbon
{
$start = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('start'), 'UTC');
if ($start === null) {
throw new \LogicException('Start date validation is not working');
}
return $start;
}
public function getEnd(): Carbon
{
$end = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC');
if ($end === null) {
throw new \LogicException('End date validation is not working');
}
return $end;
}
public function getOnlyFullDates(): bool
{
return $this->input('only_full_dates', 'false') === 'true';

View File

@@ -77,7 +77,7 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
public function view(): View
{
return view('reports.time-entry-aggregate-index-excel', [
return view('reports.time-entry-aggregate.spreadsheet', [
'data' => $this->data,
'currency' => $this->currency,
'group' => $this->group,

45
resources/pdf-js/echarts.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,127 +0,0 @@
@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

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<style>
body {
font-size: 12px;
margin-top: 40px;
margin-left: 47px;
}
</style>
</head>
<body>
<p>
Page <span class="pageNumber"></span> of <span class="totalPages"></span>
</p>
</body>
</html>

View File

@@ -0,0 +1,197 @@
@use('Brick\Math\BigDecimal')
@use('Brick\Money\Money')
@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: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif;
color: #555;
}
table {
font-size: 10px;
}
table thead {
background-color: #eee;
}
h1 {
font-size: 35px;
font-weight: bold;
}
h2 {
font-size: 20px;
font-weight: bold;
}
.range {
font-size: 24px;
font-weight: bold;
}
</style>
<script src="echarts.min.js"></script>
</head>
<body>
<h1>Report</h1>
<hr>
<div class="range">
<span>{{ $start->format('d.m.Y') }} - {{ $end->format('d.m.Y') }}</span><br><br>
</div>
<div class="properties">
<span>Duration: {{ $interval->format(CarbonInterval::seconds($aggregatedData['seconds'])) }}</span><br>
<span>Total cost: {{ Money::of(BigDecimal::ofUnscaledValue($aggregatedData['cost'], 2)->__toString(), $currency)->formatTo('en_US') }}</span><br>
</div>
<div id="main-chart" style="width: 100%; height:400px; margin-bottom: 50px;"></div>
@foreach($aggregatedData['grouped_data'] as $group1Entry)
<h2>{{ $group1Entry['description'] ?? $group1Entry['key'] ?? 'No '.Str::lower($group->description()) }}</h2>
<table>
<thead>
<tr>
<th>
{{ $subGroup->description() }}
</th>
<th>
Duration
</th>
<th>
Duration (decimal)
</th>
<th>
Cost
</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 style="text-align: left;">
{{ $group2Entry['description'] ?? $group2Entry['key'] ?? '-' }}
</td>
<td style="text-align: right;">
{{ $interval->format($duration) }}
</td>
<td style="text-align: right;">
{{ round($duration->totalHours, 2) }}
</td>
<td style="text-align: right;">
{{ Money::of(BigDecimal::ofUnscaledValue($group2Entry['cost'], 2)->__toString(), $currency)->formatTo('en_US') }}
</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("', '") !!}'],
axisLabel: {
show: true,
interval: 0,
rotate: 90,
}
},
grid: {
left: 0,
right: 0,
bottom: 0,
containLabel: true
},
yAxis: {
minInterval: 1,
axisLabel: {
show: false,
inside: true,
formatter: function (value, index) {
let totalSeconds = value;
let hours = Math.floor(totalSeconds / 3600);
if (hours < 10) {
hours = '0' + hours;
}
totalSeconds %= 3600;
let minutes = Math.floor(totalSeconds / 60);
if (minutes < 10) {
minutes = '0' + minutes;
}
let seconds = totalSeconds % 60;
if (seconds < 10) {
seconds = '0' + seconds;
}
return hours + ':' + minutes + ':' + seconds;
}
}
},
series: [
{
name: 'time',
type: 'bar',
data: [{!! collect($dataHistoryChart['grouped_data'])->pluck('seconds')->implode(', ') !!}],
label: {
show: true,
position: 'top',
formatter: function (params) {
let value = params.value;
if (value === 0) {
return '';
}
let totalSeconds = value;
let hours = Math.floor(totalSeconds / 3600);
if (hours < 10) {
hours = '0' + hours;
}
totalSeconds %= 3600;
let minutes = Math.floor(totalSeconds / 60);
if (minutes < 10) {
minutes = '0' + minutes;
}
let seconds = totalSeconds % 60;
if (seconds < 10) {
seconds = '0' + seconds;
}
return hours + ':' + minutes + ':' + seconds;
}
}
}
]
};
// Display the chart using the configuration items and data just specified.
myChart.setOption(option);
</script>
</body>
</html>

View File

@@ -1,15 +0,0 @@
<html>
<head>
<style>
body {
font-size: 12px;
margin: auto 20px;
}
</style>
</head>
<body>
<p>
<span class="pageNumber"></span> of <span class="totalPages"></span>
</p>
</body>
</html>

View File

@@ -1,60 +0,0 @@
<!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>
</head>
<body>
<h1>Detailed Report</h1>
<div>
<span>01.01.2020 - 01.01.2024</span>
</div>
<div>
<span>Duration: 20:10:10</span>
</div>
<div>
<table>
<thead>
<tr>
<th>Description</th>
<th>Task</th>
<th>Project</th>
<th>Client</th>
<th>User</th>
<th>Duration</th>
<th>Billable</th>
<th>Tags</th>
</tr>
</thead>
<tbody>
@foreach($timeEntries as $timeEntry)
<tr>
<td>{{ $timeEntry->description }}</td>
<td>{{ $timeEntry->task?->name ?? '-' }}</td>
<td>{{ $timeEntry->project?->name ?? '-' }}</td>
<td>{{ $timeEntry->client?->name ?? '-' }}</td>
<td>{{ $timeEntry->user->name }}</td>
<td>
00:00:01
{{ $timeEntry->start->format('Y-m-d H:i:s') }} - {{ $timeEntry->end->format('Y-m-d H:i:s') }}
</td>
<td>{{ $timeEntry->billable ? 'Yes' : 'no' }}</td>
<td>{{ $timeEntry->tagsRelation->implode('name', ', ') }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</body>
</html>

View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<style>
body {
font-size: 12px;
margin-top: 40px;
margin-left: 47px;
}
</style>
</head>
<body>
<p>
Page <span class="pageNumber"></span> of <span class="totalPages"></span>
</p>
</body>
</html>

View File

@@ -0,0 +1,92 @@
@use('Brick\Math\BigDecimal')
@use('Brick\Money\Money')
@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: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif;
color: #555;
}
table {
font-size: 10px;
}
table thead {
background-color: #eee;
}
h1 {
font-size: 35px;
font-weight: bold;
}
h2 {
font-size: 20px;
font-weight: bold;
}
.range {
font-size: 24px;
font-weight: bold;
}
.data {
margin-top: 40px;
}
</style>
</head>
<body>
<h1>Detailed Report</h1>
<hr>
<div class="range">
<span>{{ $start->format('d.m.Y') }} - {{ $end->format('d.m.Y') }}</span><br><br>
</div>
<div class="properties">
<span>Duration: {{ $interval->format(CarbonInterval::seconds($aggregatedData['seconds'])) }}</span><br>
<span>Total cost: {{ Money::of(BigDecimal::ofUnscaledValue($aggregatedData['cost'], 2)->__toString(), $currency)->formatTo('en_US') }}</span><br>
</div>
<div class="data">
<table>
<thead>
<tr>
<th>Description</th>
<th>Task</th>
<th>Project</th>
<th>Client</th>
<th>User</th>
<th>Duration</th>
<th>Billable</th>
<th>Tags</th>
</tr>
</thead>
<tbody>
@foreach($timeEntries as $timeEntry)
<tr>
<td>{{ $timeEntry->description === '' ? '-' : $timeEntry->description }}</td>
<td>{{ $timeEntry->task?->name ?? '-' }}</td>
<td>{{ $timeEntry->project?->name ?? '-' }}</td>
<td>{{ $timeEntry->client?->name ?? '-' }}</td>
<td>{{ $timeEntry->user->name }}</td>
<td>
{{ $timeEntry->start->format('Y-m-d H:i:s') }} - {{ $timeEntry->end->format('Y-m-d H:i:s') }}
</td>
<td>{{ $timeEntry->billable ? 'Yes' : 'No' }}</td>
<td>{{ count($timeEntry->tagsRelation) === 0 ? '-' : $timeEntry->tagsRelation->implode('name', ', ') }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</body>
</html>

View File

@@ -2,12 +2,8 @@
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;
@@ -78,22 +74,4 @@ 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

@@ -526,6 +526,8 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
$response = $this->getJson(route('api.v1.time-entries.index-export', [
$data->organization->getKey(),
'format' => ExportFormat::CSV,
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
]));
// Assert
@@ -546,6 +548,8 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
$response = $this->getJson(route('api.v1.time-entries.index-export', [
$data->organization->getKey(),
'format' => ExportFormat::PDF,
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
]));
// Assert
@@ -570,6 +574,8 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
$response = $this->getJson(route('api.v1.time-entries.index-export', [
$data->organization->getKey(),
'format' => ExportFormat::PDF,
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
]));
// Assert
@@ -593,6 +599,8 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
$response = $this->getJson(route('api.v1.time-entries.index-export', [
$data->organization->getKey(),
'format' => ExportFormat::CSV,
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
]));
// Assert
@@ -615,6 +623,8 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
$response = $this->getJson(route('api.v1.time-entries.index-export', [
$data->organization->getKey(),
'format' => ExportFormat::CSV,
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
]));
// Assert
@@ -637,6 +647,8 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
$response = $this->getJson(route('api.v1.time-entries.index-export', [
$data->organization->getKey(),
'format' => ExportFormat::ODS,
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
]));
// Assert
@@ -659,6 +671,8 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
$response = $this->getJson(route('api.v1.time-entries.index-export', [
$data->organization->getKey(),
'format' => ExportFormat::XLSX,
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
]));
// Assert
@@ -678,6 +692,8 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
$response = $this->getJson(route('api.v1.time-entries.index-export', [
$data->organization->getKey(),
'format' => ExportFormat::PDF,
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
]));
// Assert