Add pdf detailed report and placeholder for aggregate endpoint

This commit is contained in:
Constantin Graf
2024-10-23 13:21:20 +02:00
committed by Constantin Graf
parent 5593d141ea
commit b0bcc4f330
16 changed files with 685 additions and 69 deletions

View File

@@ -31,6 +31,8 @@ QUEUE_CONNECTION=sync
SESSION_DRIVER=database
SESSION_LIFETIME=120
GOTENBERG_URL=http://gotenberg:3000
MEMCACHED_HOST=127.0.0.1
REDIS_HOST=127.0.0.1

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Api;
class PdfRendererIsNotConfiguredException extends ApiException
{
public const string KEY = 'pdf_renderer_is_not_configured';
}

View File

@@ -5,8 +5,10 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Enums\ExportFormat;
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
use App\Exceptions\Api\TimeEntryStillRunningApiException;
use App\Http\Requests\V1\TimeEntry\TimeEntryAggregateExportRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryAggregateRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryDestroyMultipleRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryIndexExportRequest;
@@ -28,15 +30,20 @@ use App\Service\ReportExport\TimeEntriesDetailedExport;
use App\Service\TimeEntryAggregationService;
use App\Service\TimeEntryFilter;
use App\Service\TimezoneService;
use Gotenberg\Gotenberg;
use Gotenberg\Stream;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\File;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Maatwebsite\Excel\Facades\Excel;
use Spatie\TemporaryDirectory\TemporaryDirectory;
class TimeEntryController extends Controller
{
@@ -49,7 +56,7 @@ class TimeEntryController extends Controller
}
/**
* Get all time entries in organization
* Get time entries in organization
*
* If you only need time entries for a specific user, you can filter by `user_id`.
* Users with the permission `time-entries:view:own` can only use this endpoint with their own user ID in the user_id filter.
@@ -146,7 +153,9 @@ class TimeEntryController extends Controller
}
/**
* @throws AuthorizationException
* Export time entries in organization
*
* @throws AuthorizationException|PdfRendererIsNotConfiguredException
*
* @operationId exportTimeEntries
*/
@@ -163,21 +172,40 @@ class TimeEntryController extends Controller
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member);
$timeEntriesQuery->with([
'task',
'project' => [
'client',
],
'client',
'project',
'user',
'tagsRelation',
]);
$format = $request->getFormatValue();
//$format = ExportFormat::PDF;
$filename = 'time-entries-export-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension();
$path = 'exports/'.$filename;
$folderPath = 'exports';
$path = $folderPath.'/'.$filename;
if ($format === ExportFormat::CSV) {
$export = new TimeEntriesDetailedCsvExport(config('filesystems.private'), $filename, $timeEntriesQuery, 1000);
$export = new TimeEntriesDetailedCsvExport(config('filesystems.private'), $folderPath, $filename, $timeEntriesQuery, 1000);
$export->export();
} elseif ($format === ExportFormat::PDF) {
if (config('services.gotenberg.url') === null) {
throw new PdfRendererIsNotConfiguredException;
}
$viewFile = file_get_contents(resource_path('views/reports/time-entry-index.blade.php'));
$html = Blade::render($viewFile, ['timeEntries' => $timeEntriesQuery->get()]);
$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));
$tempFolder = TemporaryDirectory::make();
$filenameTemp = Gotenberg::save($request, $tempFolder->path());
Storage::disk(config('filesystems.private'))
->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename);
} else {
Excel::store(
new TimeEntriesDetailedExport($timeEntriesQuery),
new TimeEntriesDetailedExport($timeEntriesQuery, $format),
$path,
config('filesystems.private'),
$format->getExportPackageType(),
@@ -225,7 +253,7 @@ class TimeEntryController extends Controller
*
* @throws AuthorizationException
*/
public function aggregate(Organization $organization, TimeEntryAggregateRequest $request, TimeEntryAggregationService $aggregationService): array
public function aggregate(Organization $organization, TimeEntryAggregateRequest $request): array
{
/** @var Member|null $member */
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
@@ -235,6 +263,75 @@ class TimeEntryController extends Controller
$this->checkPermission($organization, 'time-entries:view:all');
}
$aggregatedData = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
return [
'data' => $aggregatedData,
];
}
/**
* Export aggregated time entries in organization
*
* @throws AuthorizationException
*/
public function aggregateExport(Organization $organization, TimeEntryAggregateExportRequest $request): JsonResponse
{
/** @var Member|null $member */
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
if ($member !== null && $member->user_id === Auth::id()) {
$this->checkPermission($organization, 'time-entries:view:own');
} else {
$this->checkPermission($organization, 'time-entries:view:all');
}
$aggregatedData = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
$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 (config('services.gotenberg.url') === null) {
throw new PdfRendererIsNotConfiguredException;
}
// TODO
} else {
// TODO
}
return response()->json([
'download_url' => Storage::disk(config('filesystems.private'))
->temporaryUrl($path, now()->addMinutes(5)),
]);
}
/**
* @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
* }
*/
private function getTimeEntriesAggregateQuery(Organization $organization, TimeEntryAggregateRequest|TimeEntryAggregateExportRequest $request, ?Member $member): array
{
$timeEntriesQuery = TimeEntry::query()
->whereBelongsTo($organization, 'organization');
@@ -256,7 +353,7 @@ class TimeEntryController extends Controller
$group1Type = $request->getGroup();
$group2Type = $request->getSubGroup();
$aggregatedData = $aggregationService->getAggregatedTimeEntries(
$aggregatedData = app(TimeEntryAggregationService::class)->getAggregatedTimeEntries(
$timeEntriesQuery,
$group1Type,
$group2Type,
@@ -267,9 +364,7 @@ class TimeEntryController extends Controller
$request->getEnd()
);
return [
'data' => $aggregatedData,
];
return $aggregatedData;
}
/**

View File

@@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\ExportFormat;
use App\Enums\TimeEntryAggregationType;
use App\Models\Client;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use App\Models\User;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Carbon;
use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization
*/
class TimeEntryAggregateExportRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return ValidationRule
*/
public function rules(): array
{
return [
'format' => [
'required',
'string',
Rule::enum(ExportFormat::class),
],
'group' => [
'nullable',
'required_with:group_2',
Rule::enum(TimeEntryAggregationType::class),
],
'sub_group' => [
'nullable',
Rule::enum(TimeEntryAggregationType::class),
],
// Filter by member ID
'member_id' => [
'string',
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
/** @var Builder<Member> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
],
// Filter by multiple member IDs, member IDs are OR combined, but AND combined with the member_id parameter
'member_ids' => [
'array',
'min:1',
],
'member_ids.*' => [
'string',
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
/** @var Builder<Member> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
],
// Filter by user ID
'user_id' => [
'string',
ExistsEloquent::make(User::class, null, function (Builder $builder): Builder {
/** @var Builder<User> $builder */
return $builder->belongsToOrganization($this->organization);
})->uuid(),
],
// Filter by project IDs, project IDs are OR combined
'project_ids' => [
'array',
'min:1',
],
'project_ids.*' => [
'string',
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
],
// Filter by client IDs, client IDs are OR combined
'client_ids' => [
'array',
'min:1',
],
'client_ids.*' => [
'string',
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
/** @var Builder<Client> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
],
// Filter by tag IDs, tag IDs are AND combined
'tag_ids' => [
'array',
'min:1',
],
'tag_ids.*' => [
'string',
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
/** @var Builder<Tag> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
],
// Filter by task IDs, task IDs are OR combined
'task_ids' => [
'array',
'min:1',
],
'task_ids.*' => [
'string',
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
],
// Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
'start' => [
'nullable',
'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',
'string',
'date_format:Y-m-d\TH:i:s\Z',
],
// Filter by active status (active means has no end date, is still running)
'active' => [
'string',
'in:true,false',
],
// Filter by billable status
'billable' => [
'string',
'in:true,false',
],
'fill_gaps_in_time_groups' => [
'string',
'in:true,false',
],
];
}
public function getGroup(): ?TimeEntryAggregationType
{
return $this->input('group') !== null ? TimeEntryAggregationType::from($this->input('group')) : null;
}
public function getSubGroup(): ?TimeEntryAggregationType
{
return $this->input('sub_group') !== null ? TimeEntryAggregationType::from($this->input('sub_group')) : null;
}
public function getFillGapsInTimeGroups(): bool
{
return $this->has('fill_gaps_in_time_groups') && $this->input('fill_gaps_in_time_groups') === 'true';
}
public function getStart(): ?Carbon
{
return $this->input('start') !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('start'), 'UTC') : null;
}
public function getEnd(): ?Carbon
{
return $this->input('end') !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC') : null;
}
public function getFormatValue(): ExportFormat
{
return ExportFormat::from($this->validated('format'));
}
}

View File

@@ -54,11 +54,11 @@ class TimeEntry extends Model implements AuditableContract
{
use ComputedAttributes;
use CustomAuditable;
/** @use HasFactory<TimeEntryFactory> */
use HasFactory;
use HasJsonRelationships;
use HasUuids;
/**

View File

@@ -5,10 +5,13 @@ declare(strict_types=1);
namespace App\Service\ReportExport;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\File;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Storage;
use League\Csv\Writer;
use Spatie\TemporaryDirectory\TemporaryDirectory;
/**
* @template T of Model
@@ -31,16 +34,19 @@ abstract class CsvExport
*/
private Builder $builder;
private string $folderPath;
/**
* @param Builder<T> $builder
*/
public function __construct(string $disk, string $filename, Builder $builder, int $chunk)
public function __construct(string $disk, string $folderPath, string $filename, Builder $builder, int $chunk)
{
$this->disk = $disk;
$this->filename = $filename;
$this->chunk = $chunk;
$this->builder = $builder;
$this->folderPath = $folderPath;
}
/**
@@ -49,12 +55,21 @@ abstract class CsvExport
*/
abstract public function mapRow(Model $model): array;
/**
* @throws \League\Csv\CannotInsertRecord
* @throws \League\Csv\Exception
* @throws \League\Csv\UnavailableStream
*/
public function export(): void
{
$writer = Writer::createFromPath(Storage::disk($this->disk)->path($this->filename), 'w+');
$tempDirectory = TemporaryDirectory::make();
$writer = Writer::createFromPath($tempDirectory->path($this->filename), 'w+');
$writer->setDelimiter(',');
$writer->setEnclosure('"');
$writer->setEscape('');
$writer->insertOne(static::HEADER);
$this->builder->chunk($this->chunk, function ($models) use ($writer): void {
$this->builder->chunk($this->chunk, function (Collection $models) use ($writer): void {
foreach ($models as $model) {
$data = $this->mapRow($model);
$row = $this->convertRow($data);
@@ -63,6 +78,8 @@ abstract class CsvExport
$writer->insertOne(array_values($row));
}
});
Storage::disk($this->disk)->putFileAs($this->folderPath, new File($tempDirectory->path($this->filename)), $this->filename);
$tempDirectory->delete();
}
/**
@@ -87,13 +104,11 @@ abstract class CsvExport
/**
* @param array<string, string> $row
*
* @throws \Exception
*/
private function validateRow(array $row): void
{
if (array_keys($row) !== self::HEADER) {
throw new \Exception('Invalid row');
if (array_keys($row) !== static::HEADER) {
throw new \LogicException('Invalid row');
}
}
}

View File

@@ -13,17 +13,17 @@ use Illuminate\Database\Eloquent\Model;
class TimeEntriesDetailedCsvExport extends CsvExport
{
public const array HEADER = [
'id',
'user_id',
'project_id',
'task_id',
'start_time',
'end_time',
'duration',
'description',
'created_at',
'updated_at',
'deleted_at',
'Description',
'Task',
'Project',
'Client',
'User',
'Start',
'End',
'Duration',
'Duration (decimal)',
'Billable',
'Tags',
];
/**
@@ -31,16 +31,20 @@ class TimeEntriesDetailedCsvExport extends CsvExport
*/
public function mapRow(Model $model): array
{
$duration = $model->getDuration();
return [
'id' => $model->id,
'user_id' => $model->user_id,
'project_id' => $model->project_id,
'task_id' => $model->task_id,
'start_time' => $model->start,
'end_time' => $model->end,
'description' => $model->description,
'created_at' => $model->created_at,
'updated_at' => $model->updated_at,
'Description' => $model->description,
'Task' => $model->task?->name,
'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,
'Duration (decimal)' => $duration?->totalHours,
'Billable' => $model->billable ? 'Yes' : 'No',
'Tags' => $model->tagsRelation->pluck('name')->implode(', '),
];
}
}

View File

@@ -4,18 +4,27 @@ declare(strict_types=1);
namespace App\Service\ReportExport;
use App\Enums\ExportFormat;
use App\Models\TimeEntry;
use Illuminate\Database\Eloquent\Builder;
use LogicException;
use Maatwebsite\Excel\Concerns\Exportable;
use Maatwebsite\Excel\Concerns\FromQuery;
use Maatwebsite\Excel\Concerns\WithCustomCsvSettings;
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;
use PhpOffice\PhpSpreadsheet\Shared\Date;
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
use PhpOffice\PhpSpreadsheet\Style\Style;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
/**
* @implements WithMapping<TimeEntry>
*/
class TimeEntriesDetailedExport implements FromQuery, WithCustomCsvSettings, WithHeadings, WithMapping
class TimeEntriesDetailedExport implements FromQuery, ShouldAutoSize, WithColumnFormatting, WithDefaultStyles, WithHeadings, WithMapping, WithStyles
{
use Exportable;
@@ -24,12 +33,15 @@ class TimeEntriesDetailedExport implements FromQuery, WithCustomCsvSettings, Wit
*/
private Builder $builder;
private ExportFormat $exportFormat;
/**
* @param Builder<TimeEntry> $builder
*/
public function __construct(Builder $builder)
public function __construct(Builder $builder, ExportFormat $exportFormat)
{
$this->builder = $builder;
$this->exportFormat = $exportFormat;
}
/**
@@ -40,18 +52,41 @@ class TimeEntriesDetailedExport implements FromQuery, WithCustomCsvSettings, Wit
return $this->builder;
}
public function columnFormats(): array
{
if ($this->exportFormat === ExportFormat::XLSX) {
return [
'F' => 'yyyy-mm-dd hh:mm:ss',
'G' => 'yyyy-mm-dd hh:mm:ss',
'I' => NumberFormat::FORMAT_NUMBER_00,
];
} elseif ($this->exportFormat === ExportFormat::ODS) {
return [
'I' => NumberFormat::FORMAT_NUMBER_00,
];
} else {
throw new LogicException('Unsupported export format.');
}
}
/**
* @return array<string, string|bool>
* @return array<int|string, array<string, array<string, bool>>>
*/
public function getCsvSettings(): array
public function styles(Worksheet $sheet): array
{
return [
'delimiter' => ',',
'use_bom' => false,
'output_encoding' => 'ISO-8859-1',
// Style the first row as bold text.
1 => ['font' => ['bold' => true]],
];
}
public function defaultStyles(Style $defaultStyle)
{
// Configure the default styles
return $defaultStyle->getFill(); //->setFillType(Fill::FILL_SOLID);
}
/**
* @return string[]
*/
@@ -63,10 +98,8 @@ class TimeEntriesDetailedExport implements FromQuery, WithCustomCsvSettings, Wit
'Project',
'Client',
'User',
'Start date',
'Start time',
'End date',
'End time',
'Start',
'End',
'Duration',
'Duration (decimal)',
'Billable',
@@ -82,20 +115,36 @@ class TimeEntriesDetailedExport implements FromQuery, WithCustomCsvSettings, Wit
{
$duration = $model->getDuration();
return [
$model->description,
$model->task?->name,
$model->project?->name,
$model->project?->client?->name,
$model->user->name,
$model->start->format('Y-m-d'),
$model->start->format('H:i:s'),
$model->end?->format('Y-m-d'),
$model->end?->format('H:i:s'),
$duration !== null ? (int) floor($duration->totalHours).':'.$duration->format('%I:%S') : null,
$duration?->totalHours,
$model->billable ? 'Yes' : 'No',
$model->tagsRelation->pluck('name')->implode(', '),
];
if ($this->exportFormat === ExportFormat::XLSX) {
return [
$model->description,
$model->task?->name,
$model->project?->name,
$model->client?->name,
$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?->totalHours,
$model->billable ? 'Yes' : 'No',
$model->tagsRelation->pluck('name')->implode(', '),
];
} elseif ($this->exportFormat === ExportFormat::ODS) {
return [
$model->description,
$model->task?->name,
$model->project?->name,
$model->client?->name,
$model->user->name,
$model->start->format('Y-m-d H:i:s'),
$model->end?->format('Y-m-d H:i:s'),
$duration !== null ? (int) floor($duration->totalHours).':'.$duration->format('%I:%S') : null,
$duration?->totalHours,
$model->billable ? 'Yes' : 'No',
$model->tagsRelation->pluck('name')->implode(', '),
];
} else {
throw new LogicException('Unsupported export format.');
}
}
}

View File

@@ -11,6 +11,7 @@
"dedoc/scramble": "dev-main",
"filament/filament": "^3.2",
"flowframe/laravel-trend": "^0.2.0",
"gotenberg/gotenberg-php": "^2.8",
"guzzlehttp/guzzle": "^7.2",
"inertiajs/inertia-laravel": "^1.0",
"korridor/laravel-computed-attributes": "^3.1",

162
composer.lock generated
View File

@@ -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": "7b901c08f4d2a3f90c4d667bd1470dcb",
"content-hash": "5634bfb04c10a875101045a25ac21f1a",
"packages": [
{
"name": "anourvalar/eloquent-serialize",
@@ -2794,6 +2794,87 @@
],
"time": "2023-10-12T05:21:21+00:00"
},
{
"name": "gotenberg/gotenberg-php",
"version": "v2.8.0",
"source": {
"type": "git",
"url": "https://github.com/gotenberg/gotenberg-php.git",
"reference": "e8bc519812349f7bd57b294b4ea41dce53693b7c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/gotenberg/gotenberg-php/zipball/e8bc519812349f7bd57b294b4ea41dce53693b7c",
"reference": "e8bc519812349f7bd57b294b4ea41dce53693b7c",
"shasum": ""
},
"require": {
"ext-json": "*",
"ext-mbstring": "*",
"guzzlehttp/psr7": "^1 || ^2.1",
"php": "^8.1|^8.2|^8.3",
"php-http/discovery": "^1.14",
"psr/http-client": "^1.0",
"psr/http-message": "^1.0|^2.0"
},
"require-dev": {
"doctrine/coding-standard": "^12.0",
"pestphp/pest": "^2.28",
"phpstan/phpstan": "^1.12",
"squizlabs/php_codesniffer": "^3.10"
},
"type": "library",
"autoload": {
"psr-4": {
"Gotenberg\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Julien Neuhart",
"email": "neuhart.julien@gmail.com",
"homepage": "https://github.com/gulien",
"role": "Developer"
}
],
"description": "A PHP client for interacting with Gotenberg, a developer-friendly API for converting numerous document formats into PDF files, and more!",
"homepage": "https://github.com/gotenberg/gotenberg-php",
"keywords": [
"Gotenberg",
"LibreOffice",
"chrome",
"chromium",
"convert",
"csv",
"docx",
"excel",
"html",
"markdown",
"pdf",
"pdftk",
"pptx",
"puppeteer",
"unoconv",
"wkhtmltopdf",
"word",
"xlsx"
],
"support": {
"issues": "https://github.com/gotenberg/gotenberg-php/issues",
"source": "https://github.com/gotenberg/gotenberg-php/tree/v2.8.0"
},
"funding": [
{
"url": "https://github.com/gulien",
"type": "github"
}
],
"time": "2024-09-29T17:23:42+00:00"
},
{
"name": "graham-campbell/result-type",
"version": "v1.1.3",
@@ -6996,6 +7077,85 @@
},
"time": "2020-10-15T08:29:30+00:00"
},
{
"name": "php-http/discovery",
"version": "1.20.0",
"source": {
"type": "git",
"url": "https://github.com/php-http/discovery.git",
"reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d",
"reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d",
"shasum": ""
},
"require": {
"composer-plugin-api": "^1.0|^2.0",
"php": "^7.1 || ^8.0"
},
"conflict": {
"nyholm/psr7": "<1.0",
"zendframework/zend-diactoros": "*"
},
"provide": {
"php-http/async-client-implementation": "*",
"php-http/client-implementation": "*",
"psr/http-client-implementation": "*",
"psr/http-factory-implementation": "*",
"psr/http-message-implementation": "*"
},
"require-dev": {
"composer/composer": "^1.0.2|^2.0",
"graham-campbell/phpspec-skip-example-extension": "^5.0",
"php-http/httplug": "^1.0 || ^2.0",
"php-http/message-factory": "^1.0",
"phpspec/phpspec": "^5.1 || ^6.1 || ^7.3",
"sebastian/comparator": "^3.0.5 || ^4.0.8",
"symfony/phpunit-bridge": "^6.4.4 || ^7.0.1"
},
"type": "composer-plugin",
"extra": {
"class": "Http\\Discovery\\Composer\\Plugin",
"plugin-optional": true
},
"autoload": {
"psr-4": {
"Http\\Discovery\\": "src/"
},
"exclude-from-classmap": [
"src/Composer/Plugin.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Márk Sági-Kazár",
"email": "mark.sagikazar@gmail.com"
}
],
"description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations",
"homepage": "http://php-http.org",
"keywords": [
"adapter",
"client",
"discovery",
"factory",
"http",
"message",
"psr17",
"psr7"
],
"support": {
"issues": "https://github.com/php-http/discovery/issues",
"source": "https://github.com/php-http/discovery/tree/1.20.0"
},
"time": "2024-10-02T11:20:13+00:00"
},
{
"name": "phpoffice/phpspreadsheet",
"version": "1.29.2",

9
config/services.php Normal file
View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
return [
'gotenberg' => [
'url' => env('GOTENBERG_URL'),
],
];

View File

@@ -189,6 +189,13 @@ services:
entrypoint: /etc/minio/create_bucket.sh
extra_hosts:
- "storage.${NGINX_HOST_NAME}:${REVERSE_PROXY_IP:-10.100.100.10}"
gotenberg:
image: gotenberg/gotenberg:8
networks:
- sail
healthcheck:
test: ["CMD", "curl", "--silent", "--fail", "http://localhost:3000/health"]
networks:
reverse-proxy:
name: "${NETWORK_NAME}"

View File

@@ -10,6 +10,7 @@ use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException;
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException;
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
use App\Exceptions\Api\TimeEntryStillRunningApiException;
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
@@ -33,6 +34,7 @@ return [
ChangingRoleToPlaceholderIsNotAllowed::KEY => 'Changing role to placeholder is not allowed',
ExportException::KEY => 'Export failed, please try again later or contact support',
OrganizationHasNoSubscriptionButMultipleMembersException::KEY => 'Organization has no subscription but multiple members',
PdfRendererIsNotConfiguredException::KEY => 'PDF renderer is not configured',
],
'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.',
];

View File

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

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

@@ -89,6 +89,7 @@ Route::middleware([
Route::get('/organizations/{organization}/time-entries', [TimeEntryController::class, 'index'])->name('index');
Route::get('/organizations/{organization}/time-entries/export', [TimeEntryController::class, 'indexExport'])->name('index-export');
Route::get('/organizations/{organization}/time-entries/aggregate', [TimeEntryController::class, 'aggregate'])->name('aggregate');
Route::get('/organizations/{organization}/time-entries/aggregate/export', [TimeEntryController::class, 'aggregateExport'])->name('aggregate-export');
Route::post('/organizations/{organization}/time-entries', [TimeEntryController::class, 'store'])->name('store')->middleware('check-organization-blocked');
Route::put('/organizations/{organization}/time-entries/{timeEntry}', [TimeEntryController::class, 'update'])->name('update')->middleware('check-organization-blocked');
Route::patch('/organizations/{organization}/time-entries', [TimeEntryController::class, 'updateMultiple'])->name('update-multiple')->middleware('check-organization-blocked');