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_DRIVER=database
SESSION_LIFETIME=120 SESSION_LIFETIME=120
GOTENBERG_URL=http://gotenberg:3000
MEMCACHED_HOST=127.0.0.1 MEMCACHED_HOST=127.0.0.1
REDIS_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; namespace App\Http\Controllers\Api\V1;
use App\Enums\ExportFormat; use App\Enums\ExportFormat;
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException; use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
use App\Exceptions\Api\TimeEntryStillRunningApiException; 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\TimeEntryAggregateRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryDestroyMultipleRequest; use App\Http\Requests\V1\TimeEntry\TimeEntryDestroyMultipleRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryIndexExportRequest; use App\Http\Requests\V1\TimeEntry\TimeEntryIndexExportRequest;
@@ -28,15 +30,20 @@ use App\Service\ReportExport\TimeEntriesDetailedExport;
use App\Service\TimeEntryAggregationService; use App\Service\TimeEntryAggregationService;
use App\Service\TimeEntryFilter; use App\Service\TimeEntryFilter;
use App\Service\TimezoneService; use App\Service\TimezoneService;
use Gotenberg\Gotenberg;
use Gotenberg\Stream;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\File;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Maatwebsite\Excel\Facades\Excel; use Maatwebsite\Excel\Facades\Excel;
use Spatie\TemporaryDirectory\TemporaryDirectory;
class TimeEntryController extends Controller 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`. * 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. * 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 * @operationId exportTimeEntries
*/ */
@@ -163,21 +172,40 @@ class TimeEntryController extends Controller
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member); $timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member);
$timeEntriesQuery->with([ $timeEntriesQuery->with([
'task', 'task',
'project' => [ 'client',
'client', 'project',
],
'user', 'user',
'tagsRelation', 'tagsRelation',
]); ]);
$format = $request->getFormatValue(); $format = $request->getFormatValue();
//$format = ExportFormat::PDF;
$filename = 'time-entries-export-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension(); $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) { 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(); $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 { } else {
Excel::store( Excel::store(
new TimeEntriesDetailedExport($timeEntriesQuery), new TimeEntriesDetailedExport($timeEntriesQuery, $format),
$path, $path,
config('filesystems.private'), config('filesystems.private'),
$format->getExportPackageType(), $format->getExportPackageType(),
@@ -225,7 +253,7 @@ class TimeEntryController extends Controller
* *
* @throws AuthorizationException * @throws AuthorizationException
*/ */
public function aggregate(Organization $organization, TimeEntryAggregateRequest $request, TimeEntryAggregationService $aggregationService): array public function aggregate(Organization $organization, TimeEntryAggregateRequest $request): array
{ {
/** @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;
@@ -235,6 +263,75 @@ class TimeEntryController extends Controller
$this->checkPermission($organization, 'time-entries:view:all'); $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() $timeEntriesQuery = TimeEntry::query()
->whereBelongsTo($organization, 'organization'); ->whereBelongsTo($organization, 'organization');
@@ -256,7 +353,7 @@ class TimeEntryController extends Controller
$group1Type = $request->getGroup(); $group1Type = $request->getGroup();
$group2Type = $request->getSubGroup(); $group2Type = $request->getSubGroup();
$aggregatedData = $aggregationService->getAggregatedTimeEntries( $aggregatedData = app(TimeEntryAggregationService::class)->getAggregatedTimeEntries(
$timeEntriesQuery, $timeEntriesQuery,
$group1Type, $group1Type,
$group2Type, $group2Type,
@@ -267,9 +364,7 @@ class TimeEntryController extends Controller
$request->getEnd() $request->getEnd()
); );
return [ return $aggregatedData;
'data' => $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 ComputedAttributes;
use CustomAuditable; use CustomAuditable;
/** @use HasFactory<TimeEntryFactory> */ /** @use HasFactory<TimeEntryFactory> */
use HasFactory; use HasFactory;
use HasJsonRelationships; use HasJsonRelationships;
use HasUuids; use HasUuids;
/** /**

View File

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

View File

@@ -13,17 +13,17 @@ use Illuminate\Database\Eloquent\Model;
class TimeEntriesDetailedCsvExport extends CsvExport class TimeEntriesDetailedCsvExport extends CsvExport
{ {
public const array HEADER = [ public const array HEADER = [
'id', 'Description',
'user_id', 'Task',
'project_id', 'Project',
'task_id', 'Client',
'start_time', 'User',
'end_time', 'Start',
'duration', 'End',
'description', 'Duration',
'created_at', 'Duration (decimal)',
'updated_at', 'Billable',
'deleted_at', 'Tags',
]; ];
/** /**
@@ -31,16 +31,20 @@ class TimeEntriesDetailedCsvExport extends CsvExport
*/ */
public function mapRow(Model $model): array public function mapRow(Model $model): array
{ {
$duration = $model->getDuration();
return [ return [
'id' => $model->id, 'Description' => $model->description,
'user_id' => $model->user_id, 'Task' => $model->task?->name,
'project_id' => $model->project_id, 'Project' => $model->project?->name,
'task_id' => $model->task_id, 'Client' => $model->client?->name,
'start_time' => $model->start, 'User' => $model->user->name,
'end_time' => $model->end, 'Start' => $model->start->format('Y-m-d H:i:s'),
'description' => $model->description, 'End' => $model->end?->format('Y-m-d H:i:s'),
'created_at' => $model->created_at, 'Duration' => $duration !== null ? (int) floor($duration->totalHours).':'.$duration->format('%I:%S') : null,
'updated_at' => $model->updated_at, '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; namespace App\Service\ReportExport;
use App\Enums\ExportFormat;
use App\Models\TimeEntry; use App\Models\TimeEntry;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use LogicException;
use Maatwebsite\Excel\Concerns\Exportable; use Maatwebsite\Excel\Concerns\Exportable;
use Maatwebsite\Excel\Concerns\FromQuery; 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\WithHeadings;
use Maatwebsite\Excel\Concerns\WithMapping; 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> * @implements WithMapping<TimeEntry>
*/ */
class TimeEntriesDetailedExport implements FromQuery, WithCustomCsvSettings, WithHeadings, WithMapping class TimeEntriesDetailedExport implements FromQuery, ShouldAutoSize, WithColumnFormatting, WithDefaultStyles, WithHeadings, WithMapping, WithStyles
{ {
use Exportable; use Exportable;
@@ -24,12 +33,15 @@ class TimeEntriesDetailedExport implements FromQuery, WithCustomCsvSettings, Wit
*/ */
private Builder $builder; private Builder $builder;
private ExportFormat $exportFormat;
/** /**
* @param Builder<TimeEntry> $builder * @param Builder<TimeEntry> $builder
*/ */
public function __construct(Builder $builder) public function __construct(Builder $builder, ExportFormat $exportFormat)
{ {
$this->builder = $builder; $this->builder = $builder;
$this->exportFormat = $exportFormat;
} }
/** /**
@@ -40,18 +52,41 @@ class TimeEntriesDetailedExport implements FromQuery, WithCustomCsvSettings, Wit
return $this->builder; 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 [ return [
'delimiter' => ',', // Style the first row as bold text.
'use_bom' => false, 1 => ['font' => ['bold' => true]],
'output_encoding' => 'ISO-8859-1',
]; ];
} }
public function defaultStyles(Style $defaultStyle)
{
// Configure the default styles
return $defaultStyle->getFill(); //->setFillType(Fill::FILL_SOLID);
}
/** /**
* @return string[] * @return string[]
*/ */
@@ -63,10 +98,8 @@ class TimeEntriesDetailedExport implements FromQuery, WithCustomCsvSettings, Wit
'Project', 'Project',
'Client', 'Client',
'User', 'User',
'Start date', 'Start',
'Start time', 'End',
'End date',
'End time',
'Duration', 'Duration',
'Duration (decimal)', 'Duration (decimal)',
'Billable', 'Billable',
@@ -82,20 +115,36 @@ class TimeEntriesDetailedExport implements FromQuery, WithCustomCsvSettings, Wit
{ {
$duration = $model->getDuration(); $duration = $model->getDuration();
return [ if ($this->exportFormat === ExportFormat::XLSX) {
$model->description, return [
$model->task?->name, $model->description,
$model->project?->name, $model->task?->name,
$model->project?->client?->name, $model->project?->name,
$model->user->name, $model->client?->name,
$model->start->format('Y-m-d'), $model->user->name,
$model->start->format('H:i:s'), Date::dateTimeToExcel($model->start),
$model->end?->format('Y-m-d'), $model->end !== null ? Date::dateTimeToExcel($model->end) : null,
$model->end?->format('H:i:s'), $duration !== null ? (int) floor($duration->totalHours).':'.$duration->format('%I:%S') : null,
$duration !== null ? (int) floor($duration->totalHours).':'.$duration->format('%I:%S') : null, $duration?->totalHours,
$duration?->totalHours, $model->billable ? 'Yes' : 'No',
$model->billable ? 'Yes' : 'No', $model->tagsRelation->pluck('name')->implode(', '),
$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", "dedoc/scramble": "dev-main",
"filament/filament": "^3.2", "filament/filament": "^3.2",
"flowframe/laravel-trend": "^0.2.0", "flowframe/laravel-trend": "^0.2.0",
"gotenberg/gotenberg-php": "^2.8",
"guzzlehttp/guzzle": "^7.2", "guzzlehttp/guzzle": "^7.2",
"inertiajs/inertia-laravel": "^1.0", "inertiajs/inertia-laravel": "^1.0",
"korridor/laravel-computed-attributes": "^3.1", "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", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "7b901c08f4d2a3f90c4d667bd1470dcb", "content-hash": "5634bfb04c10a875101045a25ac21f1a",
"packages": [ "packages": [
{ {
"name": "anourvalar/eloquent-serialize", "name": "anourvalar/eloquent-serialize",
@@ -2794,6 +2794,87 @@
], ],
"time": "2023-10-12T05:21:21+00:00" "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", "name": "graham-campbell/result-type",
"version": "v1.1.3", "version": "v1.1.3",
@@ -6996,6 +7077,85 @@
}, },
"time": "2020-10-15T08:29:30+00:00" "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", "name": "phpoffice/phpspreadsheet",
"version": "1.29.2", "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 entrypoint: /etc/minio/create_bucket.sh
extra_hosts: extra_hosts:
- "storage.${NGINX_HOST_NAME}:${REVERSE_PROXY_IP:-10.100.100.10}" - "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: networks:
reverse-proxy: reverse-proxy:
name: "${NETWORK_NAME}" name: "${NETWORK_NAME}"

View File

@@ -10,6 +10,7 @@ use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException;
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership; use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException; use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException;
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner; use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException; use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
use App\Exceptions\Api\TimeEntryStillRunningApiException; use App\Exceptions\Api\TimeEntryStillRunningApiException;
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException; use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
@@ -33,6 +34,7 @@ return [
ChangingRoleToPlaceholderIsNotAllowed::KEY => 'Changing role to placeholder is not allowed', ChangingRoleToPlaceholderIsNotAllowed::KEY => 'Changing role to placeholder is not allowed',
ExportException::KEY => 'Export failed, please try again later or contact support', ExportException::KEY => 'Export failed, please try again later or contact support',
OrganizationHasNoSubscriptionButMultipleMembersException::KEY => 'Organization has no subscription but multiple members', 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.', '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', [TimeEntryController::class, 'index'])->name('index');
Route::get('/organizations/{organization}/time-entries/export', [TimeEntryController::class, 'indexExport'])->name('index-export'); 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', [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::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::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'); Route::patch('/organizations/{organization}/time-entries', [TimeEntryController::class, 'updateMultiple'])->name('update-multiple')->middleware('check-organization-blocked');