Merge pull request #73 from solidtime-io/feature/reporting

Reporting
This commit is contained in:
Constantin Graf
2024-05-21 19:21:51 +02:00
committed by GitHub
164 changed files with 5494 additions and 967 deletions

View File

@@ -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.');
}

View File

@@ -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;

View 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
};
}
}

View 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';
}

View File

@@ -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) {

View 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';
}

View File

@@ -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')

View File

@@ -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;
}
}

View File

@@ -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')

View File

@@ -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);

View File

@@ -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();
});

View File

@@ -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();

View File

@@ -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'));

View File

@@ -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);

View File

@@ -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')

View File

@@ -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;

View File

@@ -85,6 +85,7 @@ class ShareInertiaData
'currency' => $organization->currency,
'membership' => [
'role' => $organization->membership->role,
'id' => $organization->membership->id,
],
];
})->all(),

View File

@@ -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' => [

View File

@@ -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;

View 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;
}
}

View File

@@ -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';
}
}

View File

@@ -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' => [

View File

@@ -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');
}),
],
];
}
}

View File

@@ -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',

View File

@@ -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
{

View File

@@ -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,
];
}
}

View File

@@ -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
{

View File

@@ -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,
];

View File

@@ -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();
}
}
}

View File

@@ -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;

View 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();
}
}

View File

@@ -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
{

View File

@@ -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()

View File

@@ -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;

View File

@@ -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)

View File

@@ -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
*/

View File

@@ -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;

View File

@@ -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);
});
}
}

View File

@@ -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>
*/

View File

@@ -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',

View File

@@ -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);
}
}

View File

@@ -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 () {

View File

@@ -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

View File

@@ -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,

View File

@@ -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);
});

View File

@@ -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;

View File

@@ -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: [

View File

@@ -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);

View File

@@ -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;

View 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;
}
}

View 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;
}
}

View File

@@ -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

View File

@@ -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())

View File

@@ -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),

View File

@@ -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 {

View File

@@ -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);
});
}

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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');
});
}
};

View File

@@ -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');
});
}
};

View File

@@ -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');
}
};

View File

@@ -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',

View File

@@ -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
View File

@@ -0,0 +1,5 @@
// TODO: Test filter
// TODO: Test date range
// TODO: Test grouping and sub-grouping

View File

@@ -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(

View File

@@ -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/),
]);
});

View File

@@ -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([])

View File

@@ -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(

View File

@@ -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',
],
];

View File

@@ -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 &#x60;time-entries:view:own&#x60; 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 &#x60;time-entries:view:own&#x60; 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 &#x60;time-entries:view:own&#x60; 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 &#x60;time-entries:view:own&#x60; 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 &#x60;time-entries:view:own&#x60; 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 &#x60;time-entries:view:own&#x60; 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 &#x60;time-entries:view:own&#x60; 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 &#x60;group&#x60; and &#x60;sub_group&#x60; allow you to group the time entries by different criteria.
If the group parameters are all set to &#x60;null&#x60; 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
View File

@@ -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",

View File

@@ -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"
}
}

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View 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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -29,7 +29,7 @@ async function invitePlaceholder(id: string) {
{
params: {
organization: organizationId,
membership: id,
member: id,
},
}
),

View 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>

View File

@@ -12,7 +12,7 @@ const iconClasses = computed(() => {
if (props.selected) {
return 'text-accent-200';
} else {
return 'text-card-border';
return 'text-white/10';
}
});
</script>

View File

@@ -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">

View File

@@ -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"

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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