improve aggregated pdf design

This commit is contained in:
Gregor Vostrak
2024-12-08 01:24:22 +01:00
committed by Constantin Graf
parent a4f3e014d9
commit 9b0cf37bc7
6 changed files with 318 additions and 127 deletions

View File

@@ -427,11 +427,12 @@ class TimeEntryController extends Controller
$request = Gotenberg::chromium(config('services.gotenberg.url')) $request = Gotenberg::chromium(config('services.gotenberg.url'))
->pdf() ->pdf()
->waitForExpression("window.status === 'ready'") ->waitForExpression("window.status === 'ready'")
->pdfa('PDF/A-3b')
->margins(0.39, 0.78, 0.39, 0.39) ->margins(0.39, 0.78, 0.39, 0.39)
->paperSize('8.27', '11.7') // A4 ->paperSize('8.27', '11.7') // A4
->footer(Stream::string('footer', $footerHtml)) ->footer(Stream::string('footer', $footerHtml))
->assets(Stream::path(resource_path('pdf-js/echarts.min.js'), 'echarts.min.js')) ->assets(Stream::path(resource_path('pdf/echarts.min.js'), 'echarts.min.js'),
Stream::path(resource_path('pdf/Outfit-VariableFont_wght.ttf'), 'outfit.ttf'),
)
->html(Stream::string('body', $html)); ->html(Stream::string('body', $html));
$tempFolder = TemporaryDirectory::make(); $tempFolder = TemporaryDirectory::make();
$filenameTemp = Gotenberg::save($request, $tempFolder->path(), $client); $filenameTemp = Gotenberg::save($request, $tempFolder->path(), $client);

View File

@@ -33,8 +33,12 @@ class ColorService
private const string VALID_REGEX = '/^#[0-9a-f]{6}$/'; private const string VALID_REGEX = '/^#[0-9a-f]{6}$/';
public function getRandomColor(): string public function getRandomColor(string $seed = null): string
{ {
if ($seed !== null) {
srand(crc32($seed));
}
return self::COLORS[array_rand(self::COLORS)]; return self::COLORS[array_rand(self::COLORS)];
} }

View File

@@ -3203,6 +3203,11 @@ If the group parameters are all set to `null` or are all missing, the
type: 'Query', type: 'Query',
schema: z.enum(['true', 'false']).optional(), schema: z.enum(['true', 'false']).optional(),
}, },
{
name: 'debug',
type: 'Query',
schema: z.enum(['true', 'false']).optional(),
},
{ {
name: 'member_ids', name: 'member_ids',
type: 'Query', type: 'Query',
@@ -3229,7 +3234,12 @@ If the group parameters are all set to `null` or are all missing, the
schema: z.array(z.string()).min(1).optional(), schema: z.array(z.string()).min(1).optional(),
}, },
], ],
response: z.object({ download_url: z.string() }).passthrough(), response: z.union([
z.object({ download_url: z.string() }).passthrough(),
z
.object({ html: z.string(), footer_html: z.string() })
.passthrough(),
]),
errors: [ errors: [
{ {
status: 400, status: 400,
@@ -3320,6 +3330,11 @@ If the group parameters are all set to `null` or are all missing, the
type: 'Query', type: 'Query',
schema: z.enum(['true', 'false']).optional(), schema: z.enum(['true', 'false']).optional(),
}, },
{
name: 'debug',
type: 'Query',
schema: z.enum(['true', 'false']).optional(),
},
{ {
name: 'member_ids', name: 'member_ids',
type: 'Query', type: 'Query',
@@ -3341,7 +3356,12 @@ If the group parameters are all set to `null` or are all missing, the
schema: z.array(z.string().uuid()).min(1).optional(), schema: z.array(z.string().uuid()).min(1).optional(),
}, },
], ],
response: z.object({ download_url: z.string() }).passthrough(), response: z.union([
z.object({ download_url: z.string() }).passthrough(),
z
.object({ html: z.string(), footer_html: z.string() })
.passthrough(),
]),
errors: [ errors: [
{ {
status: 400, status: 400,

View File

@@ -7,30 +7,101 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"/> <meta charset="utf-8" />
<title>Report</title> <title>Report</title>
<style> <style>
html, body, div, span, applet, object, iframe,
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
a, abbr, acronym, address, big, cite, code,
del, dfn, em, img, ins, kbd, q, s, samp,
small, strike, strong, sub, sup, tt, var,
b, u, i, center,
dl, dt, dd, ol, ul, li,
fieldset, form, label, legend,
table, caption, tbody, tfoot, thead, tr, th, td,
article, aside, canvas, details, embed,
figure, figcaption, footer, header, hgroup,
menu, nav, output, ruby, section, summary,
time, mark, audio, video {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
vertical-align: baseline;
box-sizing: border-box;
}
/* HTML5 display-role reset for older browsers */
article, aside, details, figcaption, figure,
footer, header, hgroup, menu, nav, section {
display: block;
}
body { body {
font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; line-height: 1;
color: #555; }
ol, ul {
list-style: none;
}
blockquote, q {
quotes: none;
}
blockquote:before, blockquote:after,
q:before, q:after {
content: '';
content: none;
} }
table { table {
font-size: 10px; border-collapse: collapse;
border-spacing: 0;
text-align: left;
} }
table thead { @font-face {
background-color: #eee; font-family: 'Outfit';
src: url('outfit.ttf');
} }
h1 { body {
font-size: 35px; font-family: 'Outfit', 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif;
font-weight: bold; color: #18181b
} }
h2 { thead {
font-size: 20px; border-bottom: 1px #d4d4d8 solid;
font-weight: bold; }
tfoot {
border-top: 1px #d4d4d8 solid;
}
table th, table tfoot td {
font-weight: 500;
padding: 6px 12px;
color: #18181b;
}
.table-wrapper table th {
background-color: #fafafa;
}
table tr {
border-bottom: 1px #e4e4e7 solid
}
table tr:last-of-type {
border-bottom: none;
}
table tr td {
font-weight: 400;
color: #3f3f46;
padding: 6px 12px;
} }
.range { .range {
@@ -38,115 +109,202 @@
font-weight: bold; font-weight: bold;
} }
.table-wrapper {
border: 1px solid #d4d4d8;
border-radius: 8px;
overflow: hidden;
width: calc(100% - 2px)
}
.data-table { .data-table {
break-after: auto; break-after: auto;
} }
.no-break { .no-break {
break-after: avoid-page; break-after: avoid-page;
} }
</style> </style>
<script> <script>
window.status = 'processing'; window.status = "processing";
</script> </script>
<script src="{{ $debug ? 'https://cdn.jsdelivr.net/npm/echarts@5.5.1/dist/echarts.min.js' : 'echarts.min.js' }}"></script> <script
src="{{ $debug ? 'https://cdn.jsdelivr.net/npm/echarts@5.5.1/dist/echarts.min.js' : 'echarts.min.js' }}"></script>
@if($debug)
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=outfit:200,300,400,500,600,700,800" rel="stylesheet" />
@endif
</head> </head>
<body> <body>
<div>
<p style="font-size: 32px; font-weight: 600; margin-bottom: 5px;">Report</p>
<div style="font-size: 16px; font-weight: 600; color: #71717a;">
<span>{{ $start->format('d.m.Y') }} - {{ $end->format('d.m.Y') }}</span><br><br>
</div>
<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>
<div id="main-chart" style="width: 100%; height:400px;"></div> <div class="table-wrapper">
<div id="pie-chart" style="width: 100%; height: 150px; margin-bottom: 50px;"></div> <div
style="background-color: #fafafa; padding: 5px 14px; border-bottom: 1px #d4d4d8 solid; display: flex; gap: 20px;">
<div style="padding: 8px 12px; border-radius: 8px;">
<div style="color: #71717a; font-weight: 600;">Duration</div>
<div
style="font-size: 24px; font-weight: 500; margin-top: 2px;">{{ $interval->format(CarbonInterval::seconds($aggregatedData['seconds'])) }} </div>
</div>
<div style="padding: 8px 12px; border-radius: 8px;">
<div style="color: #71717a; font-weight: 600;">Total cost</div>
<div
style="font-size: 24px; font-weight: 500; margin-top: 2px;">{{ Money::of(BigDecimal::ofUnscaledValue($aggregatedData['cost'], 2)->__toString(), $currency)->formatTo('en_US') }} </div>
</div>
</div>
<div id="main-chart" style="width: 700px; height: 300px; margin: 20px auto;"></div>
</div>
<div style="display: flex; align-items: center; padding-top: 40px;">
<div style="padding: 10px 0;">
<div id="pie-chart" style="width: 300px; height: 180px; margin-bottom: 20px;"></div>
</div>
<div style="flex: 1 1 0%;">
<div class="">
<table style="width: 100%; ">
<thead>
<tr>
<th>
{{ $group->description() }}
</th>
<th>Duration</th>
<th style="text-align: right;">Cost</th>
</tr>
</thead>
@foreach($aggregatedData['grouped_data'] as $group1Entry)
<tr>
<td style="display: flex; align-items: center;">
<div style="width: 12px; height: 12px; border-radius: 50%; background-color: {{
$group1Entry['color'] ?? $group1Entry['key'] ? $colorService->getRandomColor($group1Entry['key']) : '#CCCCCC'
}};">
</div>
<span style="padding-left: 8px;">
{{ $group1Entry['description'] ?? $group1Entry['key'] ?? 'No '.Str::lower($group->description()) }}
</span>
</td>
<td style="text-align: left;">
{{ $interval->format(CarbonInterval::seconds($group1Entry['seconds'])) }}
</td>
<td style="text-align: right;">
{{ Money::of(BigDecimal::ofUnscaledValue($group1Entry['cost'], 2)->__toString(), $currency)->formatTo('en_US') }}
</td>
</tr>
@endforeach
<tfoot>
<tr>
<td style="font-weight: 500;color: #18181b;">
Total
</td>
<td style="font-weight: 500;color: #18181b;">
{{ $interval->format(CarbonInterval::seconds($aggregatedData['seconds'])) }}
</td>
<td style="text-align: right; font-weight: 500;color: #18181b;">
{{ Money::of(BigDecimal::ofUnscaledValue($aggregatedData['cost'], 2)->__toString(), $currency)->formatTo('en_US') }}
</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
@foreach($aggregatedData['grouped_data'] as $group1Entry) @foreach($aggregatedData['grouped_data'] as $group1Entry)
<div class="data-table"> <div class="data-table">
<h2 class="no-break">{{ $group1Entry['description'] ?? $group1Entry['key'] ?? 'No '.Str::lower($group->description()) }}</h2> <h2 class="no-break"
style="padding-top: 16px; padding-bottom: 8px; font-size: 20px; font-weight: 600; padding-left: 6px; color: #3f3f46;">
@if($group1Entry['key'])
<span style="color: #a1a1aa;">
{{ $group->description() }}:
</span>
@endif
{{ $group1Entry['description'] ?? $group1Entry['key'] ?? 'No '.Str::lower($group->description()) }}
</h2>
<table> <div class="table-wrapper">
<thead>
<tr>
<th> <table style="width: 100%;">
{{ $subGroup->description() }} <thead>
</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> <tr>
<td style="text-align: left;"> <th>
{{ $group2Entry['description'] ?? $group2Entry['key'] ?? '-' }} {{ $subGroup->description() }}
</td> </th>
<td style="text-align: right;"> <th>
{{ $interval->format($duration) }} Duration
</td> </th>
<td style="text-align: right;"> <th>
{{ round($duration->totalHours, 2) }} Duration (h)
</td> </th>
<td style="text-align: right;"> <th>
{{ Money::of(BigDecimal::ofUnscaledValue($group2Entry['cost'], 2)->__toString(), $currency)->formatTo('en_US') }} Cost
</td> </th>
</tr> </tr>
</thead>
<tbody>
@php @php
$totalDuration += $group2Entry['seconds']; $counter = 1;
$totalCost += $group2Entry['cost']; $totalDuration = 0;
$totalCost = 0;
@endphp @endphp
@endforeach @foreach($group1Entry['grouped_data'] as $group2Entry)
</tbody> @php
</table> $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>
{{ 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>
</div>
</div> </div>
@endforeach @endforeach
<script> <script>
let elementPieChart = document.getElementById('pie-chart'); let elementPieChart = document.getElementById("pie-chart");
let pieChart = echarts.init(elementPieChart, null, { let pieChart = echarts.init(elementPieChart, null, {
renderer: 'svg' renderer: "svg"
}); });
let pieChartOptions = { let pieChartOptions = {
legend: { animation: false,
left: '25%', backgroundColor: "transparent",
align: 'left',
top: 'middle',
orient: 'vertical',
},
backgroundColor: 'transparent',
series: [ series: [
{ {
label: {
show: false,
},
data: {!! json_encode(collect($aggregatedData['grouped_data'])->map(function (array $data) use (&$colorService, $group): object { data: {!! json_encode(collect($aggregatedData['grouped_data'])->map(function (array $data) use (&$colorService, $group): object {
$color = $data['color']; $color = $data['color'];
if ($color === null) { if ($color === null) {
$color = $colorService->getRandomColor(); $color = $colorService->getRandomColor($data['key']);
} }
if ($data['key'] === null) { if ($data['key'] === null) {
$color = '#CCCCCC'; $color = '#CCCCCC';
@@ -156,7 +314,7 @@
'name' => $data['description'] ?? $data['key'] ?? 'No '.Str::lower($group->description()), 'name' => $data['description'] ?? $data['key'] ?? 'No '.Str::lower($group->description()),
'color' => $color, 'color' => $color,
'itemStyle' => (object) [ 'itemStyle' => (object) [
'color' => $color.'BB', 'color' => $color,
], ],
'emphasis' => (object) [ 'emphasis' => (object) [
'itemStyle' => (object) [ 'itemStyle' => (object) [
@@ -165,110 +323,118 @@
], ],
]; ];
})->toArray()) !!}, })->toArray()) !!},
center: ['10%', '50%'], radius: ["40%", "80%"],
radius: ['30%', '60%'], type: "pie",
left: 'left', label: {
type: 'pie', formatter: "{d}%",
}, overflow: "truncate"
], }
}
]
}; };
pieChart.on('finished', () => { pieChart.on("finished", () => {
window.pieChartFinished = true; window.pieChartFinished = true;
if (window.mainChartFinished && window.pieChartFinished) { if (window.mainChartFinished && window.pieChartFinished) {
window.status = 'ready'; window.status = "ready";
} }
}) });
pieChart.setOption(pieChartOptions); pieChart.setOption(pieChartOptions);
let elementMainChart = document.getElementById('main-chart'); let elementMainChart = document.getElementById("main-chart");
let mainChart = echarts.init(elementMainChart, null, { let mainChart = echarts.init(elementMainChart, null, {
renderer: 'svg' renderer: "svg"
}); });
let mainChartOptions = { let mainChartOptions = {
animation: false,
tooltip: {}, tooltip: {},
xAxis: { xAxis: {
data: ['{!! collect($dataHistoryChart['grouped_data'])->pluck('key')->implode("', '") !!}'], data: ['{!! collect($dataHistoryChart['grouped_data'])->pluck('key')->implode("', '") !!}'],
axisLabel: { axisLabel: {
fontSize: 12, fontSize: 10,
fontWeight: 600, fontWeight: 400,
color: 'rgb(120, 120, 120)', color: "rgb(120, 120, 120)",
margin: 16, margin: 16,
fontFamily: 'Outfit, sans-serif', fontFamily: "Outfit, sans-serif"
}, },
axisTick: { axisTick: {
interval: 0, interval: 0,
alignWithLabel: true, alignWithLabel: true
}, }
}, },
grid: { grid: {
containLabel: true containLabel: true,
left: 15,
top: 0,
right: 15,
bottom: 0
}, },
yAxis: { yAxis: {
minInterval: 1, minInterval: 1,
axisLabel: { axisLabel: {
show: false, show: false,
inside: true, inside: true,
formatter: function (value, index) { formatter: function(value, index) {
let totalSeconds = value; let totalSeconds = value;
let hours = Math.floor(totalSeconds / 3600); let hours = Math.floor(totalSeconds / 3600);
if (hours < 10) { if (hours < 10) {
hours = '0' + hours; hours = "0" + hours;
} }
totalSeconds %= 3600; totalSeconds %= 3600;
let minutes = Math.floor(totalSeconds / 60); let minutes = Math.floor(totalSeconds / 60);
if (minutes < 10) { if (minutes < 10) {
minutes = '0' + minutes; minutes = "0" + minutes;
} }
let seconds = totalSeconds % 60; let seconds = totalSeconds % 60;
if (seconds < 10) { if (seconds < 10) {
seconds = '0' + seconds; seconds = "0" + seconds;
} }
return hours + ':' + minutes + ':' + seconds; return hours + ":" + minutes + ":" + seconds;
} }
} }
}, },
series: [ series: [
{ {
name: 'time', name: "time",
type: 'bar', type: "bar",
data: [{!! collect($dataHistoryChart['grouped_data'])->pluck('seconds')->implode(', ') !!}], data: [{!! collect($dataHistoryChart['grouped_data'])->pluck('seconds')->implode(', ') !!}],
itemStyle: { itemStyle: {
borderColor: '#5470c6', borderColor: "#7dd3fc",
color: "#7dd3fc"
}, },
label: { label: {
show: true, show: true,
position: 'top', position: "top",
formatter: function (params) { formatter: function(params) {
let value = params.value; let value = params.value;
if (value === 0) { if (value === 0) {
return ''; return "";
} }
let totalSeconds = value; let totalSeconds = value;
let hours = Math.floor(totalSeconds / 3600); let hours = Math.floor(totalSeconds / 3600);
if (hours < 10) { if (hours < 10) {
hours = '0' + hours; hours = "0" + hours;
} }
totalSeconds %= 3600; totalSeconds %= 3600;
let minutes = Math.floor(totalSeconds / 60); let minutes = Math.floor(totalSeconds / 60);
if (minutes < 10) { if (minutes < 10) {
minutes = '0' + minutes; minutes = "0" + minutes;
} }
let seconds = totalSeconds % 60; let seconds = totalSeconds % 60;
if (seconds < 10) { if (seconds < 10) {
seconds = '0' + seconds; seconds = "0" + seconds;
} }
return hours + ':' + minutes + ':' + seconds; return hours + ":" + minutes + ":" + seconds;
} }
} }
} }
] ]
}; };
mainChart.on('finished', () => { mainChart.on("finished", () => {
window.mainChartFinished = true; window.mainChartFinished = true;
if (window.mainChartFinished && window.pieChartFinished) { if (window.mainChartFinished && window.pieChartFinished) {
window.status = 'ready'; window.status = "ready";
} }
}) });
mainChart.setOption(mainChartOptions); mainChart.setOption(mainChartOptions);
</script> </script>
</body> </body>