mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 00:02:15 +01:00
Updated PDF reports
This commit is contained in:
committed by
Constantin Graf
parent
0860aa9d24
commit
a4d8a02b80
@@ -164,7 +164,7 @@ class TimeEntryController extends Controller
|
|||||||
*
|
*
|
||||||
* @operationId exportTimeEntries
|
* @operationId exportTimeEntries
|
||||||
*/
|
*/
|
||||||
public function indexExport(Organization $organization, TimeEntryIndexExportRequest $request): JsonResponse
|
public function indexExport(Organization $organization, TimeEntryIndexExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
|
||||||
{
|
{
|
||||||
/** @var Member|null $member */
|
/** @var Member|null $member */
|
||||||
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
$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) {
|
if (config('services.gotenberg.url') === null) {
|
||||||
throw new PdfRendererIsNotConfiguredException;
|
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) {
|
if ($viewFile === false) {
|
||||||
throw new \LogicException('View file not found');
|
throw new \LogicException('View file not found');
|
||||||
}
|
}
|
||||||
$html = Blade::render($viewFile, ['timeEntries' => $timeEntriesQuery->get()]);
|
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
|
||||||
$footerViewFile = file_get_contents(resource_path('views/reports/time-entry-index-footer.blade.php'));
|
$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) {
|
if ($footerViewFile === false) {
|
||||||
throw new \LogicException('View file not found');
|
throw new \LogicException('View file not found');
|
||||||
}
|
}
|
||||||
@@ -372,7 +389,7 @@ class TimeEntryController extends Controller
|
|||||||
config('services.gotenberg.basic_auth_password'),
|
config('services.gotenberg.basic_auth_password'),
|
||||||
] : null,
|
] : 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) {
|
if ($viewFile === false) {
|
||||||
throw new \LogicException('View file not found');
|
throw new \LogicException('View file not found');
|
||||||
}
|
}
|
||||||
@@ -385,7 +402,7 @@ class TimeEntryController extends Controller
|
|||||||
'start' => $request->getStart()->timezone($timezone),
|
'start' => $request->getStart()->timezone($timezone),
|
||||||
'end' => $request->getEnd()->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) {
|
if ($footerViewFile === false) {
|
||||||
throw new \LogicException('View file not found');
|
throw new \LogicException('View file not found');
|
||||||
}
|
}
|
||||||
@@ -395,6 +412,7 @@ class TimeEntryController extends Controller
|
|||||||
->pdfa('PDF/A-3b')
|
->pdfa('PDF/A-3b')
|
||||||
->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'))
|
||||||
->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);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use App\Models\Tag;
|
|||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
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)
|
// Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
|
||||||
'start' => [
|
'start' => [
|
||||||
'nullable',
|
'required',
|
||||||
'string',
|
'string',
|
||||||
'date_format:Y-m-d\TH:i:s\Z',
|
'date_format:Y-m-d\TH:i:s\Z',
|
||||||
'before:end',
|
'before:end',
|
||||||
],
|
],
|
||||||
// Filter only time entries that have a start date before the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
|
// Filter only time entries that have a start date before the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
|
||||||
'end' => [
|
'end' => [
|
||||||
'nullable',
|
'required',
|
||||||
'string',
|
'string',
|
||||||
'date_format:Y-m-d\TH:i:s\Z',
|
'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
|
public function getOnlyFullDates(): bool
|
||||||
{
|
{
|
||||||
return $this->input('only_full_dates', 'false') === 'true';
|
return $this->input('only_full_dates', 'false') === 'true';
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
|
|||||||
|
|
||||||
public function view(): View
|
public function view(): View
|
||||||
{
|
{
|
||||||
return view('reports.time-entry-aggregate-index-excel', [
|
return view('reports.time-entry-aggregate.spreadsheet', [
|
||||||
'data' => $this->data,
|
'data' => $this->data,
|
||||||
'currency' => $this->currency,
|
'currency' => $this->currency,
|
||||||
'group' => $this->group,
|
'group' => $this->group,
|
||||||
|
|||||||
45
resources/pdf-js/echarts.min.js
vendored
Normal file
45
resources/pdf-js/echarts.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -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>
|
|
||||||
@@ -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>
|
||||||
197
resources/views/reports/time-entry-aggregate/pdf.blade.php
Normal file
197
resources/views/reports/time-entry-aggregate/pdf.blade.php
Normal 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>
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
92
resources/views/reports/time-entry-index/pdf.blade.php
Normal file
92
resources/views/reports/time-entry-index/pdf.blade.php
Normal 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>
|
||||||
@@ -2,12 +2,8 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
|
|
||||||
use App\Http\Controllers\Web\DashboardController;
|
use App\Http\Controllers\Web\DashboardController;
|
||||||
use App\Http\Controllers\Web\HomeController;
|
use App\Http\Controllers\Web\HomeController;
|
||||||
use Gotenberg\Gotenberg;
|
|
||||||
use Gotenberg\Stream;
|
|
||||||
use Illuminate\Support\Facades\Blade;
|
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Laravel\Jetstream\Jetstream;
|
use Laravel\Jetstream\Jetstream;
|
||||||
@@ -78,22 +74,4 @@ Route::middleware([
|
|||||||
return Inertia::render('Import');
|
return Inertia::render('Import');
|
||||||
})->name('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);
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -526,6 +526,8 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$response = $this->getJson(route('api.v1.time-entries.index-export', [
|
$response = $this->getJson(route('api.v1.time-entries.index-export', [
|
||||||
$data->organization->getKey(),
|
$data->organization->getKey(),
|
||||||
'format' => ExportFormat::CSV,
|
'format' => ExportFormat::CSV,
|
||||||
|
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
|
||||||
|
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
|
||||||
]));
|
]));
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -546,6 +548,8 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$response = $this->getJson(route('api.v1.time-entries.index-export', [
|
$response = $this->getJson(route('api.v1.time-entries.index-export', [
|
||||||
$data->organization->getKey(),
|
$data->organization->getKey(),
|
||||||
'format' => ExportFormat::PDF,
|
'format' => ExportFormat::PDF,
|
||||||
|
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
|
||||||
|
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
|
||||||
]));
|
]));
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -570,6 +574,8 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$response = $this->getJson(route('api.v1.time-entries.index-export', [
|
$response = $this->getJson(route('api.v1.time-entries.index-export', [
|
||||||
$data->organization->getKey(),
|
$data->organization->getKey(),
|
||||||
'format' => ExportFormat::PDF,
|
'format' => ExportFormat::PDF,
|
||||||
|
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
|
||||||
|
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
|
||||||
]));
|
]));
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -593,6 +599,8 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$response = $this->getJson(route('api.v1.time-entries.index-export', [
|
$response = $this->getJson(route('api.v1.time-entries.index-export', [
|
||||||
$data->organization->getKey(),
|
$data->organization->getKey(),
|
||||||
'format' => ExportFormat::CSV,
|
'format' => ExportFormat::CSV,
|
||||||
|
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
|
||||||
|
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
|
||||||
]));
|
]));
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -615,6 +623,8 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$response = $this->getJson(route('api.v1.time-entries.index-export', [
|
$response = $this->getJson(route('api.v1.time-entries.index-export', [
|
||||||
$data->organization->getKey(),
|
$data->organization->getKey(),
|
||||||
'format' => ExportFormat::CSV,
|
'format' => ExportFormat::CSV,
|
||||||
|
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
|
||||||
|
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
|
||||||
]));
|
]));
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -637,6 +647,8 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$response = $this->getJson(route('api.v1.time-entries.index-export', [
|
$response = $this->getJson(route('api.v1.time-entries.index-export', [
|
||||||
$data->organization->getKey(),
|
$data->organization->getKey(),
|
||||||
'format' => ExportFormat::ODS,
|
'format' => ExportFormat::ODS,
|
||||||
|
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
|
||||||
|
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
|
||||||
]));
|
]));
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -659,6 +671,8 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$response = $this->getJson(route('api.v1.time-entries.index-export', [
|
$response = $this->getJson(route('api.v1.time-entries.index-export', [
|
||||||
$data->organization->getKey(),
|
$data->organization->getKey(),
|
||||||
'format' => ExportFormat::XLSX,
|
'format' => ExportFormat::XLSX,
|
||||||
|
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
|
||||||
|
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
|
||||||
]));
|
]));
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -678,6 +692,8 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$response = $this->getJson(route('api.v1.time-entries.index-export', [
|
$response = $this->getJson(route('api.v1.time-entries.index-export', [
|
||||||
$data->organization->getKey(),
|
$data->organization->getKey(),
|
||||||
'format' => ExportFormat::PDF,
|
'format' => ExportFormat::PDF,
|
||||||
|
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
|
||||||
|
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
|
||||||
]));
|
]));
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
|
|||||||
Reference in New Issue
Block a user