diff --git a/app/Actions/Jetstream/UpdateMemberRole.php b/app/Actions/Jetstream/UpdateMemberRole.php index 13a52fb8..d40c36ad 100644 --- a/app/Actions/Jetstream/UpdateMemberRole.php +++ b/app/Actions/Jetstream/UpdateMemberRole.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace App\Actions\Jetstream; use App\Enums\Role; -use App\Models\Membership; +use App\Models\Member; use App\Models\Organization; use App\Models\User; use App\Service\PermissionStore; @@ -32,7 +32,7 @@ class UpdateMemberRole } $user = User::where('id', '=', $userId)->firstOrFail(); - $member = Membership::whereBelongsTo($user)->whereBelongsTo($organization)->firstOrFail(); + $member = Member::whereBelongsTo($user)->whereBelongsTo($organization)->firstOrFail(); if ($member->role === Role::Placeholder->value) { abort(403, 'Cannot update the role of a placeholder member.'); } diff --git a/app/Console/Commands/Test/TestJobCommand.php b/app/Console/Commands/Test/TestJobCommand.php index b836d6c6..c4e3d1a9 100644 --- a/app/Console/Commands/Test/TestJobCommand.php +++ b/app/Console/Commands/Test/TestJobCommand.php @@ -29,7 +29,7 @@ class TestJobCommand extends Command */ public function handle(): int { - $user = User::first(); + $user = User::firstOrFail(); TestJob::dispatch($user, 'Test job message.'); return self::SUCCESS; diff --git a/app/Enums/TimeEntryAggregationType.php b/app/Enums/TimeEntryAggregationType.php new file mode 100644 index 00000000..97a331f2 --- /dev/null +++ b/app/Enums/TimeEntryAggregationType.php @@ -0,0 +1,29 @@ + TimeEntryAggregationTypeInterval::Day, + TimeEntryAggregationType::Week => TimeEntryAggregationTypeInterval::Week, + TimeEntryAggregationType::Month => TimeEntryAggregationTypeInterval::Month, + TimeEntryAggregationType::Year => TimeEntryAggregationTypeInterval::Year, + default => null + }; + } +} diff --git a/app/Enums/TimeEntryAggregationTypeInterval.php b/app/Enums/TimeEntryAggregationTypeInterval.php new file mode 100644 index 00000000..1ad2631d --- /dev/null +++ b/app/Enums/TimeEntryAggregationTypeInterval.php @@ -0,0 +1,13 @@ + Weekday::Sunday, + Weekday::Tuesday => Weekday::Monday, + Weekday::Wednesday => Weekday::Tuesday, + Weekday::Thursday => Weekday::Wednesday, + Weekday::Friday => Weekday::Thursday, + Weekday::Saturday => Weekday::Friday, + Weekday::Sunday => Weekday::Saturday, + }; + } + public function carbonWeekDay(): int { return match ($this) { diff --git a/app/Exceptions/Api/CanNotRemoveOwnerFromOrganization.php b/app/Exceptions/Api/CanNotRemoveOwnerFromOrganization.php new file mode 100644 index 00000000..6582e41f --- /dev/null +++ b/app/Exceptions/Api/CanNotRemoveOwnerFromOrganization.php @@ -0,0 +1,10 @@ +icon('heroicon-o-inbox-arrow-down') ->action(function (Organization $record, array $data) { try { + $file = Storage::disk(config('filament.default_filesystem_disk'))->get($data['file']); + if ($file === null) { + throw new \Exception('File not found'); + } /** @var ReportDto $report */ $report = app(ImportService::class)->import( $record, $data['type'], - Storage::disk(config('filament.default_filesystem_disk'))->get($data['file']) + $file ); Notification::make() ->title('Import successful') diff --git a/app/Http/Controllers/Api/V1/Controller.php b/app/Http/Controllers/Api/V1/Controller.php index 6843a7e8..5d97fab7 100644 --- a/app/Http/Controllers/Api/V1/Controller.php +++ b/app/Http/Controllers/Api/V1/Controller.php @@ -4,9 +4,13 @@ declare(strict_types=1); namespace App\Http\Controllers\Api\V1; +use App\Models\Member; use App\Models\Organization; +use App\Models\User; use App\Service\PermissionStore; use Illuminate\Auth\Access\AuthorizationException; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Log; class Controller extends \App\Http\Controllers\Controller { @@ -25,8 +29,53 @@ class Controller extends \App\Http\Controllers\Controller } } + /** + * @param array $permissions + * + * @throws AuthorizationException + */ + protected function checkAnyPermission(Organization $organization, array $permissions): void + { + foreach ($permissions as $permission) { + if ($this->permissionStore->has($organization, $permission)) { + return; + } + } + throw new AuthorizationException(); + } + protected function hasPermission(Organization $organization, string $permission): bool { return $this->permissionStore->has($organization, $permission); } + + /** + * @throws AuthorizationException + */ + protected function user(): User + { + /** @var User|null $user */ + $user = Auth::user(); + if ($user === null) { + Log::error('This function should only be called in authenticated context'); + throw new AuthorizationException(); + } + + return $user; + } + + /** + * @throws AuthorizationException + */ + protected function member(Organization $organization): Member + { + $user = $this->user(); + $member = Member::query()->whereBelongsTo($organization, 'organization')->whereBelongsTo($user, 'user')->first(); + if ($member === null) { + Log::error('This function should only be called in authenticated context after checking the user is a member of the organization'); + throw new AuthorizationException(); + } + + return $member; + } } diff --git a/app/Http/Controllers/Api/V1/InvitationController.php b/app/Http/Controllers/Api/V1/InvitationController.php index e7d45a79..0786c72d 100644 --- a/app/Http/Controllers/Api/V1/InvitationController.php +++ b/app/Http/Controllers/Api/V1/InvitationController.php @@ -57,7 +57,7 @@ class InvitationController extends Controller $this->checkPermission($organization, 'invitations:create'); app(InvitesTeamMembers::class)->invite( - $request->user(), + $this->user(), $organization, $request->input('email'), $request->input('role') diff --git a/app/Http/Controllers/Api/V1/MemberController.php b/app/Http/Controllers/Api/V1/MemberController.php index 786bc527..450e2dce 100644 --- a/app/Http/Controllers/Api/V1/MemberController.php +++ b/app/Http/Controllers/Api/V1/MemberController.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace App\Http\Controllers\Api\V1; +use App\Enums\Role; +use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization; use App\Exceptions\Api\EntityStillInUseApiException; use App\Exceptions\Api\UserNotPlaceholderApiException; use App\Http\Requests\V1\Member\MemberIndexRequest; @@ -11,7 +13,7 @@ use App\Http\Requests\V1\Member\MemberUpdateRequest; use App\Http\Resources\V1\Member\MemberCollection; use App\Http\Resources\V1\Member\MemberPivotResource; use App\Http\Resources\V1\Member\MemberResource; -use App\Models\Membership; +use App\Models\Member; use App\Models\Organization; use App\Models\ProjectMember; use App\Models\TimeEntry; @@ -23,10 +25,10 @@ use Laravel\Jetstream\Contracts\InvitesTeamMembers; class MemberController extends Controller { - protected function checkPermission(Organization $organization, string $permission, ?Membership $membership = null): void + protected function checkPermission(Organization $organization, string $permission, ?Member $member = null): void { parent::checkPermission($organization, $permission); - if ($membership !== null && $membership->organization_id !== $organization->id) { + if ($member !== null && $member->organization_id !== $organization->id) { throw new AuthorizationException('Member does not belong to organization'); } } @@ -57,36 +59,39 @@ class MemberController extends Controller * * @operationId updateMember */ - public function update(Organization $organization, Membership $membership, MemberUpdateRequest $request): JsonResource + public function update(Organization $organization, Member $member, MemberUpdateRequest $request): JsonResource { - $this->checkPermission($organization, 'members:update', $membership); + $this->checkPermission($organization, 'members:update', $member); - $membership->billable_rate = $request->input('billable_rate'); - $membership->role = $request->input('role'); - $membership->save(); + $member->billable_rate = $request->input('billable_rate'); + $member->role = $request->input('role'); + $member->save(); - return new MemberResource($membership); + return new MemberResource($member); } /** * Remove a member of the organization. * - * @throws AuthorizationException|EntityStillInUseApiException + * @throws AuthorizationException|EntityStillInUseApiException|CanNotRemoveOwnerFromOrganization * * @operationId removeMember */ - public function destroy(Organization $organization, Membership $membership): JsonResponse + public function destroy(Organization $organization, Member $member): JsonResponse { - $this->checkPermission($organization, 'members:delete', $membership); + $this->checkPermission($organization, 'members:delete', $member); - if (TimeEntry::query()->where('user_id', $membership->user_id)->whereBelongsTo($organization, 'organization')->exists()) { + if (TimeEntry::query()->where('user_id', $member->user_id)->whereBelongsTo($organization, 'organization')->exists()) { throw new EntityStillInUseApiException('member', 'time_entry'); } - if (ProjectMember::query()->whereBelongsToOrganization($organization)->where('user_id', $membership->user_id)->exists()) { + if (ProjectMember::query()->whereBelongsToOrganization($organization)->where('user_id', $member->user_id)->exists()) { throw new EntityStillInUseApiException('member', 'project_member'); } + if ($member->role === Role::Owner->value) { + throw new CanNotRemoveOwnerFromOrganization(); + } - $membership->delete(); + $member->delete(); return response() ->json(null, 204); @@ -99,20 +104,20 @@ class MemberController extends Controller * * @operationId invitePlaceholder */ - public function invitePlaceholder(Organization $organization, Membership $membership, Request $request): JsonResponse + public function invitePlaceholder(Organization $organization, Member $member, Request $request): JsonResponse { - $this->checkPermission($organization, 'members:invite-placeholder', $membership); - $user = $membership->user; + $this->checkPermission($organization, 'members:invite-placeholder', $member); + $user = $member->user; if (! $user->is_placeholder) { throw new UserNotPlaceholderApiException(); } app(InvitesTeamMembers::class)->invite( - $request->user(), + $this->user(), $organization, $user->email, - 'employee' + Role::Employee->value, ); return response()->json(null, 204); diff --git a/app/Http/Controllers/Api/V1/ProjectController.php b/app/Http/Controllers/Api/V1/ProjectController.php index 58c4e248..5e04a9d6 100644 --- a/app/Http/Controllers/Api/V1/ProjectController.php +++ b/app/Http/Controllers/Api/V1/ProjectController.php @@ -17,7 +17,6 @@ use App\Models\User; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Resources\Json\JsonResource; -use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; class ProjectController extends Controller @@ -43,14 +42,13 @@ class ProjectController extends Controller { $this->checkPermission($organization, 'projects:view'); $canViewAllProjects = $this->hasPermission($organization, 'projects:view:all'); - /** @var User $user */ - $user = Auth::user(); + $user = $this->user(); $projectsQuery = Project::query() ->whereBelongsTo($organization, 'organization'); if (! $canViewAllProjects) { - $projectsQuery->visibleByUser($user); + $projectsQuery->visibleByEmployee($user); } $projects = $projectsQuery->paginate(config('app.pagination_per_page_default')); @@ -133,7 +131,7 @@ class ProjectController extends Controller } DB::transaction(function () use (&$project) { - $project->members()->each(function (ProjectMember $member) { + $project->members->each(function (ProjectMember $member) { $member->delete(); }); diff --git a/app/Http/Controllers/Api/V1/ProjectMemberController.php b/app/Http/Controllers/Api/V1/ProjectMemberController.php index b6cd1881..27229506 100644 --- a/app/Http/Controllers/Api/V1/ProjectMemberController.php +++ b/app/Http/Controllers/Api/V1/ProjectMemberController.php @@ -10,10 +10,10 @@ use App\Http\Requests\V1\ProjectMember\ProjectMemberStoreRequest; use App\Http\Requests\V1\ProjectMember\ProjectMemberUpdateRequest; use App\Http\Resources\V1\ProjectMember\ProjectMemberCollection; use App\Http\Resources\V1\ProjectMember\ProjectMemberResource; +use App\Models\Member; use App\Models\Organization; use App\Models\Project; use App\Models\ProjectMember; -use App\Models\User; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Resources\Json\JsonResource; @@ -62,17 +62,18 @@ class ProjectMemberController extends Controller { $this->checkPermission($organization, 'project-members:create', $project); - $user = User::findOrFail((string) $request->input('user_id')); - if ($user->is_placeholder) { + $member = Member::findOrFail((string) $request->input('member_id')); + if ($member->user->is_placeholder) { throw new InactiveUserCanNotBeUsedApiException(); } - if (ProjectMember::whereBelongsTo($project, 'project')->whereBelongsTo($user, 'user')->exists()) { + if (ProjectMember::whereBelongsTo($project, 'project')->whereBelongsTo($member, 'member')->exists()) { throw new UserIsAlreadyMemberOfProjectApiException(); } $projectMember = new ProjectMember(); $projectMember->billable_rate = $request->input('billable_rate'); - $projectMember->user()->associate($user); + $projectMember->member()->associate($member); + $projectMember->user()->associate($member->user); $projectMember->project()->associate($project); $projectMember->save(); diff --git a/app/Http/Controllers/Api/V1/TaskController.php b/app/Http/Controllers/Api/V1/TaskController.php index 0cb0e947..a83ac994 100644 --- a/app/Http/Controllers/Api/V1/TaskController.php +++ b/app/Http/Controllers/Api/V1/TaskController.php @@ -11,14 +11,10 @@ use App\Http\Requests\V1\Task\TaskUpdateRequest; use App\Http\Resources\V1\Task\TaskCollection; use App\Http\Resources\V1\Task\TaskResource; use App\Models\Organization; -use App\Models\Project; use App\Models\Task; -use App\Models\User; use Illuminate\Auth\Access\AuthorizationException; -use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\JsonResponse; use Illuminate\Http\Resources\Json\JsonResource; -use Illuminate\Support\Facades\Auth; class TaskController extends Controller { @@ -43,8 +39,7 @@ class TaskController extends Controller { $this->checkPermission($organization, 'tasks:view'); $canViewAllTasks = $this->hasPermission($organization, 'tasks:view:all'); - /** @var User $user */ - $user = Auth::user(); + $user = $this->user(); $projectId = $request->input('project_id'); @@ -56,10 +51,7 @@ class TaskController extends Controller } if (! $canViewAllTasks) { - $query->whereHas('project', function (Builder $builder) use ($user): void { - /** @var Builder $builder */ - $builder->visibleByUser($user); - }); + $query->visibleByEmployee($user); } $tasks = $query->paginate(config('app.pagination_per_page_default')); diff --git a/app/Http/Controllers/Api/V1/TimeEntryController.php b/app/Http/Controllers/Api/V1/TimeEntryController.php index 8f2fbcec..b42b7488 100644 --- a/app/Http/Controllers/Api/V1/TimeEntryController.php +++ b/app/Http/Controllers/Api/V1/TimeEntryController.php @@ -6,18 +6,23 @@ namespace App\Http\Controllers\Api\V1; use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException; use App\Exceptions\Api\TimeEntryStillRunningApiException; +use App\Http\Requests\V1\TimeEntry\TimeEntryAggregateRequest; use App\Http\Requests\V1\TimeEntry\TimeEntryIndexRequest; use App\Http\Requests\V1\TimeEntry\TimeEntryStoreRequest; +use App\Http\Requests\V1\TimeEntry\TimeEntryUpdateMultipleRequest; use App\Http\Requests\V1\TimeEntry\TimeEntryUpdateRequest; use App\Http\Resources\V1\TimeEntry\TimeEntryCollection; use App\Http\Resources\V1\TimeEntry\TimeEntryResource; +use App\Models\Member; use App\Models\Organization; use App\Models\TimeEntry; +use App\Service\TimeEntryAggregationService; +use App\Service\TimeEntryFilter; use App\Service\TimezoneService; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Resources\Json\JsonResource; -use Illuminate\Support\Carbon; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Log; @@ -43,7 +48,9 @@ class TimeEntryController extends Controller */ public function index(Organization $organization, TimeEntryIndexRequest $request): JsonResource { - if ($request->has('user_id') && $request->get('user_id') === Auth::id()) { + /** @var Member|null $member */ + $member = $request->has('member_id') ? Member::query()->findOrFail($request->get('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'); @@ -53,26 +60,17 @@ class TimeEntryController extends Controller ->whereBelongsTo($organization, 'organization') ->orderBy('start', 'desc'); - if ($request->has('before')) { - $timeEntriesQuery->where('start', '<', Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $request->input('before'), 'UTC')); - } - - if ($request->has('after')) { - $timeEntriesQuery->where('start', '>', Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $request->input('after'), 'UTC')); - } - - if ($request->has('active')) { - if ($request->get('active') === 'true') { - $timeEntriesQuery->whereNull('end'); - } - if ($request->get('active') === 'false') { - $timeEntriesQuery->whereNotNull('end'); - } - } - - if ($request->has('user_id')) { - $timeEntriesQuery->where('user_id', $request->input('user_id')); - } + $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')); $limit = $request->has('limit') ? (int) $request->get('limit', 100) : 100; if ($limit > 1000) { @@ -82,8 +80,8 @@ class TimeEntryController extends Controller $timeEntries = $timeEntriesQuery->get(); - if ($timeEntries->count() === $limit && $request->has('only_full_dates') && (bool) $request->get('only_full_dates') === true) { - $user = Auth::user(); + if ($timeEntries->count() === $limit && $request->getOnlyFullDates()) { + $user = $this->user(); $timezone = app(TimezoneService::class)->getTimezoneFromUser($user); $lastDate = null; /** @var TimeEntry $timeEntry */ @@ -115,6 +113,85 @@ class TimeEntryController extends Controller return new TimeEntryCollection($timeEntries); } + /** + * Get aggregated time entries in organization + * + * This endpoint allows you to filter time entries and aggregate them by different criteria. + * The parameters `group` and `sub_group` allow you to group the time entries by different criteria. + * If the group parameters are all set to `null` or are all missing, the endpoint will aggregate all filtered time entries. + * + * @operationId getAggregatedTimeEntries + * + * @return array{ + * data: array{ + * grouped_type: string|null, + * grouped_data: null|array + * }>, + * seconds: int, + * cost: int + * } + * } + * + * @throws AuthorizationException + */ + public function aggregate(Organization $organization, TimeEntryAggregateRequest $request, TimeEntryAggregationService $aggregationService): array + { + /** @var Member|null $member */ + $member = $request->has('member_id') ? Member::query()->findOrFail($request->get('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 = TimeEntry::query() + ->whereBelongsTo($organization, 'organization'); + + $filter = new TimeEntryFilter($timeEntriesQuery); + $filter->addEndFilter($request->input('end')); + $filter->addStartFilter($request->input('start')); + $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 = $filter->get(); + + $user = $this->user(); + + $group1Type = $request->getGroup(); + $group2Type = $request->getSubGroup(); + + $aggregatedData = $aggregationService->getAggregatedTimeEntries( + $timeEntriesQuery, + $group1Type, + $group2Type, + $user->timezone, + $user->week_start, + $request->getFillGapsInTimeGroups(), + $request->getStart(), + $request->getEnd() + ); + + return [ + 'data' => $aggregatedData, + ]; + } + /** * Create time entry * @@ -125,18 +202,21 @@ class TimeEntryController extends Controller */ public function store(Organization $organization, TimeEntryStoreRequest $request): JsonResource { - if ($request->get('user_id') === Auth::id()) { + /** @var Member $member */ + $member = Member::query()->findOrFail($request->get('member_id')); + if ($member->user_id === Auth::id()) { $this->checkPermission($organization, 'time-entries:create:own'); } else { $this->checkPermission($organization, 'time-entries:create:all'); } - if ($request->get('end') === null && TimeEntry::query()->where('user_id', $request->get('user_id'))->where('end', null)->exists()) { + if ($request->get('end') === null && TimeEntry::query()->whereBelongsTo($member, 'member')->where('end', null)->exists()) { throw new TimeEntryStillRunningApiException(); } $timeEntry = new TimeEntry(); $timeEntry->fill($request->validated()); + $timeEntry->user_id = $member->user_id; $timeEntry->description = $request->get('description') ?? ''; $timeEntry->organization()->associate($organization); $timeEntry->setComputedAttributeValue('billable_rate'); @@ -154,10 +234,12 @@ class TimeEntryController extends Controller */ public function update(Organization $organization, TimeEntry $timeEntry, TimeEntryUpdateRequest $request): JsonResource { - if ($timeEntry->user_id === Auth::id() && $request->get('user_id') === Auth::id()) { - $this->checkPermission($organization, 'time-entries:update:own', $timeEntry); + /** @var Member|null $member */ + $member = $request->has('member_id') ? Member::query()->findOrFail($request->get('member_id')) : null; + if ($timeEntry->member->user_id === Auth::id() && $member?->user_id === Auth::id()) { + $this->checkPermission($organization, 'time-entries:update:own'); } else { - $this->checkPermission($organization, 'time-entries:update:all', $timeEntry); + $this->checkPermission($organization, 'time-entries:update:all'); } if ($timeEntry->end !== null && $request->has('end') && $request->get('end') === null) { @@ -171,6 +253,56 @@ class TimeEntryController extends Controller return new TimeEntryResource($timeEntry); } + /** + * @throws AuthorizationException + */ + public function updateMultiple(Organization $organization, TimeEntryUpdateMultipleRequest $request): JsonResponse + { + $this->checkAnyPermission($organization, ['time-entries:update:all', 'time-entries:update:own']); + $canAccessAll = $this->hasPermission($organization, 'time-entries:update:all'); + + $ids = $request->get('ids'); + + $timeEntries = TimeEntry::query() + ->whereBelongsTo($organization, 'organization') + ->whereIn('id', $ids) + ->get(); + + $changes = $request->get('changes'); + + if (isset($changes['member_id']) && ! $canAccessAll && $this->member($organization)->getKey() !== $changes['member_id']) { + throw new AuthorizationException(); + } + + $success = new Collection(); + $error = new Collection(); + + foreach ($ids as $id) { + $timeEntry = $timeEntries->firstWhere('id', $id); + if ($timeEntry === null) { + // Note: ID wrong or time entry in different organization + $error->push($id); + + continue; + } + if (! $canAccessAll && $timeEntry->user_id !== Auth::id()) { + $error->push($id); + + continue; + + } + + $timeEntry->fill($changes); + $timeEntry->save(); + $success->push($id); + } + + return response()->json([ + 'success' => $success->toArray(), + 'error' => $error->toArray(), + ]); + } + /** * Delete time entry * @@ -180,7 +312,7 @@ class TimeEntryController extends Controller */ public function destroy(Organization $organization, TimeEntry $timeEntry): JsonResponse { - if ($timeEntry->user_id === Auth::id()) { + if ($timeEntry->member->user_id === Auth::id()) { $this->checkPermission($organization, 'time-entries:delete:own', $timeEntry); } else { $this->checkPermission($organization, 'time-entries:delete:all', $timeEntry); diff --git a/app/Http/Controllers/Api/V1/UserTimeEntryController.php b/app/Http/Controllers/Api/V1/UserTimeEntryController.php index 9264c411..7c69fce4 100644 --- a/app/Http/Controllers/Api/V1/UserTimeEntryController.php +++ b/app/Http/Controllers/Api/V1/UserTimeEntryController.php @@ -10,7 +10,6 @@ use App\Models\TimeEntry; use App\Models\User; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\Resources\Json\JsonResource; -use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Log; class UserTimeEntryController extends Controller @@ -24,8 +23,7 @@ class UserTimeEntryController extends Controller */ public function myActive(): JsonResource { - /** @var User $user */ - $user = Auth::user(); + $user = $this->user(); $activeTimeEntriesOfUser = TimeEntry::query() ->whereBelongsTo($user, 'user') diff --git a/app/Http/Controllers/Web/HealthCheckController.php b/app/Http/Controllers/Web/HealthCheckController.php index 3b4c38c2..5851354f 100644 --- a/app/Http/Controllers/Web/HealthCheckController.php +++ b/app/Http/Controllers/Web/HealthCheckController.php @@ -6,9 +6,9 @@ namespace App\Http\Controllers\Web; use App\Http\Controllers\Controller; use App\Models\User; -use Carbon\Carbon; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; diff --git a/app/Http/Middleware/ShareInertiaData.php b/app/Http/Middleware/ShareInertiaData.php index cbe154f3..e5c3cfba 100644 --- a/app/Http/Middleware/ShareInertiaData.php +++ b/app/Http/Middleware/ShareInertiaData.php @@ -85,6 +85,7 @@ class ShareInertiaData 'currency' => $organization->currency, 'membership' => [ 'role' => $organization->membership->role, + 'id' => $organization->membership->id, ], ]; })->all(), diff --git a/app/Http/Requests/V1/ProjectMember/ProjectMemberStoreRequest.php b/app/Http/Requests/V1/ProjectMember/ProjectMemberStoreRequest.php index adb37e53..dd176c9f 100644 --- a/app/Http/Requests/V1/ProjectMember/ProjectMemberStoreRequest.php +++ b/app/Http/Requests/V1/ProjectMember/ProjectMemberStoreRequest.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace App\Http\Requests\V1\ProjectMember; +use App\Models\Member; use App\Models\Organization; -use App\Models\User; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Database\Eloquent\Builder; use Illuminate\Foundation\Http\FormRequest; @@ -24,12 +24,12 @@ class ProjectMemberStoreRequest extends FormRequest public function rules(): array { return [ - 'user_id' => [ + 'member_id' => [ 'required', 'uuid', - new ExistsEloquent(User::class, null, function (Builder $builder): Builder { - /** @var Builder $builder */ - return $builder->belongsToOrganization($this->organization); + new ExistsEloquent(Member::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); }), ], 'billable_rate' => [ diff --git a/app/Http/Requests/V1/Task/TaskIndexRequest.php b/app/Http/Requests/V1/Task/TaskIndexRequest.php index 8d3b7480..1da3d4b6 100644 --- a/app/Http/Requests/V1/Task/TaskIndexRequest.php +++ b/app/Http/Requests/V1/Task/TaskIndexRequest.php @@ -33,7 +33,7 @@ class TaskIndexRequest extends FormRequest $builder = $builder->whereBelongsTo($this->organization, 'organization'); if (! app(PermissionStore::class)->has($this->organization, 'tasks:view:all')) { - $builder = $builder->visibleByUser(Auth::user()); + $builder = $builder->visibleByEmployee(Auth::user()); } return $builder; diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php new file mode 100644 index 00000000..09b5e8f2 --- /dev/null +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php @@ -0,0 +1,168 @@ +> + */ + public function rules(): array + { + return [ + 'group' => [ + 'nullable', + 'required_with:group_2', + Rule::enum(TimeEntryAggregationType::class), + ], + + 'sub_group' => [ + 'nullable', + Rule::enum(TimeEntryAggregationType::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 user ID + 'user_id' => [ + 'string', + 'uuid', + new ExistsEloquent(User::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->belongsToOrganization($this->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 { + 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', + ], + 'fill_gaps_in_time_groups' => [ + 'string', + 'in:true,false', + ], + ]; + } + + public function getGroup(): ?TimeEntryAggregationType + { + return $this->get('group') !== null ? TimeEntryAggregationType::from($this->get('group')) : null; + } + + public function getSubGroup(): ?TimeEntryAggregationType + { + return $this->get('sub_group') !== null ? TimeEntryAggregationType::from($this->get('sub_group')) : null; + } + + public function getFillGapsInTimeGroups(): bool + { + return $this->has('fill_gaps_in_time_groups') && $this->get('fill_gaps_in_time_groups') === 'true'; + } + + public function getStart(): ?Carbon + { + return $this->get('start') !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->get('start'), 'UTC') : null; + } + + public function getEnd(): ?Carbon + { + return $this->get('end') !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->get('end'), 'UTC') : null; + } +} diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php index 09cf44c1..d7f4fae2 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php @@ -4,8 +4,11 @@ declare(strict_types=1); namespace App\Http\Requests\V1\TimeEntry; +use App\Models\Member; use App\Models\Organization; -use App\Models\User; +use App\Models\Project; +use App\Models\Tag; +use App\Models\Task; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Database\Eloquent\Builder; use Illuminate\Foundation\Http\FormRequest; @@ -24,33 +27,90 @@ class TimeEntryIndexRequest extends FormRequest public function rules(): array { return [ - // Filter by user ID - 'user_id' => [ + // Filter by member ID + 'member_id' => [ 'string', 'uuid', - new ExistsEloquent(User::class, null, function (Builder $builder): Builder { - /** @var Builder $builder */ - return $builder->belongsToOrganization($this->organization); + new ExistsEloquent(Member::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); }), ], - // Filter only time entries that have a start date before (not including) the given date (example: 2021-12-31) - 'before' => [ + // 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:after', + 'before:end', ], - // Filter only time entries that have a start date after (not including) the given date (example: 2021-12-31) - 'after' => [ + // 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 only time entries that are active (have no end date, are still running) + // 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', @@ -64,4 +124,9 @@ class TimeEntryIndexRequest extends FormRequest ], ]; } + + public function getOnlyFullDates(): bool + { + return $this->input('only_full_dates', 'false') === 'true'; + } } diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryStoreRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryStoreRequest.php index 840cfb37..10bc8635 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryStoreRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryStoreRequest.php @@ -4,11 +4,11 @@ declare(strict_types=1); namespace App\Http\Requests\V1\TimeEntry; +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; @@ -27,14 +27,14 @@ class TimeEntryStoreRequest extends FormRequest public function rules(): array { return [ - // ID of the user that the time entry should belong to - 'user_id' => [ + // ID of the organization member that the time entry should belong to + 'member_id' => [ 'required', 'string', 'uuid', - new ExistsEloquent(User::class, null, function (Builder $builder): Builder { - /** @var Builder $builder */ - return $builder->belongsToOrganization($this->organization); + new ExistsEloquent(Member::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); }), ], 'project_id' => [ diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryUpdateMultipleRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryUpdateMultipleRequest.php new file mode 100644 index 00000000..d4d27767 --- /dev/null +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryUpdateMultipleRequest.php @@ -0,0 +1,102 @@ +> + */ + public function rules(): array + { + return [ + 'ids' => [ + 'required', + 'array', + ], + 'ids.*' => [ + 'string', + 'uuid', + ], + 'changes' => [ + 'required', + 'array', + ], + // ID of the organization member that the time entry should belong to + 'changes.member_id' => [ + 'string', + 'uuid', + new ExistsEloquent(Member::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + }), + ], + // ID of the project that the time entry should belong to + 'changes.project_id' => [ + 'nullable', + 'string', + 'uuid', + 'required_with:task_id', + new ExistsEloquent(Project::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + }), + ], + // ID of the task that the time entry should belong to + 'changes.task_id' => [ + 'nullable', + 'string', + 'uuid', + new ExistsEloquent(Task::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + }), + (new ExistsEloquent(Task::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization') + ->where('project_id', $this->input('changes.project_id')); + }))->withMessage(__('validation.task_belongs_to_project')), + ], + // Whether time entry is billable + 'changes.billable' => [ + 'boolean', + ], + // Description of time entry + 'changes.description' => [ + 'nullable', + 'string', + 'max:500', + ], + // List of tag IDs + 'changes.tags' => [ + 'nullable', + 'array', + ], + 'changes.tags.*' => [ + 'string', + 'uuid', + new ExistsEloquent(Tag::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + }), + ], + ]; + } +} diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryUpdateRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryUpdateRequest.php index a61f990f..6fd1e7e1 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryUpdateRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryUpdateRequest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Http\Requests\V1\TimeEntry; +use App\Models\Member; use App\Models\Organization; use App\Models\Project; use App\Models\Tag; @@ -26,6 +27,16 @@ class TimeEntryUpdateRequest extends FormRequest public function rules(): array { return [ + // ID of the organization member that the time entry should belong to + 'member_id' => [ + 'string', + 'uuid', + new ExistsEloquent(Member::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + }), + ], + // ID of the project that the time entry should belong to 'project_id' => [ 'nullable', 'string', diff --git a/app/Http/Resources/V1/BaseResource.php b/app/Http/Resources/V1/BaseResource.php index 5ffefb03..e757881c 100644 --- a/app/Http/Resources/V1/BaseResource.php +++ b/app/Http/Resources/V1/BaseResource.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace App\Http\Resources\V1; -use Carbon\Carbon; use Illuminate\Http\Resources\Json\JsonResource; +use Illuminate\Support\Carbon; abstract class BaseResource extends JsonResource { diff --git a/app/Http/Resources/V1/Member/MemberPivotResource.php b/app/Http/Resources/V1/Member/MemberPivotResource.php index fe56a0c1..bfd1125f 100644 --- a/app/Http/Resources/V1/Member/MemberPivotResource.php +++ b/app/Http/Resources/V1/Member/MemberPivotResource.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace App\Http\Resources\V1\Member; use App\Http\Resources\V1\BaseResource; -use App\Models\Membership; +use App\Models\Member; use App\Models\User; use Illuminate\Http\Request; @@ -21,12 +21,12 @@ class MemberPivotResource extends BaseResource */ public function toArray(Request $request): array { - /** @var Membership $membership */ - $membership = $this->resource->getRelationValue('membership'); + /** @var Member $member */ + $member = $this->resource->getRelationValue('membership'); return [ /** @var string $id ID of membership */ - 'id' => $membership->id, + 'id' => $member->id, /** @var string $id ID of user */ 'user_id' => $this->resource->id, /** @var string $name Name */ @@ -34,11 +34,11 @@ class MemberPivotResource extends BaseResource /** @var string $email Email */ 'email' => $this->resource->email, /** @var string $role Role */ - 'role' => $membership->role, + 'role' => $member->role, /** @var bool $is_placeholder Placeholder user for imports, user might not really exist and does not know about this placeholder membership */ 'is_placeholder' => $this->resource->is_placeholder, /** @var int|null $billable_rate Billable rate in cents per hour */ - 'billable_rate' => $membership->billable_rate, + 'billable_rate' => $member->billable_rate, ]; } } diff --git a/app/Http/Resources/V1/Member/MemberResource.php b/app/Http/Resources/V1/Member/MemberResource.php index 57bf619e..d5b8b087 100644 --- a/app/Http/Resources/V1/Member/MemberResource.php +++ b/app/Http/Resources/V1/Member/MemberResource.php @@ -5,12 +5,12 @@ declare(strict_types=1); namespace App\Http\Resources\V1\Member; use App\Http\Resources\V1\BaseResource; -use App\Models\Membership; +use App\Models\Member; use App\Models\User; use Illuminate\Http\Request; /** - * @property Membership $resource + * @property Member $resource */ class MemberResource extends BaseResource { diff --git a/app/Http/Resources/V1/ProjectMember/ProjectMemberResource.php b/app/Http/Resources/V1/ProjectMember/ProjectMemberResource.php index 2d8e9dc7..8e986b25 100644 --- a/app/Http/Resources/V1/ProjectMember/ProjectMemberResource.php +++ b/app/Http/Resources/V1/ProjectMember/ProjectMemberResource.php @@ -25,8 +25,8 @@ class ProjectMemberResource extends BaseResource 'id' => $this->resource->id, /** @var int|null $billable_rate Billable rate in cents per hour */ 'billable_rate' => $this->resource->billable_rate, - /** @var string $user_id ID of the user */ - 'user_id' => $this->resource->user_id, + /** @var string $member_id ID of the organization member */ + 'member_id' => $this->resource->member_id, /** @var string $project_id ID of the project */ 'project_id' => $this->resource->project_id, ]; diff --git a/app/Listeners/RemovePlaceholder.php b/app/Listeners/RemovePlaceholder.php index 853d2338..55bb93c4 100644 --- a/app/Listeners/RemovePlaceholder.php +++ b/app/Listeners/RemovePlaceholder.php @@ -4,8 +4,9 @@ declare(strict_types=1); namespace App\Listeners; -use App\Models\User; +use App\Models\Member; use App\Service\UserService; +use Illuminate\Database\Eloquent\Builder; use Laravel\Jetstream\Events\TeamMemberAdded; class RemovePlaceholder @@ -17,15 +18,21 @@ class RemovePlaceholder { /** @var UserService $userService */ $userService = app(UserService::class); - $placeholders = User::query() - ->where('is_placeholder', '=', true) - ->where('email', '=', $event->user->email) - ->belongsToOrganization($event->team) + $placeholders = Member::query() + ->whereHas('user', function (Builder $query) use ($event) { + $query->where('is_placeholder', '=', true) + ->where('email', '=', $event->user->email); + }) + ->whereBelongsTo($event->team, 'organization') + ->with(['user']) ->get(); foreach ($placeholders as $placeholder) { - $userService->assignOrganizationEntitiesToDifferentUser($event->team, $placeholder, $event->user); + /** @var Member $placeholder */ + $placeholderUser = $placeholder->user; + $userService->assignOrganizationEntitiesToDifferentUser($event->team, $placeholderUser, $event->user); $placeholder->delete(); + $placeholderUser->delete(); } } } diff --git a/app/Models/Client.php b/app/Models/Client.php index 988f5f28..cb692e2a 100644 --- a/app/Models/Client.php +++ b/app/Models/Client.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace App\Models; +use App\Models\Concerns\HasUuids; use Database\Factories\ClientFactory; -use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; diff --git a/app/Models/Concerns/HasUuids.php b/app/Models/Concerns/HasUuids.php new file mode 100644 index 00000000..6e096e23 --- /dev/null +++ b/app/Models/Concerns/HasUuids.php @@ -0,0 +1,20 @@ + + * @return BelongsTo */ public function user(): BelongsTo { @@ -44,7 +44,7 @@ class Membership extends JetstreamMembership } /** - * @return BelongsTo + * @return BelongsTo */ public function organization(): BelongsTo { diff --git a/app/Models/Organization.php b/app/Models/Organization.php index 26d1e270..315a1a6a 100644 --- a/app/Models/Organization.php +++ b/app/Models/Organization.php @@ -4,9 +4,9 @@ declare(strict_types=1); namespace App\Models; +use App\Models\Concerns\HasUuids; use Database\Factories\OrganizationFactory; use Illuminate\Database\Eloquent\Collection; -use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -30,7 +30,7 @@ use Laravel\Jetstream\Team as JetstreamTeam; * @property Collection $users * @property Collection $realUsers * @property-read Collection $teamInvitations - * @property Membership $membership + * @property Member $membership * * @method HasMany teamInvitations() * @method static OrganizationFactory factory() diff --git a/app/Models/OrganizationInvitation.php b/app/Models/OrganizationInvitation.php index f512bbdd..232ac54e 100644 --- a/app/Models/OrganizationInvitation.php +++ b/app/Models/OrganizationInvitation.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace App\Models; +use App\Models\Concerns\HasUuids; use Database\Factories\OrganizationInvitationFactory; -use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Laravel\Jetstream\Jetstream; diff --git a/app/Models/Project.php b/app/Models/Project.php index 81c8e0bd..47daddd4 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -4,10 +4,10 @@ declare(strict_types=1); namespace App\Models; +use App\Models\Concerns\HasUuids; use Database\Factories\ProjectFactory; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; -use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -25,7 +25,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; * @property-read Collection $tasks * @property-read Collection $members * - * @method Builder visibleByUser(User $user) + * @method Builder visibleByEmployee(User $user) * @method static ProjectFactory factory() */ class Project extends Model @@ -64,7 +64,7 @@ class Project extends Model */ public function members(): HasMany { - return $this->hasMany(ProjectMember::class); + return $this->hasMany(ProjectMember::class, 'project_id'); } /** @@ -86,7 +86,7 @@ class Project extends Model /** * @param Builder $builder */ - public function scopeVisibleByUser(Builder $builder, User $user): void + public function scopeVisibleByEmployee(Builder $builder, User $user): void { $builder->where(function (Builder $builder) use ($user): Builder { return $builder->where('is_public', '=', true) diff --git a/app/Models/ProjectMember.php b/app/Models/ProjectMember.php index e3b96a40..6b3e707c 100644 --- a/app/Models/ProjectMember.php +++ b/app/Models/ProjectMember.php @@ -4,9 +4,9 @@ declare(strict_types=1); namespace App\Models; +use App\Models\Concerns\HasUuids; use Database\Factories\ProjectMemberFactory; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -14,9 +14,11 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; /** * @property string $id * @property int|null $billable_rate - * @property string $project_id - * @property string $user_id + * @property string $project_id Project ID + * @property string $member_id Member ID + * @property string $user_id User ID (legacy) * @property-read Project $project + * @property-read Member $member * @property-read User $user * * @method static Builder whereBelongsToOrganization(Organization $organization) @@ -45,6 +47,8 @@ class ProjectMember extends Model } /** + * @deprecated Use member relationship instead + * * @return BelongsTo */ public function user(): BelongsTo @@ -52,6 +56,14 @@ class ProjectMember extends Model return $this->belongsTo(User::class, 'user_id'); } + /** + * @return BelongsTo + */ + public function member(): BelongsTo + { + return $this->belongsTo(Member::class, 'member_id'); + } + /** * @param Builder $builder */ diff --git a/app/Models/Tag.php b/app/Models/Tag.php index 465ec856..7fc5dc71 100644 --- a/app/Models/Tag.php +++ b/app/Models/Tag.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace App\Models; +use App\Models\Concerns\HasUuids; use Database\Factories\TagFactory; -use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; diff --git a/app/Models/Task.php b/app/Models/Task.php index e868c93b..c1cb1962 100644 --- a/app/Models/Task.php +++ b/app/Models/Task.php @@ -4,9 +4,10 @@ declare(strict_types=1); namespace App\Models; +use App\Models\Concerns\HasUuids; use Database\Factories\TaskFactory; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; -use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -63,4 +64,16 @@ class Task extends Model { return $this->hasMany(TimeEntry::class, 'task_id'); } + + /** + * @param Builder $builder + * @return Builder + */ + public function scopeVisibleByEmployee(Builder $builder, User $user): Builder + { + return $builder->whereHas('project', function (Builder $builder) use ($user): Builder { + /** @var Builder $builder */ + return $builder->visibleByEmployee($user); + }); + } } diff --git a/app/Models/TimeEntry.php b/app/Models/TimeEntry.php index 7dbfba22..a2001d5a 100644 --- a/app/Models/TimeEntry.php +++ b/app/Models/TimeEntry.php @@ -4,11 +4,11 @@ declare(strict_types=1); namespace App\Models; +use App\Models\Concerns\HasUuids; use App\Service\BillableRateService; use Carbon\CarbonInterval; use Database\Factories\TimeEntryFactory; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -24,7 +24,9 @@ use Korridor\LaravelComputedAttributes\ComputedAttributes; * @property bool $billable * @property array $tags * @property string $user_id + * @property string $member_id * @property-read User $user + * @property-read Member $member * @property string $organization_id * @property-read Organization $organization * @property string|null $project_id @@ -91,6 +93,14 @@ class TimeEntry extends Model return $this->belongsTo(User::class, 'user_id'); } + /** + * @return BelongsTo + */ + public function member(): BelongsTo + { + return $this->belongsTo(Member::class, 'member_id'); + } + /** * @return BelongsTo */ diff --git a/app/Models/User.php b/app/Models/User.php index a1c69b9a..3984b234 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Models; use App\Enums\Weekday; +use App\Models\Concerns\HasUuids; use Database\Factories\UserFactory; use Filament\Models\Contracts\FilamentUser; use Filament\Panel; @@ -12,7 +13,6 @@ use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Collection; -use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -43,7 +43,7 @@ use Laravel\Passport\HasApiTokens; * @property string $current_team_id * @property Collection $organizations * @property Collection $timeEntries - * @property Membership $membership + * @property Member $membership * * @method HasMany ownedTeams() * @method static UserFactory factory() @@ -136,7 +136,7 @@ class User extends Authenticatable implements FilamentUser, MustVerifyEmail */ public function organizations(): BelongsToMany { - return $this->belongsToMany(Organization::class, Membership::class) + return $this->belongsToMany(Organization::class, Member::class) ->withPivot([ 'id', 'role', diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 33610d74..05fc5dfd 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace App\Providers; use App\Models\Client; -use App\Models\Membership; +use App\Models\Member; use App\Models\Organization; use App\Models\OrganizationInvitation; use App\Models\Project; @@ -51,7 +51,7 @@ class AppServiceProvider extends ServiceProvider Model::preventSilentlyDiscardingAttributes(! $this->app->isProduction()); Model::preventAccessingMissingAttributes(! $this->app->isProduction()); Relation::enforceMorphMap([ - 'membership' => Membership::class, + 'membership' => Member::class, 'organization' => Organization::class, 'organization-invitation' => OrganizationInvitation::class, 'user' => User::class, @@ -85,7 +85,7 @@ class AppServiceProvider extends ServiceProvider return new PermissionStore(); }); - Route::model('member', Membership::class); + Route::model('member', Member::class); Route::model('invitation', OrganizationInvitation::class); } } diff --git a/app/Providers/JetstreamServiceProvider.php b/app/Providers/JetstreamServiceProvider.php index 91489cda..85d41da8 100644 --- a/app/Providers/JetstreamServiceProvider.php +++ b/app/Providers/JetstreamServiceProvider.php @@ -14,6 +14,7 @@ use App\Actions\Jetstream\UpdateMemberRole; use App\Actions\Jetstream\UpdateOrganization; use App\Enums\Role; use App\Enums\Weekday; +use App\Models\Member; use App\Models\Organization; use App\Models\OrganizationInvitation; use App\Models\User; @@ -52,6 +53,7 @@ class JetstreamServiceProvider extends ServiceProvider Jetstream::deleteTeamsUsing(DeleteOrganization::class); Jetstream::deleteUsersUsing(DeleteUser::class); Jetstream::useTeamModel(Organization::class); + Jetstream::useMembershipModel(Member::class); Jetstream::useTeamInvitationModel(OrganizationInvitation::class); app()->singleton(UpdateTeamMemberRole::class, UpdateMemberRole::class); Fortify::registerView(function () { diff --git a/app/Service/BillableRateService.php b/app/Service/BillableRateService.php index 791ec5eb..a5c11eac 100644 --- a/app/Service/BillableRateService.php +++ b/app/Service/BillableRateService.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace App\Service; -use App\Models\Membership; +use App\Models\Member; use App\Models\Organization; use App\Models\Project; use App\Models\ProjectMember; @@ -36,13 +36,13 @@ class BillableRateService } } // Member rate - /** @var Membership|null $membership */ - $membership = Membership::query() + /** @var Member|null $member */ + $member = Member::query() ->where('user_id', '=', $timeEntry->user_id) ->where('organization_id', '=', $timeEntry->organization_id) ->first(); - if ($membership !== null && $membership->billable_rate !== null) { - return $membership->billable_rate; + if ($member !== null && $member->billable_rate !== null) { + return $member->billable_rate; } // Organization rate diff --git a/app/Service/DashboardService.php b/app/Service/DashboardService.php index 7bb58687..3d11ba2b 100644 --- a/app/Service/DashboardService.php +++ b/app/Service/DashboardService.php @@ -10,9 +10,9 @@ use App\Models\Project; use App\Models\Task; use App\Models\TimeEntry; use App\Models\User; -use Carbon\Carbon; use Carbon\CarbonTimeZone; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Carbon; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; @@ -335,20 +335,22 @@ class DashboardService } /** - * Rhe 4 most recently active members of your team with user_id, name, description of the latest time entry, time_entry_id, task_id and a boolean status if the team member is currently working + * Rhe 4 most recently active members of your team with member_id, name, description of the latest time entry, time_entry_id, task_id and a boolean status if the team member is currently working * - * @return array + * @return array */ public function latestTeamActivity(Organization $organization): array { $timeEntries = TimeEntry::query() - ->select(DB::raw('distinct on (user_id) user_id, description, id, task_id, start, "end"')) + ->select(DB::raw('distinct on (member_id) member_id, description, id, task_id, start, "end"')) ->whereBelongsTo($organization, 'organization') - ->orderBy('user_id') + ->orderBy('member_id') ->orderBy('start', 'desc') // Note: limit here does not work because of the distinct on ->with([ - 'user', + 'member' => [ + 'user', + ], ]) ->get() ->sortByDesc('start') @@ -358,8 +360,8 @@ class DashboardService foreach ($timeEntries as $timeEntry) { $response[] = [ - 'user_id' => $timeEntry->user_id, - 'name' => $timeEntry->user->name, + 'member_id' => $timeEntry->member_id, + 'name' => $timeEntry->member->user->name, 'description' => $timeEntry->description, 'time_entry_id' => $timeEntry->id, 'task_id' => $timeEntry->task_id, diff --git a/app/Service/Import/ImportService.php b/app/Service/Import/ImportService.php index 8f42a756..eff8f98d 100644 --- a/app/Service/Import/ImportService.php +++ b/app/Service/Import/ImportService.php @@ -9,7 +9,10 @@ use App\Service\Import\Importers\ImporterContract; use App\Service\Import\Importers\ImporterProvider; use App\Service\Import\Importers\ImportException; use App\Service\Import\Importers\ReportDto; +use Carbon\Carbon; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; class ImportService { @@ -21,6 +24,8 @@ class ImportService /** @var ImporterContract $importer */ $importer = app(ImporterProvider::class)->getImporter($importerType); $importer->init($organization); + Storage::disk('s3')->put('import/'.Carbon::now()->toDateString().'-'.$organization->getKey().'-'.Str::uuid(), $data); + DB::transaction(function () use (&$importer, &$data) { $importer->importData($data); }); diff --git a/app/Service/Import/Importers/ClockifyTimeEntriesImporter.php b/app/Service/Import/Importers/ClockifyTimeEntriesImporter.php index 3b8246b6..eb893053 100644 --- a/app/Service/Import/Importers/ClockifyTimeEntriesImporter.php +++ b/app/Service/Import/Importers/ClockifyTimeEntriesImporter.php @@ -56,6 +56,10 @@ class ClockifyTimeEntriesImporter extends DefaultImporter 'timezone' => 'UTC', 'is_placeholder' => true, ]); + $memberId = $this->memberImportHelper->getKey([ + 'user_id' => $userId, + 'organization_id' => $this->organization->getKey(), + ]); $clientId = null; if ($record['Client'] !== '') { $clientId = $this->clientImportHelper->getKey([ @@ -83,6 +87,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter } $timeEntry = new TimeEntry(); $timeEntry->user_id = $userId; + $timeEntry->member_id = $memberId; $timeEntry->task_id = $taskId; $timeEntry->project_id = $projectId; $timeEntry->organization_id = $this->organization->id; diff --git a/app/Service/Import/Importers/DefaultImporter.php b/app/Service/Import/Importers/DefaultImporter.php index 98905a77..c79add23 100644 --- a/app/Service/Import/Importers/DefaultImporter.php +++ b/app/Service/Import/Importers/DefaultImporter.php @@ -6,6 +6,7 @@ namespace App\Service\Import\Importers; use App\Enums\Role; use App\Models\Client; +use App\Models\Member; use App\Models\Organization; use App\Models\Project; use App\Models\ProjectMember; @@ -26,6 +27,11 @@ abstract class DefaultImporter implements ImporterContract */ protected ImportDatabaseHelper $userImportHelper; + /** + * @var ImportDatabaseHelper + */ + protected ImportDatabaseHelper $memberImportHelper; + /** * @var ImportDatabaseHelper */ @@ -77,6 +83,10 @@ abstract class DefaultImporter implements ImporterContract 'timezone:all', ], ]); + $this->memberImportHelper = new ImportDatabaseHelper(Member::class, ['user_id', 'organization_id'], true, function (Builder $builder) { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + }); $this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'organization_id'], true, function (Builder $builder) { /** @var Builder $builder */ return $builder->where('organization_id', $this->organization->id); @@ -90,7 +100,7 @@ abstract class DefaultImporter implements ImporterContract 'integer', ], ]); - $this->projectMemberImportHelper = new ImportDatabaseHelper(ProjectMember::class, ['project_id', 'user_id'], true, function (Builder $builder) { + $this->projectMemberImportHelper = new ImportDatabaseHelper(ProjectMember::class, ['project_id', 'member_id'], true, function (Builder $builder) { /** @var Builder $builder */ return $builder->whereBelongsToOrganization($this->organization); }, validate: [ diff --git a/app/Service/Import/Importers/TogglDataImporter.php b/app/Service/Import/Importers/TogglDataImporter.php index ac8b5e9e..136e7fb0 100644 --- a/app/Service/Import/Importers/TogglDataImporter.php +++ b/app/Service/Import/Importers/TogglDataImporter.php @@ -36,6 +36,9 @@ class TogglDataImporter extends DefaultImporter throw new ImportException('File "clients.json" can not be opened'); } $clients = json_decode($clientsFileContent); + if ($clients === null) { + throw new ImportException('File "clients.json" is empty'); + } if (! file_exists($temporaryDirectory->path('projects.json'))) { throw new ImportException('File "projects.json" missing in ZIP'); } @@ -44,6 +47,9 @@ class TogglDataImporter extends DefaultImporter throw new ImportException('File "projects.json" can not be opened'); } $projects = json_decode($projectsFileContent); + if ($projects === null) { + throw new ImportException('File "projects.json" is empty'); + } if (! file_exists($temporaryDirectory->path('tags.json'))) { throw new ImportException('File "tags.json" missing in ZIP'); } @@ -52,6 +58,9 @@ class TogglDataImporter extends DefaultImporter throw new ImportException('File "tags.json" can not be opened'); } $tags = json_decode($tagsFileContent); + if ($tags === null) { + throw new ImportException('File "tags.json" is empty'); + } if (! file_exists($temporaryDirectory->path('workspace_users.json'))) { throw new ImportException('File "workspace_users.json" missing in ZIP'); } @@ -60,6 +69,9 @@ class TogglDataImporter extends DefaultImporter throw new ImportException('File "workspace_users.json" can not be opened'); } $workspaceUsers = json_decode($workspaceUsersFileContent); + if ($workspaceUsers === null) { + throw new ImportException('File "workspace_users.json" is empty'); + } foreach ($clients as $client) { $this->clientImportHelper->getKey([ 'name' => $client->name, @@ -74,13 +86,17 @@ class TogglDataImporter extends DefaultImporter } foreach ($workspaceUsers as $workspaceUser) { - $this->userImportHelper->getKey([ + $userId = $this->userImportHelper->getKey([ 'email' => $workspaceUser->email, ], [ 'name' => $workspaceUser->name, 'timezone' => $workspaceUser->timezone ?? 'UTC', 'is_placeholder' => true, ], (string) $workspaceUser->uid); + $memberId = $this->memberImportHelper->getKey([ + 'user_id' => $userId, + 'organization_id' => $this->organization->getKey(), + ], [], $userId); } foreach ($projects as $project) { @@ -113,11 +129,16 @@ class TogglDataImporter extends DefaultImporter throw new ImportException('File "projects_users/'.$project->id.'.json" can not be opened'); } $projectMembers = json_decode($projectMembersFileContent); + if ($projectMembers === null) { + throw new ImportException('File "projects_users/'.$project->id.'.json" is empty'); + } foreach ($projectMembers as $projectMember) { + $userId = $this->userImportHelper->getKeyByExternalIdentifier((string) $projectMember->user_id); $this->projectMemberImportHelper->getKey([ 'project_id' => $projectId, - 'user_id' => $this->userImportHelper->getKeyByExternalIdentifier((string) $projectMember->user_id), + 'member_id' => $this->memberImportHelper->getKeyByExternalIdentifier($userId), ], [ + 'user_id' => $userId, 'billable_rate' => $projectMember->rate !== null ? (int) ($projectMember->rate * 100) : null, ]); } @@ -132,6 +153,9 @@ class TogglDataImporter extends DefaultImporter throw new ImportException('File "tasks/'.$projectIdExternal.'.json" can not be opened'); } $tasks = json_decode($tasksFileContent); + if ($tasks === null) { + throw new ImportException('File "tasks/'.$projectIdExternal.'.json" is empty'); + } foreach ($tasks as $task) { $projectId = $this->projectImportHelper->getKeyByExternalIdentifier((string) $projectIdExternal); diff --git a/app/Service/Import/Importers/TogglTimeEntriesImporter.php b/app/Service/Import/Importers/TogglTimeEntriesImporter.php index 826e56d6..6ea004ab 100644 --- a/app/Service/Import/Importers/TogglTimeEntriesImporter.php +++ b/app/Service/Import/Importers/TogglTimeEntriesImporter.php @@ -56,6 +56,10 @@ class TogglTimeEntriesImporter extends DefaultImporter 'timezone' => 'UTC', 'is_placeholder' => true, ]); + $memberId = $this->memberImportHelper->getKey([ + 'user_id' => $userId, + 'organization_id' => $this->organization->getKey(), + ]); $clientId = null; if ($record['Client'] !== '') { $clientId = $this->clientImportHelper->getKey([ @@ -83,6 +87,7 @@ class TogglTimeEntriesImporter extends DefaultImporter } $timeEntry = new TimeEntry(); $timeEntry->user_id = $userId; + $timeEntry->member_id = $memberId; $timeEntry->task_id = $taskId; $timeEntry->project_id = $projectId; $timeEntry->organization_id = $this->organization->id; diff --git a/app/Service/TimeEntryAggregationService.php b/app/Service/TimeEntryAggregationService.php new file mode 100644 index 00000000..2e75d536 --- /dev/null +++ b/app/Service/TimeEntryAggregationService.php @@ -0,0 +1,295 @@ + $timeEntriesQuery + * @return array{ + * grouped_type: string|null, + * grouped_data: null|array + * }>, + * seconds: int, + * cost: int + * } + */ + public function getAggregatedTimeEntries(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end): array + { + $fillGapsInTimeGroupsIsPossible = $fillGapsInTimeGroups && $start !== null && $end !== null; + $group1Select = null; + $group2Select = null; + $groupBy = null; + if ($group1Type !== null) { + $group1Select = $this->getGroupByQuery($group1Type, $timezone, $startOfWeek); + $groupBy = ['group_1']; + if ($group2Type !== null) { + $group2Select = $this->getGroupByQuery($group2Type, $timezone, $startOfWeek); + $groupBy = ['group_1', 'group_2']; + } + } + + $timeEntriesQuery->selectRaw( + ($group1Select !== null ? $group1Select.' as group_1,' : ''). + ($group2Select !== null ? $group2Select.' as group_2,' : ''). + ' round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate,'. + ' round( + sum( + extract(epoch from (coalesce("end", now()) - start)) * (coalesce(billable_rate, 0)::float/60/60) + ) + ) as cost' + ); + if ($groupBy !== null) { + $timeEntriesQuery->groupBy($groupBy); + } + + $timeEntriesAggregates = $timeEntriesQuery->get(); + + if ($group1Select !== null) { + $groupedAggregates = $timeEntriesAggregates->groupBy($group2Select !== null ? ['group_1', 'group_2'] : ['group_1']); + + $group1Response = []; + $group1ResponseSum = 0; + $group1ResponseCost = 0; + foreach ($groupedAggregates as $group1 => $group1Aggregates) { + /** @var string|int $group1 */ + $group2Response = []; + if ($group2Select !== null) { + $group2ResponseSum = 0; + $group2ResponseCost = 0; + foreach ($group1Aggregates as $group2 => $aggregate) { + /** @var string|int $group2 */ + /** @var Collection $aggregate */ + $group2Response[] = [ + 'key' => $group2 === '' ? null : (string) $group2, + 'seconds' => (int) $aggregate->get(0)->aggregate, + 'cost' => (int) $aggregate->get(0)->cost, + 'grouped_type' => null, + 'grouped_data' => null, + ]; + $group2ResponseSum += (int) $aggregate->get(0)->aggregate; + $group2ResponseCost += (int) $aggregate->get(0)->cost; + } + } else { + /** @var Collection $group1Aggregates */ + $group2ResponseSum = (int) $group1Aggregates->get(0)->aggregate; + $group2ResponseCost = (int) $group1Aggregates->get(0)->cost; + $group2Response = null; + } + + $group1Response[] = [ + 'key' => $group1 === '' ? null : (string) $group1, + 'seconds' => $group2ResponseSum, + 'cost' => $group2ResponseCost, + 'grouped_type' => $group2Type?->value, + 'grouped_data' => $group2Response, + ]; + $group1ResponseSum += $group2ResponseSum; + $group1ResponseCost += $group2ResponseCost; + } + + if ($fillGapsInTimeGroupsIsPossible) { + $group1Response = $this->fillGapsInTimeGroups($group1Response, $group1Type, $group2Type, $timezone, $startOfWeek, $start, $end); + } + } else { + $group1Response = null; + /** @var Collection $timeEntriesAggregates */ + $group1ResponseSum = (int) $timeEntriesAggregates->get(0)->aggregate; + $group1ResponseCost = (int) $timeEntriesAggregates->get(0)->cost; + } + + return [ + 'seconds' => $group1ResponseSum, + 'cost' => $group1ResponseCost, + 'grouped_type' => $group1Type?->value, + 'grouped_data' => $group1Response, + ]; + } + + /** + * @param array + * }> $data + * @return array + * }> + */ + public function fillGapsInTimeGroups(array $data, TimeEntryAggregationType $groupType, ?TimeEntryAggregationType $subGroupType, string $timezone, Weekday $startOfWeek, Carbon $start, Carbon $end): array + { + $interval = $groupType->toInterval(); + if ($interval === null) { + foreach ($data as $key => $item) { + $data[$key]['grouped_data'] = $this->fillGapsInTimeGroups( + $item['grouped_data'], + $subGroupType, + null, + $timezone, + $startOfWeek, + $start, + $end + ); + } + + return $data; + } else { + $format = match ($interval) { + TimeEntryAggregationTypeInterval::Day => 'Y-m-d', + TimeEntryAggregationTypeInterval::Week => 'Y-m-d H:i:s', + TimeEntryAggregationTypeInterval::Month => 'Y-m', + TimeEntryAggregationTypeInterval::Year => 'Y', + }; + $slots = $this->timeSlotsBetween($start, $end, $timezone, $startOfWeek, $interval, $format); + $filledData = []; + foreach ($slots as $slot) { + $foundDataSet = null; + foreach ($data as $item) { + if ($item['key'] === $slot) { + $foundDataSet = $item; + break; + } + } + if ($foundDataSet !== null) { + $filledData[] = [ + 'key' => $slot, + 'seconds' => $foundDataSet['seconds'], + 'cost' => $foundDataSet['cost'], + 'grouped_type' => $subGroupType?->value, + 'grouped_data' => $subGroupType === null + ? null + : $this->fillGapsInTimeGroups( + $foundDataSet['grouped_data'], + $subGroupType, + null, + $timezone, + $startOfWeek, + $start, + $end + ), + ]; + } else { + $filledData[] = [ + 'key' => $slot, + 'seconds' => 0, + 'cost' => 0, + 'grouped_type' => $subGroupType?->value, + 'grouped_data' => $subGroupType === null ? null : [], + ]; + } + } + + return $filledData; + } + } + + private function getGroupByQuery(TimeEntryAggregationType $group, string $timezone, Weekday $startOfWeek): string + { + $timezoneShift = app(TimezoneService::class)->getShiftFromUtc(new CarbonTimeZone($timezone)); + if ($timezoneShift > 0) { + $dateWithTimeZone = 'start + INTERVAL \''.$timezoneShift.' second\''; + } elseif ($timezoneShift < 0) { + $dateWithTimeZone = 'start - INTERVAL \''.abs($timezoneShift).' second\''; + } else { + $dateWithTimeZone = 'start'; + } + $startOfWeek = Carbon::now()->setTimezone($timezone)->startOfWeek($startOfWeek->carbonWeekDay())->utc()->toDateTimeString(); + if ($group === TimeEntryAggregationType::Day) { + return 'date('.$dateWithTimeZone.')'; + } elseif ($group === TimeEntryAggregationType::Week) { + return "to_char(date_bin('7 days', ".$dateWithTimeZone.", timestamp '".$startOfWeek."'), 'YYYY-MM-DD HH24:MI:SS')"; + } elseif ($group === TimeEntryAggregationType::Month) { + return 'to_char('.$dateWithTimeZone.', \'YYYY-MM\')'; + } elseif ($group === TimeEntryAggregationType::Year) { + return 'to_char('.$dateWithTimeZone.', \'YYYY\')'; + } elseif ($group === TimeEntryAggregationType::User) { + return 'user_id'; + } elseif ($group === TimeEntryAggregationType::Project) { + return 'project_id'; + } elseif ($group === TimeEntryAggregationType::Task) { + return 'task_id'; + } elseif ($group === TimeEntryAggregationType::Client) { + return 'client_id'; + } elseif ($group === TimeEntryAggregationType::Billable) { + return 'billable'; + } + } + + /** + * @return Collection + */ + public function timeSlotsBetween(Carbon $start, Carbon $end, string $timezone, Weekday $startOfWeek, TimeEntryAggregationTypeInterval $interval, string $format): Collection + { + if ($start->gt($end)) { + throw new \InvalidArgumentException('Start date must be before end date'); + } + $slots = new Collection(); + $current = $start->copy()->timezone($timezone); + if ($interval === TimeEntryAggregationTypeInterval::Day) { + $current->startOfDay(); + } elseif ($interval === TimeEntryAggregationTypeInterval::Week) { + $current->startOfWeek($startOfWeek->carbonWeekDay())->utc(); + } elseif ($interval === TimeEntryAggregationTypeInterval::Month) { + $current->startOfMonth(); + } elseif ($interval === TimeEntryAggregationTypeInterval::Year) { + $current->startOfYear(); + } else { + throw new \InvalidArgumentException('Invalid interval'); + } + + while ($current->lt($end)) { + $slots->push($current->format($format)); + if ($interval === TimeEntryAggregationTypeInterval::Day) { + $current->addDay(); + } elseif ($interval === TimeEntryAggregationTypeInterval::Week) { + $current->addWeek(); + } elseif ($interval === TimeEntryAggregationTypeInterval::Month) { + $current->addMonth(); + } elseif ($interval === TimeEntryAggregationTypeInterval::Year) { + $current->addYear(); + } + } + + return $slots; + } +} diff --git a/app/Service/TimeEntryFilter.php b/app/Service/TimeEntryFilter.php new file mode 100644 index 00000000..7f35fe3a --- /dev/null +++ b/app/Service/TimeEntryFilter.php @@ -0,0 +1,161 @@ + + */ + private Builder $builder; + + /** + * @param Builder $builder + */ + public function __construct(Builder $builder) + { + $this->builder = $builder; + } + + public function addEndFilter(?string $dateTime): self + { + if ($dateTime === null) { + return $this; + } + $this->builder->where('start', '<', Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $dateTime, 'UTC')); + + return $this; + } + + public function addStartFilter(?string $dateTime): self + { + if ($dateTime === null) { + return $this; + } + $this->builder->where('start', '>', Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $dateTime, 'UTC')); + + return $this; + } + + public function addActiveFilter(?string $active): self + { + if ($active === null) { + return $this; + } + if ($active === 'true') { + $this->builder->whereNull('end'); + } + if ($active === 'false') { + $this->builder->whereNotNull('end'); + } + + return $this; + } + + public function addMemberIdFilter(?Member $member): self + { + if ($member === null) { + return $this; + } + $this->builder->where('member_id', $member->getKey()); + + return $this; + } + + /** + * @param array|null $memberIds + */ + public function addMemberIdsFilter(?array $memberIds): self + { + if ($memberIds === null) { + return $this; + } + $this->builder->whereIn('member_id', $memberIds); + + return $this; + } + + public function addBillableFilter(?string $billable): self + { + if ($billable === null) { + return $this; + } + if ($billable === 'true') { + $this->builder->where('billable', '=', true); + } elseif ($billable === 'false') { + $this->builder->where('billable', '=', false); + } else { + Log::warning('Invalid billable filter value', ['value' => $billable]); + } + + return $this; + } + + /** + * @param array|null $clientIds + */ + public function addClientIdsFilter(?array $clientIds): self + { + if ($clientIds === null) { + return $this; + } + $this->builder->whereIn('client_id', $clientIds); + + return $this; + } + + /** + * @param array|null $projectIds + */ + public function addProjectIdsFilter(?array $projectIds): self + { + if ($projectIds === null) { + return $this; + } + $this->builder->whereIn('project_id', $projectIds); + + return $this; + } + + /** + * @param array|null $tagIds + */ + public function addTagIdsFilter(?array $tagIds): self + { + if ($tagIds === null) { + return $this; + } + $this->builder->whereJsonContains('tags', $tagIds); + + return $this; + } + + /** + * @param array|null $taskIds + */ + public function addTaskIdsFilter(?array $taskIds): self + { + if ($taskIds === null) { + return $this; + } + $this->builder->whereIn('task_id', $taskIds); + + return $this; + } + + /** + * @return Builder + */ + public function get(): Builder + { + return $this->builder; + } +} diff --git a/app/Service/TimezoneService.php b/app/Service/TimezoneService.php index 6714c775..ec430a2f 100644 --- a/app/Service/TimezoneService.php +++ b/app/Service/TimezoneService.php @@ -5,8 +5,8 @@ declare(strict_types=1); namespace App\Service; use App\Models\User; -use Carbon\Carbon; use Carbon\CarbonTimeZone; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Log; class TimezoneService diff --git a/app/Service/UserService.php b/app/Service/UserService.php index ffa1b56d..7afeb9ef 100644 --- a/app/Service/UserService.php +++ b/app/Service/UserService.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace App\Service; use App\Enums\Role; -use App\Models\Membership; +use App\Models\Member; use App\Models\Organization; use App\Models\ProjectMember; use App\Models\TimeEntry; @@ -19,12 +19,22 @@ class UserService */ public function assignOrganizationEntitiesToDifferentUser(Organization $organization, User $fromUser, User $toUser): void { + /** @var Member|null $toMember */ + $toMember = Member::query() + ->whereBelongsTo($organization, 'organization') + ->whereBelongsTo($toUser, 'user') + ->first(); + if ($toMember === null) { + throw new \InvalidArgumentException('User is not a member of the organization'); + } + // Time entries TimeEntry::query() ->whereBelongsTo($organization, 'organization') ->whereBelongsTo($fromUser, 'user') ->update([ 'user_id' => $toUser->getKey(), + 'member_id' => $toMember->getKey(), ]); // Project members @@ -33,6 +43,7 @@ class UserService ->whereBelongsTo($fromUser, 'user') ->update([ 'user_id' => $toUser->getKey(), + 'member_id' => $toMember->getKey(), ]); } @@ -45,13 +56,17 @@ class UserService $organization->update([ 'user_id' => $newOwner->getKey(), ]); - $userMembership = Membership::query() + /** @var Member|null $userMembership */ + $userMembership = Member::query() ->whereBelongsTo($organization, 'organization') ->whereBelongsTo($newOwner, 'user') ->first(); + if ($userMembership === null) { + throw new \InvalidArgumentException('User is not a member of the organization'); + } $userMembership->role = Role::Owner->value; $userMembership->save(); - $oldOwners = Membership::query() + $oldOwners = Member::query() ->whereBelongsTo($organization, 'organization') ->where('role', '=', Role::Owner->value) ->where('user_id', '!=', $newOwner->getKey()) diff --git a/config/auth.php b/config/auth.php index 6ac82835..8143c2ee 100644 --- a/config/auth.php +++ b/config/auth.php @@ -117,9 +117,9 @@ return [ 'super_admins' => ! is_string(env('SUPER_ADMINS', null)) ? [] : explode(',', env('SUPER_ADMINS')), - 'terms_url' => env('TERMS_URL'), + 'terms_url' => env('TERMS_URL', ''), - 'privacy_policy_url' => env('PRIVACY_POLICY_URL'), + 'privacy_policy_url' => env('PRIVACY_POLICY_URL', ''), 'newsletter_consent' => env('NEWSLETTER_CONSENT', false), diff --git a/database/factories/MembershipFactory.php b/database/factories/MemberFactory.php similarity index 78% rename from database/factories/MembershipFactory.php rename to database/factories/MemberFactory.php index f2b7b1fe..3b2bf185 100644 --- a/database/factories/MembershipFactory.php +++ b/database/factories/MemberFactory.php @@ -5,15 +5,15 @@ declare(strict_types=1); namespace Database\Factories; use App\Enums\Role; -use App\Models\Membership; +use App\Models\Member; use App\Models\Organization; use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; /** - * @extends Factory + * @extends Factory */ -class MembershipFactory extends Factory +class MemberFactory extends Factory { /** * Define the model's default state. @@ -24,11 +24,20 @@ class MembershipFactory extends Factory { return [ 'role' => Role::Employee, - 'organization_id' => OrganizationFactory::class, - 'user_id' => UserFactory::class, + 'organization_id' => Organization::factory(), + 'user_id' => User::factory(), ]; } + public function role(Role $role): static + { + return $this->state(function (array $attributes) use ($role): array { + return [ + 'role' => $role->value, + ]; + }); + } + public function forOrganization(Organization $organization): static { return $this->state(function (array $attributes) use ($organization): array { diff --git a/database/factories/ProjectFactory.php b/database/factories/ProjectFactory.php index 0a05d150..55faa471 100644 --- a/database/factories/ProjectFactory.php +++ b/database/factories/ProjectFactory.php @@ -5,10 +5,10 @@ declare(strict_types=1); namespace Database\Factories; use App\Models\Client; +use App\Models\Member; use App\Models\Organization; use App\Models\Project; use App\Models\ProjectMember; -use App\Models\User; use App\Service\ColorService; use Illuminate\Database\Eloquent\Factories\Factory; @@ -61,12 +61,12 @@ class ProjectFactory extends Factory }); } - public function addMember(User $user, array $attributes = []): self + public function addMember(Member $member, array $attributes = []): self { - return $this->afterCreating(function (Project $project) use ($user, $attributes): void { + return $this->afterCreating(function (Project $project) use ($member, $attributes): void { ProjectMember::factory() ->forProject($project) - ->forUser($user) + ->forMember($member) ->create($attributes); }); } diff --git a/database/factories/ProjectMemberFactory.php b/database/factories/ProjectMemberFactory.php index 23a66fa4..1ab38ef5 100644 --- a/database/factories/ProjectMemberFactory.php +++ b/database/factories/ProjectMemberFactory.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Database\Factories; +use App\Models\Member; use App\Models\Project; use App\Models\ProjectMember; use App\Models\User; @@ -25,9 +26,13 @@ class ProjectMemberFactory extends Factory 'billable_rate' => $this->faker->numberBetween(10, 10000) * 100, 'project_id' => Project::factory(), 'user_id' => User::factory(), + 'member_id' => Member::factory(), ]; } + /** + * @deprecated Use forMember instead + */ public function forUser(User $user): self { return $this->state(function (array $attributes) use ($user): array { @@ -37,6 +42,16 @@ class ProjectMemberFactory extends Factory }); } + public function forMember(Member $member): self + { + return $this->state(function (array $attributes) use ($member): array { + return [ + 'member_id' => $member->getKey(), + 'user_id' => $member->user_id, // Legacy + ]; + }); + } + public function forProject(Project $project): self { return $this->state(function (array $attributes) use ($project): array { diff --git a/database/factories/TimeEntryFactory.php b/database/factories/TimeEntryFactory.php index 24ba97a5..c565a78b 100644 --- a/database/factories/TimeEntryFactory.php +++ b/database/factories/TimeEntryFactory.php @@ -4,14 +4,15 @@ declare(strict_types=1); namespace Database\Factories; +use App\Models\Member; use App\Models\Organization; use App\Models\Project; use App\Models\Tag; use App\Models\Task; use App\Models\TimeEntry; use App\Models\User; -use Carbon\Carbon; use Illuminate\Database\Eloquent\Factories\Factory; +use Illuminate\Support\Carbon; /** * @extends Factory @@ -34,6 +35,7 @@ class TimeEntryFactory extends Factory 'billable' => $this->faker->boolean(), 'tags' => [], 'user_id' => User::factory(), + 'member_id' => Member::factory(), 'task_id' => null, 'project_id' => null, 'organization_id' => Organization::factory(), @@ -65,11 +67,13 @@ class TimeEntryFactory extends Factory }); } - public function startBetween(Carbon $rangeStart, Carbon $rangeEnd): self + public function startBetween(Carbon $rangeStart, Carbon $rangeEnd, bool $fixedValueForMultiple = false): self { - $start = Carbon::instance($this->faker->dateTimeBetween($rangeStart, $rangeEnd)); + $fixedStart = Carbon::instance($this->faker->dateTimeBetween($rangeStart, $rangeEnd)); + + return $this->state(function (array $attributes) use ($rangeStart, $rangeEnd, $fixedStart, $fixedValueForMultiple): array { + $start = $fixedValueForMultiple ? $fixedStart : Carbon::instance($this->faker->dateTimeBetween($rangeStart, $rangeEnd)); - return $this->state(function (array $attributes) use ($start): array { return [ 'start' => $start->utc(), 'end' => $this->faker->dateTimeBetween($start, 'now'), @@ -86,6 +90,9 @@ class TimeEntryFactory extends Factory }); } + /** + * @deprecated Use forMember instead + */ public function forUser(User $user): self { return $this->state(function (array $attributes) use ($user) { @@ -95,6 +102,45 @@ class TimeEntryFactory extends Factory }); } + public function forMember(Member $member): static + { + return $this->state(function (array $attributes) use ($member): array { + return [ + 'member_id' => $member->getKey(), + 'user_id' => $member->user_id, + 'organization_id' => $member->organization_id, + ]; + }); + } + + public function billable(): self + { + return $this->state(function (array $attributes): array { + return [ + 'billable' => true, + ]; + }); + } + + public function startWithDuration(Carbon $start, int $durationInSeconds): self + { + return $this->state(function (array $attributes) use ($start, $durationInSeconds): array { + return [ + 'start' => $start->utc(), + 'end' => $start->copy()->addSeconds($durationInSeconds), + ]; + }); + } + + public function start(Carbon $start): self + { + return $this->state(function (array $attributes) use ($start): array { + return [ + 'start' => $start->utc(), + ]; + }); + } + public function forOrganization(Organization $organization): self { return $this->state(function (array $attributes) use ($organization) { diff --git a/database/migrations/2024_05_07_134711_move_from_user_id_to_member_id_in_project_members_table.php b/database/migrations/2024_05_07_134711_move_from_user_id_to_member_id_in_project_members_table.php new file mode 100644 index 00000000..a3b60bc3 --- /dev/null +++ b/database/migrations/2024_05_07_134711_move_from_user_id_to_member_id_in_project_members_table.php @@ -0,0 +1,46 @@ +foreignUuid('member_id') + ->nullable() + ->constrained('organization_user') + ->cascadeOnDelete() + ->cascadeOnUpdate(); + }); + DB::statement(' + update project_members + set member_id = organization_user.id + from projects + join organization_user on organization_user.organization_id = projects.organization_id + where projects.id = project_members.project_id and project_members.user_id = organization_user.user_id + '); + Schema::table('project_members', function (Blueprint $table): void { + $table->uuid('member_id')->nullable(false)->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('project_members', function (Blueprint $table): void { + $table->dropForeign(['member_id']); + $table->dropColumn('member_id'); + }); + } +}; diff --git a/database/migrations/2024_05_07_141842_move_from_user_id_to_member_id_in_time_entries_table.php b/database/migrations/2024_05_07_141842_move_from_user_id_to_member_id_in_time_entries_table.php new file mode 100644 index 00000000..37c0764d --- /dev/null +++ b/database/migrations/2024_05_07_141842_move_from_user_id_to_member_id_in_time_entries_table.php @@ -0,0 +1,47 @@ +foreignUuid('member_id') + ->nullable() + ->constrained('organization_user') + ->cascadeOnDelete() + ->cascadeOnUpdate(); + }); + DB::statement(' + update time_entries + set member_id = organization_user.id + from organization_user + where time_entries.organization_id = organization_user.organization_id and + time_entries.user_id = organization_user.user_id + '); + Schema::table('time_entries', function (Blueprint $table): void { + $table->uuid('member_id')->nullable(false)->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('time_entries', function (Blueprint $table): void { + $table->dropForeign(['member_id']); + $table->dropColumn('member_id'); + }); + } +}; diff --git a/database/migrations/2024_05_13_171020_rename_table_organization_user_to_members.php b/database/migrations/2024_05_13_171020_rename_table_organization_user_to_members.php new file mode 100644 index 00000000..d5ce77ed --- /dev/null +++ b/database/migrations/2024_05_13_171020_rename_table_organization_user_to_members.php @@ -0,0 +1,25 @@ +deleteAll(); + $userWithMultipleOrganizations = User::factory()->withPersonalOrganization()->create([ + 'name' => 'Mister Overemployed', + 'email' => 'overemployed@acme.test', + ]); + $userAcmeOwner = User::factory()->withPersonalOrganization()->create([ 'name' => 'Acme Owner', 'email' => 'owner@acme.test', @@ -34,7 +39,7 @@ class DatabaseSeeder extends Seeder 'personal_team' => false, 'currency' => 'EUR', ]); - $userAcmeManager = User::factory()->withPersonalOrganization()->create([ + $userRivalManager = User::factory()->withPersonalOrganization()->create([ 'name' => 'Acme Manager', 'email' => 'test@example.com', ]); @@ -51,36 +56,28 @@ class DatabaseSeeder extends Seeder 'email' => 'old.employee@acme.test', 'password' => null, ]); - $userAcmeOwner->organizations()->attach($organizationAcme, [ - 'role' => Role::Owner->value, - ]); - $userAcmeManager->organizations()->attach($organizationAcme, [ - 'role' => Role::Manager->value, - ]); - $userAcmeAdmin->organizations()->attach($organizationAcme, [ - 'role' => Role::Admin->value, - ]); - $userAcmeEmployee->organizations()->attach($organizationAcme, [ - 'role' => Role::Employee->value, - ]); - $userAcmePlaceholder->organizations()->attach($organizationAcme, [ - 'role' => Role::Placeholder->value, - ]); + $userAcmeOwnerMember = Member::factory()->forUser($userAcmeOwner)->forOrganization($organizationAcme)->role(Role::Owner)->create(); + $userAcmeManagerMember = Member::factory()->forUser($userRivalManager)->forOrganization($organizationAcme)->role(Role::Manager)->create(); + $userAcmeAdminMember = Member::factory()->forUser($userAcmeAdmin)->forOrganization($organizationAcme)->role(Role::Admin)->create(); + $userAcmeEmployeeMember = Member::factory()->forUser($userAcmeEmployee)->forOrganization($organizationAcme)->role(Role::Employee)->create(); + $userAcmePlaceholderMember = Member::factory()->forUser($userAcmePlaceholder)->forOrganization($organizationAcme)->role(Role::Placeholder)->create(); + $userWithMultipleOrganizationsAcmeMember = Member::factory()->forUser($userWithMultipleOrganizations)->forOrganization($organizationAcme)->role(Role::Employee)->create(); - $timeEntriesAcmeAdmin = TimeEntry::factory() + TimeEntry::factory() ->count(10) - ->forUser($userAcmeAdmin) - ->forOrganization($organizationAcme) + ->forMember($userAcmeAdminMember) ->create(); - $timeEntriesAcmePlaceholder = TimeEntry::factory() + TimeEntry::factory() ->count(10) - ->forUser($userAcmePlaceholder) - ->forOrganization($organizationAcme) + ->forMember($userAcmePlaceholderMember) ->create(); - $timeEntriesAcmePlaceholder = TimeEntry::factory() + TimeEntry::factory() ->count(10) - ->forUser($userAcmeEmployee) - ->forOrganization($organizationAcme) + ->forMember($userAcmeEmployeeMember) + ->create(); + TimeEntry::factory() + ->count(5) + ->forMember($userWithMultipleOrganizationsAcmeMember) ->create(); $client = Client::factory()->forOrganization($organizationAcme)->create([ 'name' => 'Big Company', @@ -88,6 +85,10 @@ class DatabaseSeeder extends Seeder $bigCompanyProject = Project::factory()->forOrganization($organizationAcme)->forClient($client)->create([ 'name' => 'Big Company Project', ]); + ProjectMember::factory()->forProject($bigCompanyProject)->forMember($userAcmeEmployeeMember)->create(); + ProjectMember::factory()->forProject($bigCompanyProject)->forMember($userAcmeAdminMember)->create(); + ProjectMember::factory()->forProject($bigCompanyProject)->forMember($userWithMultipleOrganizationsAcmeMember)->create(); + Task::factory()->forOrganization($organizationAcme)->forProject($bigCompanyProject)->create(); $internalProject = Project::factory()->forOrganization($organizationAcme)->create([ @@ -98,21 +99,26 @@ class DatabaseSeeder extends Seeder 'name' => 'Other Owner', 'email' => 'owner@rival-company.test', ]); - $organization2 = Organization::factory()->withOwner($organization2Owner)->create([ + $organizationRival = Organization::factory()->withOwner($organization2Owner)->create([ 'name' => 'Rival Corp', 'personal_team' => true, 'currency' => 'USD', ]); - $userAcmeManager = User::factory()->withPersonalOrganization()->create([ + $userRivalManager = User::factory()->withPersonalOrganization()->create([ 'name' => 'Other User', 'email' => 'test@rival-company.test', ]); - $userAcmeManager->organizations()->attach($organization2, [ - 'role' => Role::Admin->value, - ]); - $otherCompanyProject = Project::factory()->forOrganization($organization2)->forClient($client)->create([ + $userRivalManagerMember = Member::factory()->forUser($userRivalManager)->forOrganization($organizationRival)->role(Role::Admin)->create(); + $userWithMultipleOrganizationsRivalMember = Member::factory()->forUser($userWithMultipleOrganizations)->forOrganization($organizationRival)->role(Role::Employee)->create(); + $otherCompanyProject = Project::factory()->forOrganization($organizationRival)->forClient($client)->create([ 'name' => 'Scale Company', ]); + ProjectMember::factory()->forProject($otherCompanyProject)->forMember($userRivalManagerMember)->create(); + ProjectMember::factory()->forProject($otherCompanyProject)->forMember($userWithMultipleOrganizationsRivalMember)->create(); + TimeEntry::factory() + ->count(5) + ->forMember($userWithMultipleOrganizationsRivalMember) + ->create(); User::factory()->withPersonalOrganization()->create([ 'email' => 'admin@example.com', diff --git a/e2e/projects.spec.ts b/e2e/projects.spec.ts index 937ce670..797963a1 100644 --- a/e2e/projects.spec.ts +++ b/e2e/projects.spec.ts @@ -14,7 +14,7 @@ test('test that creating and deleting a new project via the modal works', async 'New Project ' + Math.floor(1 + Math.random() * 10000); await goToProjectsOverview(page); await page.getByRole('button', { name: 'Create Project' }).click(); - await page.getByPlaceholder('Project Name').fill(newProjectName); + await page.getByLabel('Project Name').fill(newProjectName); await Promise.all([ page.getByRole('button', { name: 'Create Project' }).nth(1).click(), page.waitForResponse( diff --git a/e2e/reporting.spec.ts b/e2e/reporting.spec.ts new file mode 100644 index 00000000..102a54c7 --- /dev/null +++ b/e2e/reporting.spec.ts @@ -0,0 +1,5 @@ +// TODO: Test filter + +// TODO: Test date range + +// TODO: Test grouping and sub-grouping diff --git a/e2e/tasks.spec.ts b/e2e/tasks.spec.ts index 4a8196b4..54b7be78 100644 --- a/e2e/tasks.spec.ts +++ b/e2e/tasks.spec.ts @@ -14,7 +14,7 @@ test('test that creating and deleting a new tag in a new project works', async ( 'New Project ' + Math.floor(1 + Math.random() * 10000); await goToProjectsOverview(page); await page.getByRole('button', { name: 'Create Project' }).click(); - await page.getByPlaceholder('Project Name').fill(newProjectName); + await page.getByLabel('Project Name').fill(newProjectName); await Promise.all([ page.getByRole('button', { name: 'Create Project' }).nth(1).click(), page.waitForResponse( diff --git a/e2e/time.spec.ts b/e2e/time.spec.ts index ee63ea75..1de2fb11 100644 --- a/e2e/time.spec.ts +++ b/e2e/time.spec.ts @@ -57,7 +57,7 @@ test('test that starting and stopping an empty time entry shows a new time entry async function assertThatTimeEntryRowIsStopped(newTimeEntry: Locator) { await expect(newTimeEntry.getByTestId('timer_button')).toHaveClass( - /bg-accent-300\/50/ + /bg-accent-300\/70/ ); } @@ -297,7 +297,7 @@ test('test that stopping a time entry from the overview works', async ({ ]); await expect(newTimeEntry.getByTestId('timer_button')).toHaveClass( - /bg-accent-300\/50/ + /bg-accent-300\/70/ ); }); @@ -311,7 +311,7 @@ test('test that starting a time entry from the overview works', async ({ const newTimeEntry = timeEntryRows.first(); const startButton = newTimeEntry.getByTestId('timer_button'); - await expect(startButton).toHaveClass(/bg-accent-300\/50/); + await expect(startButton).toHaveClass(/bg-accent-300\/70/); await Promise.all([ page.waitForResponse(async (response) => { @@ -341,7 +341,7 @@ test('test that starting a time entry from the overview works', async ({ ); }), startOrStopTimerWithButton(page), - expect(startButton).toHaveClass(/bg-accent-300\/50/), + expect(startButton).toHaveClass(/bg-accent-300\/70/), ]); }); @@ -401,7 +401,7 @@ test('test that updating a the duration in the overview for a running timer work ); }), startOrStopTimerWithButton(page), - expect(startButton).toHaveClass(/bg-accent-300\/50/), + expect(startButton).toHaveClass(/bg-accent-300\/70/), ]); }); diff --git a/e2e/timetracker.spec.ts b/e2e/timetracker.spec.ts index 4d294fb5..7b19b197 100644 --- a/e2e/timetracker.spec.ts +++ b/e2e/timetracker.spec.ts @@ -115,7 +115,6 @@ test('test that starting and updating the time while running works', async ({ (await response.json()).data.project_id === null && (await response.json()).data.description === '' && (await response.json()).data.task_id === null && - (await response.json()).data.duration === null && (await response.json()).data.user_id !== null && JSON.stringify((await response.json()).data.tags) === JSON.stringify([]) diff --git a/e2e/utils/currentTimeEntry.ts b/e2e/utils/currentTimeEntry.ts index a616552f..5de2771b 100644 --- a/e2e/utils/currentTimeEntry.ts +++ b/e2e/utils/currentTimeEntry.ts @@ -27,7 +27,6 @@ export function newTimeEntryResponse( (await response.json()).data.project_id === null && (await response.json()).data.description === description && (await response.json()).data.task_id === null && - (await response.json()).data.duration === null && (await response.json()).data.user_id !== null && JSON.stringify((await response.json()).data.tags) === JSON.stringify(tags) @@ -40,7 +39,7 @@ export async function assertThatTimerIsStopped(page: Page) { page.locator( '[data-testid="dashboard_timer"] [data-testid="timer_button"]' ) - ).toHaveClass(/bg-accent-300\/50/); + ).toHaveClass(/bg-accent-300\/70/); } export async function stoppedTimeEntryResponse( diff --git a/lang/en/exceptions.php b/lang/en/exceptions.php index 8a819eae..fa2bf17b 100644 --- a/lang/en/exceptions.php +++ b/lang/en/exceptions.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization; use App\Exceptions\Api\EntityStillInUseApiException; use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException; use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException; @@ -17,5 +18,6 @@ return [ InactiveUserCanNotBeUsedApiException::KEY => 'Inactive user can not be used', UserIsAlreadyMemberOfProjectApiException::KEY => 'User is already a member of the project', EntityStillInUseApiException::KEY => 'The :modelToDelete is still used by a :modelInUse and can not be deleted.', + CanNotRemoveOwnerFromOrganization::KEY => 'Can not remove owner from organization', ], ]; diff --git a/openapi.json.client.ts b/openapi.json.client.ts index f876523b..696268cd 100644 --- a/openapi.json.client.ts +++ b/openapi.json.client.ts @@ -83,13 +83,13 @@ const ProjectMemberResource = z .object({ id: z.string(), billable_rate: z.union([z.number(), z.null()]), - user_id: z.string(), + member_id: z.string(), project_id: z.string(), }) .passthrough(); const createProjectMember_Body = z .object({ - user_id: z.string().uuid(), + member_id: z.string().uuid(), billable_rate: z.union([z.number(), z.null()]).optional(), }) .passthrough(); @@ -118,7 +118,7 @@ const TaskResource = z const createTask_Body = z .object({ name: z.string(), project_id: z.string() }) .passthrough(); -const before = z.union([z.string(), z.null()]).optional(); +const start = z.union([z.string(), z.null()]).optional(); const TimeEntryResource = z .object({ id: z.string(), @@ -137,7 +137,7 @@ const TimeEntryResource = z const TimeEntryCollection = z.array(TimeEntryResource); const createTimeEntry_Body = z .object({ - user_id: z.string().uuid(), + member_id: z.string().uuid(), project_id: z.union([z.string(), z.null()]).optional(), task_id: z.union([z.string(), z.null()]).optional(), start: z.string(), @@ -147,8 +147,25 @@ const createTimeEntry_Body = z tags: z.union([z.array(z.string()), z.null()]).optional(), }) .passthrough(); +const v1_time_entries_update_multiple_Body = z + .object({ + ids: z.array(z.string()), + changes: z + .object({ + member_id: z.string().uuid(), + project_id: z.union([z.string(), z.null()]), + task_id: z.union([z.string(), z.null()]), + billable: z.boolean(), + description: z.union([z.string(), z.null()]), + tags: z.union([z.array(z.string()), z.null()]), + }) + .partial() + .passthrough(), + }) + .passthrough(); const updateTimeEntry_Body = z .object({ + member_id: z.string().uuid().optional(), project_id: z.union([z.string(), z.null()]).optional(), task_id: z.union([z.string(), z.null()]).optional(), start: z.string(), @@ -180,10 +197,11 @@ export const schemas = { TagCollection, TaskResource, createTask_Body, - before, + start, TimeEntryResource, TimeEntryCollection, createTimeEntry_Body, + v1_time_entries_update_multiple_Body, updateTimeEntry_Body, }; @@ -197,7 +215,7 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.object({ data: OrganizationResource }).passthrough(), @@ -228,7 +246,7 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.object({ data: OrganizationResource }).passthrough(), @@ -264,7 +282,7 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.object({ data: ClientCollection }).passthrough(), @@ -295,7 +313,7 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.object({ data: ClientResource }).passthrough(), @@ -336,12 +354,12 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, { name: 'client', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.object({ data: ClientResource }).passthrough(), @@ -382,12 +400,12 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, { name: 'client', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.null(), @@ -429,7 +447,7 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z @@ -497,7 +515,7 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z @@ -535,7 +553,7 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z @@ -608,7 +626,7 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.null(), @@ -649,12 +667,12 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, { name: 'invitation', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.null(), @@ -685,12 +703,12 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, { name: 'invitation', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.null(), @@ -716,7 +734,7 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z @@ -777,7 +795,7 @@ const endpoints = makeApi([ }, { method: 'put', - path: '/v1/organizations/:organization/members/:membership', + path: '/v1/organizations/:organization/members/:member', alias: 'updateMember', requestFormat: 'json', parameters: [ @@ -789,12 +807,12 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, { - name: 'membership', + name: 'member', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.object({ data: MemberResource }).passthrough(), @@ -823,7 +841,7 @@ const endpoints = makeApi([ }, { method: 'delete', - path: '/v1/organizations/:organization/members/:membership', + path: '/v1/organizations/:organization/members/:member', alias: 'removeMember', requestFormat: 'json', parameters: [ @@ -835,12 +853,12 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, { - name: 'membership', + name: 'member', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.null(), @@ -870,7 +888,7 @@ const endpoints = makeApi([ }, { method: 'post', - path: '/v1/organizations/:organization/members/:membership/invite-placeholder', + path: '/v1/organizations/:organization/members/:member/invite-placeholder', alias: 'invitePlaceholder', requestFormat: 'json', parameters: [ @@ -882,12 +900,12 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, { - name: 'membership', + name: 'member', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.null(), @@ -929,12 +947,12 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, { name: 'projectMember', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.object({ data: ProjectMemberResource }).passthrough(), @@ -975,12 +993,12 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, { name: 'projectMember', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.null(), @@ -1006,7 +1024,12 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), + }, + { + name: 'page', + type: 'Query', + schema: z.number().int().gte(1).optional(), }, ], response: z @@ -1053,6 +1076,16 @@ const endpoints = makeApi([ description: `Not found`, schema: z.object({ message: z.string() }).passthrough(), }, + { + status: 422, + description: `Validation error`, + schema: z + .object({ + message: z.string(), + errors: z.record(z.array(z.string())), + }) + .passthrough(), + }, ], }, { @@ -1069,7 +1102,7 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.object({ data: ProjectResource }).passthrough(), @@ -1105,12 +1138,12 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, { name: 'project', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.object({ data: ProjectResource }).passthrough(), @@ -1141,12 +1174,12 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, { name: 'project', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.object({ data: ProjectResource }).passthrough(), @@ -1187,12 +1220,12 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, { name: 'project', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.null(), @@ -1229,12 +1262,12 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, { name: 'project', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z @@ -1297,12 +1330,12 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, { name: 'project', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.object({ data: ProjectMemberResource }).passthrough(), @@ -1349,7 +1382,7 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.object({ data: TagCollection }).passthrough(), @@ -1380,7 +1413,7 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.object({ data: TagResource }).passthrough(), @@ -1421,12 +1454,12 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, { name: 'tag', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.object({ data: TagResource }).passthrough(), @@ -1467,12 +1500,12 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, { name: 'tag', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.null(), @@ -1509,7 +1542,7 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, { name: 'project_id', @@ -1587,7 +1620,7 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.object({ data: TaskResource }).passthrough(), @@ -1628,12 +1661,12 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, { name: 'task', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.object({ data: TaskResource }).passthrough(), @@ -1674,12 +1707,12 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, { name: 'task', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.null(), @@ -1718,28 +1751,33 @@ Users with the permission `time-entries:view:own` can only use this en { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, { - name: 'user_id', + name: 'member_id', type: 'Query', schema: z.string().uuid().optional(), }, { - name: 'before', + name: 'start', type: 'Query', - schema: before, + schema: start, }, { - name: 'after', + name: 'end', type: 'Query', - schema: before, + schema: start, }, { name: 'active', type: 'Query', schema: z.enum(['true', 'false']).optional(), }, + { + name: 'billable', + type: 'Query', + schema: z.enum(['true', 'false']).optional(), + }, { name: 'limit', type: 'Query', @@ -1750,6 +1788,26 @@ Users with the permission `time-entries:view:own` can only use this en type: 'Query', schema: z.enum(['true', 'false']).optional(), }, + { + name: 'member_ids', + type: 'Query', + schema: z.array(z.string()).min(1).optional(), + }, + { + name: 'project_ids', + type: 'Query', + schema: z.array(z.string()).min(1).optional(), + }, + { + name: 'tag_ids', + type: 'Query', + schema: z.array(z.string()).min(1).optional(), + }, + { + name: 'task_ids', + type: 'Query', + schema: z.array(z.string()).min(1).optional(), + }, ], response: z.object({ data: TimeEntryCollection }).passthrough(), errors: [ @@ -1789,7 +1847,7 @@ Users with the permission `time-entries:view:own` can only use this en { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.object({ data: TimeEntryResource }).passthrough(), @@ -1827,6 +1885,49 @@ Users with the permission `time-entries:view:own` can only use this en }, ], }, + { + method: 'patch', + path: '/v1/organizations/:organization/time-entries', + alias: 'v1.time-entries.update-multiple', + requestFormat: 'json', + parameters: [ + { + name: 'body', + type: 'Body', + schema: v1_time_entries_update_multiple_Body, + }, + { + name: 'organization', + type: 'Path', + schema: z.string(), + }, + ], + response: z + .object({ success: z.string(), error: z.string() }) + .passthrough(), + errors: [ + { + status: 403, + description: `Authorization error`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 404, + description: `Not found`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 422, + description: `Validation error`, + schema: z + .object({ + message: z.string(), + errors: z.record(z.array(z.string())), + }) + .passthrough(), + }, + ], + }, { method: 'put', path: '/v1/organizations/:organization/time-entries/:timeEntry', @@ -1841,12 +1942,12 @@ Users with the permission `time-entries:view:own` can only use this en { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, { name: 'timeEntry', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.object({ data: TimeEntryResource }).passthrough(), @@ -1898,12 +1999,12 @@ Users with the permission `time-entries:view:own` can only use this en { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, { name: 'timeEntry', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.null(), @@ -1920,6 +2021,179 @@ Users with the permission `time-entries:view:own` can only use this en }, ], }, + { + method: 'get', + path: '/v1/organizations/:organization/time-entries/aggregate', + alias: 'getAggregatedTimeEntries', + description: `This endpoint allows you to filter time entries and aggregate them by different criteria. +The parameters `group` and `sub_group` allow you to group the time entries by different criteria. +If the group parameters are all set to `null` or are all missing, the endpoint will aggregate all filtered time entries.`, + requestFormat: 'json', + parameters: [ + { + name: 'organization', + type: 'Path', + schema: z.string(), + }, + { + name: 'group', + type: 'Query', + schema: z + .enum([ + 'day', + 'week', + 'month', + 'year', + 'user', + 'project', + 'task', + 'client', + 'billable', + ]) + .optional(), + }, + { + name: 'sub_group', + type: 'Query', + schema: z + .enum([ + 'day', + 'week', + 'month', + 'year', + 'user', + 'project', + 'task', + 'client', + 'billable', + ]) + .optional(), + }, + { + name: 'member_id', + type: 'Query', + schema: z.string().uuid().optional(), + }, + { + name: 'user_id', + type: 'Query', + schema: z.string().uuid().optional(), + }, + { + name: 'start', + type: 'Query', + schema: start, + }, + { + name: 'end', + type: 'Query', + schema: start, + }, + { + name: 'active', + type: 'Query', + schema: z.enum(['true', 'false']).optional(), + }, + { + name: 'billable', + type: 'Query', + schema: z.enum(['true', 'false']).optional(), + }, + { + name: 'fill_gaps_in_time_groups', + type: 'Query', + schema: z.enum(['true', 'false']).optional(), + }, + { + name: 'member_ids', + type: 'Query', + schema: z.array(z.string()).min(1).optional(), + }, + { + name: 'project_ids', + type: 'Query', + schema: z.array(z.string()).min(1).optional(), + }, + { + name: 'tag_ids', + type: 'Query', + schema: z.array(z.string()).min(1).optional(), + }, + { + name: 'task_ids', + type: 'Query', + schema: z.array(z.string()).min(1).optional(), + }, + ], + response: z + .object({ + data: z + .object({ + grouped_type: z.union([z.string(), z.null()]), + grouped_data: z.union([ + z.array( + z + .object({ + key: z.union([z.string(), z.null()]), + seconds: z.number().int(), + cost: z.number().int(), + grouped_type: z.union([ + z.string(), + z.null(), + ]), + grouped_data: z.union([ + z.array( + z + .object({ + key: z.union([ + z.string(), + z.null(), + ]), + seconds: z + .number() + .int(), + cost: z.number().int(), + grouped_type: z.null(), + grouped_data: z.null(), + }) + .passthrough() + ), + z.null(), + ]), + }) + .passthrough() + ), + z.null(), + ]), + seconds: z.number().int(), + cost: z.number().int(), + }) + .passthrough(), + }) + .passthrough(), + errors: [ + { + status: 403, + description: `Authorization error`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 404, + description: `Not found`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 422, + description: `Validation error`, + schema: z + .object({ + message: z.string(), + errors: z.record(z.array(z.string())), + }) + .passthrough(), + }, + ], + }, { method: 'get', path: '/v1/users/me/time-entries/active', diff --git a/package-lock.json b/package-lock.json index 508cfc1a..3175d14d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "html", + "name": "solidtime", "lockfileVersion": 3, "requires": true, "packages": { @@ -19,7 +19,7 @@ "pinia": "^2.1.7", "radix-vue": "^1.5.2", "tailwind-merge": "^2.2.1", - "vue-echarts": "^6.6.9" + "vue-echarts": "^6.7.2" }, "devDependencies": { "@inertiajs/vue3": "^1.0.0", @@ -5735,9 +5735,9 @@ } }, "node_modules/vue-echarts": { - "version": "6.6.9", - "resolved": "https://registry.npmjs.org/vue-echarts/-/vue-echarts-6.6.9.tgz", - "integrity": "sha512-mojIq3ZvsjabeVmDthhAUDV8Kgf2Rr/X4lV4da7gEFd1fP05gcSJ0j7wa7HQkW5LlFmF2gdCJ8p4Chas6NNIQQ==", + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/vue-echarts/-/vue-echarts-6.7.2.tgz", + "integrity": "sha512-SG8Vmszhx24KjtySsk361DogZLRkPCyLhgoyh7iN1eH3WGJ0kyl3k0g4QiSJqK0+F1Ej0HDopq4A5OGcBlAwzw==", "hasInstallScript": true, "dependencies": { "resize-detector": "^0.3.0", diff --git a/package.json b/package.json index 835beac3..ab99128c 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,6 @@ "pinia": "^2.1.7", "radix-vue": "^1.5.2", "tailwind-merge": "^2.2.1", - "vue-echarts": "^6.6.9" + "vue-echarts": "^6.7.2" } } diff --git a/resources/css/app.css b/resources/css/app.css index 8236cc91..c055ca05 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -2,16 +2,40 @@ @tailwind components; @tailwind utilities; +:root { + --color-bg-primary: #0f1011; + --color-bg-secondary: #1b1c20; + --color-bg-tertiary: #2A2C32; + --color-bg-quaternary: #141518; + --color-text-primary: #ffffff; + --color-text-secondary: #e3e4e6; + --color-text-tertiary: #969799; + --color-text-quaternary: #595a5c; + --color-border-primary: #191b1f; + --color-border-secondary: #23252a; + --color-border-tertiary: #2c2e33; + --color-border-quaternary: #393B42; + --color-input-border-active: rgba(255,255,255,0.3); -:root{ - --theme-color-default-background: #0b0d1c; - --theme-color-icon-default: #42466C; - --theme-color-card-background: #13152B; - --theme-color-card-background-active: #1C1E34; - --theme-color-card-background-separator: #1c2033; - --theme-color-card-border: #1c2033; - --theme-color-card-border-active: #2A3461; - --theme-color-default-background-separator: #141a2f; + --color-accent-primary: 14, 165, 233; /* sky-500 */ + --color-accent-secondary: 56, 189, 248; + --color-accent-tertiary: 125, 211, 252; + --color-accent-quaternary: 186, 230, 253; + + --theme-color-default-background: var(--color-bg-primary); + --theme-color-icon-default: var(--color-text-tertiary); + --theme-color-icon-active: rgb(var(--color-text-tertiary)); + --theme-color-card-background: var(--color-bg-secondary); + --theme-color-card-background-active: var(--color-bg-tertiary); + --theme-color-card-background-separator: var(--color-border-quaternary); + --theme-color-card-border: var(--color-border-secondary); + --theme-color-card-border-active: var(--color-border-tertiary); + --theme-color-default-background-separator: var(--color-border-primary); + --theme-color-primary-text: var(--color-text-primary); + --theme-color-muted-text: var(--color-text-secondary); + --theme-color-menu-active: var(--color-bg-secondary); + --theme-color-input-border: var(--color-border-quaternary); + --theme-color-input-background: var(--color-bg-secondary); --theme-color-tab-background: var(--theme-color-card-background); --theme-color-tab-background-active: var(--theme-color-card-background-active); --theme-color-tab-border: var(--theme-color-card-border); @@ -21,17 +45,15 @@ --theme-color-row-heading-border: var(--theme-color-card-border); } -*{ +* { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } - [x-cloak] { display: none; } - -body{ +body { background-color: var(--theme-color-default-background); } diff --git a/resources/js/Components/Common/Badge.vue b/resources/js/Components/Common/Badge.vue index 52c32983..1bb6d429 100644 --- a/resources/js/Components/Common/Badge.vue +++ b/resources/js/Components/Common/Badge.vue @@ -37,10 +37,10 @@ const borderClasses = computed(() => { :is="tag" :class=" twMerge( - props.class, badgeClasses[size], borderClasses, - 'rounded inline-flex items-center font-semibold text-white' + 'rounded inline-flex items-center font-semibold text-white', + props.class ) "> diff --git a/resources/js/Components/Common/BillableRateInput.vue b/resources/js/Components/Common/BillableRateInput.vue index 7bba4cce..f43277da 100644 --- a/resources/js/Components/Common/BillableRateInput.vue +++ b/resources/js/Components/Common/BillableRateInput.vue @@ -6,6 +6,10 @@ import { getOrganizationCurrencySymbol, } from '../../utils/money'; +defineProps<{ + name: string; +}>(); + const model = defineModel({ default: null, type: Number, @@ -51,13 +55,14 @@ function formatCents(modelValue: number) { diff --git a/resources/js/Components/Common/DateRangePicker.vue b/resources/js/Components/Common/DateRangePicker.vue new file mode 100644 index 00000000..5b39535b --- /dev/null +++ b/resources/js/Components/Common/DateRangePicker.vue @@ -0,0 +1,39 @@ + + + + + diff --git a/resources/js/Components/Common/GroupedItemsCountButton.vue b/resources/js/Components/Common/GroupedItemsCountButton.vue new file mode 100644 index 00000000..1a8d66e9 --- /dev/null +++ b/resources/js/Components/Common/GroupedItemsCountButton.vue @@ -0,0 +1,37 @@ + + + + + diff --git a/resources/js/Components/Common/Icons/BillableIcon.vue b/resources/js/Components/Common/Icons/BillableIcon.vue new file mode 100644 index 00000000..e2a32170 --- /dev/null +++ b/resources/js/Components/Common/Icons/BillableIcon.vue @@ -0,0 +1,14 @@ + + + + + diff --git a/resources/js/Components/Common/Member/MemberCombobox.vue b/resources/js/Components/Common/Member/MemberCombobox.vue index 83714877..8a40ed54 100644 --- a/resources/js/Components/Common/Member/MemberCombobox.vue +++ b/resources/js/Components/Common/Member/MemberCombobox.vue @@ -37,7 +37,7 @@ const filteredMembers = computed(() => { .toLowerCase() .includes(searchValue.value?.toLowerCase()?.trim() || '') && !props.hiddenMembers.some( - (hiddenMember) => hiddenMember.user_id === member.user_id + (hiddenMember) => hiddenMember.id === member.id ) && member.is_placeholder === false ); @@ -54,7 +54,7 @@ onMounted(() => { function resetHighlightedItem() { if (filteredMembers.value.length > 0) { - highlightedItemId.value = filteredMembers.value[0].user_id; + highlightedItemId.value = filteredMembers.value[0].id; } } @@ -65,10 +65,10 @@ function updateSearchValue(event: Event) { const highlightedClientId = highlightedItemId.value; if (highlightedClientId) { const highlightedClient = members.value.find( - (member) => member.user_id === highlightedClientId + (member) => member.id === highlightedClientId ); if (highlightedClient) { - model.value = highlightedClient.user_id; + model.value = highlightedClient.id; } } } else { @@ -94,10 +94,10 @@ function moveHighlightUp() { ); if (currentHightlightedIndex === 0) { highlightedItemId.value = - filteredMembers.value[filteredMembers.value.length - 1].user_id; + filteredMembers.value[filteredMembers.value.length - 1].id; } else { highlightedItemId.value = - filteredMembers.value[currentHightlightedIndex - 1].user_id; + filteredMembers.value[currentHightlightedIndex - 1].id; } } } @@ -108,10 +108,10 @@ function moveHighlightDown() { highlightedItem.value ); if (currentHightlightedIndex === filteredMembers.value.length - 1) { - highlightedItemId.value = filteredMembers.value[0].user_id; + highlightedItemId.value = filteredMembers.value[0].id; } else { highlightedItemId.value = - filteredMembers.value[currentHightlightedIndex + 1].user_id; + filteredMembers.value[currentHightlightedIndex + 1].id; } } } @@ -119,14 +119,13 @@ function moveHighlightDown() { const highlightedItemId = ref(null); const highlightedItem = computed(() => { return members.value.find( - (member) => member.user_id === highlightedItemId.value + (member) => member.id === highlightedItemId.value ); }); const currentValue = computed(() => { if (model.value) { - return members.value.find((member) => member.user_id === model.value) - ?.name; + return members.value.find((member) => member.id === model.value)?.name; } return searchValue.value; }); @@ -186,18 +185,18 @@ function onUnfocus() {
+ :data-client-id="member.id">
diff --git a/resources/js/Components/Common/Member/MemberMultiselectDropdown.vue b/resources/js/Components/Common/Member/MemberMultiselectDropdown.vue new file mode 100644 index 00000000..5d332749 --- /dev/null +++ b/resources/js/Components/Common/Member/MemberMultiselectDropdown.vue @@ -0,0 +1,29 @@ + + + diff --git a/resources/js/Components/Common/Member/MemberTableRow.vue b/resources/js/Components/Common/Member/MemberTableRow.vue index 56c4d3ab..dc4d64a4 100644 --- a/resources/js/Components/Common/Member/MemberTableRow.vue +++ b/resources/js/Components/Common/Member/MemberTableRow.vue @@ -29,7 +29,7 @@ async function invitePlaceholder(id: string) { { params: { organization: organizationId, - membership: id, + member: id, }, } ), diff --git a/resources/js/Components/Common/MultiselectDropdown.vue b/resources/js/Components/Common/MultiselectDropdown.vue new file mode 100644 index 00000000..6176cbc6 --- /dev/null +++ b/resources/js/Components/Common/MultiselectDropdown.vue @@ -0,0 +1,191 @@ + + + + + diff --git a/resources/js/Components/Common/Tag/TagDropdownItem.vue b/resources/js/Components/Common/MultiselectDropdownItem.vue similarity index 95% rename from resources/js/Components/Common/Tag/TagDropdownItem.vue rename to resources/js/Components/Common/MultiselectDropdownItem.vue index 74d8a79f..d8702d26 100644 --- a/resources/js/Components/Common/Tag/TagDropdownItem.vue +++ b/resources/js/Components/Common/MultiselectDropdownItem.vue @@ -12,7 +12,7 @@ const iconClasses = computed(() => { if (props.selected) { return 'text-accent-200'; } else { - return 'text-card-border'; + return 'text-white/10'; } }); diff --git a/resources/js/Components/Common/Project/ProjectColorSelector.vue b/resources/js/Components/Common/Project/ProjectColorSelector.vue index afa1f2c4..4b8fc1de 100644 --- a/resources/js/Components/Common/Project/ProjectColorSelector.vue +++ b/resources/js/Components/Common/Project/ProjectColorSelector.vue @@ -6,15 +6,17 @@ const model = defineModel({ default: '' });