mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-06-15 13:32:43 +01:00
Add pdf detailed report and placeholder for aggregate endpoint
This commit is contained in:
committed by
Constantin Graf
parent
5593d141ea
commit
b0bcc4f330
@@ -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
|
||||||
|
|||||||
10
app/Exceptions/Api/PdfRendererIsNotConfiguredException.php
Normal file
10
app/Exceptions/Api/PdfRendererIsNotConfiguredException.php
Normal 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';
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(', '),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
162
composer.lock
generated
@@ -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
9
config/services.php
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'gotenberg' => [
|
||||||
|
'url' => env('GOTENBERG_URL'),
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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.',
|
||||||
];
|
];
|
||||||
|
|||||||
15
resources/views/reports/time-entry-index-footer.blade.php
Normal file
15
resources/views/reports/time-entry-index-footer.blade.php
Normal 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>
|
||||||
60
resources/views/reports/time-entry-index.blade.php
Normal file
60
resources/views/reports/time-entry-index.blade.php
Normal 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>
|
||||||
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user