mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 08:12:17 +01:00
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
29
app/Enums/TimeEntryAggregationType.php
Normal file
29
app/Enums/TimeEntryAggregationType.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum TimeEntryAggregationType: string
|
||||
{
|
||||
case Day = 'day';
|
||||
case Week = 'week';
|
||||
case Month = 'month';
|
||||
case Year = 'year';
|
||||
case User = 'user';
|
||||
case Project = 'project';
|
||||
case Task = 'task';
|
||||
case Client = 'client';
|
||||
case Billable = 'billable';
|
||||
|
||||
public function toInterval(): ?TimeEntryAggregationTypeInterval
|
||||
{
|
||||
return match ($this) {
|
||||
TimeEntryAggregationType::Day => TimeEntryAggregationTypeInterval::Day,
|
||||
TimeEntryAggregationType::Week => TimeEntryAggregationTypeInterval::Week,
|
||||
TimeEntryAggregationType::Month => TimeEntryAggregationTypeInterval::Month,
|
||||
TimeEntryAggregationType::Year => TimeEntryAggregationTypeInterval::Year,
|
||||
default => null
|
||||
};
|
||||
}
|
||||
}
|
||||
13
app/Enums/TimeEntryAggregationTypeInterval.php
Normal file
13
app/Enums/TimeEntryAggregationTypeInterval.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum TimeEntryAggregationTypeInterval: string
|
||||
{
|
||||
case Day = 'day';
|
||||
case Week = 'week';
|
||||
case Month = 'month';
|
||||
case Year = 'year';
|
||||
}
|
||||
@@ -16,6 +16,19 @@ enum Weekday: string
|
||||
case Saturday = 'saturday';
|
||||
case Sunday = 'sunday';
|
||||
|
||||
public function toEndOfWeek(): self
|
||||
{
|
||||
return match ($this) {
|
||||
Weekday::Monday => 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) {
|
||||
|
||||
10
app/Exceptions/Api/CanNotRemoveOwnerFromOrganization.php
Normal file
10
app/Exceptions/Api/CanNotRemoveOwnerFromOrganization.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Api;
|
||||
|
||||
class CanNotRemoveOwnerFromOrganization extends ApiException
|
||||
{
|
||||
public const string KEY = 'can_not_remove_owner_from_organization';
|
||||
}
|
||||
@@ -108,11 +108,15 @@ class OrganizationResource extends Resource
|
||||
->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')
|
||||
|
||||
@@ -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<string> $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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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<Project> $builder */
|
||||
$builder->visibleByUser($user);
|
||||
});
|
||||
$query->visibleByEmployee($user);
|
||||
}
|
||||
|
||||
$tasks = $query->paginate(config('app.pagination_per_page_default'));
|
||||
|
||||
@@ -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<array{
|
||||
* key: string|null,
|
||||
* seconds: int,
|
||||
* cost: int,
|
||||
* grouped_type: string|null,
|
||||
* grouped_data: null|array<array{
|
||||
* key: string|null,
|
||||
* seconds: int,
|
||||
* cost: int,
|
||||
* grouped_type: null,
|
||||
* grouped_data: null
|
||||
* }>
|
||||
* }>,
|
||||
* seconds: int,
|
||||
* cost: int
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* @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);
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ class ShareInertiaData
|
||||
'currency' => $organization->currency,
|
||||
'membership' => [
|
||||
'role' => $organization->membership->role,
|
||||
'id' => $organization->membership->id,
|
||||
],
|
||||
];
|
||||
})->all(),
|
||||
|
||||
@@ -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<User> $builder */
|
||||
return $builder->belongsToOrganization($this->organization);
|
||||
new ExistsEloquent(Member::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Member> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}),
|
||||
],
|
||||
'billable_rate' => [
|
||||
|
||||
@@ -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;
|
||||
|
||||
168
app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php
Normal file
168
app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php
Normal file
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\TimeEntry;
|
||||
|
||||
use App\Enums\TimeEntryAggregationType;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tag;
|
||||
use App\Models\Task;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||
|
||||
/**
|
||||
* @property Organization $organization
|
||||
*/
|
||||
class TimeEntryAggregateRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
|
||||
*/
|
||||
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<Member> $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<Member> $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<User> $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<Project> $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<Tag> $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;
|
||||
}
|
||||
}
|
||||
@@ -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<User> $builder */
|
||||
return $builder->belongsToOrganization($this->organization);
|
||||
new ExistsEloquent(Member::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Member> $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<Member> $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<Project> $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<Tag> $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<Task> $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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<User> $builder */
|
||||
return $builder->belongsToOrganization($this->organization);
|
||||
new ExistsEloquent(Member::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Member> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}),
|
||||
],
|
||||
'project_id' => [
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
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 Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||
|
||||
/**
|
||||
* @property Organization $organization Organization from model binding
|
||||
*/
|
||||
class TimeEntryUpdateMultipleRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, array<string|ValidationRule>>
|
||||
*/
|
||||
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<Member> $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<Project> $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<Task> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}),
|
||||
(new ExistsEloquent(Task::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Task> $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<Tag> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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<Member> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}),
|
||||
],
|
||||
// ID of the project that the time entry should belong to
|
||||
'project_id' => [
|
||||
'nullable',
|
||||
'string',
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
20
app/Models/Concerns/HasUuids.php
Normal file
20
app/Models/Concerns/HasUuids.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models\Concerns;
|
||||
|
||||
use Ramsey\Uuid\Uuid;
|
||||
|
||||
trait HasUuids
|
||||
{
|
||||
use \Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
|
||||
/**
|
||||
* Generate a new UUID for the model.
|
||||
*/
|
||||
public function newUniqueId(): string
|
||||
{
|
||||
return (string) Uuid::uuid4();
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\MembershipFactory;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use App\Models\Concerns\HasUuids;
|
||||
use Database\Factories\MemberFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Laravel\Jetstream\Membership as JetstreamMembership;
|
||||
@@ -21,9 +21,9 @@ use Laravel\Jetstream\Membership as JetstreamMembership;
|
||||
* @property-read Organization $organization
|
||||
* @property-read User $user
|
||||
*
|
||||
* @method static MembershipFactory factory()
|
||||
* @method static MemberFactory factory()
|
||||
*/
|
||||
class Membership extends JetstreamMembership
|
||||
class Member extends JetstreamMembership
|
||||
{
|
||||
use HasFactory;
|
||||
use HasUuids;
|
||||
@@ -33,10 +33,10 @@ class Membership extends JetstreamMembership
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'organization_user';
|
||||
protected $table = 'members';
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, Membership>
|
||||
* @return BelongsTo<User, Member>
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
@@ -44,7 +44,7 @@ class Membership extends JetstreamMembership
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Organization, Membership>
|
||||
* @return BelongsTo<Organization, Member>
|
||||
*/
|
||||
public function organization(): BelongsTo
|
||||
{
|
||||
@@ -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<int, User> $users
|
||||
* @property Collection<int, User> $realUsers
|
||||
* @property-read Collection<int, OrganizationInvitation> $teamInvitations
|
||||
* @property Membership $membership
|
||||
* @property Member $membership
|
||||
*
|
||||
* @method HasMany<OrganizationInvitation> teamInvitations()
|
||||
* @method static OrganizationFactory factory()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<int, Task> $tasks
|
||||
* @property-read Collection<int, ProjectMember> $members
|
||||
*
|
||||
* @method Builder<Project> visibleByUser(User $user)
|
||||
* @method Builder<Project> 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<Project> $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)
|
||||
|
||||
@@ -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<ProjectMember> whereBelongsToOrganization(Organization $organization)
|
||||
@@ -45,6 +47,8 @@ class ProjectMember extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use member relationship instead
|
||||
*
|
||||
* @return BelongsTo<User, ProjectMember>
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
@@ -52,6 +56,14 @@ class ProjectMember extends Model
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Member, ProjectMember>
|
||||
*/
|
||||
public function member(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Member::class, 'member_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<ProjectMember> $builder
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Task> $builder
|
||||
* @return Builder<Task>
|
||||
*/
|
||||
public function scopeVisibleByEmployee(Builder $builder, User $user): Builder
|
||||
{
|
||||
return $builder->whereHas('project', function (Builder $builder) use ($user): Builder {
|
||||
/** @var Builder<Project> $builder */
|
||||
return $builder->visibleByEmployee($user);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Member, TimeEntry>
|
||||
*/
|
||||
public function member(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Member::class, 'member_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Organization, TimeEntry>
|
||||
*/
|
||||
|
||||
@@ -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<int, Organization> $organizations
|
||||
* @property Collection<int, TimeEntry> $timeEntries
|
||||
* @property Membership $membership
|
||||
* @property Member $membership
|
||||
*
|
||||
* @method HasMany<Organization> 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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<int, array{user_id: string, name: string, description: string|null, time_entry_id: string, task_id: string|null, status: bool }>
|
||||
* @return array<int, array{member_id: string, name: string, description: string|null, time_entry_id: string, task_id: string|null, status: bool }>
|
||||
*/
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Member>
|
||||
*/
|
||||
protected ImportDatabaseHelper $memberImportHelper;
|
||||
|
||||
/**
|
||||
* @var ImportDatabaseHelper<Project>
|
||||
*/
|
||||
@@ -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<Member> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
});
|
||||
$this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'organization_id'], true, function (Builder $builder) {
|
||||
/** @var Builder<Project> $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<ProjectMember> $builder */
|
||||
return $builder->whereBelongsToOrganization($this->organization);
|
||||
}, validate: [
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
295
app/Service/TimeEntryAggregationService.php
Normal file
295
app/Service/TimeEntryAggregationService.php
Normal file
@@ -0,0 +1,295 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Enums\TimeEntryAggregationType;
|
||||
use App\Enums\TimeEntryAggregationTypeInterval;
|
||||
use App\Enums\Weekday;
|
||||
use App\Models\TimeEntry;
|
||||
use Carbon\CarbonTimeZone;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class TimeEntryAggregationService
|
||||
{
|
||||
/**
|
||||
* @param Builder<TimeEntry> $timeEntriesQuery
|
||||
* @return array{
|
||||
* grouped_type: string|null,
|
||||
* grouped_data: null|array<array{
|
||||
* key: string|null,
|
||||
* seconds: int,
|
||||
* cost: int,
|
||||
* grouped_type: string|null,
|
||||
* grouped_data: null|array<array{
|
||||
* key: string|null,
|
||||
* seconds: int,
|
||||
* cost: int,
|
||||
* grouped_type: null,
|
||||
* grouped_data: null
|
||||
* }>
|
||||
* }>,
|
||||
* seconds: int,
|
||||
* cost: int
|
||||
* }
|
||||
*/
|
||||
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<int, object{aggregate: int, cost: int}> $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<int, object{aggregate: int, cost: int}> $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<int, object{aggregate: int, cost: int}> $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<array{
|
||||
* key: string|null,
|
||||
* seconds: int,
|
||||
* cost: int,
|
||||
* grouped_type: string|null,
|
||||
* grouped_data: null|array<array{
|
||||
* key: string|null,
|
||||
* seconds: int,
|
||||
* cost: int,
|
||||
* grouped_type: null|mixed,
|
||||
* grouped_data: null|mixed
|
||||
* }>
|
||||
* }> $data
|
||||
* @return array<array{
|
||||
* key: string|null,
|
||||
* seconds: int,
|
||||
* cost: int,
|
||||
* grouped_type: string|null,
|
||||
* grouped_data: null|array<array{
|
||||
* key: string|null,
|
||||
* seconds: int,
|
||||
* cost: int,
|
||||
* grouped_type: null|mixed,
|
||||
* grouped_data: null|mixed
|
||||
* }>
|
||||
* }>
|
||||
*/
|
||||
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<int, string>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
161
app/Service/TimeEntryFilter.php
Normal file
161
app/Service/TimeEntryFilter.php
Normal file
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Models\Member;
|
||||
use App\Models\TimeEntry;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class TimeEntryFilter
|
||||
{
|
||||
/**
|
||||
* @var Builder<TimeEntry>
|
||||
*/
|
||||
private Builder $builder;
|
||||
|
||||
/**
|
||||
* @param Builder<TimeEntry> $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<string>|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<string>|null $clientIds
|
||||
*/
|
||||
public function addClientIdsFilter(?array $clientIds): self
|
||||
{
|
||||
if ($clientIds === null) {
|
||||
return $this;
|
||||
}
|
||||
$this->builder->whereIn('client_id', $clientIds);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string>|null $projectIds
|
||||
*/
|
||||
public function addProjectIdsFilter(?array $projectIds): self
|
||||
{
|
||||
if ($projectIds === null) {
|
||||
return $this;
|
||||
}
|
||||
$this->builder->whereIn('project_id', $projectIds);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string>|null $tagIds
|
||||
*/
|
||||
public function addTagIdsFilter(?array $tagIds): self
|
||||
{
|
||||
if ($tagIds === null) {
|
||||
return $this;
|
||||
}
|
||||
$this->builder->whereJsonContains('tags', $tagIds);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string>|null $taskIds
|
||||
*/
|
||||
public function addTaskIdsFilter(?array $taskIds): self
|
||||
{
|
||||
if ($taskIds === null) {
|
||||
return $this;
|
||||
}
|
||||
$this->builder->whereIn('task_id', $taskIds);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Builder<TimeEntry>
|
||||
*/
|
||||
public function get(): Builder
|
||||
{
|
||||
return $this->builder;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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<Membership>
|
||||
* @extends Factory<Member>
|
||||
*/
|
||||
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 {
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<TimeEntry>
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('project_members', function (Blueprint $table): void {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
|
||||
Schema::table('time_entries', function (Blueprint $table): void {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::rename('organization_user', 'members');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::rename('members', 'organization_user');
|
||||
}
|
||||
};
|
||||
@@ -4,9 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use App\Enums\Role;
|
||||
use App\Models\Client;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectMember;
|
||||
@@ -25,6 +25,11 @@ class DatabaseSeeder extends Seeder
|
||||
public function run(): void
|
||||
{
|
||||
$this->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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
5
e2e/reporting.spec.ts
Normal file
5
e2e/reporting.spec.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// TODO: Test filter
|
||||
|
||||
// TODO: Test date range
|
||||
|
||||
// TODO: Test grouping and sub-grouping
|
||||
@@ -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(
|
||||
|
||||
@@ -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/),
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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([])
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -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',
|
||||
|
||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
">
|
||||
<slot></slot>
|
||||
|
||||
@@ -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) {
|
||||
<template>
|
||||
<div class="relative">
|
||||
<TextInput
|
||||
id="projectMemberRate"
|
||||
:id="name"
|
||||
ref="projectMemberRateInput"
|
||||
:modelValue="formatCents(model)"
|
||||
@blur="updateRate($event.target.value)"
|
||||
type="text"
|
||||
:name="name"
|
||||
placeholder="Billable Rate"
|
||||
class="mt-1 block w-full"
|
||||
class="mt-2 block w-full"
|
||||
autocomplete="teamMemberRate" />
|
||||
<span>
|
||||
<div
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import BillableIcon from '@/Components/Common/Icons/BillableIcon.vue';
|
||||
const active = defineModel({ default: false });
|
||||
const emit = defineEmits(['changed']);
|
||||
function toggleBillable() {
|
||||
@@ -19,7 +20,7 @@ const props = withDefaults(
|
||||
|
||||
const iconColorClasses = computed(() => {
|
||||
if (active.value) {
|
||||
return 'text-accent-200/80 focus:text-accent-200 hover:text-accent-200';
|
||||
return 'text-accent-300 focus:text-accent-200 hover:text-accent-200';
|
||||
} else {
|
||||
return 'text-icon-default focus:text-icon-active hover:text-icon-active';
|
||||
}
|
||||
@@ -49,18 +50,7 @@ const iconSizeWrapperClasses =
|
||||
'flex-shrink-0 ring-0 focus:outline-none focus:ring-0 transition focus:bg-card-background-separator hover:bg-card-background-separator rounded-full flex items-center justify-center'
|
||||
)
|
||||
">
|
||||
<svg
|
||||
:class="iconSizeClasses"
|
||||
viewBox="0 0 8 14"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M4 1V13M1 10.182L1.879 10.841C3.05 11.72 4.949 11.72 6.121 10.841C7.293 9.962 7.293 8.538 6.121 7.659C5.536 7.219 4.768 7 4 7C3.275 7 2.55 6.78 1.997 6.341C0.891 5.462 0.891 4.038 1.997 3.159C3.103 2.28 4.897 2.28 6.003 3.159L6.418 3.489"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
</svg>
|
||||
<BillableIcon :class="iconSizeClasses"></BillableIcon>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
|
||||
39
resources/js/Components/Common/DateRangePicker.vue
Normal file
39
resources/js/Components/Common/DateRangePicker.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { CalendarIcon } from '@heroicons/vue/20/solid';
|
||||
import Dropdown from '@/Components/Dropdown.vue';
|
||||
import DatePicker from '@/Components/Common/DatePicker.vue';
|
||||
import { formatDate } from '../../utils/time';
|
||||
|
||||
const start = defineModel('start', { default: '' });
|
||||
const end = defineModel('end', { default: '' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dropdown :close-on-content-click="false" align="bottom-end">
|
||||
<template #trigger>
|
||||
<button
|
||||
class="px-3 py-1.5 bg-input-background border border-input-border font-medium rounded-lg flex items-center space-x-2">
|
||||
<CalendarIcon class="w-5"></CalendarIcon>
|
||||
<div class="text-white">
|
||||
{{ formatDate(start) }}
|
||||
<span class="px-1.5 text-muted">-</span>
|
||||
{{ formatDate(end) }}
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="overflow-hidden w-[280px] px-3 py-1.5">
|
||||
<div class="flex space-x-3 items-center justify-between">
|
||||
<div class="text-sm font-medium text-muted">Start Date</div>
|
||||
<DatePicker v-model="start"></DatePicker>
|
||||
</div>
|
||||
<div class="mt-2 flex space-x-3 items-center justify-between">
|
||||
<div class="text-sm font-medium text-muted">End Date</div>
|
||||
<DatePicker v-model="end"></DatePicker>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
37
resources/js/Components/Common/GroupedItemsCountButton.vue
Normal file
37
resources/js/Components/Common/GroupedItemsCountButton.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
expanded?: boolean;
|
||||
size: string;
|
||||
}>(),
|
||||
{
|
||||
expanded: false,
|
||||
size: 'w-7 h-7',
|
||||
}
|
||||
);
|
||||
|
||||
const expandedStatusClasses = computed(() => {
|
||||
if (props.expanded) {
|
||||
return 'border-card-border border bg-card-background-active text-white';
|
||||
}
|
||||
return 'border-card-border border bg-card-background text-muted';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:class="
|
||||
twMerge(
|
||||
'font-medium rounded flex items-center transition justify-center',
|
||||
expandedStatusClasses,
|
||||
props.size
|
||||
)
|
||||
">
|
||||
<slot></slot>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
14
resources/js/Components/Common/Icons/BillableIcon.vue
Normal file
14
resources/js/Components/Common/Icons/BillableIcon.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<svg viewBox="0 0 8 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M4 1V13M1 10.182L1.879 10.841C3.05 11.72 4.949 11.72 6.121 10.841C7.293 9.962 7.293 8.538 6.121 7.659C5.536 7.219 4.768 7 4 7C3.275 7 2.55 6.78 1.997 6.341C0.891 5.462 0.891 4.038 1.997 3.159C3.103 2.28 4.897 2.28 6.003 3.159L6.418 3.489"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -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<string | null>(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() {
|
||||
</div>
|
||||
<div
|
||||
v-for="member in filteredMembers"
|
||||
:key="member.user_id"
|
||||
:key="member.id"
|
||||
role="option"
|
||||
:value="member.user_id"
|
||||
:value="member.id"
|
||||
:class="{
|
||||
'bg-card-background-active':
|
||||
member.user_id === highlightedItemId,
|
||||
member.id === highlightedItemId,
|
||||
}"
|
||||
@click="updateMember(member.user_id)"
|
||||
@click="updateMember(member.id)"
|
||||
data-testid="client_dropdown_entries"
|
||||
:data-client-id="member.user_id">
|
||||
:data-client-id="member.id">
|
||||
<ClientDropdownItem
|
||||
:selected="isMemberSelected(member.user_id)"
|
||||
:selected="isMemberSelected(member.id)"
|
||||
:name="member.name"></ClientDropdownItem>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import MultiselectDropdown from '@/Components/Common/MultiselectDropdown.vue';
|
||||
import { useMembersStore } from '@/utils/useMembers';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import type { Member } from '@/utils/api';
|
||||
|
||||
const membersStore = useMembersStore();
|
||||
const { members } = storeToRefs(membersStore);
|
||||
|
||||
function getKeyFromItem(item: Member) {
|
||||
return item.id;
|
||||
}
|
||||
|
||||
function getNameForItem(item: Member) {
|
||||
return item.name;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MultiselectDropdown
|
||||
searchPlaceholder="Search for a Member..."
|
||||
:items="members"
|
||||
:get-key-from-item="getKeyFromItem"
|
||||
:get-name-for-item="getNameForItem">
|
||||
<template #trigger>
|
||||
<slot name="trigger"></slot>
|
||||
</template>
|
||||
</MultiselectDropdown>
|
||||
</template>
|
||||
@@ -29,7 +29,7 @@ async function invitePlaceholder(id: string) {
|
||||
{
|
||||
params: {
|
||||
organization: organizationId,
|
||||
membership: id,
|
||||
member: id,
|
||||
},
|
||||
}
|
||||
),
|
||||
|
||||
191
resources/js/Components/Common/MultiselectDropdown.vue
Normal file
191
resources/js/Components/Common/MultiselectDropdown.vue
Normal file
@@ -0,0 +1,191 @@
|
||||
<script setup lang="ts" generic="T">
|
||||
import Dropdown from '@/Components/Dropdown.vue';
|
||||
import { type Component, computed, nextTick, ref, watch } from 'vue';
|
||||
import MultiselectDropdownItem from '@/Components/Common/MultiselectDropdownItem.vue';
|
||||
|
||||
const model = defineModel<string[]>({
|
||||
default: [],
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
items: T[];
|
||||
searchPlaceholder: string;
|
||||
getKeyFromItem: (item: T) => string;
|
||||
getNameForItem: (item: T) => string;
|
||||
}>();
|
||||
|
||||
const searchInput = ref<HTMLInputElement | null>(null);
|
||||
const open = ref(false);
|
||||
const dropdownViewport = ref<Component | null>(null);
|
||||
|
||||
const searchValue = ref('');
|
||||
|
||||
function isItemSelected(id: string) {
|
||||
return model.value.includes(id);
|
||||
}
|
||||
|
||||
function addOrRemoveItemFromSelection(id: string) {
|
||||
if (model.value.includes(id)) {
|
||||
model.value = model.value.filter((itemId) => itemId !== id);
|
||||
} else {
|
||||
model.value.push(id);
|
||||
}
|
||||
emit('changed');
|
||||
}
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
nextTick(() => {
|
||||
searchInput.value?.focus();
|
||||
});
|
||||
|
||||
// sort tags alphabetically
|
||||
[...props.items].sort((a, b) => {
|
||||
const aIsSelected = model.value.includes(props.getKeyFromItem(a));
|
||||
const bIsSelected = model.value.includes(props.getKeyFromItem(b));
|
||||
if (aIsSelected === bIsSelected) {
|
||||
return props
|
||||
.getNameForItem(a)
|
||||
.localeCompare(props.getNameForItem(b));
|
||||
}
|
||||
return model.value.includes(props.getKeyFromItem(a)) ? -1 : 1;
|
||||
});
|
||||
nextTick(() => {
|
||||
if (filteredItems.value.length > 0) {
|
||||
highlightedItemId.value = props.getKeyFromItem(
|
||||
filteredItems.value[0]
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const filteredItems = computed<T[]>(() => {
|
||||
return props.items.filter((item: T) => {
|
||||
return props
|
||||
.getNameForItem(item)
|
||||
.toLowerCase()
|
||||
.includes(searchValue.value?.toLowerCase()?.trim() || '');
|
||||
});
|
||||
});
|
||||
|
||||
watch(filteredItems, () => {
|
||||
if (filteredItems.value.length > 0) {
|
||||
highlightedItemId.value = props.getKeyFromItem(filteredItems.value[0]);
|
||||
}
|
||||
});
|
||||
|
||||
function updateSearchValue(event: Event) {
|
||||
const newInput = (event.target as HTMLInputElement).value;
|
||||
if (newInput === ' ') {
|
||||
searchValue.value = '';
|
||||
const highlightedTagId = highlightedItemId.value;
|
||||
if (highlightedTagId) {
|
||||
const highlightedItem = props.items.find(
|
||||
(item) => props.getKeyFromItem(item) === highlightedTagId
|
||||
);
|
||||
if (highlightedItem) {
|
||||
addOrRemoveItemFromSelection(
|
||||
props.getKeyFromItem(highlightedItem)
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
searchValue.value = newInput;
|
||||
}
|
||||
}
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'changed']);
|
||||
|
||||
function toggleItem(newValue: string | null) {
|
||||
if (newValue !== null) {
|
||||
if (model.value.includes(newValue)) {
|
||||
model.value = [...model.value].filter((id) => id !== newValue);
|
||||
} else {
|
||||
model.value = [...model.value, newValue];
|
||||
}
|
||||
emit('changed');
|
||||
}
|
||||
}
|
||||
|
||||
function moveHighlightUp() {
|
||||
if (highlightedItem.value) {
|
||||
const currentHightlightedIndex = filteredItems.value.indexOf(
|
||||
highlightedItem.value
|
||||
);
|
||||
if (currentHightlightedIndex === 0) {
|
||||
highlightedItemId.value = props.getKeyFromItem(
|
||||
filteredItems.value[filteredItems.value.length - 1]
|
||||
);
|
||||
} else {
|
||||
highlightedItemId.value = props.getKeyFromItem(
|
||||
filteredItems.value[currentHightlightedIndex - 1]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function moveHighlightDown() {
|
||||
if (highlightedItem.value) {
|
||||
const currentHightlightedIndex = filteredItems.value.indexOf(
|
||||
highlightedItem.value
|
||||
);
|
||||
if (currentHightlightedIndex === filteredItems.value.length - 1) {
|
||||
highlightedItemId.value = props.getKeyFromItem(
|
||||
filteredItems.value[0]
|
||||
);
|
||||
} else {
|
||||
highlightedItemId.value = props.getKeyFromItem(
|
||||
filteredItems.value[currentHightlightedIndex + 1]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const highlightedItemId = ref<string | null>(null);
|
||||
const highlightedItem = computed(() => {
|
||||
return props.items.find(
|
||||
(item) => props.getKeyFromItem(item) === highlightedItemId.value
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dropdown v-model="open" align="bottom-start" :closeOnContentClick="false">
|
||||
<template #trigger>
|
||||
<slot name="trigger"></slot>
|
||||
</template>
|
||||
<template #content>
|
||||
<input
|
||||
:value="searchValue"
|
||||
@input="updateSearchValue"
|
||||
@keydown.up.prevent="moveHighlightUp"
|
||||
@keydown.down.prevent="moveHighlightDown"
|
||||
@keydown.enter="toggleItem(highlightedItemId)"
|
||||
ref="searchInput"
|
||||
class="bg-card-background border-0 placeholder-muted text-sm text-white py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
|
||||
:placeholder="searchPlaceholder" />
|
||||
<div ref="dropdownViewport" class="w-60">
|
||||
<div
|
||||
v-for="item in filteredItems"
|
||||
:key="props.getKeyFromItem(item)"
|
||||
role="option"
|
||||
:value="props.getKeyFromItem(item)"
|
||||
:class="{
|
||||
'bg-card-background-active':
|
||||
props.getKeyFromItem(item) === highlightedItemId,
|
||||
}"
|
||||
:data-item-id="props.getKeyFromItem(item)">
|
||||
<MultiselectDropdownItem
|
||||
:selected="isItemSelected(props.getKeyFromItem(item))"
|
||||
@click="toggleItem(props.getKeyFromItem(item))"
|
||||
:name="
|
||||
props.getNameForItem(item)
|
||||
"></MultiselectDropdownItem>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -12,7 +12,7 @@ const iconClasses = computed(() => {
|
||||
if (props.selected) {
|
||||
return 'text-accent-200';
|
||||
} else {
|
||||
return 'text-card-border';
|
||||
return 'text-white/10';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -6,15 +6,17 @@ const model = defineModel<string>({ default: '' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-3">
|
||||
<div>
|
||||
<Dropdown align="bottom">
|
||||
<template #trigger>
|
||||
<div
|
||||
:style="{
|
||||
backgroundColor: model,
|
||||
boxShadow: `var(--tw-ring-inset) 0 0 0 calc(5px + var(--tw-ring-offset-width)) ${model}30`,
|
||||
}"
|
||||
class="w-4 h-4 rounded-full cursor-pointer"></div>
|
||||
<button
|
||||
class="p-2 bg-input-background hover:bg-tertiary transition rounded-full border border-input-border">
|
||||
<div
|
||||
:style="{
|
||||
backgroundColor: model,
|
||||
}"
|
||||
class="w-5 h-5 rounded-full cursor-pointer"></div>
|
||||
</button>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="text-white grid grid-cols-6 gap-3 px-3 py-3">
|
||||
|
||||
@@ -9,12 +9,13 @@ import PrimaryButton from '@/Components/PrimaryButton.vue';
|
||||
import { useProjectsStore } from '@/utils/useProjects';
|
||||
import { useFocus } from '@vueuse/core';
|
||||
import ClientDropdown from '@/Components/Common/Client/ClientDropdown.vue';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import Badge from '@/Components/Common/Badge.vue';
|
||||
import { useClientsStore } from '@/utils/useClients';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import ProjectColorSelector from '@/Components/Common/Project/ProjectColorSelector.vue';
|
||||
import BillableRateInput from '@/Components/Common/BillableRateInput.vue';
|
||||
import { UserCircleIcon } from '@heroicons/vue/20/solid';
|
||||
import InputLabel from '@/Components/InputLabel.vue';
|
||||
|
||||
const { createProject } = useProjectsStore();
|
||||
const { clients } = storeToRefs(useClientsStore());
|
||||
@@ -63,33 +64,47 @@ const currentClientName = computed(() => {
|
||||
<div
|
||||
class="sm:flex items-center space-y-2 sm:space-y-0 sm:space-x-4">
|
||||
<div class="flex-1 flex items-center">
|
||||
<ProjectColorSelector
|
||||
v-model="project.color"></ProjectColorSelector>
|
||||
<TextInput
|
||||
id="projectName"
|
||||
ref="projectNameInput"
|
||||
v-model="project.name"
|
||||
type="text"
|
||||
placeholder="Project Name"
|
||||
@keydown.enter="submit()"
|
||||
class="mt-1 block w-full"
|
||||
required
|
||||
autocomplete="projectName" />
|
||||
<div class="text-center pr-5">
|
||||
<InputLabel for="color" value="Color" />
|
||||
<ProjectColorSelector
|
||||
class="mt-2.5"
|
||||
v-model="project.color"></ProjectColorSelector>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<InputLabel for="projectName" value="Project name" />
|
||||
<TextInput
|
||||
id="projectName"
|
||||
name="projectName"
|
||||
ref="projectNameInput"
|
||||
v-model="project.name"
|
||||
type="text"
|
||||
placeholder="The next big thing"
|
||||
@keydown.enter="submit()"
|
||||
class="mt-2 block w-full"
|
||||
required
|
||||
autocomplete="projectName" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="sm:max-w-[120px]">
|
||||
<BillableRateInput v-model="project.billable_rate" />
|
||||
<InputLabel for="billableRate" value="Billable Rate" />
|
||||
<BillableRateInput
|
||||
v-model="project.billable_rate"
|
||||
name="billableRate" />
|
||||
</div>
|
||||
<div>
|
||||
<ClientDropdown v-model="project.client_id">
|
||||
<InputLabel for="client" value="Client" />
|
||||
<ClientDropdown class="mt-2" v-model="project.client_id">
|
||||
<template #trigger>
|
||||
<Badge size="large">
|
||||
<div
|
||||
:class="
|
||||
twMerge('inline-block rounded-full')
|
||||
"></div>
|
||||
<span>
|
||||
{{ currentClientName }}
|
||||
</span>
|
||||
<Badge
|
||||
class="bg-input-background cursor-pointer hover:bg-tertiary"
|
||||
size="xlarge">
|
||||
<div class="flex items-center space-x-2">
|
||||
<UserCircleIcon
|
||||
class="w-5 text-icon-default"></UserCircleIcon>
|
||||
<span>
|
||||
{{ currentClientName }}
|
||||
</span>
|
||||
</div>
|
||||
</Badge>
|
||||
</template>
|
||||
</ClientDropdown>
|
||||
@@ -97,7 +112,7 @@ const currentClientName = computed(() => {
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<SecondaryButton @click="show = false"> Cancel </SecondaryButton>
|
||||
<SecondaryButton @click="show = false"> Cancel</SecondaryButton>
|
||||
|
||||
<PrimaryButton
|
||||
class="ms-3"
|
||||
|
||||
@@ -78,7 +78,9 @@ const currentClientName = computed(() => {
|
||||
autocomplete="projectName" />
|
||||
</div>
|
||||
<div class="sm:max-w-[120px]">
|
||||
<BillableRateInput v-model="project.billable_rate" />
|
||||
<BillableRateInput
|
||||
v-model="project.billable_rate"
|
||||
name="billable_rate" />
|
||||
</div>
|
||||
<div class="">
|
||||
<ClientDropdown v-model="project.client_id">
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import MultiselectDropdown from '@/Components/Common/MultiselectDropdown.vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useProjectsStore } from '@/utils/useProjects';
|
||||
import type { Project } from '@/utils/api';
|
||||
|
||||
const projectsStore = useProjectsStore();
|
||||
const { projects } = storeToRefs(projectsStore);
|
||||
|
||||
function getKeyFromItem(item: Project) {
|
||||
return item.id;
|
||||
}
|
||||
|
||||
function getNameForItem(item: Project) {
|
||||
return item.name;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MultiselectDropdown
|
||||
searchPlaceholder="Search for a Project..."
|
||||
:items="projects"
|
||||
:get-key-from-item="getKeyFromItem"
|
||||
:get-name-for-item="getNameForItem">
|
||||
<template #trigger>
|
||||
<slot name="trigger"></slot>
|
||||
</template>
|
||||
</MultiselectDropdown>
|
||||
</template>
|
||||
@@ -18,7 +18,7 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const projectMember = ref<CreateProjectMemberBody>({
|
||||
user_id: '',
|
||||
member_id: '',
|
||||
billable_rate: null,
|
||||
});
|
||||
|
||||
@@ -26,7 +26,7 @@ async function submit() {
|
||||
await createProjectMember(props.projectId, projectMember.value);
|
||||
show.value = false;
|
||||
projectMember.value = {
|
||||
user_id: '',
|
||||
member_id: '',
|
||||
billable_rate: null,
|
||||
};
|
||||
}
|
||||
@@ -49,10 +49,11 @@ useFocus(projectNameInput, { initialValue: true });
|
||||
<div class="col-span-3 sm:col-span-2">
|
||||
<MemberCombobox
|
||||
:hidden-members="props.existingMembers"
|
||||
v-model="projectMember.user_id"></MemberCombobox>
|
||||
v-model="projectMember.member_id"></MemberCombobox>
|
||||
</div>
|
||||
<div class="col-span-3 sm:col-span-1 flex-1">
|
||||
<BillableRateInput
|
||||
name="billable_rate"
|
||||
v-model="
|
||||
projectMember.billable_rate
|
||||
"></BillableRateInput>
|
||||
|
||||
@@ -22,7 +22,7 @@ function deleteProjectMember() {
|
||||
const { members } = storeToRefs(useMembersStore());
|
||||
const member = computed(() => {
|
||||
return members.value.find(
|
||||
(member) => member.user_id === props.projectMember.user_id
|
||||
(member) => member.id === props.projectMember.member_id
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
174
resources/js/Components/Common/Reporting/ReportingChart.vue
Normal file
174
resources/js/Components/Common/Reporting/ReportingChart.vue
Normal file
@@ -0,0 +1,174 @@
|
||||
<script setup lang="ts">
|
||||
import VChart, { THEME_KEY } from 'vue-echarts';
|
||||
import { computed, provide, ref } from 'vue';
|
||||
import LinearGradient from 'zrender/lib/graphic/LinearGradient';
|
||||
import {
|
||||
formatDate,
|
||||
formatHumanReadableDuration,
|
||||
formatWeek,
|
||||
} from '@/utils/time';
|
||||
import { use } from 'echarts/core';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
import { BarChart } from 'echarts/charts';
|
||||
import {
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
TitleComponent,
|
||||
TooltipComponent,
|
||||
} from 'echarts/components';
|
||||
import type { AggregatedTimeEntries } from '@/utils/api';
|
||||
import { useCssVar } from '@vueuse/core';
|
||||
|
||||
use([
|
||||
CanvasRenderer,
|
||||
BarChart,
|
||||
TitleComponent,
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
]);
|
||||
|
||||
provide(THEME_KEY, 'dark');
|
||||
|
||||
type GroupedData = AggregatedTimeEntries['grouped_data'];
|
||||
|
||||
const props = defineProps<{
|
||||
groupedData: GroupedData;
|
||||
groupedType: string | null;
|
||||
}>();
|
||||
|
||||
const xAxisLabels = computed(() => {
|
||||
if (props.groupedType === 'week') {
|
||||
return props?.groupedData?.map((el) => formatWeek(el.key));
|
||||
}
|
||||
return props?.groupedData?.map((el) => formatDate(el.key ?? ''));
|
||||
});
|
||||
const accentColor = useCssVar('--color-accent-quaternary');
|
||||
|
||||
const seriesData = computed(() => {
|
||||
return props?.groupedData?.map((el) => {
|
||||
return {
|
||||
value: el.seconds,
|
||||
...{
|
||||
itemStyle: {
|
||||
borderColor: new LinearGradient(0, 0, 0, 1, [
|
||||
{
|
||||
offset: 0,
|
||||
color: 'rgba(' + accentColor.value + ',0.7)',
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: 'rgba(' + accentColor.value + ',0.5)',
|
||||
},
|
||||
]),
|
||||
emphasis: {
|
||||
color: new LinearGradient(0, 0, 0, 1, [
|
||||
{
|
||||
offset: 0,
|
||||
color: 'rgba(' + accentColor.value + ',0.9)',
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: 'rgba(' + accentColor.value + ',0.7)',
|
||||
},
|
||||
]),
|
||||
},
|
||||
borderRadius: [12, 12, 0, 0],
|
||||
color: new LinearGradient(0, 0, 0, 1, [
|
||||
{
|
||||
offset: 0,
|
||||
color: 'rgba(' + accentColor.value + ',0.7)',
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: 'rgba(' + accentColor.value + ',0.5)',
|
||||
},
|
||||
]),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const option = ref({
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
},
|
||||
grid: {
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 50,
|
||||
left: 0,
|
||||
},
|
||||
backgroundColor: 'transparent',
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: xAxisLabels,
|
||||
markLine: {
|
||||
lineStyle: {
|
||||
color: 'rgba(125,156,188,0.1)',
|
||||
type: 'dashed',
|
||||
},
|
||||
},
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: 'transparent', // Set desired color here
|
||||
},
|
||||
},
|
||||
axisLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
margin: 16,
|
||||
fontFamily: 'Outfit, sans-serif',
|
||||
},
|
||||
axisTick: {
|
||||
lineStyle: {
|
||||
color: 'transparent', // Set desired color here
|
||||
},
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: 'rgba(125,156,188,0.2)', // Set desired color here
|
||||
},
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
data: seriesData,
|
||||
type: 'bar',
|
||||
tooltip: {
|
||||
valueFormatter: (value: number) => {
|
||||
return formatHumanReadableDuration(value);
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-[calc(100%-1px)]">
|
||||
<v-chart
|
||||
v-if="groupedData && groupedData?.length > 0"
|
||||
:autoresize="true"
|
||||
class="chart"
|
||||
:option="option" />
|
||||
<div class="chart flex flex-col items-center justify-center" v-else>
|
||||
<p class="text-lg text-white font-semibold">
|
||||
No time entries found
|
||||
</p>
|
||||
<p>Try to change the filters and time range</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chart {
|
||||
height: 300px;
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import Badge from '@/Components/Common/Badge.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
icon: Component;
|
||||
title: string;
|
||||
count?: number;
|
||||
active?: boolean;
|
||||
}>();
|
||||
import { type Component, computed } from 'vue';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
const activeClass = computed(() => {
|
||||
if (props.active) {
|
||||
return 'border-accent-300/50 bg-accent-300/10 hover:bg-accent-300/20';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Badge
|
||||
size="large"
|
||||
:class="
|
||||
twMerge(
|
||||
'cursor-pointer hover:bg-card-background transition flex',
|
||||
activeClass
|
||||
)
|
||||
">
|
||||
<component :is="icon" class="h-4 text-muted"></component>
|
||||
<span> {{ title }} </span>
|
||||
<div
|
||||
v-if="count"
|
||||
class="bg-accent-300/20 w-5 h-5 font-medium rounded flex items-center transition justify-center">
|
||||
{{ count }}
|
||||
</div>
|
||||
</Badge>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { FolderIcon } from '@heroicons/vue/16/solid';
|
||||
import SelectDropdown from '@/Components/Common/SelectDropdown.vue';
|
||||
import Badge from '@/Components/Common/Badge.vue';
|
||||
import { computed } from 'vue';
|
||||
import { CheckCircleIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
|
||||
import BillableIcon from '@/Components/Common/Icons/BillableIcon.vue';
|
||||
|
||||
const groupByOptions = [
|
||||
{
|
||||
label: 'Members',
|
||||
value: 'user',
|
||||
icon: UserGroupIcon,
|
||||
},
|
||||
{
|
||||
label: 'Projects',
|
||||
value: 'project',
|
||||
icon: FolderIcon,
|
||||
},
|
||||
{
|
||||
label: 'Tasks',
|
||||
value: 'task',
|
||||
icon: CheckCircleIcon,
|
||||
},
|
||||
{
|
||||
label: 'Billable',
|
||||
value: 'billable',
|
||||
icon: BillableIcon,
|
||||
},
|
||||
];
|
||||
|
||||
const model = defineModel<string | null>({ default: null });
|
||||
|
||||
const icon = computed(() => {
|
||||
return groupByOptions.find((option) => option.value === model.value)?.icon;
|
||||
});
|
||||
const title = computed(() => {
|
||||
return groupByOptions.find((option) => option.value === model.value)?.label;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectDropdown
|
||||
v-model="model"
|
||||
:get-key-from-item="(item) => item.value"
|
||||
:get-name-for-item="(item) => item.label"
|
||||
:items="groupByOptions">
|
||||
<template v-slot:trigger>
|
||||
<Badge
|
||||
size="large"
|
||||
class="cursor-pointer hover:bg-card-background transition space-x-5 flex">
|
||||
<component :is="icon" class="h-4 text-muted"></component>
|
||||
<span> {{ title }} </span>
|
||||
</Badge>
|
||||
</template>
|
||||
</SelectDropdown>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
135
resources/js/Components/Common/Reporting/ReportingPieChart.vue
Normal file
135
resources/js/Components/Common/Reporting/ReportingPieChart.vue
Normal file
@@ -0,0 +1,135 @@
|
||||
<script setup lang="ts">
|
||||
import VChart, { THEME_KEY } from 'vue-echarts';
|
||||
import { computed, provide, ref } from 'vue';
|
||||
import LinearGradient from 'zrender/lib/graphic/LinearGradient';
|
||||
import { use } from 'echarts/core';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
import { PieChart } from 'echarts/charts';
|
||||
import {
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
TitleComponent,
|
||||
TooltipComponent,
|
||||
} from 'echarts/components';
|
||||
import { useCssVar } from '@vueuse/core';
|
||||
import { formatHumanReadableDuration } from '@/utils/time';
|
||||
import { getRandomColorWithSeed } from '@/utils/color';
|
||||
import type { GroupedDataEntries } from '@/utils/api';
|
||||
import { useReportingStore } from '@/utils/useReporting';
|
||||
|
||||
use([
|
||||
CanvasRenderer,
|
||||
PieChart,
|
||||
TitleComponent,
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
]);
|
||||
|
||||
provide(THEME_KEY, 'dark');
|
||||
|
||||
const backgroundColor = useCssVar('--theme-color-default-background');
|
||||
|
||||
function hexToRGBA(hex: string, opacity = 1) {
|
||||
// Remove the hash at the start if it's there
|
||||
hex = hex.replace(/^#/, '');
|
||||
|
||||
// Parse the hex color
|
||||
let r, g, b;
|
||||
if (hex.length === 3) {
|
||||
r = parseInt(hex.charAt(0) + hex.charAt(0), 16);
|
||||
g = parseInt(hex.charAt(1) + hex.charAt(1), 16);
|
||||
b = parseInt(hex.charAt(2) + hex.charAt(2), 16);
|
||||
} else if (hex.length === 6) {
|
||||
r = parseInt(hex.substring(0, 2), 16);
|
||||
g = parseInt(hex.substring(2, 4), 16);
|
||||
b = parseInt(hex.substring(4, 6), 16);
|
||||
} else {
|
||||
throw new Error('Invalid HEX color.');
|
||||
}
|
||||
|
||||
// Return the RGBA color string
|
||||
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
data: GroupedDataEntries | null;
|
||||
type: string | null;
|
||||
}>();
|
||||
const { getNameForReportingRowEntry } = useReportingStore();
|
||||
|
||||
const groupChartData = computed(() => {
|
||||
return (
|
||||
props?.data?.map((entry) => {
|
||||
return {
|
||||
value: entry.seconds,
|
||||
name: getNameForReportingRowEntry(entry.key, props.type),
|
||||
color: getRandomColorWithSeed(entry.key ?? 'none'),
|
||||
};
|
||||
}) ?? []
|
||||
);
|
||||
});
|
||||
|
||||
const seriesData = computed(() => {
|
||||
return groupChartData.value.map((el) => {
|
||||
return {
|
||||
...el,
|
||||
...{
|
||||
itemStyle: {
|
||||
borderRadius: 15,
|
||||
// TODO: Fix dynamic color
|
||||
borderColor: backgroundColor.value,
|
||||
borderWidth: 18,
|
||||
color: new LinearGradient(0, 0, 0, 1, [
|
||||
{
|
||||
offset: 0,
|
||||
color: hexToRGBA(el.color, 0.8),
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: hexToRGBA(el.color, 0.4),
|
||||
},
|
||||
]),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
const option = ref({
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
},
|
||||
legend: {
|
||||
orient: 'vertical',
|
||||
bottom: 'bottom',
|
||||
},
|
||||
backgroundColor: 'transparent',
|
||||
series: [
|
||||
{
|
||||
label: {
|
||||
show: false,
|
||||
},
|
||||
tooltip: {
|
||||
valueFormatter: (value: number) => {
|
||||
return formatHumanReadableDuration(value);
|
||||
},
|
||||
},
|
||||
data: seriesData,
|
||||
radius: ['30%', '65%'],
|
||||
type: 'pie',
|
||||
top: '-10%',
|
||||
},
|
||||
],
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-chart class="chart" :autoresize="true" :option="option" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chart {
|
||||
height: 300px;
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
73
resources/js/Components/Common/Reporting/ReportingRow.vue
Normal file
73
resources/js/Components/Common/Reporting/ReportingRow.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { formatHumanReadableDuration } from '@/utils/time';
|
||||
import { formatMoney } from '@/utils/money';
|
||||
import GroupedItemsCountButton from '@/Components/Common/GroupedItemsCountButton.vue';
|
||||
import { ref } from 'vue';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { useReportingStore } from '@/utils/useReporting';
|
||||
const { getNameForReportingRowEntry } = useReportingStore();
|
||||
|
||||
type AggregatedGroupedData = GroupedData & {
|
||||
grouped_type?: string | null;
|
||||
grouped_data?: GroupedData[] | null;
|
||||
};
|
||||
|
||||
type GroupedData = {
|
||||
key: string | null;
|
||||
seconds: number;
|
||||
cost: number;
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
entry: AggregatedGroupedData;
|
||||
indent?: boolean;
|
||||
type: string | null;
|
||||
}>();
|
||||
|
||||
function getNameForKey(key: string | null) {
|
||||
return getNameForReportingRowEntry(key, props.type);
|
||||
}
|
||||
const expanded = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="contents text-white [&>*]:transition [&>*]:border-card-background-separator [&>*]:border-b [&>*]:h-[50px]">
|
||||
<div
|
||||
:class="
|
||||
twMerge(
|
||||
'pl-6 font-medium flex items-center space-x-3',
|
||||
props.indent ? 'pl-16' : ''
|
||||
)
|
||||
">
|
||||
<GroupedItemsCountButton
|
||||
:expanded="expanded"
|
||||
@click="expanded = !expanded"
|
||||
v-if="entry.grouped_data && entry.grouped_data?.length > 0">
|
||||
{{ entry.grouped_data?.length }}
|
||||
</GroupedItemsCountButton>
|
||||
<span>
|
||||
{{ getNameForKey(entry.key) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="justify-end flex items-center">
|
||||
{{ formatHumanReadableDuration(entry.seconds) }}
|
||||
</div>
|
||||
<div class="justify-end pr-6 flex items-center">
|
||||
{{ formatMoney(entry.cost) }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="col-span-3 grid bg-quaternary"
|
||||
style="grid-template-columns: 1fr 150px 150px"
|
||||
v-if="expanded && entry.grouped_data">
|
||||
<ReportingRow
|
||||
indent
|
||||
v-for="subEntry in entry.grouped_data"
|
||||
:type="entry?.grouped_type ?? null"
|
||||
:key="subEntry.key ?? 'none'"
|
||||
:entry="subEntry"></ReportingRow>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
142
resources/js/Components/Common/SelectDropdown.vue
Normal file
142
resources/js/Components/Common/SelectDropdown.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<script setup lang="ts" generic="T">
|
||||
import Dropdown from '@/Components/Dropdown.vue';
|
||||
import { type Component, computed, ref, watch } from 'vue';
|
||||
import SelectDropdownItem from '@/Components/Common/SelectDropdownItem.vue';
|
||||
import { onKeyStroke } from '@vueuse/core';
|
||||
|
||||
const model = defineModel<string | null>({
|
||||
default: null,
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
items: T[];
|
||||
getKeyFromItem: (item: T) => string | null;
|
||||
getNameForItem: (item: T) => string;
|
||||
}>();
|
||||
|
||||
const open = ref(false);
|
||||
const dropdownViewport = ref<Component | null>(null);
|
||||
|
||||
const searchValue = ref('');
|
||||
|
||||
// DropdownMultiselect
|
||||
const filteredItems = computed<T[]>(() => {
|
||||
return props.items.filter((item: T) => {
|
||||
return props
|
||||
.getNameForItem(item)
|
||||
.toLowerCase()
|
||||
.includes(searchValue.value?.toLowerCase()?.trim() || '');
|
||||
});
|
||||
});
|
||||
|
||||
watch(filteredItems, () => {
|
||||
if (filteredItems.value.length > 0) {
|
||||
highlightedItemId.value = props.getKeyFromItem(filteredItems.value[0]);
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'changed']);
|
||||
|
||||
function setItem(newValue: string | null) {
|
||||
model.value = newValue;
|
||||
emit('changed');
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
function moveHighlightUp() {
|
||||
if (highlightedItem.value) {
|
||||
const currentHightlightedIndex = filteredItems.value.indexOf(
|
||||
highlightedItem.value
|
||||
);
|
||||
if (currentHightlightedIndex === 0) {
|
||||
highlightedItemId.value = props.getKeyFromItem(
|
||||
filteredItems.value[filteredItems.value.length - 1]
|
||||
);
|
||||
} else {
|
||||
highlightedItemId.value = props.getKeyFromItem(
|
||||
filteredItems.value[currentHightlightedIndex - 1]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function moveHighlightDown() {
|
||||
if (highlightedItem.value) {
|
||||
const currentHightlightedIndex = filteredItems.value.indexOf(
|
||||
highlightedItem.value
|
||||
);
|
||||
if (currentHightlightedIndex === filteredItems.value.length - 1) {
|
||||
highlightedItemId.value = props.getKeyFromItem(
|
||||
filteredItems.value[0]
|
||||
);
|
||||
} else {
|
||||
highlightedItemId.value = props.getKeyFromItem(
|
||||
filteredItems.value[currentHightlightedIndex + 1]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const highlightedItemId = ref<string | null>(null);
|
||||
const highlightedItem = computed(() => {
|
||||
return props.items.find(
|
||||
(item) => props.getKeyFromItem(item) === highlightedItemId.value
|
||||
);
|
||||
});
|
||||
|
||||
onKeyStroke('ArrowDown', (e) => {
|
||||
if (open.value === true) {
|
||||
moveHighlightDown();
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
onKeyStroke('ArrowUp', (e) => {
|
||||
if (open.value === true) {
|
||||
moveHighlightUp();
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
onKeyStroke('Enter', (e) => {
|
||||
if (open.value === true) {
|
||||
setItem(highlightedItemId.value);
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
watch(open, () => {
|
||||
if (open.value === true) {
|
||||
highlightedItemId.value = model.value;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dropdown v-model="open" align="bottom-start" :closeOnContentClick="false">
|
||||
<template #trigger>
|
||||
<slot name="trigger"></slot>
|
||||
</template>
|
||||
<template #content>
|
||||
<div ref="dropdownViewport" class="w-60">
|
||||
<div
|
||||
v-for="item in filteredItems"
|
||||
:key="props.getKeyFromItem(item) ?? 'none'"
|
||||
role="option"
|
||||
:value="props.getKeyFromItem(item)"
|
||||
:class="{
|
||||
'bg-card-background-active':
|
||||
props.getKeyFromItem(item) === highlightedItemId,
|
||||
}"
|
||||
:data-item-id="props.getKeyFromItem(item)">
|
||||
<SelectDropdownItem
|
||||
:selected="props.getKeyFromItem(item) === model"
|
||||
@click="setItem(props.getKeyFromItem(item))"
|
||||
:name="props.getNameForItem(item)"></SelectDropdownItem>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
23
resources/js/Components/Common/SelectDropdownItem.vue
Normal file
23
resources/js/Components/Common/SelectDropdownItem.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
const props = defineProps<{
|
||||
name: string;
|
||||
selected: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
twMerge(
|
||||
'flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out',
|
||||
|
||||
props.selected ? 'bg-accent-300/20' : 'hover:bg-card-background'
|
||||
)
|
||||
">
|
||||
<span>{{ name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -2,9 +2,10 @@
|
||||
import { PlusCircleIcon } from '@heroicons/vue/20/solid';
|
||||
import Dropdown from '@/Components/Dropdown.vue';
|
||||
import { type Component, computed, nextTick, ref, watch } from 'vue';
|
||||
import TagDropdownItem from '@/Components/Common/Tag/TagDropdownItem.vue';
|
||||
import { useTagsStore } from '@/utils/useTags';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import TagCreateModal from '@/Components/Common/Tag/TagCreateModal.vue';
|
||||
import MultiselectDropdownItem from '@/Components/Common/MultiselectDropdownItem.vue';
|
||||
|
||||
const tagsStore = useTagsStore();
|
||||
const { tags } = storeToRefs(tagsStore);
|
||||
@@ -47,6 +48,11 @@ watch(open, (isOpen) => {
|
||||
}
|
||||
return model.value.includes(a.id) ? -1 : 1;
|
||||
});
|
||||
nextTick(() => {
|
||||
if (filteredTags.value.length > 0) {
|
||||
highlightedItemId.value = filteredTags.value[0].id;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -96,17 +102,15 @@ function updateSearchValue(event: Event) {
|
||||
}
|
||||
}
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'changed']);
|
||||
const emit = defineEmits(['update:modelValue', 'changed', 'submit']);
|
||||
|
||||
function toggleTag(newValue: string) {
|
||||
if (model.value.includes(newValue)) {
|
||||
model.value = model.value.filter((id) => id !== newValue);
|
||||
model.value = [...model.value].filter((id) => id !== newValue);
|
||||
} else {
|
||||
model.value.push(newValue);
|
||||
model.value = [...model.value, newValue];
|
||||
}
|
||||
nextTick(() => {
|
||||
emit('changed');
|
||||
});
|
||||
emit('changed');
|
||||
}
|
||||
|
||||
function moveHighlightUp() {
|
||||
@@ -142,10 +146,17 @@ const highlightedItemId = ref<string | null>(null);
|
||||
const highlightedItem = computed(() => {
|
||||
return tags.value.find((tag) => tag.id === highlightedItemId.value);
|
||||
});
|
||||
|
||||
const showCreateTagModal = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dropdown width="120" v-model="open" :closeOnContentClick="false">
|
||||
<TagCreateModal v-model:show="showCreateTagModal"></TagCreateModal>
|
||||
<Dropdown
|
||||
@submit="emit('submit')"
|
||||
v-model="open"
|
||||
align="bottom-start"
|
||||
:closeOnContentClick="false">
|
||||
<template #trigger>
|
||||
<slot name="trigger"></slot>
|
||||
</template>
|
||||
@@ -159,20 +170,20 @@ const highlightedItem = computed(() => {
|
||||
@keydown.down.prevent="moveHighlightDown"
|
||||
ref="searchInput"
|
||||
class="bg-card-background border-0 placeholder-muted text-sm text-white py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
|
||||
placeholder="Search for a tag..." />
|
||||
placeholder="Search for a Tag..." />
|
||||
<div ref="dropdownViewport" class="w-60">
|
||||
<div
|
||||
v-if="searchValue.length > 0 && filteredTags.length === 0"
|
||||
class="bg-card-background-active">
|
||||
class="bg-card-background-active rounded-b-lg">
|
||||
<div
|
||||
@click="addTagIfNoneExists"
|
||||
class="text-white flex space-x-3 items-center px-4 py-3 text-xs font-medium border-t rounded-b-lg border-card-background-separator">
|
||||
class="text-white flex space-x-3 items-center px-4 py-3 text-xs font-medium border-t border-card-background-separator">
|
||||
<PlusCircleIcon
|
||||
class="w-5 flex-shrink-0"></PlusCircleIcon>
|
||||
<span>Add "{{ searchValue }}" as a new Tag</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else></div>
|
||||
|
||||
<div
|
||||
v-for="tag in filteredTags"
|
||||
:key="tag.id"
|
||||
@@ -184,10 +195,22 @@ const highlightedItem = computed(() => {
|
||||
}"
|
||||
data-testid="tag_dropdown_entries"
|
||||
:data-tag-id="tag.id">
|
||||
<TagDropdownItem
|
||||
<MultiselectDropdownItem
|
||||
:selected="isTagSelected(tag.id)"
|
||||
@click="toggleTag(tag.id)"
|
||||
:name="tag.name"></TagDropdownItem>
|
||||
:name="tag.name"></MultiselectDropdownItem>
|
||||
</div>
|
||||
<div class="hover:bg-card-background-active rounded-b-lg">
|
||||
<button
|
||||
@click="
|
||||
open = false;
|
||||
showCreateTagModal = true;
|
||||
"
|
||||
class="text-white flex space-x-3 items-center px-4 py-3 text-xs font-semibold border-t border-card-background-separator">
|
||||
<PlusCircleIcon
|
||||
class="w-5 flex-shrink-0 text-icon-default"></PlusCircleIcon>
|
||||
<span>Create new Tag</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import MultiselectDropdown from '@/Components/Common/MultiselectDropdown.vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import type { Task } from '@/utils/api';
|
||||
import { useTasksStore } from '@/utils/useTasks';
|
||||
|
||||
const tasksStore = useTasksStore();
|
||||
const { tasks } = storeToRefs(tasksStore);
|
||||
|
||||
function getKeyFromItem(item: Task) {
|
||||
return item.id;
|
||||
}
|
||||
|
||||
function getNameForItem(item: Task) {
|
||||
return item.name;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MultiselectDropdown
|
||||
searchPlaceholder="Search for a Task..."
|
||||
:items="tasks"
|
||||
:get-key-from-item="getKeyFromItem"
|
||||
:get-name-for-item="getNameForItem">
|
||||
<template #trigger>
|
||||
<slot name="trigger"></slot>
|
||||
</template>
|
||||
</MultiselectDropdown>
|
||||
</template>
|
||||
@@ -14,13 +14,13 @@ import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
|
||||
import TimeEntryMoreOptionsDropdown from '@/Components/Common/TimeEntry/TimeEntryMoreOptionsDropdown.vue';
|
||||
import TimeTrackerProjectTaskDropdown from '@/Components/Common/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
|
||||
import BillableToggleButton from '@/Components/Common/BillableToggleButton.vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { ref } from 'vue';
|
||||
import {
|
||||
formatHumanReadableDuration,
|
||||
formatStartEnd,
|
||||
} from '../../../utils/time';
|
||||
import TimeEntryRow from '@/Components/Common/TimeEntry/TimeEntryRow.vue';
|
||||
import GroupedItemsCountButton from '@/Components/Common/GroupedItemsCountButton.vue';
|
||||
|
||||
const currentTimeEntryStore = useCurrentTimeEntryStore();
|
||||
const { stopTimer } = currentTimeEntryStore;
|
||||
@@ -99,13 +99,6 @@ function updateProjectAndTask(projectId: string, taskId: string) {
|
||||
}
|
||||
|
||||
const expanded = ref(false);
|
||||
|
||||
const expandedStatusClasses = computed(() => {
|
||||
if (expanded.value) {
|
||||
return 'border-card-border border bg-card-background-active text-white';
|
||||
}
|
||||
return 'border-card-border border bg-card-background text-muted';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -118,18 +111,11 @@ const expandedStatusClasses = computed(() => {
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded bg-card-background border-input-border text-accent-500/80 focus:ring-accent-500/80" />
|
||||
<button
|
||||
@click="expanded = !expanded"
|
||||
:class="
|
||||
twMerge(
|
||||
expandedStatusClasses,
|
||||
'font-medium w-7 h-7 rounded flex items-center transition justify-center'
|
||||
)
|
||||
">
|
||||
<span>
|
||||
{{ timeEntry?.timeEntries?.length }}
|
||||
</span>
|
||||
</button>
|
||||
<GroupedItemsCountButton
|
||||
:expanded="expanded"
|
||||
@click="expanded = !expanded">
|
||||
{{ timeEntry?.timeEntries?.length }}
|
||||
</GroupedItemsCountButton>
|
||||
<TimeEntryDescriptionInput
|
||||
@changed="updateTimeEntryDescription"
|
||||
class="flex-1"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user