mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 00:02:15 +01:00
improve aggregated pdf design
This commit is contained in:
committed by
Constantin Graf
parent
a4f3e014d9
commit
9b0cf37bc7
@@ -427,11 +427,12 @@ class TimeEntryController extends Controller
|
||||
$request = Gotenberg::chromium(config('services.gotenberg.url'))
|
||||
->pdf()
|
||||
->waitForExpression("window.status === 'ready'")
|
||||
->pdfa('PDF/A-3b')
|
||||
->margins(0.39, 0.78, 0.39, 0.39)
|
||||
->paperSize('8.27', '11.7') // A4
|
||||
->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));
|
||||
$tempFolder = TemporaryDirectory::make();
|
||||
$filenameTemp = Gotenberg::save($request, $tempFolder->path(), $client);
|
||||
|
||||
@@ -33,8 +33,12 @@ class ColorService
|
||||
|
||||
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)];
|
||||
}
|
||||
|
||||
|
||||
@@ -3203,6 +3203,11 @@ If the group parameters are all set to `null` or are all missing, the
|
||||
type: 'Query',
|
||||
schema: z.enum(['true', 'false']).optional(),
|
||||
},
|
||||
{
|
||||
name: 'debug',
|
||||
type: 'Query',
|
||||
schema: z.enum(['true', 'false']).optional(),
|
||||
},
|
||||
{
|
||||
name: 'member_ids',
|
||||
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(),
|
||||
},
|
||||
],
|
||||
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: [
|
||||
{
|
||||
status: 400,
|
||||
@@ -3320,6 +3330,11 @@ If the group parameters are all set to `null` or are all missing, the
|
||||
type: 'Query',
|
||||
schema: z.enum(['true', 'false']).optional(),
|
||||
},
|
||||
{
|
||||
name: 'debug',
|
||||
type: 'Query',
|
||||
schema: z.enum(['true', 'false']).optional(),
|
||||
},
|
||||
{
|
||||
name: 'member_ids',
|
||||
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(),
|
||||
},
|
||||
],
|
||||
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: [
|
||||
{
|
||||
status: 400,
|
||||
|
||||
@@ -10,27 +10,98 @@
|
||||
<meta charset="utf-8" />
|
||||
<title>Report</title>
|
||||
<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 {
|
||||
font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif;
|
||||
color: #555;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
ol, ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
blockquote, q {
|
||||
quotes: none;
|
||||
}
|
||||
|
||||
blockquote:before, blockquote:after,
|
||||
q:before, q:after {
|
||||
content: '';
|
||||
content: none;
|
||||
}
|
||||
|
||||
table {
|
||||
font-size: 10px;
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
table thead {
|
||||
background-color: #eee;
|
||||
@font-face {
|
||||
font-family: 'Outfit';
|
||||
src: url('outfit.ttf');
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 35px;
|
||||
font-weight: bold;
|
||||
body {
|
||||
font-family: 'Outfit', 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif;
|
||||
color: #18181b
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
thead {
|
||||
border-bottom: 1px #d4d4d8 solid;
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -38,43 +109,135 @@
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.table-wrapper {
|
||||
border: 1px solid #d4d4d8;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
width: calc(100% - 2px)
|
||||
}
|
||||
|
||||
.data-table {
|
||||
break-after: auto;
|
||||
}
|
||||
|
||||
.no-break {
|
||||
break-after: avoid-page;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
window.status = 'processing';
|
||||
window.status = "processing";
|
||||
</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>
|
||||
<body>
|
||||
|
||||
<h1>Report</h1>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="range">
|
||||
<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>
|
||||
|
||||
<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;"></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)
|
||||
<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">
|
||||
|
||||
|
||||
<table style="width: 100%;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
@@ -84,7 +247,7 @@
|
||||
Duration
|
||||
</th>
|
||||
<th>
|
||||
Duration (decimal)
|
||||
Duration (h)
|
||||
</th>
|
||||
<th>
|
||||
Cost
|
||||
@@ -102,16 +265,16 @@
|
||||
$duration = CarbonInterval::seconds($group2Entry['seconds']);
|
||||
@endphp
|
||||
<tr>
|
||||
<td style="text-align: left;">
|
||||
<td>
|
||||
{{ $group2Entry['description'] ?? $group2Entry['key'] ?? '-' }}
|
||||
</td>
|
||||
<td style="text-align: right;">
|
||||
<td>
|
||||
{{ $interval->format($duration) }}
|
||||
</td>
|
||||
<td style="text-align: right;">
|
||||
<td>
|
||||
{{ round($duration->totalHours, 2) }}
|
||||
</td>
|
||||
<td style="text-align: right;">
|
||||
<td>
|
||||
{{ Money::of(BigDecimal::ofUnscaledValue($group2Entry['cost'], 2)->__toString(), $currency)->formatTo('en_US') }}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -123,30 +286,25 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
<script>
|
||||
let elementPieChart = document.getElementById('pie-chart');
|
||||
let elementPieChart = document.getElementById("pie-chart");
|
||||
let pieChart = echarts.init(elementPieChart, null, {
|
||||
renderer: 'svg'
|
||||
renderer: "svg"
|
||||
});
|
||||
let pieChartOptions = {
|
||||
legend: {
|
||||
left: '25%',
|
||||
align: 'left',
|
||||
top: 'middle',
|
||||
orient: 'vertical',
|
||||
},
|
||||
backgroundColor: 'transparent',
|
||||
animation: false,
|
||||
backgroundColor: "transparent",
|
||||
|
||||
series: [
|
||||
{
|
||||
label: {
|
||||
show: false,
|
||||
},
|
||||
data: {!! json_encode(collect($aggregatedData['grouped_data'])->map(function (array $data) use (&$colorService, $group): object {
|
||||
$color = $data['color'];
|
||||
if ($color === null) {
|
||||
$color = $colorService->getRandomColor();
|
||||
$color = $colorService->getRandomColor($data['key']);
|
||||
}
|
||||
if ($data['key'] === null) {
|
||||
$color = '#CCCCCC';
|
||||
@@ -156,7 +314,7 @@
|
||||
'name' => $data['description'] ?? $data['key'] ?? 'No '.Str::lower($group->description()),
|
||||
'color' => $color,
|
||||
'itemStyle' => (object) [
|
||||
'color' => $color.'BB',
|
||||
'color' => $color,
|
||||
],
|
||||
'emphasis' => (object) [
|
||||
'itemStyle' => (object) [
|
||||
@@ -165,43 +323,50 @@
|
||||
],
|
||||
];
|
||||
})->toArray()) !!},
|
||||
center: ['10%', '50%'],
|
||||
radius: ['30%', '60%'],
|
||||
left: 'left',
|
||||
type: 'pie',
|
||||
},
|
||||
],
|
||||
radius: ["40%", "80%"],
|
||||
type: "pie",
|
||||
label: {
|
||||
formatter: "{d}%",
|
||||
overflow: "truncate"
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
pieChart.on('finished', () => {
|
||||
pieChart.on("finished", () => {
|
||||
window.pieChartFinished = true;
|
||||
if (window.mainChartFinished && window.pieChartFinished) {
|
||||
window.status = 'ready';
|
||||
window.status = "ready";
|
||||
}
|
||||
})
|
||||
});
|
||||
pieChart.setOption(pieChartOptions);
|
||||
|
||||
let elementMainChart = document.getElementById('main-chart');
|
||||
let elementMainChart = document.getElementById("main-chart");
|
||||
let mainChart = echarts.init(elementMainChart, null, {
|
||||
renderer: 'svg'
|
||||
renderer: "svg"
|
||||
});
|
||||
let mainChartOptions = {
|
||||
animation: false,
|
||||
tooltip: {},
|
||||
xAxis: {
|
||||
data: ['{!! collect($dataHistoryChart['grouped_data'])->pluck('key')->implode("', '") !!}'],
|
||||
axisLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: 'rgb(120, 120, 120)',
|
||||
fontSize: 10,
|
||||
fontWeight: 400,
|
||||
color: "rgb(120, 120, 120)",
|
||||
margin: 16,
|
||||
fontFamily: 'Outfit, sans-serif',
|
||||
fontFamily: "Outfit, sans-serif"
|
||||
},
|
||||
axisTick: {
|
||||
interval: 0,
|
||||
alignWithLabel: true,
|
||||
},
|
||||
alignWithLabel: true
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
containLabel: true
|
||||
containLabel: true,
|
||||
left: 15,
|
||||
top: 0,
|
||||
right: 15,
|
||||
bottom: 0
|
||||
},
|
||||
yAxis: {
|
||||
minInterval: 1,
|
||||
@@ -212,63 +377,64 @@
|
||||
let totalSeconds = value;
|
||||
let hours = Math.floor(totalSeconds / 3600);
|
||||
if (hours < 10) {
|
||||
hours = '0' + hours;
|
||||
hours = "0" + hours;
|
||||
}
|
||||
totalSeconds %= 3600;
|
||||
let minutes = Math.floor(totalSeconds / 60);
|
||||
if (minutes < 10) {
|
||||
minutes = '0' + minutes;
|
||||
minutes = "0" + minutes;
|
||||
}
|
||||
let seconds = totalSeconds % 60;
|
||||
if (seconds < 10) {
|
||||
seconds = '0' + seconds;
|
||||
seconds = "0" + seconds;
|
||||
}
|
||||
return hours + ':' + minutes + ':' + seconds;
|
||||
return hours + ":" + minutes + ":" + seconds;
|
||||
}
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: 'time',
|
||||
type: 'bar',
|
||||
name: "time",
|
||||
type: "bar",
|
||||
data: [{!! collect($dataHistoryChart['grouped_data'])->pluck('seconds')->implode(', ') !!}],
|
||||
itemStyle: {
|
||||
borderColor: '#5470c6',
|
||||
borderColor: "#7dd3fc",
|
||||
color: "#7dd3fc"
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: 'top',
|
||||
position: "top",
|
||||
formatter: function(params) {
|
||||
let value = params.value;
|
||||
if (value === 0) {
|
||||
return '';
|
||||
return "";
|
||||
}
|
||||
let totalSeconds = value;
|
||||
let hours = Math.floor(totalSeconds / 3600);
|
||||
if (hours < 10) {
|
||||
hours = '0' + hours;
|
||||
hours = "0" + hours;
|
||||
}
|
||||
totalSeconds %= 3600;
|
||||
let minutes = Math.floor(totalSeconds / 60);
|
||||
if (minutes < 10) {
|
||||
minutes = '0' + minutes;
|
||||
minutes = "0" + minutes;
|
||||
}
|
||||
let seconds = totalSeconds % 60;
|
||||
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;
|
||||
if (window.mainChartFinished && window.pieChartFinished) {
|
||||
window.status = 'ready';
|
||||
window.status = "ready";
|
||||
}
|
||||
})
|
||||
});
|
||||
mainChart.setOption(mainChartOptions);
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user