From ad79f2ce20261898d362b66dc0ddc2723b4474cf Mon Sep 17 00:00:00 2001 From: Constantin Graf Date: Wed, 16 Oct 2024 13:29:54 +0200 Subject: [PATCH 01/11] Add report exports --- app/Enums/ExportFormat.php | 35 ++ .../Api/V1/TimeEntryController.php | 93 ++- .../TimeEntry/TimeEntryIndexExportRequest.php | 143 +++++ app/Models/Tag.php | 13 + app/Models/TimeEntry.php | 17 +- app/Service/ReportExport/CsvExport.php | 99 ++++ .../TimeEntriesDetailedCsvExport.php | 46 ++ .../TimeEntriesDetailedExport.php | 101 ++++ composer.json | 3 + composer.lock | 536 +++++++++++++++++- config/excel.php | 382 +++++++++++++ routes/api.php | 1 + 12 files changed, 1452 insertions(+), 17 deletions(-) create mode 100644 app/Enums/ExportFormat.php create mode 100644 app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php create mode 100644 app/Service/ReportExport/CsvExport.php create mode 100644 app/Service/ReportExport/TimeEntriesDetailedCsvExport.php create mode 100644 app/Service/ReportExport/TimeEntriesDetailedExport.php create mode 100644 config/excel.php diff --git a/app/Enums/ExportFormat.php b/app/Enums/ExportFormat.php new file mode 100644 index 00000000..62c04edc --- /dev/null +++ b/app/Enums/ExportFormat.php @@ -0,0 +1,35 @@ + 'csv', + self::PDF => 'pdf', + self::XLSX => 'xlsx', + self::ODS => 'ods', + }; + } + + public function getExportPackageType(): string + { + return match ($this) { + self::CSV => Excel::CSV, + self::PDF => Excel::MPDF, + self::XLSX => Excel::XLSX, + self::ODS => Excel::ODS, + }; + } +} diff --git a/app/Http/Controllers/Api/V1/TimeEntryController.php b/app/Http/Controllers/Api/V1/TimeEntryController.php index ee3a1f59..4d4b3624 100644 --- a/app/Http/Controllers/Api/V1/TimeEntryController.php +++ b/app/Http/Controllers/Api/V1/TimeEntryController.php @@ -4,10 +4,12 @@ namespace App\Http\Controllers\Api\V1; +use App\Enums\ExportFormat; use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException; use App\Exceptions\Api\TimeEntryStillRunningApiException; use App\Http\Requests\V1\TimeEntry\TimeEntryAggregateRequest; use App\Http\Requests\V1\TimeEntry\TimeEntryDestroyMultipleRequest; +use App\Http\Requests\V1\TimeEntry\TimeEntryIndexExportRequest; use App\Http\Requests\V1\TimeEntry\TimeEntryIndexRequest; use App\Http\Requests\V1\TimeEntry\TimeEntryStoreRequest; use App\Http\Requests\V1\TimeEntry\TimeEntryUpdateMultipleRequest; @@ -21,15 +23,20 @@ use App\Models\Project; use App\Models\Task; use App\Models\TimeEntry; +use App\Service\ReportExport\TimeEntriesDetailedCsvExport; +use App\Service\ReportExport\TimeEntriesDetailedExport; use App\Service\TimeEntryAggregationService; use App\Service\TimeEntryFilter; use App\Service\TimezoneService; use Illuminate\Auth\Access\AuthorizationException; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\JsonResponse; use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Storage; +use Maatwebsite\Excel\Facades\Excel; class TimeEntryController extends Controller { @@ -63,21 +70,7 @@ public function index(Organization $organization, TimeEntryIndexRequest $request $this->checkPermission($organization, 'time-entries:view:all'); } - $timeEntriesQuery = TimeEntry::query() - ->whereBelongsTo($organization, 'organization') - ->orderBy('start', 'desc'); - - $filter = new TimeEntryFilter($timeEntriesQuery); - $filter->addStartFilter($request->input('start')); - $filter->addEndFilter($request->input('end')); - $filter->addActiveFilter($request->input('active')); - $filter->addMemberIdFilter($member); - $filter->addMemberIdsFilter($request->input('member_ids')); - $filter->addProjectIdsFilter($request->input('project_ids')); - $filter->addTagIdsFilter($request->input('tag_ids')); - $filter->addTaskIdsFilter($request->input('task_ids')); - $filter->addClientIdsFilter($request->input('client_ids')); - $filter->addBillableFilter($request->input('billable')); + $timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member); $totalCount = $timeEntriesQuery->count(); @@ -128,6 +121,76 @@ public function index(Organization $organization, TimeEntryIndexRequest $request ]); } + /** + * @return Builder + */ + private function getTimeEntriesQuery(Organization $organization, TimeEntryIndexRequest|TimeEntryIndexExportRequest $request, ?Member $member): Builder + { + $timeEntriesQuery = TimeEntry::query() + ->whereBelongsTo($organization, 'organization') + ->orderBy('start', 'desc'); + + $filter = new TimeEntryFilter($timeEntriesQuery); + $filter->addStartFilter($request->input('start')); + $filter->addEndFilter($request->input('end')); + $filter->addActiveFilter($request->input('active')); + $filter->addMemberIdFilter($member); + $filter->addMemberIdsFilter($request->input('member_ids')); + $filter->addProjectIdsFilter($request->input('project_ids')); + $filter->addTagIdsFilter($request->input('tag_ids')); + $filter->addTaskIdsFilter($request->input('task_ids')); + $filter->addClientIdsFilter($request->input('client_ids')); + $filter->addBillableFilter($request->input('billable')); + + return $filter->get(); + } + + /** + * @throws AuthorizationException + */ + public function indexExport(Organization $organization, TimeEntryIndexExportRequest $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'); + } + + $timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member); + $timeEntriesQuery->with([ + 'task', + 'project' => [ + 'client', + ], + 'user', + 'tagsRelation', + ]); + $format = $request->getFormatValue(); + $filename = 'time-entries-export-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension(); + $path = 'exports/'.$filename; + if ($format === ExportFormat::CSV) { + $export = new TimeEntriesDetailedCsvExport(config('filesystems.private'), $filename, $timeEntriesQuery, 1000); + $export->export(); + } else { + Excel::store( + new TimeEntriesDetailedExport($timeEntriesQuery), + $path, + config('filesystems.private'), + $format->getExportPackageType(), + [ + 'visibility' => 'private', + ] + ); + } + + return response()->json([ + 'download_url' => Storage::disk(config('filesystems.private')) + ->temporaryUrl($path, now()->addMinutes(5)), + ]); + } + /** * Get aggregated time entries in organization * diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php new file mode 100644 index 00000000..5de41b4b --- /dev/null +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php @@ -0,0 +1,143 @@ +> + */ + public function rules(): array + { + return [ + 'format' => [ + 'required', + 'string', + Rule::enum(ExportFormat::class), + ], + // Filter by member ID + 'member_id' => [ + 'string', + 'uuid', + new ExistsEloquent(Member::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + }), + ], + // 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', + 'uuid', + new ExistsEloquent(Member::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + }), + ], + // Filter by project IDs, project IDs are OR combined + 'project_ids' => [ + 'array', + 'min:1', + ], + 'project_ids.*' => [ + 'string', + 'uuid', + new ExistsEloquent(Project::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + }), + ], + // Filter by tag IDs, tag IDs are AND combined + 'tag_ids' => [ + 'array', + 'min:1', + ], + 'tag_ids.*' => [ + 'string', + 'uuid', + new ExistsEloquent(Tag::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + }), + ], + // Filter by task IDs, task IDs are OR combined + 'task_ids' => [ + 'array', + 'min:1', + ], + 'task_ids.*' => [ + 'string', + 'uuid', + new ExistsEloquent(Task::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + }), + ], + // 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', + ], + // Limit the number of returned time entries (default: 150) + 'limit' => [ + 'integer', + 'min:1', + 'max:500', + ], + // Filter makes sure that only time entries of a whole date are returned + 'only_full_dates' => [ + 'string', + 'in:true,false', + ], + ]; + } + + public function getOnlyFullDates(): bool + { + return $this->input('only_full_dates', 'false') === 'true'; + } + + public function getFormatValue(): ExportFormat + { + return ExportFormat::from($this->validated('format')); + } +} diff --git a/app/Models/Tag.php b/app/Models/Tag.php index 8e8baf10..634f8d07 100644 --- a/app/Models/Tag.php +++ b/app/Models/Tag.php @@ -12,6 +12,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Support\Carbon; use OwenIt\Auditing\Contracts\Auditable as AuditableContract; +use Staudenmeir\EloquentJsonRelations\HasJsonRelationships; +use Staudenmeir\EloquentJsonRelations\Relations\HasManyJson; /** * @property string $id @@ -30,6 +32,7 @@ class Tag extends Model implements AuditableContract /** @use HasFactory */ use HasFactory; + use HasJsonRelationships; use HasUuids; /** @@ -48,4 +51,14 @@ public function organization(): BelongsTo { return $this->belongsTo(Organization::class, 'organization_id'); } + + /** + * Warning: This relation based on a JSON column. Please make sure that there are no performance issues, before using it. + * + * @return HasManyJson + */ + public function timeEntries(): HasManyJson + { + return $this->hasManyJson(TimeEntry::class, 'tags'); + } } diff --git a/app/Models/TimeEntry.php b/app/Models/TimeEntry.php index b60b659c..31fc264e 100644 --- a/app/Models/TimeEntry.php +++ b/app/Models/TimeEntry.php @@ -10,6 +10,7 @@ use Carbon\CarbonInterval; use Database\Factories\TimeEntryFactory; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -17,6 +18,8 @@ use Illuminate\Support\Carbon; use Korridor\LaravelComputedAttributes\ComputedAttributes; use OwenIt\Auditing\Contracts\Auditable as AuditableContract; +use Staudenmeir\EloquentJsonRelations\HasJsonRelationships; +use Staudenmeir\EloquentJsonRelations\Relations\BelongsToJson; /** * @property string $id @@ -42,6 +45,7 @@ * @property-read Client|null $client * @property string|null $task_id * @property-read Task|null $task + * @property-read Collection $tagsRelation * * @method Builder hasTag(Tag $tag) * @method static TimeEntryFactory factory() @@ -50,10 +54,11 @@ class TimeEntry extends Model implements AuditableContract { use ComputedAttributes; use CustomAuditable; - /** @use HasFactory */ use HasFactory; + use HasJsonRelationships; + use HasUuids; /** @@ -197,4 +202,14 @@ public function client(): BelongsTo { return $this->belongsTo(Client::class, 'client_id'); } + + /** + * Warning: This relation based on a JSON column. Please make sure that there are no performance issues, before using it. + * + * @return BelongsToJson + */ + public function tagsRelation(): BelongsToJson + { + return $this->belongsToJson(Tag::class, 'tags'); + } } diff --git a/app/Service/ReportExport/CsvExport.php b/app/Service/ReportExport/CsvExport.php new file mode 100644 index 00000000..b43ba04b --- /dev/null +++ b/app/Service/ReportExport/CsvExport.php @@ -0,0 +1,99 @@ + + */ + private Builder $builder; + + /** + * @param Builder $builder + */ + public function __construct(string $disk, string $filename, Builder $builder, int $chunk) + { + + $this->disk = $disk; + $this->filename = $filename; + $this->chunk = $chunk; + $this->builder = $builder; + } + + /** + * @param T $model + * @return array + */ + abstract public function mapRow(Model $model): array; + + public function export(): void + { + $writer = Writer::createFromPath(Storage::disk($this->disk)->path($this->filename), 'w+'); + $writer->insertOne(static::HEADER); + + $this->builder->chunk($this->chunk, function ($models) use ($writer): void { + foreach ($models as $model) { + $data = $this->mapRow($model); + $row = $this->convertRow($data); + $this->validateRow($row); + + $writer->insertOne(array_values($row)); + } + }); + } + + /** + * @param array $data + * @return array + */ + private function convertRow(array $data): array + { + $convertedRow = []; + foreach ($data as $key => $value) { + if ($value instanceof Carbon) { + $convertedRow[$key] = $value->toIso8601String(); + } elseif ($value === null) { + $convertedRow[$key] = ''; + } else { + $convertedRow[$key] = $value; + } + } + + return $convertedRow; + } + + /** + * @param array $row + * + * @throws \Exception + */ + private function validateRow(array $row): void + { + if (array_keys($row) !== self::HEADER) { + throw new \Exception('Invalid row'); + } + } +} diff --git a/app/Service/ReportExport/TimeEntriesDetailedCsvExport.php b/app/Service/ReportExport/TimeEntriesDetailedCsvExport.php new file mode 100644 index 00000000..f4eab41e --- /dev/null +++ b/app/Service/ReportExport/TimeEntriesDetailedCsvExport.php @@ -0,0 +1,46 @@ + + */ +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', + ]; + + /** + * @param TimeEntry $model + */ + public function mapRow(Model $model): array + { + 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, + ]; + } +} diff --git a/app/Service/ReportExport/TimeEntriesDetailedExport.php b/app/Service/ReportExport/TimeEntriesDetailedExport.php new file mode 100644 index 00000000..fda462f8 --- /dev/null +++ b/app/Service/ReportExport/TimeEntriesDetailedExport.php @@ -0,0 +1,101 @@ + + */ +class TimeEntriesDetailedExport implements FromQuery, WithCustomCsvSettings, WithHeadings, WithMapping +{ + use Exportable; + + /** + * @var Builder + */ + private Builder $builder; + + /** + * @param Builder $builder + */ + public function __construct(Builder $builder) + { + $this->builder = $builder; + } + + /** + * @return Builder + */ + public function query(): Builder + { + return $this->builder; + } + + /** + * @return array + */ + public function getCsvSettings(): array + { + return [ + 'delimiter' => ',', + 'use_bom' => false, + 'output_encoding' => 'ISO-8859-1', + ]; + } + + /** + * @return string[] + */ + public function headings(): array + { + return [ + 'Description', + 'Task', + 'Project', + 'Client', + 'User', + 'Start date', + 'Start time', + 'End date', + 'End time', + 'Duration', + 'Duration (decimal)', + 'Billable', + 'Tags', + ]; + } + + /** + * @param TimeEntry $model + * @return array + */ + public function map($model): array + { + $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(', '), + ]; + } +} diff --git a/composer.json b/composer.json index e367355e..0b9888da 100644 --- a/composer.json +++ b/composer.json @@ -20,12 +20,15 @@ "laravel/octane": "^2.3", "laravel/passport": "^12.0", "laravel/tinker": "^2.8", + "league/csv": "^9.16.0", "league/flysystem-aws-s3-v3": "^3.0", + "maatwebsite/excel": "^3.1", "novadaemon/filament-pretty-json": "^2.2", "nwidart/laravel-modules": "^11.0.11", "owen-it/laravel-auditing": "^13.6", "pxlrbt/filament-environment-indicator": "^2.0", "spatie/temporary-directory": "^2.2", + "staudenmeir/eloquent-json-relations": "^1.1", "stechstudio/filament-impersonate": "^3.8", "tightenco/ziggy": "^2.1.0", "tpetry/laravel-postgresql-enhanced": "^2.0.0", diff --git a/composer.lock b/composer.lock index 022cc1f8..4f0067e9 100644 --- a/composer.lock +++ b/composer.lock @@ -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": "84e9d436af2f46e57ecc42a117e94259", + "content-hash": "7b901c08f4d2a3f90c4d667bd1470dcb", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -2093,6 +2093,67 @@ ], "time": "2023-10-06T06:47:41+00:00" }, + { + "name": "ezyang/htmlpurifier", + "version": "v4.17.0", + "source": { + "type": "git", + "url": "https://github.com/ezyang/htmlpurifier.git", + "reference": "bbc513d79acf6691fa9cf10f192c90dd2957f18c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/bbc513d79acf6691fa9cf10f192c90dd2957f18c", + "reference": "bbc513d79acf6691fa9cf10f192c90dd2957f18c", + "shasum": "" + }, + "require": { + "php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0" + }, + "require-dev": { + "cerdic/css-tidy": "^1.7 || ^2.0", + "simpletest/simpletest": "dev-master" + }, + "suggest": { + "cerdic/css-tidy": "If you want to use the filter 'Filter.ExtractStyleBlocks'.", + "ext-bcmath": "Used for unit conversion and imagecrash protection", + "ext-iconv": "Converts text to and from non-UTF-8 encodings", + "ext-tidy": "Used for pretty-printing HTML" + }, + "type": "library", + "autoload": { + "files": [ + "library/HTMLPurifier.composer.php" + ], + "psr-0": { + "HTMLPurifier": "library/" + }, + "exclude-from-classmap": [ + "/library/HTMLPurifier/Language/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "Edward Z. Yang", + "email": "admin@htmlpurifier.org", + "homepage": "http://ezyang.com" + } + ], + "description": "Standards compliant HTML filter written in PHP", + "homepage": "http://htmlpurifier.org/", + "keywords": [ + "html" + ], + "support": { + "issues": "https://github.com/ezyang/htmlpurifier/issues", + "source": "https://github.com/ezyang/htmlpurifier/tree/v4.17.0" + }, + "time": "2023-11-17T15:01:25+00:00" + }, { "name": "filament/actions", "version": "v3.2.115", @@ -5435,6 +5496,271 @@ ], "time": "2024-07-15T18:27:32+00:00" }, + { + "name": "maatwebsite/excel", + "version": "3.1.58", + "source": { + "type": "git", + "url": "https://github.com/SpartnerNL/Laravel-Excel.git", + "reference": "18495a71b112f43af8ffab35111a58b4e4ba4a4d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SpartnerNL/Laravel-Excel/zipball/18495a71b112f43af8ffab35111a58b4e4ba4a4d", + "reference": "18495a71b112f43af8ffab35111a58b4e4ba4a4d", + "shasum": "" + }, + "require": { + "composer/semver": "^3.3", + "ext-json": "*", + "illuminate/support": "5.8.*||^6.0||^7.0||^8.0||^9.0||^10.0||^11.0", + "php": "^7.0||^8.0", + "phpoffice/phpspreadsheet": "^1.29.1", + "psr/simple-cache": "^1.0||^2.0||^3.0" + }, + "require-dev": { + "laravel/scout": "^7.0||^8.0||^9.0||^10.0", + "orchestra/testbench": "^6.0||^7.0||^8.0||^9.0", + "predis/predis": "^1.1" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Maatwebsite\\Excel\\ExcelServiceProvider" + ], + "aliases": { + "Excel": "Maatwebsite\\Excel\\Facades\\Excel" + } + } + }, + "autoload": { + "psr-4": { + "Maatwebsite\\Excel\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Patrick Brouwers", + "email": "patrick@spartner.nl" + } + ], + "description": "Supercharged Excel exports and imports in Laravel", + "keywords": [ + "PHPExcel", + "batch", + "csv", + "excel", + "export", + "import", + "laravel", + "php", + "phpspreadsheet" + ], + "support": { + "issues": "https://github.com/SpartnerNL/Laravel-Excel/issues", + "source": "https://github.com/SpartnerNL/Laravel-Excel/tree/3.1.58" + }, + "funding": [ + { + "url": "https://laravel-excel.com/commercial-support", + "type": "custom" + }, + { + "url": "https://github.com/patrickbrouwers", + "type": "github" + } + ], + "time": "2024-09-07T13:53:36+00:00" + }, + { + "name": "maennchen/zipstream-php", + "version": "3.1.1", + "source": { + "type": "git", + "url": "https://github.com/maennchen/ZipStream-PHP.git", + "reference": "6187e9cc4493da94b9b63eb2315821552015fca9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/6187e9cc4493da94b9b63eb2315821552015fca9", + "reference": "6187e9cc4493da94b9b63eb2315821552015fca9", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "ext-zlib": "*", + "php-64bit": "^8.1" + }, + "require-dev": { + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.16", + "guzzlehttp/guzzle": "^7.5", + "mikey179/vfsstream": "^1.6", + "php-coveralls/php-coveralls": "^2.5", + "phpunit/phpunit": "^10.0", + "vimeo/psalm": "^5.0" + }, + "suggest": { + "guzzlehttp/psr7": "^2.4", + "psr/http-message": "^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "ZipStream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paul Duncan", + "email": "pabs@pablotron.org" + }, + { + "name": "Jonatan Männchen", + "email": "jonatan@maennchen.ch" + }, + { + "name": "Jesse Donat", + "email": "donatj@gmail.com" + }, + { + "name": "András Kolesár", + "email": "kolesar@kolesar.hu" + } + ], + "description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.", + "keywords": [ + "stream", + "zip" + ], + "support": { + "issues": "https://github.com/maennchen/ZipStream-PHP/issues", + "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.1.1" + }, + "funding": [ + { + "url": "https://github.com/maennchen", + "type": "github" + } + ], + "time": "2024-10-10T12:33:01+00:00" + }, + { + "name": "markbaker/complex", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/MarkBaker/PHPComplex.git", + "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9", + "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-master", + "phpcompatibility/php-compatibility": "^9.3", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Complex\\": "classes/src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mark Baker", + "email": "mark@lange.demon.co.uk" + } + ], + "description": "PHP Class for working with complex numbers", + "homepage": "https://github.com/MarkBaker/PHPComplex", + "keywords": [ + "complex", + "mathematics" + ], + "support": { + "issues": "https://github.com/MarkBaker/PHPComplex/issues", + "source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2" + }, + "time": "2022-12-06T16:21:08+00:00" + }, + { + "name": "markbaker/matrix", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/MarkBaker/PHPMatrix.git", + "reference": "728434227fe21be27ff6d86621a1b13107a2562c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c", + "reference": "728434227fe21be27ff6d86621a1b13107a2562c", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-master", + "phpcompatibility/php-compatibility": "^9.3", + "phpdocumentor/phpdocumentor": "2.*", + "phploc/phploc": "^4.0", + "phpmd/phpmd": "2.*", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", + "sebastian/phpcpd": "^4.0", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Matrix\\": "classes/src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mark Baker", + "email": "mark@demon-angel.eu" + } + ], + "description": "PHP Class for working with matrices", + "homepage": "https://github.com/MarkBaker/PHPMatrix", + "keywords": [ + "mathematics", + "matrix", + "vector" + ], + "support": { + "issues": "https://github.com/MarkBaker/PHPMatrix/issues", + "source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1" + }, + "time": "2022-12-02T22:17:43+00:00" + }, { "name": "masterminds/html5", "version": "2.9.0", @@ -6670,6 +6996,111 @@ }, "time": "2020-10-15T08:29:30+00:00" }, + { + "name": "phpoffice/phpspreadsheet", + "version": "1.29.2", + "source": { + "type": "git", + "url": "https://github.com/PHPOffice/PhpSpreadsheet.git", + "reference": "3a5a818d7d3e4b5bd2e56fb9de44dbded6eae07f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/3a5a818d7d3e4b5bd2e56fb9de44dbded6eae07f", + "reference": "3a5a818d7d3e4b5bd2e56fb9de44dbded6eae07f", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-dom": "*", + "ext-fileinfo": "*", + "ext-gd": "*", + "ext-iconv": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-simplexml": "*", + "ext-xml": "*", + "ext-xmlreader": "*", + "ext-xmlwriter": "*", + "ext-zip": "*", + "ext-zlib": "*", + "ezyang/htmlpurifier": "^4.15", + "maennchen/zipstream-php": "^2.1 || ^3.0", + "markbaker/complex": "^3.0", + "markbaker/matrix": "^3.0", + "php": "^7.4 || ^8.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-main", + "dompdf/dompdf": "^1.0 || ^2.0", + "friendsofphp/php-cs-fixer": "^3.2", + "mitoteam/jpgraph": "^10.3", + "mpdf/mpdf": "^8.1.1", + "phpcompatibility/php-compatibility": "^9.3", + "phpstan/phpstan": "^1.1", + "phpstan/phpstan-phpunit": "^1.0", + "phpunit/phpunit": "^8.5 || ^9.0", + "squizlabs/php_codesniffer": "^3.7", + "tecnickcom/tcpdf": "^6.5" + }, + "suggest": { + "dompdf/dompdf": "Option for rendering PDF with PDF Writer", + "ext-intl": "PHP Internationalization Functions", + "mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers", + "mpdf/mpdf": "Option for rendering PDF with PDF Writer", + "tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer" + }, + "type": "library", + "autoload": { + "psr-4": { + "PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Maarten Balliauw", + "homepage": "https://blog.maartenballiauw.be" + }, + { + "name": "Mark Baker", + "homepage": "https://markbakeruk.net" + }, + { + "name": "Franck Lefevre", + "homepage": "https://rootslabs.net" + }, + { + "name": "Erik Tilt" + }, + { + "name": "Adrien Crivelli" + } + ], + "description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine", + "homepage": "https://github.com/PHPOffice/PhpSpreadsheet", + "keywords": [ + "OpenXML", + "excel", + "gnumeric", + "ods", + "php", + "spreadsheet", + "xls", + "xlsx" + ], + "support": { + "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues", + "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/1.29.2" + }, + "time": "2024-09-29T07:04:47+00:00" + }, { "name": "phpoption/phpoption", "version": "1.9.3", @@ -8407,6 +8838,109 @@ ], "time": "2023-12-25T11:46:58+00:00" }, + { + "name": "staudenmeir/eloquent-has-many-deep-contracts", + "version": "v1.2.1", + "source": { + "type": "git", + "url": "https://github.com/staudenmeir/eloquent-has-many-deep-contracts.git", + "reference": "3ad76c6eeda60042f262d113bf471dcce584d88b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staudenmeir/eloquent-has-many-deep-contracts/zipball/3ad76c6eeda60042f262d113bf471dcce584d88b", + "reference": "3ad76c6eeda60042f262d113bf471dcce584d88b", + "shasum": "" + }, + "require": { + "illuminate/database": "^11.0", + "php": "^8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Staudenmeir\\EloquentHasManyDeepContracts\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jonas Staudenmeir", + "email": "mail@jonas-staudenmeir.de" + } + ], + "description": "Contracts for staudenmeir/eloquent-has-many-deep", + "support": { + "issues": "https://github.com/staudenmeir/eloquent-has-many-deep-contracts/issues", + "source": "https://github.com/staudenmeir/eloquent-has-many-deep-contracts/tree/v1.2.1" + }, + "time": "2024-09-25T18:24:22+00:00" + }, + { + "name": "staudenmeir/eloquent-json-relations", + "version": "v1.13.1", + "source": { + "type": "git", + "url": "https://github.com/staudenmeir/eloquent-json-relations.git", + "reference": "65533e304061ee649c0bcfd0e0da9376712e8b0e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staudenmeir/eloquent-json-relations/zipball/65533e304061ee649c0bcfd0e0da9376712e8b0e", + "reference": "65533e304061ee649c0bcfd0e0da9376712e8b0e", + "shasum": "" + }, + "require": { + "illuminate/database": "^11.0", + "php": "^8.2", + "staudenmeir/eloquent-has-many-deep-contracts": "^1.2" + }, + "require-dev": { + "barryvdh/laravel-ide-helper": "^3.0", + "larastan/larastan": "^2.9", + "orchestra/testbench": "^9.0", + "phpunit/phpunit": "^11.0", + "staudenmeir/eloquent-has-many-deep": "^1.20" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Staudenmeir\\EloquentJsonRelations\\IdeHelperServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Staudenmeir\\EloquentJsonRelations\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jonas Staudenmeir", + "email": "mail@jonas-staudenmeir.de" + } + ], + "description": "Laravel Eloquent relationships with JSON keys", + "support": { + "issues": "https://github.com/staudenmeir/eloquent-json-relations/issues", + "source": "https://github.com/staudenmeir/eloquent-json-relations/tree/v1.13.1" + }, + "funding": [ + { + "url": "https://paypal.me/JonasStaudenmeir", + "type": "custom" + } + ], + "time": "2024-10-06T19:12:12+00:00" + }, { "name": "stechstudio/filament-impersonate", "version": "3.14", diff --git a/config/excel.php b/config/excel.php new file mode 100644 index 00000000..22ade426 --- /dev/null +++ b/config/excel.php @@ -0,0 +1,382 @@ + [ + + /* + |-------------------------------------------------------------------------- + | Chunk size + |-------------------------------------------------------------------------- + | + | When using FromQuery, the query is automatically chunked. + | Here you can specify how big the chunk should be. + | + */ + 'chunk_size' => 1000, + + /* + |-------------------------------------------------------------------------- + | Pre-calculate formulas during export + |-------------------------------------------------------------------------- + */ + 'pre_calculate_formulas' => false, + + /* + |-------------------------------------------------------------------------- + | Enable strict null comparison + |-------------------------------------------------------------------------- + | + | When enabling strict null comparison empty cells ('') will + | be added to the sheet. + */ + 'strict_null_comparison' => false, + + /* + |-------------------------------------------------------------------------- + | CSV Settings + |-------------------------------------------------------------------------- + | + | Configure e.g. delimiter, enclosure and line ending for CSV exports. + | + */ + 'csv' => [ + 'delimiter' => ',', + 'enclosure' => '"', + 'line_ending' => PHP_EOL, + 'use_bom' => false, + 'include_separator_line' => false, + 'excel_compatibility' => false, + 'output_encoding' => '', + 'test_auto_detect' => true, + ], + + /* + |-------------------------------------------------------------------------- + | Worksheet properties + |-------------------------------------------------------------------------- + | + | Configure e.g. default title, creator, subject,... + | + */ + 'properties' => [ + 'creator' => '', + 'lastModifiedBy' => '', + 'title' => '', + 'description' => '', + 'subject' => '', + 'keywords' => '', + 'category' => '', + 'manager' => '', + 'company' => '', + ], + ], + + 'imports' => [ + + /* + |-------------------------------------------------------------------------- + | Read Only + |-------------------------------------------------------------------------- + | + | When dealing with imports, you might only be interested in the + | data that the sheet exists. By default we ignore all styles, + | however if you want to do some logic based on style data + | you can enable it by setting read_only to false. + | + */ + 'read_only' => true, + + /* + |-------------------------------------------------------------------------- + | Ignore Empty + |-------------------------------------------------------------------------- + | + | When dealing with imports, you might be interested in ignoring + | rows that have null values or empty strings. By default rows + | containing empty strings or empty values are not ignored but can be + | ignored by enabling the setting ignore_empty to true. + | + */ + 'ignore_empty' => false, + + /* + |-------------------------------------------------------------------------- + | Heading Row Formatter + |-------------------------------------------------------------------------- + | + | Configure the heading row formatter. + | Available options: none|slug|custom + | + */ + 'heading_row' => [ + 'formatter' => 'slug', + ], + + /* + |-------------------------------------------------------------------------- + | CSV Settings + |-------------------------------------------------------------------------- + | + | Configure e.g. delimiter, enclosure and line ending for CSV imports. + | + */ + 'csv' => [ + 'delimiter' => null, + 'enclosure' => '"', + 'escape_character' => '\\', + 'contiguous' => false, + 'input_encoding' => Csv::GUESS_ENCODING, + ], + + /* + |-------------------------------------------------------------------------- + | Worksheet properties + |-------------------------------------------------------------------------- + | + | Configure e.g. default title, creator, subject,... + | + */ + 'properties' => [ + 'creator' => '', + 'lastModifiedBy' => '', + 'title' => '', + 'description' => '', + 'subject' => '', + 'keywords' => '', + 'category' => '', + 'manager' => '', + 'company' => '', + ], + + /* + |-------------------------------------------------------------------------- + | Cell Middleware + |-------------------------------------------------------------------------- + | + | Configure middleware that is executed on getting a cell value + | + */ + 'cells' => [ + 'middleware' => [ + //\Maatwebsite\Excel\Middleware\TrimCellValue::class, + //\Maatwebsite\Excel\Middleware\ConvertEmptyCellValuesToNull::class, + ], + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Extension detector + |-------------------------------------------------------------------------- + | + | Configure here which writer/reader type should be used when the package + | needs to guess the correct type based on the extension alone. + | + */ + 'extension_detector' => [ + 'xlsx' => Excel::XLSX, + 'xlsm' => Excel::XLSX, + 'xltx' => Excel::XLSX, + 'xltm' => Excel::XLSX, + 'xls' => Excel::XLS, + 'xlt' => Excel::XLS, + 'ods' => Excel::ODS, + 'ots' => Excel::ODS, + 'slk' => Excel::SLK, + 'xml' => Excel::XML, + 'gnumeric' => Excel::GNUMERIC, + 'htm' => Excel::HTML, + 'html' => Excel::HTML, + 'csv' => Excel::CSV, + 'tsv' => Excel::TSV, + + /* + |-------------------------------------------------------------------------- + | PDF Extension + |-------------------------------------------------------------------------- + | + | Configure here which Pdf driver should be used by default. + | Available options: Excel::MPDF | Excel::TCPDF | Excel::DOMPDF + | + */ + 'pdf' => Excel::DOMPDF, + ], + + /* + |-------------------------------------------------------------------------- + | Value Binder + |-------------------------------------------------------------------------- + | + | PhpSpreadsheet offers a way to hook into the process of a value being + | written to a cell. In there some assumptions are made on how the + | value should be formatted. If you want to change those defaults, + | you can implement your own default value binder. + | + | Possible value binders: + | + | [x] Maatwebsite\Excel\DefaultValueBinder::class + | [x] PhpOffice\PhpSpreadsheet\Cell\StringValueBinder::class + | [x] PhpOffice\PhpSpreadsheet\Cell\AdvancedValueBinder::class + | + */ + 'value_binder' => [ + 'default' => Maatwebsite\Excel\DefaultValueBinder::class, + ], + + 'cache' => [ + /* + |-------------------------------------------------------------------------- + | Default cell caching driver + |-------------------------------------------------------------------------- + | + | By default PhpSpreadsheet keeps all cell values in memory, however when + | dealing with large files, this might result into memory issues. If you + | want to mitigate that, you can configure a cell caching driver here. + | When using the illuminate driver, it will store each value in the + | cache store. This can slow down the process, because it needs to + | store each value. You can use the "batch" store if you want to + | only persist to the store when the memory limit is reached. + | + | Drivers: memory|illuminate|batch + | + */ + 'driver' => 'memory', + + /* + |-------------------------------------------------------------------------- + | Batch memory caching + |-------------------------------------------------------------------------- + | + | When dealing with the "batch" caching driver, it will only + | persist to the store when the memory limit is reached. + | Here you can tweak the memory limit to your liking. + | + */ + 'batch' => [ + 'memory_limit' => 60000, + ], + + /* + |-------------------------------------------------------------------------- + | Illuminate cache + |-------------------------------------------------------------------------- + | + | When using the "illuminate" caching driver, it will automatically use + | your default cache store. However if you prefer to have the cell + | cache on a separate store, you can configure the store name here. + | You can use any store defined in your cache config. When leaving + | at "null" it will use the default store. + | + */ + 'illuminate' => [ + 'store' => null, + ], + + /* + |-------------------------------------------------------------------------- + | Cache Time-to-live (TTL) + |-------------------------------------------------------------------------- + | + | The TTL of items written to cache. If you want to keep the items cached + | indefinitely, set this to null. Otherwise, set a number of seconds, + | a \DateInterval, or a callable. + | + | Allowable types: callable|\DateInterval|int|null + | + */ + 'default_ttl' => 10800, + ], + + /* + |-------------------------------------------------------------------------- + | Transaction Handler + |-------------------------------------------------------------------------- + | + | By default the import is wrapped in a transaction. This is useful + | for when an import may fail and you want to retry it. With the + | transactions, the previous import gets rolled-back. + | + | You can disable the transaction handler by setting this to null. + | Or you can choose a custom made transaction handler here. + | + | Supported handlers: null|db + | + */ + 'transactions' => [ + 'handler' => 'db', + 'db' => [ + 'connection' => null, + ], + ], + + 'temporary_files' => [ + + /* + |-------------------------------------------------------------------------- + | Local Temporary Path + |-------------------------------------------------------------------------- + | + | When exporting and importing files, we use a temporary file, before + | storing reading or downloading. Here you can customize that path. + | permissions is an array with the permission flags for the directory (dir) + | and the create file (file). + | + */ + 'local_path' => storage_path('framework/cache/laravel-excel'), + + /* + |-------------------------------------------------------------------------- + | Local Temporary Path Permissions + |-------------------------------------------------------------------------- + | + | Permissions is an array with the permission flags for the directory (dir) + | and the create file (file). + | If omitted the default permissions of the filesystem will be used. + | + */ + 'local_permissions' => [ + // 'dir' => 0755, + // 'file' => 0644, + ], + + /* + |-------------------------------------------------------------------------- + | Remote Temporary Disk + |-------------------------------------------------------------------------- + | + | When dealing with a multi server setup with queues in which you + | cannot rely on having a shared local temporary path, you might + | want to store the temporary file on a shared disk. During the + | queue executing, we'll retrieve the temporary file from that + | location instead. When left to null, it will always use + | the local path. This setting only has effect when using + | in conjunction with queued imports and exports. + | + */ + 'remote_disk' => null, + 'remote_prefix' => null, + + /* + |-------------------------------------------------------------------------- + | Force Resync + |-------------------------------------------------------------------------- + | + | When dealing with a multi server setup as above, it's possible + | for the clean up that occurs after entire queue has been run to only + | cleanup the server that the last AfterImportJob runs on. The rest of the server + | would still have the local temporary file stored on it. In this case your + | local storage limits can be exceeded and future imports won't be processed. + | To mitigate this you can set this config value to be true, so that after every + | queued chunk is processed the local temporary file is deleted on the server that + | processed it. + | + */ + 'force_resync_remote' => null, + ], +]; diff --git a/routes/api.php b/routes/api.php index 28ea0a41..00dd005d 100644 --- a/routes/api.php +++ b/routes/api.php @@ -87,6 +87,7 @@ // Time entry routes Route::name('time-entries.')->group(static function (): void { 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::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'); From a14dfdec23930e8cfcd737526e82782d428c8309 Mon Sep 17 00:00:00 2001 From: Gregor Vostrak Date: Wed, 16 Oct 2024 14:50:56 +0200 Subject: [PATCH 02/11] add Export download buttons --- .../Api/V1/TimeEntryController.php | 2 + resources/js/Pages/Reporting.vue | 67 +++++++- .../packages/api/src/openapi.json.client.ts | 149 +++++++++++++++--- 3 files changed, 192 insertions(+), 26 deletions(-) diff --git a/app/Http/Controllers/Api/V1/TimeEntryController.php b/app/Http/Controllers/Api/V1/TimeEntryController.php index 4d4b3624..e67218ce 100644 --- a/app/Http/Controllers/Api/V1/TimeEntryController.php +++ b/app/Http/Controllers/Api/V1/TimeEntryController.php @@ -147,6 +147,8 @@ private function getTimeEntriesQuery(Organization $organization, TimeEntryIndexR /** * @throws AuthorizationException + * + * @operationId exportTimeEntries */ public function indexExport(Organization $organization, TimeEntryIndexExportRequest $request): JsonResponse { diff --git a/resources/js/Pages/Reporting.vue b/resources/js/Pages/Reporting.vue index edc6ecbc..aec899de 100644 --- a/resources/js/Pages/Reporting.vue +++ b/resources/js/Pages/Reporting.vue @@ -8,10 +8,12 @@ import { UserGroupIcon, CheckCircleIcon, TagIcon, + ArrowDownTrayIcon, } from '@heroicons/vue/20/solid'; import DateRangePicker from '@/packages/ui/src/Input/DateRangePicker.vue'; import ReportingChart from '@/Components/Common/Reporting/ReportingChart.vue'; import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue'; + import { onMounted, ref } from 'vue'; import { formatHumanReadableDuration, @@ -21,7 +23,7 @@ import { import { type GroupingOption, useReportingStore } from '@/utils/useReporting'; import { storeToRefs } from 'pinia'; import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue'; -import type { AggregatedTimeEntriesQueryParams } from '@/packages/api/src'; +import { type AggregatedTimeEntriesQueryParams, api } from '@/packages/api/src'; import ReportingFilterBadge from '@/Components/Common/Reporting/ReportingFilterBadge.vue'; import ProjectMultiselectDropdown from '@/Components/Common/Project/ProjectMultiselectDropdown.vue'; import MemberMultiselectDropdown from '@/Components/Common/Member/MemberMultiselectDropdown.vue'; @@ -31,7 +33,11 @@ import ReportingGroupBySelect from '@/Components/Common/Reporting/ReportingGroup import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue'; import { getOrganizationCurrencyString } from '@/utils/money'; import ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue'; -import { getCurrentMembershipId, getCurrentRole } from '@/utils/useUser'; +import { + getCurrentMembershipId, + getCurrentOrganizationId, + getCurrentRole, +} from '@/utils/useUser'; import ClientMultiselectDropdown from '@/Components/Common/Client/ClientMultiselectDropdown.vue'; import { useTagsStore } from '@/utils/useTags'; import { formatCents } from '@/packages/ui/src/utils/money'; @@ -39,6 +45,10 @@ import { useSessionStorage, useStorage } from '@vueuse/core'; import TabBar from '@/Components/Common/TabBar/TabBar.vue'; import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue'; import { router } from '@inertiajs/vue3'; +import { SecondaryButton } from '@/packages/ui/src'; +import Dropdown from '@/packages/ui/src/Input/Dropdown.vue'; +import { useNotificationsStore } from '@/utils/notification'; +const { handleApiRequestNotifications } = useNotificationsStore(); const startDate = useSessionStorage( 'reporting-start-date', @@ -149,6 +159,29 @@ const { tags } = storeToRefs(useTagsStore()); async function createTag(tag: string) { return await useTagsStore().createTag(tag); } + +type ExportFormat = 'xlsx' | 'csv' | 'ods'; + +async function downloadExport(format: ExportFormat) { + const organizationId = getCurrentOrganizationId(); + if (organizationId) { + const response = await handleApiRequestNotifications( + () => + api.exportTimeEntries({ + params: { + organization: organizationId, + }, + queries: { + ...getFilterAttributes(), + format: format, + }, + }), + 'Export successful', + 'Export failed' + ); + window.open(response.download_url, '_self')?.focus(); + } +}