Compare commits

..

34 Commits

Author SHA1 Message Date
Constantin Graf
ee2f125062 Fixed typo 2024-07-15 21:41:37 +02:00
Constantin Graf
fd8d596e9b Moved invitation from jetstream to API; Deactived moved jetstream features 2024-07-15 17:35:10 +02:00
Constantin Graf
555417dbbd Added tests for billable rate in time entries endpoint 2024-07-15 17:34:56 +02:00
Gregor Vostrak
7aab3d98fc remove billable_rate_update_time_entries flag and always update all time entries 2024-07-15 17:34:56 +02:00
Constantin Graf
1dc35f1f55 Removed option to update billable rate without updating time entries 2024-07-15 17:34:56 +02:00
Gregor Vostrak
be50397775 refactor billableratemodal to use a common component for shared logic 2024-07-08 17:22:48 +02:00
Gregor Vostrak
e3b4cfd881 add billable rate updates for time entries in the past to projects and project members, fixes ST-304 2024-07-08 17:22:48 +02:00
Constantin Graf
7fd5d25781 Fixed failed jobs table 2024-07-03 17:14:35 +02:00
Constantin Graf
4c2748ff50 Added tests of extension to phpunit config 2024-07-03 15:05:00 +02:00
Gregor Vostrak
c69701aa66 add ability to change role of a user 2024-07-03 14:21:00 +02:00
Gregor Vostrak
c194785034 hide more options in members table if no options are avaliable, fixes ST-129 2024-07-03 14:08:26 +02:00
Gregor Vostrak
53e5805937 fix type, fixes ST-301 2024-07-03 12:55:11 +02:00
Gregor Vostrak
a8d82d0d2c remove owner from invite member select, fix modal not closing bug 2024-07-03 12:53:52 +02:00
Gregor Vostrak
8f0be6efce respect has_subscription property in frontend for displaying the member add popup 2024-07-02 17:17:27 +02:00
Gregor Vostrak
6593a8c24f add support for archiving projects and marking tasks as done 2024-07-02 17:01:12 +02:00
Constantin Graf
0f32e42002 Fixed typo 2024-07-01 19:15:57 +02:00
Constantin Graf
8ddce667cc Added billing information to inertia data 2024-07-01 18:34:06 +02:00
Gregor Vostrak
726c2ee623 fix members test 2024-07-01 17:28:19 +02:00
Constantin Graf
7decb095ee Fixed static code analyser and added unit tests for ip lookup 2024-07-01 17:25:20 +02:00
Gregor Vostrak
442da936d0 Merge branch 'feature/member_features' of github.com:solidtime-io/solidtime into feature/update_billable_rate
# Conflicts:
#	e2e/members.spec.ts
#	e2e/organization.spec.ts
2024-07-01 17:15:08 +02:00
Constantin Graf
3a17ae83ae Member update endpoint can now change ownership 2024-07-01 17:06:44 +02:00
Gregor Vostrak
264b7c9b8d add billable rate time entries update support for existing time entries (member & organization) 2024-07-01 17:06:44 +02:00
Constantin Graf
c3a7ef7585 Fixed api docs 2024-07-01 17:06:44 +02:00
Constantin Graf
de1accba4a Added ip lookup on registration, fixes ST-245 2024-07-01 17:06:44 +02:00
Constantin Graf
364168debd Add ability to set task to done, fixes ST-244 2024-07-01 17:06:44 +02:00
Constantin Graf
75e739f6fb Changed billable_rate_update_time_entries to real boolean 2024-07-01 17:06:44 +02:00
Constantin Graf
a69d1cb4c4 Added ability to archive projects and clients, fixes ST-37 2024-07-01 17:06:44 +02:00
Constantin Graf
f21a2d4bdd Fix unhandled error on jetstream page with non-UUID id, fixes ST-274 2024-07-01 17:06:44 +02:00
Constantin Graf
512089ccbd Make name fields in projects, tasks, clients and tags unique; fixes ST-265 2024-07-01 17:06:44 +02:00
Constantin Graf
313cee2db0 Restrict roles available to invitation and member.update, fixes ST-264 2024-07-01 17:06:44 +02:00
Constantin Graf
2184b3c835 Add ability to update billable rate of existing time entries 2024-07-01 17:06:44 +02:00
Constantin Graf
7c26cee1ea Added PHPUnit annotations 2024-07-01 17:06:44 +02:00
Gregor Vostrak
ce82dddc6a change invite tests to use members section instead of organization setting 2024-07-01 17:03:47 +02:00
Gregor Vostrak
099926f95c change member invite to api route, add resend invitation mail, add delete invitation, fixes ST-87 2024-07-01 17:03:47 +02:00
166 changed files with 4974 additions and 807 deletions

View File

@@ -9,6 +9,7 @@ use App\Enums\Weekday;
use App\Events\NewsletterRegistered; use App\Events\NewsletterRegistered;
use App\Models\Organization; use App\Models\Organization;
use App\Models\User; use App\Models\User;
use App\Service\IpLookup\IpLookupServiceContract;
use App\Service\TimezoneService; use App\Service\TimezoneService;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -18,6 +19,7 @@ use Illuminate\Validation\ValidationException;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent; use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
use Laravel\Fortify\Contracts\CreatesNewUsers; use Laravel\Fortify\Contracts\CreatesNewUsers;
use Laravel\Jetstream\Jetstream; use Laravel\Jetstream\Jetstream;
use Log;
class CreateNewUser implements CreatesNewUsers class CreateNewUser implements CreatesNewUsers
{ {
@@ -55,20 +57,49 @@ class CreateNewUser implements CreatesNewUsers
], ],
])->validate(); ])->validate();
$timezone = 'UTC'; $timezone = null;
if (array_key_exists('timezone', $input) && is_string($input['timezone']) && app(TimezoneService::class)->isValid($input['timezone'])) { if (array_key_exists('timezone', $input) && is_string($input['timezone'])) {
$timezone = $input['timezone']; if (app(TimezoneService::class)->isValid($input['timezone'])) {
$timezone = $input['timezone'];
} else {
Log::debug('Invalid timezone', ['timezone' => $input['timezone']]);
}
} }
$user = DB::transaction(function () use ($input, $timezone) { $ipLookupResponse = app(IpLookupServiceContract::class)->lookup(request()->ip());
$startOfWeek = Weekday::Monday;
$currency = null;
if ($ipLookupResponse !== null) {
$startOfWeek = $ipLookupResponse->startOfWeek ?? Weekday::Monday;
if ($timezone === null) {
$timezone = $ipLookupResponse->timezone;
}
$currency = $ipLookupResponse->currency;
}
$user = DB::transaction(function () use ($input, $timezone, $startOfWeek, $currency) {
return tap(User::create([ return tap(User::create([
'name' => $input['name'], 'name' => $input['name'],
'email' => $input['email'], 'email' => $input['email'],
'password' => Hash::make($input['password']), 'password' => Hash::make($input['password']),
'timezone' => $timezone, 'timezone' => $timezone ?? 'UTC',
'week_start' => Weekday::Monday, 'week_start' => $startOfWeek,
]), function (User $user) { ]), function (User $user) use ($currency): void {
$this->createTeam($user); $organization = new Organization();
$organization->name = explode(' ', $user->name, 2)[0]."'s Organization";
$organization->personal_team = true;
$organization->currency = $currency ?? 'EUR';
$organization->owner()->associate($user);
$organization->save();
$organization->users()->attach(
$user, [
'role' => Role::Owner->value,
]
);
$user->ownedTeams()->save($organization);
}); });
}); });
@@ -79,24 +110,4 @@ class CreateNewUser implements CreatesNewUsers
return $user; return $user;
} }
/**
* Create a personal team for the user.
*/
protected function createTeam(User $user): void
{
$organization = new Organization();
$organization->name = explode(' ', $user->name, 2)[0]."'s Organization";
$organization->personal_team = true;
$organization->owner()->associate($user);
$organization->save();
$organization->users()->attach(
$user, [
'role' => Role::Owner->value,
]
);
$user->ownedTeams()->save($organization);
}
} }

View File

@@ -7,7 +7,6 @@ namespace App\Actions\Jetstream;
use App\Enums\Role; use App\Enums\Role;
use App\Models\Organization; use App\Models\Organization;
use App\Models\User; use App\Models\User;
use App\Service\UserService;
use Closure; use Closure;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
@@ -43,10 +42,6 @@ class AddOrganizationMember implements AddsTeamMembers
$organization->users()->attach( $organization->users()->attach(
$newOrganizationMember, ['role' => $role] $newOrganizationMember, ['role' => $role]
); );
if ($role === Role::Owner->value) {
app(UserService::class)->changeOwnership($organization, $newOrganizationMember);
}
}); });
TeamMemberAdded::dispatch($organization, $newOrganizationMember); TeamMemberAdded::dispatch($organization, $newOrganizationMember);
@@ -84,7 +79,6 @@ class AddOrganizationMember implements AddsTeamMembers
'required', 'required',
'string', 'string',
Rule::in([ Rule::in([
Role::Owner->value,
Role::Admin->value, Role::Admin->value,
Role::Manager->value, Role::Manager->value,
Role::Employee->value, Role::Employee->value,

View File

@@ -15,6 +15,7 @@ class DeleteOrganization implements DeletesTeams
*/ */
public function delete(Organization $organization): void public function delete(Organization $organization): void
{ {
/** @see ValidateOrganizationDeletion */
app(DeletionService::class)->deleteOrganization($organization); app(DeletionService::class)->deleteOrganization($organization);
} }
} }

View File

@@ -14,6 +14,8 @@ class DeleteUser implements DeletesUsers
{ {
/** /**
* Delete the given user. * Delete the given user.
*
* @throws ValidationException
*/ */
public function delete(User $user): void public function delete(User $user): void
{ {

View File

@@ -4,103 +4,21 @@ declare(strict_types=1);
namespace App\Actions\Jetstream; namespace App\Actions\Jetstream;
use App\Enums\Role; use App\Exceptions\MovedToApiException;
use App\Models\Organization; use App\Models\Organization;
use App\Models\OrganizationInvitation;
use App\Models\User; use App\Models\User;
use App\Service\PermissionStore; use Exception;
use Closure;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
use Laravel\Jetstream\Contracts\InvitesTeamMembers; use Laravel\Jetstream\Contracts\InvitesTeamMembers;
use Laravel\Jetstream\Events\InvitingTeamMember;
use Laravel\Jetstream\Mail\TeamInvitation;
class InviteOrganizationMember implements InvitesTeamMembers class InviteOrganizationMember implements InvitesTeamMembers
{ {
/** /**
* Invite a new team member to the given team. * Invite a new team member to the given team.
* *
* @throws AuthorizationException * @throws Exception
*/ */
public function invite(User $user, Organization $organization, string $email, ?string $role = null): void public function invite(User $user, Organization $organization, string $email, ?string $role = null): void
{ {
if (! app(PermissionStore::class)->has($organization, 'invitations:create')) { throw new MovedToApiException();
throw new AuthorizationException();
}
$this->validate($organization, $email, $role);
InvitingTeamMember::dispatch($organization, $email, $role);
/** @var OrganizationInvitation $invitation */
$invitation = $organization->teamInvitations()->create([
'email' => $email,
'role' => $role,
]);
Mail::to($email)->send(new TeamInvitation($invitation));
}
/**
* Validate the invite member operation.
*/
protected function validate(Organization $organization, string $email, ?string $role): void
{
Validator::make([
'email' => $email,
'role' => $role,
], $this->rules($organization))->after(
$this->ensureUserIsNotAlreadyOnTeam($organization, $email)
)->validateWithBag('addTeamMember');
}
/**
* Get the validation rules for inviting a team member.
*
* @return array<string, array<ValidationRule|Rule|string|In>>
*/
protected function rules(Organization $organization): array
{
return array_filter([
'email' => [
'required',
'email',
(new UniqueEloquent(OrganizationInvitation::class, 'email', function (Builder $builder) use ($organization) {
/** @var Builder<OrganizationInvitation> $builder */
return $builder->whereBelongsTo($organization, 'organization');
}))->withMessage(__('This user has already been invited to the team.')),
],
'role' => [
'required',
'string',
Rule::in([
Role::Owner->value,
Role::Admin->value,
Role::Manager->value,
Role::Employee->value,
]),
],
]);
}
/**
* Ensure that the user is not already on the team.
*/
protected function ensureUserIsNotAlreadyOnTeam(Organization $organization, string $email): Closure
{
return function ($validator) use ($organization, $email) {
$validator->errors()->addIf(
$organization->hasRealUserWithEmail($email),
'email',
__('This user already belongs to the team.')
);
};
} }
} }

View File

@@ -4,50 +4,21 @@ declare(strict_types=1);
namespace App\Actions\Jetstream; namespace App\Actions\Jetstream;
use App\Exceptions\MovedToApiException;
use App\Models\Organization; use App\Models\Organization;
use App\Models\User; use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException; use Exception;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\ValidationException;
use Laravel\Jetstream\Contracts\RemovesTeamMembers; use Laravel\Jetstream\Contracts\RemovesTeamMembers;
use Laravel\Jetstream\Events\TeamMemberRemoved;
class RemoveOrganizationMember implements RemovesTeamMembers class RemoveOrganizationMember implements RemovesTeamMembers
{ {
/** /**
* Remove the team member from the given team. * Remove the team member from the given team.
*
* @throws Exception
*/ */
public function remove(User $user, Organization $organization, User $teamMember): void public function remove(User $user, Organization $organization, User $teamMember): void
{ {
$this->authorize($user, $organization, $teamMember); throw new MovedToApiException();
$this->ensureUserDoesNotOwnTeam($teamMember, $organization);
$organization->removeUser($teamMember);
TeamMemberRemoved::dispatch($organization, $teamMember);
}
/**
* Authorize that the user can remove the team member.
*/
protected function authorize(User $user, Organization $organization, User $teamMember): void
{
if (! Gate::forUser($user)->check('removeTeamMember', $organization) &&
$user->id !== $teamMember->id) {
throw new AuthorizationException;
}
}
/**
* Ensure that the currently authenticated user does not own the team.
*/
protected function ensureUserDoesNotOwnTeam(User $teamMember, Organization $organization): void
{
if ($teamMember->id === $organization->owner->id) {
throw ValidationException::withMessages([
'team' => [__('You may not leave a team that you created.')],
])->errorBag('removeTeamMember');
}
} }
} }

View File

@@ -5,63 +5,21 @@ declare(strict_types=1);
namespace App\Actions\Jetstream; namespace App\Actions\Jetstream;
use App\Enums\Role; use App\Enums\Role;
use App\Exceptions\MovedToApiException;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\User; use App\Models\User;
use App\Service\PermissionStore; use Exception;
use App\Service\UserService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Laravel\Jetstream\Events\TeamMemberUpdated;
class UpdateMemberRole class UpdateMemberRole
{ {
/** /**
* Update the role for the given team member. * Update the role for the given team member.
* *
* @throws AuthorizationException * @throws Exception
* @throws ValidationException
*/ */
public function update(User $actingUser, Organization $organization, string $userId, string $role): void public function update(User $actingUser, Organization $organization, string $userId, string $role): void
{ {
if (! app(PermissionStore::class)->has($organization, 'members:change-role')) { throw new MovedToApiException();
throw new AuthorizationException();
}
$user = User::where('id', '=', $userId)->firstOrFail();
$member = Member::whereBelongsTo($user)->whereBelongsTo($organization)->firstOrFail();
if ($member->role === Role::Placeholder->value) {
abort(403, 'Cannot update the role of a placeholder member.');
}
Validator::make([
'role' => $role,
], [
'role' => [
'required',
'string',
Rule::in([
Role::Owner->value,
Role::Admin->value,
Role::Manager->value,
Role::Employee->value,
]),
],
])->validate();
DB::transaction(function () use ($organization, $userId, $role, $user) {
$organization->users()->updateExistingPivot($userId, [
'role' => $role,
]);
if ($role === Role::Owner->value) {
app(UserService::class)->changeOwnership($organization, $user);
}
});
TeamMemberUpdated::dispatch($organization->fresh(), User::findOrFail($userId));
} }
} }

View File

@@ -15,7 +15,7 @@ class TestJobCommand extends Command
* *
* @var string * @var string
*/ */
protected $signature = 'test:job'; protected $signature = 'test:job {--fail}';
/** /**
* The console command description. * The console command description.
@@ -30,7 +30,9 @@ class TestJobCommand extends Command
public function handle(): int public function handle(): int
{ {
$user = User::firstOrFail(); $user = User::firstOrFail();
TestJob::dispatch($user, 'Test job message.'); $fail = (bool) $this->option('fail');
TestJob::dispatch($user, 'Test job message.', $fail);
return self::SUCCESS; return self::SUCCESS;
} }

View File

@@ -11,5 +11,4 @@ enum Role: string
case Manager = 'manager'; case Manager = 'manager';
case Employee = 'employee'; case Employee = 'employee';
case Placeholder = 'placeholder'; case Placeholder = 'placeholder';
} }

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Api;
class ChangingRoleToPlaceholderIsNotAllowed extends ApiException
{
public const string KEY = 'changing_role_to_placeholder_is_not_allowed';
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Api;
class OnlyOwnerCanChangeOwnership extends ApiException
{
public const string KEY = 'only_owner_can_change_ownership';
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Api;
class OrganizationNeedsAtLeastOneOwner extends ApiException
{
public const string KEY = 'organization_needs_at_least_one_owner';
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Api;
class UserIsAlreadyMemberOfOrganizationApiException extends ApiException
{
public const string KEY = 'user_is_already_member_of_organization';
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Exceptions;
use Symfony\Component\HttpKernel\Exception\HttpException;
class MovedToApiException extends HttpException
{
public function __construct()
{
parent::__construct(403, 'Moved to API');
}
}

View File

@@ -5,14 +5,16 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use App\Exceptions\Api\EntityStillInUseApiException; use App\Exceptions\Api\EntityStillInUseApiException;
use App\Http\Requests\V1\Tag\TagStoreRequest; use App\Http\Requests\V1\Client\ClientIndexRequest;
use App\Http\Requests\V1\Tag\TagUpdateRequest; use App\Http\Requests\V1\Client\ClientStoreRequest;
use App\Http\Requests\V1\Client\ClientUpdateRequest;
use App\Http\Resources\V1\Client\ClientCollection; use App\Http\Resources\V1\Client\ClientCollection;
use App\Http\Resources\V1\Client\ClientResource; use App\Http\Resources\V1\Client\ClientResource;
use App\Models\Client; use App\Models\Client;
use App\Models\Organization; use App\Models\Organization;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Support\Carbon;
class ClientController extends Controller class ClientController extends Controller
{ {
@@ -33,14 +35,22 @@ class ClientController extends Controller
* *
* @operationId getClients * @operationId getClients
*/ */
public function index(Organization $organization): ClientCollection public function index(Organization $organization, ClientIndexRequest $request): ClientCollection
{ {
$this->checkPermission($organization, 'clients:view'); $this->checkPermission($organization, 'clients:view');
$clients = Client::query() $clientsQuery = Client::query()
->whereBelongsTo($organization, 'organization') ->whereBelongsTo($organization, 'organization')
->orderBy('created_at', 'desc') ->orderBy('created_at', 'desc');
->paginate(config('app.pagination_per_page_default'));
$filterArchived = $request->getFilterArchived();
if ($filterArchived === 'true') {
$clientsQuery->whereNotNull('archived_at');
} elseif ($filterArchived === 'false') {
$clientsQuery->whereNull('archived_at');
}
$clients = $clientsQuery->paginate(config('app.pagination_per_page_default'));
return new ClientCollection($clients); return new ClientCollection($clients);
} }
@@ -52,7 +62,7 @@ class ClientController extends Controller
* *
* @operationId createClient * @operationId createClient
*/ */
public function store(Organization $organization, TagStoreRequest $request): ClientResource public function store(Organization $organization, ClientStoreRequest $request): ClientResource
{ {
$this->checkPermission($organization, 'clients:create'); $this->checkPermission($organization, 'clients:create');
@@ -71,11 +81,14 @@ class ClientController extends Controller
* *
* @operationId updateClient * @operationId updateClient
*/ */
public function update(Organization $organization, Client $client, TagUpdateRequest $request): ClientResource public function update(Organization $organization, Client $client, ClientUpdateRequest $request): ClientResource
{ {
$this->checkPermission($organization, 'clients:update', $client); $this->checkPermission($organization, 'clients:update', $client);
$client->name = $request->input('name'); $client->name = $request->input('name');
if ($request->has('is_archived')) {
$client->archived_at = $request->getIsArchived() ? Carbon::now() : null;
}
$client->save(); $client->save();
return new ClientResource($client); return new ClientResource($client);

View File

@@ -4,17 +4,18 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
use App\Http\Requests\V1\Invitation\InvitationIndexRequest; use App\Http\Requests\V1\Invitation\InvitationIndexRequest;
use App\Http\Requests\V1\Invitation\InvitationStoreRequest; use App\Http\Requests\V1\Invitation\InvitationStoreRequest;
use App\Http\Resources\V1\Invitation\InvitationCollection; use App\Http\Resources\V1\Invitation\InvitationCollection;
use App\Http\Resources\V1\Invitation\InvitationResource; use App\Http\Resources\V1\Invitation\InvitationResource;
use App\Mail\OrganizationInvitationMail;
use App\Models\Organization; use App\Models\Organization;
use App\Models\OrganizationInvitation; use App\Models\OrganizationInvitation;
use App\Service\InvitationService;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Mail;
use Laravel\Jetstream\Contracts\InvitesTeamMembers;
use Laravel\Jetstream\Mail\TeamInvitation;
class InvitationController extends Controller class InvitationController extends Controller
{ {
@@ -49,19 +50,18 @@ class InvitationController extends Controller
* Invite a user to the organization * Invite a user to the organization
* *
* @throws AuthorizationException * @throws AuthorizationException
* @throws UserIsAlreadyMemberOfOrganizationApiException
* *
* @operationId invite * @operationId invite
*/ */
public function store(Organization $organization, InvitationStoreRequest $request): JsonResponse public function store(Organization $organization, InvitationStoreRequest $request, InvitationService $invitationService): JsonResponse
{ {
$this->checkPermission($organization, 'invitations:create'); $this->checkPermission($organization, 'invitations:create');
app(InvitesTeamMembers::class)->invite( $email = $request->getEmail();
$this->user(), $role = $request->getRole();
$organization,
$request->input('email'), $invitationService->inviteUser($organization, $email, $role);
$request->input('role')
);
return response()->json(null, 204); return response()->json(null, 204);
} }
@@ -77,7 +77,8 @@ class InvitationController extends Controller
{ {
$this->checkPermission($organization, 'invitations:resend', $invitation); $this->checkPermission($organization, 'invitations:resend', $invitation);
Mail::to($invitation->email)->send(new TeamInvitation($invitation)); Mail::to($invitation->email)
->queue(new OrganizationInvitationMail($invitation));
return response()->json(null, 204); return response()->json(null, 204);
} }

View File

@@ -6,7 +6,10 @@ namespace App\Http\Controllers\Api\V1;
use App\Enums\Role; use App\Enums\Role;
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization; use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
use App\Exceptions\Api\EntityStillInUseApiException; use App\Exceptions\Api\EntityStillInUseApiException;
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
use App\Exceptions\Api\UserNotPlaceholderApiException; use App\Exceptions\Api\UserNotPlaceholderApiException;
use App\Http\Requests\V1\Member\MemberIndexRequest; use App\Http\Requests\V1\Member\MemberIndexRequest;
use App\Http\Requests\V1\Member\MemberUpdateRequest; use App\Http\Requests\V1\Member\MemberUpdateRequest;
@@ -17,11 +20,12 @@ use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\ProjectMember; use App\Models\ProjectMember;
use App\Models\TimeEntry; use App\Models\TimeEntry;
use App\Service\BillableRateService;
use App\Service\InvitationService;
use App\Service\MemberService;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
use Laravel\Jetstream\Contracts\InvitesTeamMembers;
class MemberController extends Controller class MemberController extends Controller
{ {
@@ -56,15 +60,40 @@ class MemberController extends Controller
* Update a member of the organization * Update a member of the organization
* *
* @throws AuthorizationException * @throws AuthorizationException
* @throws OrganizationNeedsAtLeastOneOwner
* @throws OnlyOwnerCanChangeOwnership
* @throws ChangingRoleToPlaceholderIsNotAllowed
* *
* @operationId updateMember * @operationId updateMember
*/ */
public function update(Organization $organization, Member $member, MemberUpdateRequest $request): JsonResource public function update(Organization $organization, Member $member, MemberUpdateRequest $request, BillableRateService $billableRateService, MemberService $memberService): JsonResource
{ {
$this->checkPermission($organization, 'members:update', $member); $this->checkPermission($organization, 'members:update', $member);
$member->billable_rate = $request->input('billable_rate'); if ($request->has('billable_rate') && $member->billable_rate !== $request->getBillableRate()) {
$member->role = $request->input('role'); $member->billable_rate = $request->getBillableRate();
$billableRateService->updateTimeEntriesBillableRateForMember($member);
}
if ($request->has('role') && $member->role !== $request->getRole()->value) {
$newRole = $request->getRole();
$oldRole = Role::from($member->role);
if ($oldRole === Role::Owner) {
throw new OrganizationNeedsAtLeastOneOwner();
}
if ($newRole === Role::Placeholder) {
throw new ChangingRoleToPlaceholderIsNotAllowed();
}
if ($newRole === Role::Owner) {
if ($this->hasPermission($organization, 'members:change-ownership')) {
$memberService->changeOwnership($organization, $member);
} else {
throw new OnlyOwnerCanChangeOwnership();
}
} else {
$member->role = $request->getRole()->value;
}
}
$member->save(); $member->save();
return new MemberResource($member); return new MemberResource($member);
@@ -104,7 +133,7 @@ class MemberController extends Controller
* *
* @operationId invitePlaceholder * @operationId invitePlaceholder
*/ */
public function invitePlaceholder(Organization $organization, Member $member, Request $request): JsonResponse public function invitePlaceholder(Organization $organization, Member $member, InvitationService $invitationService): JsonResponse
{ {
$this->checkPermission($organization, 'members:invite-placeholder', $member); $this->checkPermission($organization, 'members:invite-placeholder', $member);
$user = $member->user; $user = $member->user;
@@ -113,12 +142,7 @@ class MemberController extends Controller
throw new UserNotPlaceholderApiException(); throw new UserNotPlaceholderApiException();
} }
app(InvitesTeamMembers::class)->invite( $invitationService->inviteUser($organization, $user->email, Role::Employee);
$this->user(),
$organization,
$user->email,
Role::Employee->value,
);
return response()->json(null, 204); return response()->json(null, 204);
} }

View File

@@ -7,6 +7,7 @@ namespace App\Http\Controllers\Api\V1;
use App\Http\Requests\V1\Organization\OrganizationUpdateRequest; use App\Http\Requests\V1\Organization\OrganizationUpdateRequest;
use App\Http\Resources\V1\Organization\OrganizationResource; use App\Http\Resources\V1\Organization\OrganizationResource;
use App\Models\Organization; use App\Models\Organization;
use App\Service\BillableRateService;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
class OrganizationController extends Controller class OrganizationController extends Controller
@@ -32,14 +33,19 @@ class OrganizationController extends Controller
* *
* @throws AuthorizationException * @throws AuthorizationException
*/ */
public function update(Organization $organization, OrganizationUpdateRequest $request): OrganizationResource public function update(Organization $organization, OrganizationUpdateRequest $request, BillableRateService $billableRateService): OrganizationResource
{ {
$this->checkPermission($organization, 'organizations:update'); $this->checkPermission($organization, 'organizations:update');
$organization->name = $request->input('name'); $organization->name = $request->input('name');
$oldBillableRate = $organization->billable_rate;
$organization->billable_rate = $request->getBillableRate(); $organization->billable_rate = $request->getBillableRate();
$organization->save(); $organization->save();
if ($oldBillableRate !== $request->getBillableRate()) {
$billableRateService->updateTimeEntriesBillableRateForOrganization($organization);
}
return new OrganizationResource($organization); return new OrganizationResource($organization);
} }
} }

View File

@@ -13,10 +13,11 @@ use App\Http\Resources\V1\Project\ProjectResource;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Project; use App\Models\Project;
use App\Models\ProjectMember; use App\Models\ProjectMember;
use App\Models\User; use App\Service\BillableRateService;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
class ProjectController extends Controller class ProjectController extends Controller
@@ -50,6 +51,12 @@ class ProjectController extends Controller
if (! $canViewAllProjects) { if (! $canViewAllProjects) {
$projectsQuery->visibleByEmployee($user); $projectsQuery->visibleByEmployee($user);
} }
$filterArchived = $request->getFilterArchived();
if ($filterArchived === 'true') {
$projectsQuery->whereNotNull('archived_at');
} elseif ($filterArchived === 'false') {
$projectsQuery->whereNull('archived_at');
}
$projects = $projectsQuery->paginate(config('app.pagination_per_page_default')); $projects = $projectsQuery->paginate(config('app.pagination_per_page_default'));
@@ -101,16 +108,24 @@ class ProjectController extends Controller
* *
* @operationId updateProject * @operationId updateProject
*/ */
public function update(Organization $organization, Project $project, ProjectUpdateRequest $request): JsonResource public function update(Organization $organization, Project $project, ProjectUpdateRequest $request, BillableRateService $billableRateService): JsonResource
{ {
$this->checkPermission($organization, 'projects:update', $project); $this->checkPermission($organization, 'projects:update', $project);
$project->name = $request->input('name'); $project->name = $request->input('name');
$project->color = $request->input('color'); $project->color = $request->input('color');
$project->is_billable = (bool) $request->input('is_billable'); $project->is_billable = (bool) $request->input('is_billable');
if ($request->has('is_archived')) {
$project->archived_at = $request->getIsArchived() ? Carbon::now() : null;
}
$oldBillableRate = $project->billable_rate;
$project->billable_rate = $request->getBillableRate(); $project->billable_rate = $request->getBillableRate();
$project->client_id = $request->input('client_id'); $project->client_id = $request->input('client_id');
$project->save(); $project->save();
if ($oldBillableRate !== $request->getBillableRate()) {
$billableRateService->updateTimeEntriesBillableRateForProject($project);
}
return new ProjectResource($project); return new ProjectResource($project);
} }

View File

@@ -14,6 +14,7 @@ use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Project; use App\Models\Project;
use App\Models\ProjectMember; use App\Models\ProjectMember;
use App\Service\BillableRateService;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
@@ -87,12 +88,17 @@ class ProjectMemberController extends Controller
* *
* @operationId updateProjectMember * @operationId updateProjectMember
*/ */
public function update(Organization $organization, ProjectMember $projectMember, ProjectMemberUpdateRequest $request): JsonResource public function update(Organization $organization, ProjectMember $projectMember, ProjectMemberUpdateRequest $request, BillableRateService $billableRateService): JsonResource
{ {
$this->checkPermission($organization, 'project-members:update', projectMember: $projectMember); $this->checkPermission($organization, 'project-members:update', projectMember: $projectMember);
$oldBillableRate = $projectMember->billable_rate;
$projectMember->billable_rate = $request->getBillableRate(); $projectMember->billable_rate = $request->getBillableRate();
$projectMember->save(); $projectMember->save();
if ($oldBillableRate !== $request->getBillableRate()) {
$billableRateService->updateTimeEntriesBillableRateForProjectMember($projectMember);
}
return new ProjectMemberResource($projectMember); return new ProjectMemberResource($projectMember);
} }

View File

@@ -15,6 +15,7 @@ use App\Models\Task;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Carbon;
class TaskController extends Controller class TaskController extends Controller
{ {
@@ -53,6 +54,12 @@ class TaskController extends Controller
if (! $canViewAllTasks) { if (! $canViewAllTasks) {
$query->visibleByEmployee($user); $query->visibleByEmployee($user);
} }
$doneFilter = $request->getFilterDone();
if ($doneFilter === 'true') {
$query->whereNotNull('done_at');
} elseif ($doneFilter === 'false') {
$query->whereNull('done_at');
}
$tasks = $query->paginate(config('app.pagination_per_page_default')); $tasks = $query->paginate(config('app.pagination_per_page_default'));
@@ -89,6 +96,9 @@ class TaskController extends Controller
{ {
$this->checkPermission($organization, 'tasks:update', $task); $this->checkPermission($organization, 'tasks:update', $task);
$task->name = $request->input('name'); $task->name = $request->input('name');
if ($request->has('is_done')) {
$task->done_at = $request->getIsDone() ? Carbon::now() : null;
}
$task->save(); $task->save();
return new TaskResource($task); return new TaskResource($task);

View File

@@ -257,12 +257,17 @@ class TimeEntryController extends Controller
$timeEntry->fill($request->validated()); $timeEntry->fill($request->validated());
$timeEntry->description = $request->input('description', $timeEntry->description) ?? ''; $timeEntry->description = $request->input('description', $timeEntry->description) ?? '';
$timeEntry->setComputedAttributeValue('billable_rate');
$timeEntry->save(); $timeEntry->save();
return new TimeEntryResource($timeEntry); return new TimeEntryResource($timeEntry);
} }
/** /**
* Update multiple time entries
*
* @operationId updateMultipleTimeEntries
*
* @throws AuthorizationException * @throws AuthorizationException
*/ */
public function updateMultiple(Organization $organization, TimeEntryUpdateMultipleRequest $request): JsonResponse public function updateMultiple(Organization $organization, TimeEntryUpdateMultipleRequest $request): JsonResponse

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Service\BillingContract;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Middleware; use Inertia\Middleware;
use Nwidart\Modules\Facades\Module; use Nwidart\Modules\Facades\Module;
@@ -38,8 +39,20 @@ class HandleInertiaRequests extends Middleware
*/ */
public function share(Request $request): array public function share(Request $request): array
{ {
$hasBilling = Module::has('Billing') && Module::isEnabled('Billing');
$billing = null;
if ($hasBilling) {
/** @var BillingContract $billing */
$billing = app(BillingContract::class);
}
$currentOrganization = $request->user()?->currentTeam;
return array_merge(parent::share($request), [ return array_merge(parent::share($request), [
'has_billing_extension' => Module::has('Billing'), 'has_billing_extension' => $hasBilling,
'billing' => $billing !== null ? [
'has_subscription' => $currentOrganization !== null ? $billing->hasSubscription($currentOrganization) : null,
] : null,
'flash' => [ 'flash' => [
'message' => fn () => $request->session()->get('message'), 'message' => fn () => $request->session()->get('message'),
], ],

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Client;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class ClientIndexRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule>>
*/
public function rules(): array
{
return [
'page' => [
'integer',
'min:1',
],
'archived' => [
'string',
'in:true,false,all',
],
];
}
public function getFilterArchived(): string
{
return $this->input('archived', 'false');
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Client;
use App\Models\Client;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/**
* @property Organization $organization Organization from model binding
*/
class ClientStoreRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule>>
*/
public function rules(): array
{
return [
'name' => [
'required',
'string',
'min:1',
'max:255',
(new UniqueEloquent(Client::class, 'name', function (Builder $builder): Builder {
/** @var Builder<Client> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
}))->withCustomTranslation('validation.client_name_already_exists'),
],
];
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Client;
use App\Models\Client;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/**
* @property Organization $organization Organization from model binding
* @property Client|null $client Client from model binding
*/
class ClientUpdateRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule>>
*/
public function rules(): array
{
return [
// Name of the client
'name' => [
'required',
'string',
'min:1',
'max:255',
(new UniqueEloquent(Client::class, 'name', function (Builder $builder): Builder {
/** @var Builder<Client> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
}))->ignore($this->client?->getKey())->withCustomTranslation('validation.client_name_already_exists'),
],
'is_archived' => [
'boolean',
],
];
}
public function getIsArchived(): bool
{
assert($this->has('is_archived'));
return (bool) $this->input('is_archived');
}
}

View File

@@ -6,9 +6,12 @@ namespace App\Http\Requests\V1\Invitation;
use App\Enums\Role; use App\Enums\Role;
use App\Models\Organization; use App\Models\Organization;
use App\Models\OrganizationInvitation;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/** /**
* @property Organization $organization * @property Organization $organization
@@ -26,13 +29,27 @@ class InvitationStoreRequest extends FormRequest
'email' => [ 'email' => [
'required', 'required',
'email', 'email',
(new UniqueEloquent(OrganizationInvitation::class, 'email', function (Builder $builder): Builder {
/** @var Builder<OrganizationInvitation> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
}))->withCustomTranslation('validation.invitation_already_exists'),
], ],
'role' => [ 'role' => [
'required', 'required',
'string', 'string',
// TODO: placeholder role should not be allowed Rule::enum(Role::class)
Rule::enum(Role::class), ->except([Role::Owner, Role::Placeholder]),
], ],
]; ];
} }
public function getRole(): Role
{
return Role::from($this->input('role'));
}
public function getEmail(): string
{
return $this->input('email');
}
} }

View File

@@ -23,17 +23,15 @@ class MemberUpdateRequest extends FormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
'role' => [
'string',
Rule::enum(Role::class),
],
'billable_rate' => [ 'billable_rate' => [
'nullable', 'nullable',
'integer', 'integer',
'min:0', 'min:0',
], ],
'role' => [
'required',
'string',
// TODO: placeholder role should not be allowed
Rule::enum(Role::class),
],
]; ];
} }
@@ -43,4 +41,9 @@ class MemberUpdateRequest extends FormRequest
return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null; return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null;
} }
public function getRole(): Role
{
return Role::from($this->input('role'));
}
} }

View File

@@ -21,6 +21,15 @@ class ProjectIndexRequest extends FormRequest
'integer', 'integer',
'min:1', 'min:1',
], ],
'archived' => [
'string',
'in:true,false,all',
],
]; ];
} }
public function getFilterArchived(): string
{
return $this->input('archived', 'false');
}
} }

View File

@@ -6,11 +6,13 @@ namespace App\Http\Requests\V1\Project;
use App\Models\Client; use App\Models\Client;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Project;
use App\Rules\ColorRule; use App\Rules\ColorRule;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/** /**
* @property Organization $organization Organization from model binding * @property Organization $organization Organization from model binding
@@ -26,11 +28,14 @@ class ProjectStoreRequest extends FormRequest
{ {
return [ return [
'name' => [ 'name' => [
// TODO: unique
'required', 'required',
'string', 'string',
'min:1', 'min:1',
'max:255', 'max:255',
(new UniqueEloquent(Project::class, 'name', function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
}))->withCustomTranslation('validation.project_name_already_exists'),
], ],
'color' => [ 'color' => [
'required', 'required',

View File

@@ -6,14 +6,17 @@ namespace App\Http\Requests\V1\Project;
use App\Models\Client; use App\Models\Client;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Project;
use App\Rules\ColorRule; use App\Rules\ColorRule;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/** /**
* @property Organization $organization Organization from model binding * @property Organization $organization Organization from model binding
* @property Project|null $project Project from model binding
*/ */
class ProjectUpdateRequest extends FormRequest class ProjectUpdateRequest extends FormRequest
{ {
@@ -26,10 +29,13 @@ class ProjectUpdateRequest extends FormRequest
{ {
return [ return [
'name' => [ 'name' => [
// TODO: unique
'required', 'required',
'string', 'string',
'max:255', 'max:255',
(new UniqueEloquent(Project::class, 'name', function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
}))->ignore($this->project?->getKey())->withCustomTranslation('validation.project_name_already_exists'),
], ],
'color' => [ 'color' => [
'required', 'required',
@@ -41,10 +47,8 @@ class ProjectUpdateRequest extends FormRequest
'required', 'required',
'boolean', 'boolean',
], ],
'billable_rate' => [ 'is_archived' => [
'nullable', 'boolean',
'integer',
'min:0',
], ],
'client_id' => [ 'client_id' => [
'nullable', 'nullable',
@@ -53,9 +57,21 @@ class ProjectUpdateRequest extends FormRequest
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
}), }),
], ],
'billable_rate' => [
'nullable',
'integer',
'min:0',
],
]; ];
} }
public function getIsArchived(): bool
{
assert($this->has('is_archived'));
return (bool) $this->input('is_archived');
}
public function getBillableRate(): ?int public function getBillableRate(): ?int
{ {
$input = $this->input('billable_rate'); $input = $this->input('billable_rate');

View File

@@ -4,9 +4,16 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Tag; namespace App\Http\Requests\V1\Tag;
use App\Models\Organization;
use App\Models\Tag;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/**
* @property Organization $organization Organization from model binding
*/
class TagStoreRequest extends FormRequest class TagStoreRequest extends FormRequest
{ {
/** /**
@@ -18,11 +25,14 @@ class TagStoreRequest extends FormRequest
{ {
return [ return [
'name' => [ 'name' => [
// TODO: unique
'required', 'required',
'string', 'string',
'min:1', 'min:1',
'max:255', 'max:255',
(new UniqueEloquent(Tag::class, 'name', function (Builder $builder): Builder {
/** @var Builder<Tag> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
}))->withCustomTranslation('validation.tag_name_already_exists'),
], ],
]; ];
} }

View File

@@ -4,9 +4,17 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Tag; namespace App\Http\Requests\V1\Tag;
use App\Models\Organization;
use App\Models\Tag;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/**
* @property Organization $organization Organization from model binding
* @property Tag|null $tag Tag from model binding
*/
class TagUpdateRequest extends FormRequest class TagUpdateRequest extends FormRequest
{ {
/** /**
@@ -18,11 +26,14 @@ class TagUpdateRequest extends FormRequest
{ {
return [ return [
'name' => [ 'name' => [
// TODO: unique
'required', 'required',
'string', 'string',
'min:1', 'min:1',
'max:255', 'max:255',
(new UniqueEloquent(Tag::class, 'name', function (Builder $builder): Builder {
/** @var Builder<Tag> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
}))->ignore($this->tag?->getKey())->withCustomTranslation('validation.tag_name_already_exists'),
], ],
]; ];
} }

View File

@@ -39,6 +39,15 @@ class TaskIndexRequest extends FormRequest
return $builder; return $builder;
}), }),
], ],
'done' => [
'string',
'in:true,false,all',
],
]; ];
} }
public function getFilterDone(): string
{
return $this->input('done', 'false');
}
} }

View File

@@ -6,10 +6,12 @@ namespace App\Http\Requests\V1\Task;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Project; use App\Models\Project;
use App\Models\Task;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/** /**
* @property Organization $organization Organization from model binding * @property Organization $organization Organization from model binding
@@ -25,11 +27,14 @@ class TaskStoreRequest extends FormRequest
{ {
return [ return [
'name' => [ 'name' => [
// TODO: unique
'required', 'required',
'string', 'string',
'min:1', 'min:1',
'max:255', 'max:255',
(new UniqueEloquent(Task::class, 'name', function (Builder $builder): Builder {
/** @var Builder<Task> $builder */
return $builder->where('project_id', '=', $this->input('project_id'));
}))->withCustomTranslation('validation.task_name_already_exists'),
], ],
'project_id' => [ 'project_id' => [
'required', 'required',

View File

@@ -5,11 +5,15 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Task; namespace App\Http\Requests\V1\Task;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Task;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/** /**
* @property Organization $organization Organization from model binding * @property Organization $organization Organization from model binding
* @property Task|null $task Task from model binding
*/ */
class TaskUpdateRequest extends FormRequest class TaskUpdateRequest extends FormRequest
{ {
@@ -22,12 +26,25 @@ class TaskUpdateRequest extends FormRequest
{ {
return [ return [
'name' => [ 'name' => [
// TODO: unique
'required', 'required',
'string', 'string',
'min:1', 'min:1',
'max:255', 'max:255',
(new UniqueEloquent(Task::class, 'name', function (Builder $builder): Builder {
/** @var Builder<Task> $builder */
return $builder->where('project_id', '=', $this->task->project_id);
}))->ignore($this->task?->getKey())->withCustomTranslation('validation.task_name_already_exists'),
],
'is_done' => [
'boolean',
], ],
]; ];
} }
public function getIsDone(): bool
{
assert($this->has('is_done'));
return $this->boolean('is_done');
}
} }

View File

@@ -25,6 +25,8 @@ class ClientResource extends BaseResource
'id' => $this->resource->id, 'id' => $this->resource->id,
/** @var string $name Name */ /** @var string $name Name */
'name' => $this->resource->name, 'name' => $this->resource->name,
/** @var bool $is_archived Whether the client is archived */
'is_archived' => $this->resource->is_archived,
/** @var string $created_at When the tag was created */ /** @var string $created_at When the tag was created */
'created_at' => $this->formatDateTime($this->resource->created_at), 'created_at' => $this->formatDateTime($this->resource->created_at),
/** @var string $updated_at When the tag was last updated */ /** @var string $updated_at When the tag was last updated */

View File

@@ -29,6 +29,8 @@ class ProjectResource extends BaseResource
'color' => $this->resource->color, 'color' => $this->resource->color,
/** @var string|null $client_id ID of client */ /** @var string|null $client_id ID of client */
'client_id' => $this->resource->client_id, 'client_id' => $this->resource->client_id,
/** @var bool $is_archived Whether the client is archived */
'is_archived' => $this->resource->is_archived,
/** @var int|null $billable_rate Billable rate in cents per hour */ /** @var int|null $billable_rate Billable rate in cents per hour */
'billable_rate' => $this->resource->billable_rate, 'billable_rate' => $this->resource->billable_rate,
/** @var bool $is_billable Project time entries billable default */ /** @var bool $is_billable Project time entries billable default */

View File

@@ -26,6 +26,8 @@ class TaskResource extends BaseResource
'id' => $this->resource->id, 'id' => $this->resource->id,
/** @var string $name Name */ /** @var string $name Name */
'name' => $this->resource->name, 'name' => $this->resource->name,
/** @var bool $is_done Whether the task is done */
'is_done' => $this->resource->is_done,
/** @var string $project_id ID of the project */ /** @var string $project_id ID of the project */
'project_id' => $this->resource->project_id, 'project_id' => $this->resource->project_id,
/** @var string $created_at When the tag was created */ /** @var string $created_at When the tag was created */

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Jobs\Test; namespace App\Jobs\Test;
use App\Models\User; use App\Models\User;
use Exception;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
@@ -23,22 +24,30 @@ class TestJob implements ShouldQueue
private string $message; private string $message;
private bool $fail;
/** /**
* Create a new job instance. * Create a new job instance.
*/ */
public function __construct(User $user, string $message) public function __construct(User $user, string $message, bool $fail = false)
{ {
$this->user = $user; $this->user = $user;
$this->message = $message; $this->message = $message;
$this->fail = $fail;
} }
/** /**
* Execute the job. * Execute the job.
*
* @throws Exception
*/ */
public function handle(): void public function handle(): void
{ {
Log::debug('TestJob: '.$this->message, [ Log::debug('TestJob: '.$this->message, [
'user' => $this->user->getKey(), 'user' => $this->user->getKey(),
]); ]);
if ($this->fail) {
throw new Exception('TestJob failed.');
}
} }
} }

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use App\Models\OrganizationInvitation;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\URL;
class OrganizationInvitationMail extends Mailable
{
use Queueable, SerializesModels;
public OrganizationInvitation $invitation;
/**
* Create a new message instance.
*
* @return void
*/
public function __construct(OrganizationInvitation $invitation)
{
$this->invitation = $invitation;
}
/**
* Build the message.
*/
public function build(): self
{
return $this->markdown('emails.organization-invitation', [
'acceptUrl' => URL::signedRoute('team-invitations.accept', [
'invitation' => $this->invitation,
]),
])->subject(__('Organization Invitation'));
}
}

View File

@@ -6,6 +6,7 @@ namespace App\Models;
use App\Models\Concerns\HasUuids; use App\Models\Concerns\HasUuids;
use Database\Factories\ClientFactory; use Database\Factories\ClientFactory;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -16,6 +17,8 @@ use Illuminate\Support\Carbon;
* @property string $id * @property string $id
* @property string $name * @property string $name
* @property string $organization_id * @property string $organization_id
* @property-read bool $is_archived
* @property Carbon|null $archived_at
* @property Carbon|null $created_at * @property Carbon|null $created_at
* @property Carbon|null $updated_at * @property Carbon|null $updated_at
* @property-read Organization $organization * @property-read Organization $organization
@@ -51,4 +54,14 @@ class Client extends Model
{ {
return $this->hasMany(Project::class, 'client_id'); return $this->hasMany(Project::class, 'client_id');
} }
/**
* @return Attribute<bool, never>
*/
protected function isArchived(): Attribute
{
return Attribute::make(
get: fn (mixed $value, array $attributes) => isset($attributes['archived_at']),
);
}
} }

View File

@@ -8,6 +8,7 @@ use App\Models\Concerns\HasUuids;
use Database\Factories\MemberFactory; use Database\Factories\MemberFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Laravel\Jetstream\Membership as JetstreamMembership; use Laravel\Jetstream\Membership as JetstreamMembership;
/** /**
@@ -50,4 +51,12 @@ class Member extends JetstreamMembership
{ {
return $this->belongsTo(Organization::class, 'organization_id'); return $this->belongsTo(Organization::class, 'organization_id');
} }
/**
* @return HasMany<ProjectMember>
*/
public function projectMembers(): HasMany
{
return $this->hasMany(ProjectMember::class, 'member_id');
}
} }

View File

@@ -8,9 +8,11 @@ use App\Models\Concerns\HasUuids;
use Database\Factories\OrganizationFactory; use Database\Factories\OrganizationFactory;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use Laravel\Jetstream\Events\TeamCreated; use Laravel\Jetstream\Events\TeamCreated;
use Laravel\Jetstream\Events\TeamDeleted; use Laravel\Jetstream\Events\TeamDeleted;
use Laravel\Jetstream\Events\TeamUpdated; use Laravel\Jetstream\Events\TeamUpdated;
@@ -123,4 +125,21 @@ class Organization extends JetstreamTeam
return $this->users() return $this->users()
->where('is_placeholder', false); ->where('is_placeholder', false);
} }
/**
* This method prevents an unhandled exception when the ID is not a UUID.
* Normally this can be fixed with a route pattern, but Jetstream does not use route model binding.
*
* @param array<string> $columns
*/
public function findOrFail(string $id, array $columns = ['*']): \Laravel\Jetstream\Team
{
if (! Str::isUuid($id)) {
throw (new ModelNotFoundException)->setModel(
self::class, $id
);
}
return parent::findOrFail($id, $columns);
}
} }

View File

@@ -7,11 +7,13 @@ namespace App\Models;
use App\Models\Concerns\HasUuids; use App\Models\Concerns\HasUuids;
use Database\Factories\ProjectFactory; use Database\Factories\ProjectFactory;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;
/** /**
* @property string $id * @property string $id
@@ -21,6 +23,10 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
* @property string $client_id * @property string $client_id
* @property int|null $billable_rate * @property int|null $billable_rate
* @property bool $is_billable * @property bool $is_billable
* @property-read bool $is_archived
* @property Carbon|null $archived_at
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property-read Organization $organization * @property-read Organization $organization
* @property-read Client|null $client * @property-read Client|null $client
* @property-read Collection<int, Task> $tasks * @property-read Collection<int, Task> $tasks
@@ -105,4 +111,14 @@ class Project extends Model
}); });
}); });
} }
/**
* @return Attribute<bool, never>
*/
protected function isArchived(): Attribute
{
return Attribute::make(
get: fn (mixed $value, array $attributes) => isset($attributes['archived_at']),
);
}
} }

View File

@@ -7,6 +7,7 @@ namespace App\Models;
use App\Models\Concerns\HasUuids; use App\Models\Concerns\HasUuids;
use Database\Factories\TaskFactory; use Database\Factories\TaskFactory;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@@ -19,11 +20,13 @@ use Illuminate\Support\Carbon;
* @property string $name * @property string $name
* @property string $project_id * @property string $project_id
* @property string $organization_id * @property string $organization_id
* @property Carbon|null $done_at
* @property Carbon|null $created_at * @property Carbon|null $created_at
* @property Carbon|null $updated_at * @property Carbon|null $updated_at
* @property-read Project $project * @property-read Project $project
* @property-read Organization $organization * @property-read Organization $organization
* @property-read Collection<int, TimeEntry> $timeEntries * @property-read Collection<int, TimeEntry> $timeEntries
* @property-read bool $is_done
* *
* @method static TaskFactory factory() * @method static TaskFactory factory()
*/ */
@@ -76,4 +79,14 @@ class Task extends Model
return $builder->visibleByEmployee($user); return $builder->visibleByEmployee($user);
}); });
} }
/**
* @return Attribute<bool, never>
*/
public function isDone(): Attribute
{
return Attribute::make(
get: fn (mixed $value, array $attributes) => isset($attributes['done_at']),
);
}
} }

View File

@@ -70,7 +70,7 @@ class OrganizationPolicy
return true; return true;
} }
return $user->ownsTeam($organization); return true;
} }
/** /**
@@ -82,7 +82,8 @@ class OrganizationPolicy
return true; return true;
} }
return $user->ownsTeam($organization); // Note: since this policy is only used for jetstream endpoints, we can return false here
return false;
} }
/** /**
@@ -94,7 +95,8 @@ class OrganizationPolicy
return true; return true;
} }
return $user->ownsTeam($organization); // Note: since this policy is only used for jetstream endpoints that are no longer in use, we can return false here
return false;
} }
/** /**

View File

@@ -13,6 +13,9 @@ use App\Models\Tag;
use App\Models\Task; use App\Models\Task;
use App\Models\TimeEntry; use App\Models\TimeEntry;
use App\Models\User; use App\Models\User;
use App\Service\BillingContract;
use App\Service\IpLookup\IpLookupServiceContract;
use App\Service\IpLookup\NoIpLookupService;
use App\Service\PermissionStore; use App\Service\PermissionStore;
use Dedoc\Scramble\Scramble; use Dedoc\Scramble\Scramble;
use Dedoc\Scramble\Support\Generator\OpenApi; use Dedoc\Scramble\Support\Generator\OpenApi;
@@ -85,6 +88,10 @@ class AppServiceProvider extends ServiceProvider
return new PermissionStore(); return new PermissionStore();
}); });
// Extensions
$this->app->bind(IpLookupServiceContract::class, NoIpLookupService::class);
$this->app->bind(BillingContract::class);
Route::model('member', Member::class); Route::model('member', Member::class);
Route::model('invitation', OrganizationInvitation::class); Route::model('invitation', OrganizationInvitation::class);
} }

View File

@@ -23,6 +23,7 @@ use App\Service\TimezoneService;
use Brick\Money\Currency; use Brick\Money\Currency;
use Brick\Money\ISOCurrencyProvider; use Brick\Money\ISOCurrencyProvider;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Inertia\Inertia; use Inertia\Inertia;
use Laravel\Fortify\Fortify; use Laravel\Fortify\Fortify;
@@ -66,6 +67,9 @@ class JetstreamServiceProvider extends ServiceProvider
'newsletter_consent' => config('auth.newsletter_consent'), 'newsletter_consent' => config('auth.newsletter_consent'),
]); ]);
}); });
Gate::define('removeTeamMember', function (User $user, Organization $team) {
return false;
});
} }
/** /**
@@ -116,7 +120,7 @@ class JetstreamServiceProvider extends ServiceProvider
'invitations:remove', 'invitations:remove',
'members:view', 'members:view',
'members:invite-placeholder', 'members:invite-placeholder',
'members:change-role', 'members:change-ownership',
'members:update', 'members:update',
'members:delete', 'members:delete',
])->description('Owner users can perform any action. There is only one owner per organization.'); ])->description('Owner users can perform any action. There is only one owner per organization.');
@@ -160,6 +164,7 @@ class JetstreamServiceProvider extends ServiceProvider
'invitations:resend', 'invitations:resend',
'invitations:remove', 'invitations:remove',
'members:view', 'members:view',
'members:update',
'members:invite-placeholder', 'members:invite-placeholder',
])->description('Administrator users can perform any action, except accessing the billing dashboard.'); ])->description('Administrator users can perform any action, except accessing the billing dashboard.');

View File

@@ -9,9 +9,75 @@ use App\Models\Organization;
use App\Models\Project; use App\Models\Project;
use App\Models\ProjectMember; use App\Models\ProjectMember;
use App\Models\TimeEntry; use App\Models\TimeEntry;
use Illuminate\Database\Eloquent\Builder;
class BillableRateService class BillableRateService
{ {
public function updateTimeEntriesBillableRateForProjectMember(ProjectMember $projectMember): void
{
TimeEntry::query()
->where('billable', '=', true)
->where('member_id', '=', $projectMember->member_id)
->where('project_id', '=', $projectMember->project_id)
->update(['billable_rate' => $projectMember->billable_rate]);
}
public function updateTimeEntriesBillableRateForProject(Project $project): void
{
TimeEntry::query()
->where('billable', '=', true)
->where('organization_id', '=', $project->organization_id)
->whereBelongsTo($project, 'project')
->whereDoesntHave('member', function (Builder $query) use ($project) {
/** @var Builder<Member> $query */
$query->whereHas('projectMembers', function (Builder $query) use ($project) {
/** @var Builder<ProjectMember> $query */
$query->whereBelongsTo($project, 'project')
->whereNotNull('billable_rate');
});
})
->update(['billable_rate' => $project->billable_rate]);
}
public function updateTimeEntriesBillableRateForMember(Member $member): void
{
TimeEntry::query()
->where('billable', '=', true)
->where('organization_id', '=', $member->organization_id)
->where('member_id', '=', $member->getKey())
->whereDoesntHave('project', function (Builder $builder) use ($member): void {
/** @var Builder<Project> $builder */
$builder->whereNotNull('billable_rate')
->orWhereHas('members', function (Builder $builder) use ($member): void {
/** @var Builder<ProjectMember> $builder */
$builder->whereNotNull('billable_rate')
->where('member_id', '=', $member->getKey());
});
})
->update(['billable_rate' => $member->billable_rate]);
}
public function updateTimeEntriesBillableRateForOrganization(Organization $organization): void
{
TimeEntry::query()
->where('billable', '=', true)
->where('organization_id', '=', $organization->getKey())
->whereDoesntHave('member', function (Builder $builder) {
/** @var Builder<Member> $builder */
$builder->whereNotNull('billable_rate');
})
->whereDoesntHave('project', function (Builder $builder): void {
/** @var Builder<Project> $builder */
$builder->whereNotNull('billable_rate')
->orWhereHas('members', function (Builder $builder): void {
/** @var Builder<ProjectMember> $builder */
$builder->whereNotNull('billable_rate')
->whereRaw('member_id = time_entries.member_id');
});
})
->update(['billable_rate' => $organization->billable_rate]);
}
public function getBillableRateForTimeEntryWithGivenRelations(TimeEntry $timeEntry, ?ProjectMember $projectMember, ?Project $project, ?Member $member, ?Organization $organization): ?int public function getBillableRateForTimeEntryWithGivenRelations(TimeEntry $timeEntry, ?ProjectMember $projectMember, ?Project $project, ?Member $member, ?Organization $organization): ?int
{ {
if (! $timeEntry->billable) { if (! $timeEntry->billable) {

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Models\Organization;
class BillingContract
{
public function hasSubscription(Organization $organization): bool
{
return false;
}
}

View File

@@ -24,9 +24,12 @@ class DeletionService
{ {
private UserService $userService; private UserService $userService;
public function __construct(UserService $userService) private MemberService $memberService;
public function __construct(UserService $userService, MemberService $memberService)
{ {
$this->userService = $userService; $this->userService = $userService;
$this->memberService = $memberService;
} }
public function deleteOrganization(Organization $organization, bool $inTransaction = true, ?User $ignoreUser = null): void public function deleteOrganization(Organization $organization, bool $inTransaction = true, ?User $ignoreUser = null): void
@@ -145,7 +148,7 @@ class DeletionService
if ($member->role === Role::Owner->value) { if ($member->role === Role::Owner->value) {
$this->deleteOrganization($member->organization, false, $user); $this->deleteOrganization($member->organization, false, $user);
} else { } else {
$this->userService->makeMemberToPlaceholder($member); $this->memberService->makeMemberToPlaceholder($member);
} }
} }

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Enums\Role;
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
use App\Mail\OrganizationInvitationMail;
use App\Models\Member;
use App\Models\Organization;
use App\Models\OrganizationInvitation;
use Illuminate\Support\Facades\Mail;
use Laravel\Jetstream\Events\InvitingTeamMember;
class InvitationService
{
/**
* @throws UserIsAlreadyMemberOfOrganizationApiException
*/
public function inviteUser(Organization $organization, string $email, Role $role): OrganizationInvitation
{
if (Member::query()
->whereBelongsTo($organization, 'organization')
->whereRelation('user', 'email', '=', $email)
->where('role', '!=', Role::Placeholder->value)
->exists()) {
throw new UserIsAlreadyMemberOfOrganizationApiException();
}
InvitingTeamMember::dispatch($organization, $email, $role->value);
$invitation = new OrganizationInvitation();
$invitation->email = $email;
$invitation->role = $role->value;
$invitation->organization()->associate($organization);
$invitation->save();
Mail::to($email)->queue(new OrganizationInvitationMail($invitation));
return $invitation;
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Service\IpLookup;
use App\Enums\Weekday;
class IpLookupResponseDto
{
public ?string $timezone;
public ?Weekday $startOfWeek;
public ?string $currency;
public function __construct(?string $timezone, ?Weekday $startOfWeek, ?string $currency)
{
$this->timezone = $timezone;
$this->startOfWeek = $startOfWeek;
$this->currency = $currency;
}
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Service\IpLookup;
interface IpLookupServiceContract
{
public function lookup(string $ip): ?IpLookupResponseDto;
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Service\IpLookup;
class NoIpLookupService implements IpLookupServiceContract
{
public function lookup(string $ip): ?IpLookupResponseDto
{
return null;
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Enums\Role;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use InvalidArgumentException;
class MemberService
{
private UserService $userService;
public function __construct(UserService $userService)
{
$this->userService = $userService;
}
/**
* Change the ownership of an organization to a new user.
* The previous owner will be demoted to an admin.
*/
public function changeOwnership(Organization $organization, Member $newOwner): void
{
$organization->update([
'user_id' => $newOwner->user_id,
]);
if ($newOwner->organization_id !== $organization->getKey()) {
throw new InvalidArgumentException('Member is not part of the organization');
}
$newOwner->role = Role::Owner->value;
$newOwner->save();
$oldOwners = Member::query()
->whereBelongsTo($organization, 'organization')
->where('role', '=', Role::Owner->value)
->where('id', '!=', $newOwner->getKey())
->get();
foreach ($oldOwners as $oldOwner) {
$oldOwner->role = Role::Admin->value;
$oldOwner->save();
}
}
public function makeMemberToPlaceholder(Member $member): void
{
$user = $member->user;
$placeholderUser = $user->replicate();
$placeholderUser->is_placeholder = true;
$placeholderUser->save();
$member->user()->associate($placeholderUser);
$member->role = Role::Placeholder->value;
$member->save();
$this->userService->assignOrganizationEntitiesToDifferentMember($member->organization, $user, $placeholderUser, $member);
$this->userService->makeSureUserHasAtLeastOneOrganization($user);
}
}

View File

@@ -31,7 +31,7 @@ class UserService
$this->assignOrganizationEntitiesToDifferentMember($organization, $fromUser, $toUser, $toMember); $this->assignOrganizationEntitiesToDifferentMember($organization, $fromUser, $toUser, $toMember);
} }
private function assignOrganizationEntitiesToDifferentMember(Organization $organization, User $fromUser, User $toUser, Member $toMember): void public function assignOrganizationEntitiesToDifferentMember(Organization $organization, User $fromUser, User $toUser, Member $toMember): void
{ {
// Time entries // Time entries
TimeEntry::query() TimeEntry::query()
@@ -52,21 +52,6 @@ class UserService
]); ]);
} }
public function makeMemberToPlaceholder(Member $member): void
{
$user = $member->user;
$placeholderUser = $user->replicate();
$placeholderUser->is_placeholder = true;
$placeholderUser->save();
$member->user()->associate($placeholderUser);
$member->role = Role::Placeholder->value;
$member->save();
$this->assignOrganizationEntitiesToDifferentMember($member->organization, $user, $placeholderUser, $member);
$this->makeSureUserHasAtLeastOneOrganization($user);
}
public function makeSureUserHasAtLeastOneOrganization(User $user): void public function makeSureUserHasAtLeastOneOrganization(User $user): void
{ {
if ($user->organizations()->count() > 0) { if ($user->organizations()->count() > 0) {

View File

@@ -68,7 +68,7 @@ return [
'servers' => [ 'servers' => [
'Production' => 'https://app.solidtime.io/api', 'Production' => 'https://app.solidtime.io/api',
'Staging' => 'https://app.staging.solidtime.io/api', 'Staging' => 'https://app.staging.solidtime.io/api',
'Local' => 'https://soldtime.test/api', 'Local' => 'https://solidtime.test/api',
], ],
'middleware' => [ 'middleware' => [

View File

@@ -22,6 +22,7 @@ class ClientFactory extends Factory
{ {
return [ return [
'name' => $this->faker->company(), 'name' => $this->faker->company(),
'archived_at' => null,
'organization_id' => Organization::factory(), 'organization_id' => Organization::factory(),
]; ];
} }
@@ -43,4 +44,13 @@ class ClientFactory extends Factory
]; ];
}); });
} }
public function archived(): self
{
return $this->state(function (array $attributes): array {
return [
'archived_at' => $this->faker->dateTime(),
];
});
}
} }

View File

@@ -23,6 +23,7 @@ class MemberFactory extends Factory
public function definition(): array public function definition(): array
{ {
return [ return [
'billable_rate' => null,
'role' => Role::Employee, 'role' => Role::Employee,
'organization_id' => Organization::factory(), 'organization_id' => Organization::factory(),
'user_id' => User::factory(), 'user_id' => User::factory(),
@@ -68,6 +69,20 @@ class MemberFactory extends Factory
}); });
} }
public function billableRate(?int $billableRate): self
{
return $this->state(fn (array $attributes) => [
'billable_rate' => $billableRate,
]);
}
public function withBillableRate(): self
{
return $this->state(fn (array $attributes) => [
'billable_rate' => $this->faker->numberBetween(50, 1000) * 100,
]);
}
public function attachToOrganization(Organization $organization, array $pivot = []): static public function attachToOrganization(Organization $organization, array $pivot = []): static
{ {
return $this->afterCreating(function (User $user) use ($organization, $pivot) { return $this->afterCreating(function (User $user) use ($organization, $pivot) {

View File

@@ -23,12 +23,26 @@ class OrganizationFactory extends Factory
return [ return [
'name' => $this->faker->unique()->company(), 'name' => $this->faker->unique()->company(),
'currency' => $this->faker->currencyCode(), 'currency' => $this->faker->currencyCode(),
'billable_rate' => $this->faker->numberBetween(50, 1000) * 100, 'billable_rate' => null,
'user_id' => User::factory(), 'user_id' => User::factory(),
'personal_team' => true, 'personal_team' => true,
]; ];
} }
public function billableRate(?int $billableRate): self
{
return $this->state(fn (array $attributes) => [
'billable_rate' => $billableRate,
]);
}
public function withBillableRate(): self
{
return $this->state(fn (array $attributes) => [
'billable_rate' => $this->faker->numberBetween(50, 1000) * 100,
]);
}
public function withOwner(?User $owner = null): self public function withOwner(?User $owner = null): self
{ {
return $this->state(fn (array $attributes) => [ return $this->state(fn (array $attributes) => [

View File

@@ -30,6 +30,7 @@ class ProjectFactory extends Factory
'is_billable' => false, 'is_billable' => false,
'billable_rate' => null, 'billable_rate' => null,
'is_public' => false, 'is_public' => false,
'archived_at' => null,
'client_id' => null, 'client_id' => null,
'organization_id' => Organization::factory(), 'organization_id' => Organization::factory(),
]; ];
@@ -45,6 +46,15 @@ class ProjectFactory extends Factory
}); });
} }
public function archived(): self
{
return $this->state(function (array $attributes): array {
return [
'archived_at' => $this->faker->dateTime(),
];
});
}
public function forOrganization(Organization $organization): self public function forOrganization(Organization $organization): self
{ {
return $this->state(function (array $attributes) use ($organization): array { return $this->state(function (array $attributes) use ($organization): array {

View File

@@ -25,6 +25,7 @@ class TaskFactory extends Factory
'name' => $this->faker->word(), 'name' => $this->faker->word(),
'project_id' => Project::factory(), 'project_id' => Project::factory(),
'organization_id' => Organization::factory(), 'organization_id' => Organization::factory(),
'done_at' => null,
]; ];
} }
@@ -37,6 +38,15 @@ class TaskFactory extends Factory
}); });
} }
public function isDone(): self
{
return $this->state(function (array $attributes) {
return [
'done_at' => $this->faker->dateTime('now', 'UTC'),
];
});
}
public function forOrganization(Organization $organization): self public function forOrganization(Organization $organization): self
{ {
return $this->state(function (array $attributes) use ($organization) { return $this->state(function (array $attributes) use ($organization) {

View File

@@ -40,9 +40,29 @@ class TimeEntryFactory extends Factory
'task_id' => null, 'task_id' => null,
'project_id' => null, 'project_id' => null,
'organization_id' => Organization::factory(), 'organization_id' => Organization::factory(),
'billable_rate' => null,
]; ];
} }
public function notBillable(): self
{
return $this->state(function (array $attributes): array {
return [
'billable' => false,
];
});
}
public function billableRate(int $billableRate): self
{
return $this->state(function (array $attributes) use ($billableRate): array {
return [
'billable' => true,
'billable_rate' => $billableRate,
];
});
}
public function withTask(Organization $organization): self public function withTask(Organization $organization): self
{ {
return $this->state(function (array $attributes) use (&$organization): array { return $this->state(function (array $attributes) use (&$organization): array {

View File

@@ -116,6 +116,7 @@ class UserFactory extends Factory
->when(is_callable($callback), $callback) ->when(is_callable($callback), $callback)
->create(); ->create();
$organization->owner()->associate($user);
$organization->users()->attach($user, ['role' => Role::Owner->value]); $organization->users()->attach($user, ['role' => Role::Owner->value]);
$user->currentTeam()->associate($organization); $user->currentTeam()->associate($organization);
$user->save(); $user->save();

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('projects', function (Blueprint $table): void {
$table->dateTime('archived_at')->nullable();
});
Schema::table('clients', function (Blueprint $table): void {
$table->dateTime('archived_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('projects', function (Blueprint $table): void {
$table->dropColumn('archived_at');
});
Schema::table('clients', function (Blueprint $table): void {
$table->dropColumn('archived_at');
});
}
};

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('tasks', function (Blueprint $table): void {
$table->dateTime('done_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('tasks', function (Blueprint $table): void {
$table->dropColumn('done_at');
});
}
};

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
DB::table('failed_jobs')->truncate();
Schema::table('failed_jobs', function (Blueprint $table): void {
$table->dropColumn('id');
});
Schema::table('failed_jobs', function (Blueprint $table): void {
$table->id();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
DB::table('failed_jobs')->truncate();
Schema::table('failed_jobs', function (Blueprint $table): void {
$table->dropColumn('id');
});
Schema::table('failed_jobs', function (Blueprint $table): void {
$table->uuid('id')->primary();
});
}
};

View File

@@ -1 +1,110 @@
// TODO: Edit Billable Rate // TODO: Edit Billable Rate
// TODO: Resend Email Invitation
// TODO: Remove Invitation
import { expect, test } from '../playwright/fixtures';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
async function goToMembersPage(page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/members');
}
async function openInviteMemberModal(page) {
await Promise.all([
page.getByRole('button', { name: 'Invite Member' }).click(),
expect(page.getByPlaceholder('Member Email')).toBeVisible(),
]);
}
test('test that new manager can be invited', async ({ page }) => {
await goToMembersPage(page);
await openInviteMemberModal(page);
const editorId = Math.round(Math.random() * 10000);
await page.getByLabel('Email').fill(`new+${editorId}@editor.test`);
await page.getByRole('button', { name: 'Manager' }).click();
await Promise.all([
page
.getByRole('button', { name: 'Invite Member', exact: true })
.click(),
expect(page.getByRole('main')).toContainText(
`new+${editorId}@editor.test`
),
]);
});
test('test that new employee can be invited', async ({ page }) => {
await goToMembersPage(page);
await openInviteMemberModal(page);
const editorId = Math.round(Math.random() * 10000);
await page.getByLabel('Email').fill(`new+${editorId}@editor.test`);
await page.getByRole('button', { name: 'Employee' }).click();
await Promise.all([
page
.getByRole('button', { name: 'Invite Member', exact: true })
.click(),
await expect(page.getByRole('main')).toContainText(
`new+${editorId}@editor.test`
),
]);
});
test('test that new admin can be invited', async ({ page }) => {
await goToMembersPage(page);
await openInviteMemberModal(page);
const adminId = Math.round(Math.random() * 10000);
await page.getByLabel('Email').fill(`new+${adminId}@admin.test`);
await page.getByRole('button', { name: 'Administrator' }).click();
await Promise.all([
page
.getByRole('button', { name: 'Invite Member', exact: true })
.click(),
expect(page.getByRole('main')).toContainText(
`new+${adminId}@admin.test`
),
]);
});
test('test that error shows if no role is selected', async ({ page }) => {
await goToMembersPage(page);
await openInviteMemberModal(page);
const noRoleId = Math.round(Math.random() * 10000);
await page.getByLabel('Email').fill(`new+${noRoleId}@norole.test`);
await Promise.all([
page
.getByRole('button', { name: 'Invite Member', exact: true })
.click(),
expect(page.getByText('Please select a role')).toBeVisible(),
]);
});
test('test that organization billable rate can be updated with all existing time entries', async ({
page,
}) => {
await goToMembersPage(page);
const newBillableRate = Math.round(Math.random() * 10000);
await page.getByRole('row').first().getByRole('button').click();
await page.getByRole('button').getByText('Edit').first().click();
await page.getByText('Organization Default Rate').click();
await page.getByText('Custom Rate').click();
await page
.getByPlaceholder('Billable Rate')
.fill(newBillableRate.toString());
await page.getByRole('button', { name: 'Update Member' }).click();
await Promise.all([
page.getByRole('button', { name: 'Yes, update existing time' }).click(),
page.waitForRequest(
async (request) =>
request.url().includes('/members/') &&
request.method() === 'PUT' &&
request.postDataJSON().billable_rate === newBillableRate * 100
),
page.waitForResponse(
async (response) =>
response.url().includes('/organizations/') &&
response.request().method() === 'PUT' &&
response.status() === 200 &&
(await response.json()).data.billable_rate ===
newBillableRate * 100
),
]);
});

View File

@@ -1,4 +1,4 @@
import { test, expect } from '../playwright/fixtures'; import { expect, test } from '../playwright/fixtures';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
async function goToOrganizationSettings(page) { async function goToOrganizationSettings(page) {
@@ -17,53 +17,36 @@ test('test that organization name can be updated', async ({ page }) => {
).toContainText('NEW ORG NAME'); ).toContainText('NEW ORG NAME');
}); });
test('test that new manager can be invited', async ({ page }) => { test('test that organization billable rate can be updated with all existing time entries', async ({
page,
}) => {
await goToOrganizationSettings(page); await goToOrganizationSettings(page);
const editorId = Math.round(Math.random() * 10000); const newBillableRate = Math.round(Math.random() * 10000);
await page.getByLabel('Email').fill(`new+${editorId}@editor.test`); await page.getByLabel('Organization Billable Rate').click();
await page.getByRole('button', { name: 'Manager' }).click(); await page
.getByLabel('Organization Billable Rate')
.fill(newBillableRate.toString());
await page
.locator('button')
.filter({ hasText: /^Save$/ })
.click();
await Promise.all([ await Promise.all([
page.getByRole('button', { name: 'Add', exact: true }).click(), page
expect(page.getByRole('main')).toContainText( .getByRole('button', { name: 'Yes, update existing time entries' })
`new+${editorId}@editor.test` .click(),
page.waitForRequest(
async (request) =>
request.url().includes('/organizations/') &&
request.method() === 'PUT' &&
request.postDataJSON().billable_rate === newBillableRate * 100
), ),
]); page.waitForResponse(
}); async (response) =>
response.url().includes('/organizations/') &&
test('test that new employee can be invited', async ({ page }) => { response.request().method() === 'PUT' &&
await goToOrganizationSettings(page); response.status() === 200 &&
const editorId = Math.round(Math.random() * 10000); (await response.json()).data.billable_rate ===
await page.getByLabel('Email').fill(`new+${editorId}@editor.test`); newBillableRate * 100
await page.getByRole('button', { name: 'Employee' }).click();
await Promise.all([
page.getByRole('button', { name: 'Add', exact: true }).click(),
await expect(page.getByRole('main')).toContainText(
`new+${editorId}@editor.test`
),
]);
});
test('test that new admin can be invited', async ({ page }) => {
await goToOrganizationSettings(page);
const adminId = Math.round(Math.random() * 10000);
await page.getByLabel('Email').fill(`new+${adminId}@admin.test`);
await page.getByRole('button', { name: 'Administrator' }).click();
await Promise.all([
page.getByRole('button', { name: 'Add', exact: true }).click(),
expect(page.getByRole('main')).toContainText(
`new+${adminId}@admin.test`
),
]);
});
test('test that error shows if no role is selected', async ({ page }) => {
await goToOrganizationSettings(page);
const noRoleId = Math.round(Math.random() * 10000);
await page.getByLabel('Email').fill(`new+${noRoleId}@norole.test`);
await Promise.all([
page.getByRole('button', { name: 'Add', exact: true }).click(),
expect(page.getByRole('main')).toContainText(
'The role field is required.'
), ),
]); ]);
}); });

View File

@@ -0,0 +1,63 @@
import { expect, Page } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
import { formatCents } from '../resources/js/utils/money';
async function goToProjectsOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
}
test('test that updating project member billable rate works for existing time entries', async ({
page,
}) => {
const newProjectName =
'New Project ' + Math.floor(1 + Math.random() * 10000);
const newBillableRate = Math.round(Math.random() * 10000);
await goToProjectsOverview(page);
await page.getByRole('button', { name: 'Create Project' }).click();
await page.getByLabel('Project Name').fill(newProjectName);
await page.getByRole('button', { name: 'Create Project' }).nth(1).click();
await expect(page.getByText(newProjectName)).toBeVisible();
await page.getByText(newProjectName).click();
await page.getByRole('button', { name: 'Add Member' }).click();
await expect(page.getByText('Add Project Member').first()).toBeVisible();
await page.keyboard.press('Enter');
await page.getByRole('button', { name: 'Add Project Member' }).click();
await page
.getByTestId('project_member_table')
.getByRole('row')
.first()
.getByRole('button')
.click();
await page.getByRole('button', { name: 'Edit' }).first().click();
await page.getByLabel('Billable Rate').fill(newBillableRate.toString());
await page.getByRole('button', { name: 'Update Project Member' }).click();
await Promise.all([
page.getByRole('button', { name: 'Yes, update existing time' }).click(),
page.waitForRequest(
async (request) =>
request.url().includes('/project-members/') &&
request.method() === 'PUT' &&
request.postDataJSON().billable_rate === newBillableRate * 100
),
page.waitForResponse(
async (response) =>
response.url().includes('/project-members/') &&
response.request().method() === 'PUT' &&
response.status() === 200 &&
(await response.json()).data.billable_rate ===
newBillableRate * 100
),
]);
await expect(
page
.getByRole('row')
.first()
.getByText(formatCents(newBillableRate * 100))
).toBeVisible();
});

View File

@@ -1,6 +1,7 @@
import { expect, Page } from '@playwright/test'; import { expect, Page } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures'; import { test } from '../playwright/fixtures';
import { formatCents } from '../resources/js/utils/money';
async function goToProjectsOverview(page: Page) { async function goToProjectsOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/projects'); await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
@@ -54,6 +55,86 @@ test('test that creating and deleting a new project via the modal works', async
); );
}); });
test('test that archiving and unarchiving projects works', async ({ page }) => {
const newProjectName =
'New Project ' + Math.floor(1 + Math.random() * 10000);
await goToProjectsOverview(page);
await page.getByRole('button', { name: 'Create Project' }).click();
await page.getByLabel('Project Name').fill(newProjectName);
await page.getByRole('button', { name: 'Create Project' }).nth(1).click();
await expect(page.getByText(newProjectName)).toBeVisible();
await page.getByRole('row').first().getByRole('button').click();
await Promise.all([
page.getByRole('button').getByText('Archive').first().click(),
expect(page.getByText(newProjectName)).not.toBeVisible(),
]);
await Promise.all([
page.getByRole('tab', { name: 'Archived' }).click(),
expect(page.getByText(newProjectName)).toBeVisible(),
]);
await page.getByRole('row').first().getByRole('button').click();
await Promise.all([
page.getByRole('button').getByText('Unarchive').first().click(),
expect(page.getByText(newProjectName)).not.toBeVisible(),
]);
await Promise.all([
page.getByRole('tab', { name: 'Active' }).click(),
expect(page.getByText(newProjectName)).toBeVisible(),
]);
});
test('test that updating billable rate works with existing time entries', async ({
page,
}) => {
const newProjectName =
'New Project ' + Math.floor(1 + Math.random() * 10000);
const newBillableRate = Math.round(Math.random() * 10000);
await goToProjectsOverview(page);
await page.getByRole('button', { name: 'Create Project' }).click();
await page.getByLabel('Project Name').fill(newProjectName);
await page.getByRole('button', { name: 'Create Project' }).nth(1).click();
await expect(page.getByText(newProjectName)).toBeVisible();
await page.getByRole('row').first().getByRole('button').click();
await page.getByRole('button').getByText('Edit').first().click(),
await page.getByText('Non-Billable').click();
await page.getByText('Custom Rate').click();
await page
.getByPlaceholder('Billable Rate')
.fill(newBillableRate.toString());
await page.getByRole('button', { name: 'Update Project' }).click();
await Promise.all([
page
.getByRole('button', { name: 'Yes, update existing time entries' })
.click(),
page.waitForRequest(
async (request) =>
request.url().includes('/projects/') &&
request.method() === 'PUT' &&
request.postDataJSON().billable_rate === newBillableRate * 100
),
page.waitForResponse(
async (response) =>
response.url().includes('/projects/') &&
response.request().method() === 'PUT' &&
response.status() === 200 &&
(await response.json()).data.billable_rate ===
newBillableRate * 100
),
]);
await expect(
page
.getByRole('row')
.first()
.getByText(formatCents(newBillableRate * 100))
).toBeVisible();
});
// Create new project with new Client // Create new project with new Client
// Create new project with existing Client // Create new project with existing Client

View File

@@ -98,6 +98,47 @@ test('test that creating and deleting a new tag in a new project works', async (
); );
}); });
test('test that archiving and unarchiving tasks works', async ({ page }) => {
const newProjectName =
'New Project ' + Math.floor(1 + Math.random() * 10000);
const newTaskName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
await goToProjectsOverview(page);
await page.getByRole('button', { name: 'Create Project' }).click();
await page.getByLabel('Project Name').fill(newProjectName);
await page.getByRole('button', { name: 'Create Project' }).nth(1).click();
await expect(page.getByText(newProjectName)).toBeVisible();
await page.getByText(newProjectName).click();
await page.getByRole('button', { name: 'Create Task' }).click();
await page.getByPlaceholder('Task Name').fill(newTaskName);
await page.getByRole('button', { name: 'Create Task' }).nth(1).click();
await expect(page.getByRole('table')).toContainText(newTaskName);
await page.getByRole('row').first().getByRole('button').click();
await Promise.all([
page.getByRole('button').getByText('Mark as done').first().click(),
expect(page.getByText(newTaskName)).not.toBeVisible(),
]);
await Promise.all([
page.getByRole('tab', { name: 'Done' }).click(),
expect(page.getByText(newTaskName)).toBeVisible(),
]);
await page.getByRole('row').first().getByRole('button').click();
await Promise.all([
page.getByRole('button').getByText('Mark as active').first().click(),
expect(page.getByText(newTaskName)).not.toBeVisible(),
]);
await Promise.all([
page.getByRole('tab', { name: 'Active' }).click(),
expect(page.getByText(newTaskName)).toBeVisible(),
]);
});
// Create new project with new Client // Create new project with new Client
// Create new project with existing Client // Create new project with existing Client

View File

@@ -4,10 +4,14 @@ declare(strict_types=1);
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers; use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization; use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
use App\Exceptions\Api\EntityStillInUseApiException; use App\Exceptions\Api\EntityStillInUseApiException;
use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException; use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException;
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException; use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
use App\Exceptions\Api\TimeEntryStillRunningApiException; use App\Exceptions\Api\TimeEntryStillRunningApiException;
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
use App\Exceptions\Api\UserIsAlreadyMemberOfProjectApiException; use App\Exceptions\Api\UserIsAlreadyMemberOfProjectApiException;
use App\Exceptions\Api\UserNotPlaceholderApiException; use App\Exceptions\Api\UserNotPlaceholderApiException;
@@ -17,10 +21,14 @@ return [
UserNotPlaceholderApiException::KEY => 'The given user is not a placeholder', UserNotPlaceholderApiException::KEY => 'The given user is not a placeholder',
TimeEntryCanNotBeRestartedApiException::KEY => 'Time entry is already stopped and can not be restarted', TimeEntryCanNotBeRestartedApiException::KEY => 'Time entry is already stopped and can not be restarted',
InactiveUserCanNotBeUsedApiException::KEY => 'Inactive user can not be used', InactiveUserCanNotBeUsedApiException::KEY => 'Inactive user can not be used',
UserIsAlreadyMemberOfOrganizationApiException::KEY => 'User is already a member of the organization',
UserIsAlreadyMemberOfProjectApiException::KEY => 'User is already a member of the project', 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.', EntityStillInUseApiException::KEY => 'The :modelToDelete is still used by a :modelInUse and can not be deleted.',
CanNotRemoveOwnerFromOrganization::KEY => 'Can not remove owner from organization', CanNotRemoveOwnerFromOrganization::KEY => 'Can not remove owner from organization',
CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers::KEY => 'Can not delete user who is owner of organization with multiple members. Please delete the organization first.', CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers::KEY => 'Can not delete user who is owner of organization with multiple members. Please delete the organization first.',
OnlyOwnerCanChangeOwnership::KEY => 'Only owner can change ownership',
OrganizationNeedsAtLeastOneOwner::KEY => 'Organization needs at least one owner',
ChangingRoleToPlaceholderIsNotAllowed::KEY => 'Changing role to placeholder is not allowed',
], ],
'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.', 'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.',
]; ];

View File

@@ -202,6 +202,11 @@ return [
'currency' => 'The :attribute field must be a valid currency code (ISO 4217).', 'currency' => 'The :attribute field must be a valid currency code (ISO 4217).',
'organization' => 'The :attribute does not exist.', 'organization' => 'The :attribute does not exist.',
'task_belongs_to_project' => 'The :attribute is not part of the given project.', 'task_belongs_to_project' => 'The :attribute is not part of the given project.',
'project_name_already_exists' => 'A project with the same name already exists in the organization.',
'tag_name_already_exists' => 'A tag with the same name already exists in the organization.',
'client_name_already_exists' => 'A client with the same name already exists in the organization.',
'task_name_already_exists' => 'A task with the same name already exists in the project.',
'invitation_already_exists' => 'The email has already been invited to the organization. Please wait for the user to accept the invitation or resend the invitation email.',
'entities' => [ 'entities' => [
'organization' => 'organization', 'organization' => 'organization',

View File

@@ -5,11 +5,15 @@ const ClientResource = z
.object({ .object({
id: z.string(), id: z.string(),
name: z.string(), name: z.string(),
is_archived: z.boolean(),
created_at: z.string(), created_at: z.string(),
updated_at: z.string(), updated_at: z.string(),
}) })
.passthrough(); .passthrough();
const ClientCollection = z.array(ClientResource); const ClientCollection = z.array(ClientResource);
const updateClient_Body = z
.object({ name: z.string(), is_archived: z.boolean().optional() })
.passthrough();
const importData_Body = z const importData_Body = z
.object({ type: z.string(), data: z.string() }) .object({ type: z.string(), data: z.string() })
.passthrough(); .passthrough();
@@ -32,10 +36,8 @@ const MemberPivotResource = z
}) })
.passthrough(); .passthrough();
const updateMember_Body = z const updateMember_Body = z
.object({ .object({ role: Role, billable_rate: z.union([z.number(), z.null()]) })
billable_rate: z.union([z.number(), z.null()]).optional(), .partial()
role: Role,
})
.passthrough(); .passthrough();
const MemberResource = z const MemberResource = z
.object({ .object({
@@ -68,6 +70,7 @@ const ProjectResource = z
name: z.string(), name: z.string(),
color: z.string(), color: z.string(),
client_id: z.union([z.string(), z.null()]), client_id: z.union([z.string(), z.null()]),
is_archived: z.boolean(),
billable_rate: z.union([z.number(), z.null()]), billable_rate: z.union([z.number(), z.null()]),
is_billable: z.boolean(), is_billable: z.boolean(),
}) })
@@ -81,6 +84,16 @@ const createProject_Body = z
client_id: z.union([z.string(), z.null()]).optional(), client_id: z.union([z.string(), z.null()]).optional(),
}) })
.passthrough(); .passthrough();
const updateProject_Body = z
.object({
name: z.string(),
color: z.string(),
is_billable: z.boolean(),
is_archived: z.boolean().optional(),
client_id: z.union([z.string(), z.null()]).optional(),
billable_rate: z.union([z.number(), z.null()]).optional(),
})
.passthrough();
const ProjectMemberResource = z const ProjectMemberResource = z
.object({ .object({
id: z.string(), id: z.string(),
@@ -112,6 +125,7 @@ const TaskResource = z
.object({ .object({
id: z.string(), id: z.string(),
name: z.string(), name: z.string(),
is_done: z.boolean(),
project_id: z.string(), project_id: z.string(),
created_at: z.string(), created_at: z.string(),
updated_at: z.string(), updated_at: z.string(),
@@ -120,6 +134,9 @@ const TaskResource = z
const createTask_Body = z const createTask_Body = z
.object({ name: z.string(), project_id: z.string() }) .object({ name: z.string(), project_id: z.string() })
.passthrough(); .passthrough();
const updateTask_Body = z
.object({ name: z.string(), is_done: z.boolean().optional() })
.passthrough();
const start = z.union([z.string(), z.null()]).optional(); const start = z.union([z.string(), z.null()]).optional();
const TimeEntryResource = z const TimeEntryResource = z
.object({ .object({
@@ -149,7 +166,7 @@ const createTimeEntry_Body = z
tags: z.union([z.array(z.string()), z.null()]).optional(), tags: z.union([z.array(z.string()), z.null()]).optional(),
}) })
.passthrough(); .passthrough();
const v1_time_entries_update_multiple_Body = z const updateMultipleTimeEntries_Body = z
.object({ .object({
ids: z.array(z.string()), ids: z.array(z.string()),
changes: z changes: z
@@ -182,6 +199,7 @@ const updateTimeEntry_Body = z
export const schemas = { export const schemas = {
ClientResource, ClientResource,
ClientCollection, ClientCollection,
updateClient_Body,
importData_Body, importData_Body,
InvitationResource, InvitationResource,
Role, Role,
@@ -193,6 +211,7 @@ export const schemas = {
updateOrganization_Body, updateOrganization_Body,
ProjectResource, ProjectResource,
createProject_Body, createProject_Body,
updateProject_Body,
ProjectMemberResource, ProjectMemberResource,
createProjectMember_Body, createProjectMember_Body,
updateProjectMember_Body, updateProjectMember_Body,
@@ -200,11 +219,12 @@ export const schemas = {
TagCollection, TagCollection,
TaskResource, TaskResource,
createTask_Body, createTask_Body,
updateTask_Body,
start, start,
TimeEntryResource, TimeEntryResource,
TimeEntryCollection, TimeEntryCollection,
createTimeEntry_Body, createTimeEntry_Body,
v1_time_entries_update_multiple_Body, updateMultipleTimeEntries_Body,
updateTimeEntry_Body, updateTimeEntry_Body,
}; };
@@ -287,6 +307,16 @@ const endpoints = makeApi([
type: 'Path', type: 'Path',
schema: z.string(), schema: z.string(),
}, },
{
name: 'page',
type: 'Query',
schema: z.number().int().gte(1).optional(),
},
{
name: 'archived',
type: 'Query',
schema: z.enum(['true', 'false', 'all']).optional(),
},
], ],
response: z.object({ data: ClientCollection }).passthrough(), response: z.object({ data: ClientCollection }).passthrough(),
errors: [ errors: [
@@ -300,6 +330,16 @@ const endpoints = makeApi([
description: `Not found`, description: `Not found`,
schema: z.object({ message: z.string() }).passthrough(), 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(),
},
], ],
}, },
{ {
@@ -352,7 +392,7 @@ const endpoints = makeApi([
{ {
name: 'body', name: 'body',
type: 'Body', type: 'Body',
schema: z.object({ name: z.string() }).passthrough(), schema: updateClient_Body,
}, },
{ {
name: 'organization', name: 'organization',
@@ -820,6 +860,17 @@ const endpoints = makeApi([
], ],
response: z.object({ data: MemberResource }).passthrough(), response: z.object({ data: MemberResource }).passthrough(),
errors: [ errors: [
{
status: 400,
description: `API exception`,
schema: z
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{ {
status: 403, status: 403,
description: `Authorization error`, description: `Authorization error`,
@@ -1034,6 +1085,11 @@ const endpoints = makeApi([
type: 'Query', type: 'Query',
schema: z.number().int().gte(1).optional(), schema: z.number().int().gte(1).optional(),
}, },
{
name: 'archived',
type: 'Query',
schema: z.enum(['true', 'false', 'all']).optional(),
},
], ],
response: z response: z
.object({ .object({
@@ -1172,7 +1228,7 @@ const endpoints = makeApi([
{ {
name: 'body', name: 'body',
type: 'Body', type: 'Body',
schema: createProject_Body, schema: updateProject_Body,
}, },
{ {
name: 'organization', name: 'organization',
@@ -1552,6 +1608,11 @@ const endpoints = makeApi([
type: 'Query', type: 'Query',
schema: z.string().uuid().optional(), schema: z.string().uuid().optional(),
}, },
{
name: 'done',
type: 'Query',
schema: z.enum(['true', 'false', 'all']).optional(),
},
], ],
response: z response: z
.object({ .object({
@@ -1659,7 +1720,7 @@ const endpoints = makeApi([
{ {
name: 'body', name: 'body',
type: 'Body', type: 'Body',
schema: z.object({ name: z.string() }).passthrough(), schema: updateTask_Body,
}, },
{ {
name: 'organization', name: 'organization',
@@ -1891,13 +1952,13 @@ Users with the permission &#x60;time-entries:view:own&#x60; can only use this en
{ {
method: 'patch', method: 'patch',
path: '/v1/organizations/:organization/time-entries', path: '/v1/organizations/:organization/time-entries',
alias: 'v1.time-entries.update-multiple', alias: 'updateMultipleTimeEntries',
requestFormat: 'json', requestFormat: 'json',
parameters: [ parameters: [
{ {
name: 'body', name: 'body',
type: 'Body', type: 'Body',
schema: v1_time_entries_update_multiple_Body, schema: updateMultipleTimeEntries_Body,
}, },
{ {
name: 'organization', name: 'organization',

View File

@@ -11,11 +11,21 @@
<testsuite name="Feature"> <testsuite name="Feature">
<directory>tests/Feature</directory> <directory>tests/Feature</directory>
</testsuite> </testsuite>
<testsuite name="Modules">
<directory suffix="Test.php">./extensions/*/tests/Feature</directory>
<directory suffix="Test.php">./extensions/*/tests/Unit</directory>
</testsuite>
</testsuites> </testsuites>
<source> <source>
<include> <include>
<directory>app</directory> <directory>app</directory>
<directory suffix=".php">./extensions</directory>
</include> </include>
<exclude>
<directory suffix=".php">./extensions/*/database</directory>
<directory suffix=".php">./extensions/*/resources</directory>
<directory suffix=".php">./extensions/*/tests</directory>
</exclude>
</source> </source>
<php> <php>
<env name="APP_ENV" value="testing"/> <env name="APP_ENV" value="testing"/>

View File

@@ -56,6 +56,7 @@ function updateRate(value: string) {
} }
inputValue.value = formatValue(model.value); inputValue.value = formatValue(model.value);
} }
function formatValue(modelValue: number | null) { function formatValue(modelValue: number | null) {
const formattedValue = formatCents(modelValue ?? 0); const formattedValue = formatCents(modelValue ?? 0);
return formattedValue.replace(getOrganizationCurrencySymbol(), '').trim(); return formattedValue.replace(getOrganizationCurrencySymbol(), '').trim();
@@ -81,14 +82,12 @@ const inputValue = ref(formatValue(model.value));
placeholder="Billable Rate" placeholder="Billable Rate"
class="mt-2 block w-full" class="mt-2 block w-full"
autocomplete="teamMemberRate" /> autocomplete="teamMemberRate" />
<span> <div
<div class="absolute top-0 right-0 h-full flex items-center px-4 font-medium pointer-events-none">
class="absolute top-0 right-0 h-full flex items-center px-4 font-medium"> <span>
<span> {{ getOrganizationCurrencyString() }}
{{ getOrganizationCurrencyString() }} </span>
</span> </div>
</div>
</span>
</div> </div>
</template> </template>

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
import PrimaryButton from '@/Components/PrimaryButton.vue';
import DialogModal from '@/Components/DialogModal.vue';
import SecondaryButton from '@/Components/SecondaryButton.vue';
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/solid';
const show = defineModel('show', { default: false });
const saving = defineModel('saving', { default: false });
const emit = defineEmits<{
submit: [];
}>();
defineProps<{
title: string;
}>();
</script>
<template>
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex justify-center">
<span> {{ title }} </span>
</div>
</template>
<template #content>
<div class="flex items-center space-x-4">
<div class="col-span-6 sm:col-span-4 flex-1">
<slot></slot>
<div class="space-x-3 pt-5 pb-2 flex justify-center">
<PrimaryButton
:class="{ 'opacity-25': saving }"
:disabled="saving"
@click="emit('submit')">
Yes, update existing time entries
</PrimaryButton>
</div>
<p class="text-center pt-3 pb-1">
Learn more about the
<a
target="_blank"
href="https://docs.solidtime.io/user-guide/billable-rates"
class="text-blue-400 hover:text-blue-500 transition"
>billable rate logic
<ArrowTopRightOnSquareIcon
class="w-4 -mt-0.5 inline-block"></ArrowTopRightOnSquareIcon
></a>
</p>
</div>
</div>
</template>
<template #footer>
<SecondaryButton @click="show = false"> Cancel </SecondaryButton>
</template>
</DialogModal>
</template>
<style scoped></style>

View File

@@ -19,7 +19,7 @@ defineProps<{
{{ title }} {{ title }}
</span> </span>
</h3> </h3>
<div> <div class="flex-1 flex justify-end items-center">
<slot name="actions"></slot> <slot name="actions"></slot>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,46 @@
<script setup lang="ts">
import Dropdown from '@/Components/Dropdown.vue';
import { TrashIcon, ArrowPathIcon } from '@heroicons/vue/20/solid';
const emit = defineEmits<{
delete: [];
resend: [];
}>();
</script>
<template>
<Dropdown align="bottom-end">
<template #trigger>
<svg
data-testid="invitation_actions"
class="h-10 w-10 p-2 rounded-full hover:bg-card-background opacity-20 group-hover:opacity-100 transition"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M12 5.92A.96.96 0 1 0 12 4a.96.96 0 0 0 0 1.92m0 7.04a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92M12 20a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92" />
</svg>
</template>
<template #content>
<button
@click="emit('resend')"
data-testid="invitation_delete"
class="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">
<ArrowPathIcon class="w-5 text-icon-active"></ArrowPathIcon>
<span>Resend Invitation</span>
</button>
<button
@click="emit('delete')"
data-testid="invitation_delete"
class="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">
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
<span>Delete</span>
</button>
</template>
</Dropdown>
</template>
<style scoped></style>

View File

@@ -18,7 +18,7 @@ onMounted(async () => {
<div <div
data-testid="client_table" data-testid="client_table"
class="grid min-w-full" class="grid min-w-full"
style="grid-template-columns: 1fr 1fr"> style="grid-template-columns: 1fr 1fr 80px">
<InvitationTableHeading></InvitationTableHeading> <InvitationTableHeading></InvitationTableHeading>
<template <template
v-for="invitation in invitations" v-for="invitation in invitations"

View File

@@ -4,8 +4,15 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
<template> <template>
<TableHeading> <TableHeading>
<div class="px-3 py-1.5 text-left font-semibold text-white">Email</div> <div
class="px-3 py-1.5 text-left font-semibold text-white pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
Email
</div>
<div class="px-3 py-1.5 text-left font-semibold text-white">Role</div> <div class="px-3 py-1.5 text-left font-semibold text-white">Role</div>
<div
class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12 bg-row-heading-background">
<span class="sr-only">Edit</span>
</div>
</TableHeading> </TableHeading>
</template> </template>

View File

@@ -2,20 +2,76 @@
import type { Invitation } from '@/utils/api'; import type { Invitation } from '@/utils/api';
import TableRow from '@/Components/TableRow.vue'; import TableRow from '@/Components/TableRow.vue';
import { capitalizeFirstLetter } from '../../../utils/format'; import { capitalizeFirstLetter } from '../../../utils/format';
import InvitationMoreOptionsDropdown from '@/Components/Common/Invitation/InvitationMoreOptionsDropdown.vue';
import { api } from '../../../../../openapi.json.client';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { useNotificationsStore } from '@/utils/notification';
import { useInvitationsStore } from '@/utils/useInvitations';
const { handleApiRequestNotifications } = useNotificationsStore();
defineProps<{ const props = defineProps<{
invitation: Invitation; invitation: Invitation;
}>(); }>();
async function deleteInvitation() {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
await handleApiRequestNotifications(
() =>
api.removeInvitation(
{},
{
params: {
invitation: props.invitation.id,
organization: organizationId,
},
}
),
'Invitation removed successfully',
'Error removing invitation',
() => {
useInvitationsStore().fetchInvitations();
}
);
}
}
async function resendInvitation() {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
await handleApiRequestNotifications(
() =>
api.resendInvitationEmail(
{},
{
params: {
invitation: props.invitation.id,
organization: organizationId,
},
}
),
'Invitation mail sent successfully',
'Error sending invitation mail'
);
}
}
</script> </script>
<template> <template>
<TableRow> <TableRow>
<div class="whitespace-nowrap px-3 py-4 text-sm text-muted"> <div
class="whitespace-nowrap px-3 py-4 text-sm text-muted pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
{{ invitation.email }} {{ invitation.email }}
</div> </div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-muted"> <div class="whitespace-nowrap px-3 py-4 text-sm text-muted">
{{ capitalizeFirstLetter(invitation.role) }} {{ capitalizeFirstLetter(invitation.role) }}
</div> </div>
<div
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<InvitationMoreOptionsDropdown
@delete="deleteInvitation"
@resend="resendInvitation" />
</div>
</TableRow> </TableRow>
</template> </template>

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import { formatCents } from '../../../utils/money';
import BillableRateModal from '@/Components/Common/BillableRateModal.vue';
const show = defineModel('show', { default: false });
const saving = defineModel('saving', { default: false });
defineProps<{
newBillableRate?: number | null;
memberName: string;
}>();
defineEmits<{
submit: [];
}>();
</script>
<template>
<BillableRateModal
@submit="$emit('submit')"
v-model:show="show"
v-model:saving="saving"
title="Update Member Billable Rate">
<p class="py-1 text-center">
The billable rate of {{ memberName }} will be updated to
<strong>{{
newBillableRate
? formatCents(newBillableRate)
: ' the default rate of the organization'
}}</strong
>.
</p>
<p class="py-1 text-center font-semibold max-w-md mx-auto">
Do you want to update all existing time entries, where the member
billable rate applies as well?
</p>
</BillableRateModal>
</template>
<style scoped></style>

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
import SelectDropdown from '@/Components/Common/SelectDropdown.vue';
import type { BillableKey } from '@/utils/useProjects';
import Badge from '@/Components/Common/Badge.vue';
import { ChevronDownIcon } from '@heroicons/vue/20/solid';
const model = defineModel<BillableKey>({
default: 'default-rate',
});
type Option = { key: BillableKey; name: string };
const options: Option[] = [
{
key: 'default-rate',
name: 'Organization Default Rate',
},
{
key: 'custom-rate',
name: 'Custom Rate',
},
];
function getKeyFromItem(item: Option) {
return item.key;
}
function getNameFromItem(item: Option) {
return item.name;
}
function getNameForKey(key: BillableKey | undefined) {
const item = options.find((item) => getKeyFromItem(item) === key);
if (item) {
return getNameFromItem(item);
}
return '';
}
</script>
<template>
<SelectDropdown
v-model="model"
:get-key-from-item="getKeyFromItem"
:get-name-for-item="getNameFromItem"
:items="options">
<template #trigger>
<Badge size="xlarge" class="bg-input-background cursor-pointer">
<span>
{{ getNameForKey(model) }}
</span>
<ChevronDownIcon class="text-muted w-5"></ChevronDownIcon>
</Badge>
</template>
</SelectDropdown>
</template>
<style scoped></style>

View File

@@ -1,11 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import SecondaryButton from '@/Components/SecondaryButton.vue'; import SecondaryButton from '@/Components/SecondaryButton.vue';
import DialogModal from '@/Components/DialogModal.vue'; import DialogModal from '@/Components/DialogModal.vue';
import { ref } from 'vue'; import { computed, ref } from 'vue';
import type { Member, UpdateMemberBody } from '@/utils/api'; import type { Member, UpdateMemberBody } from '@/utils/api';
import PrimaryButton from '@/Components/PrimaryButton.vue'; import PrimaryButton from '@/Components/PrimaryButton.vue';
import { useMembersStore } from '@/utils/useMembers'; import { type MemberBillableKey, useMembersStore } from '@/utils/useMembers';
import BillableRateInput from '@/Components/Common/BillableRateInput.vue'; import BillableRateInput from '@/Components/Common/BillableRateInput.vue';
import InputLabel from '@/Components/InputLabel.vue';
import MemberBillableRateModal from '@/Components/Common/Member/MemberBillableRateModal.vue';
import MemberBillableSelect from '@/Components/Common/Member/MemberBillableSelect.vue';
import { onMounted, watch } from 'vue';
import MemberRoleSelect from '@/Components/Common/Member/MemberRoleSelect.vue';
import MemberOwnershipTransferConfirmModal from '@/Components/Common/Member/MemberOwnershipTransferConfirmModal.vue';
const { updateMember } = useMembersStore(); const { updateMember } = useMembersStore();
const show = defineModel('show', { default: false }); const show = defineModel('show', { default: false });
@@ -21,13 +27,92 @@ const memberBody = ref<UpdateMemberBody>({
billable_rate: props.member.billable_rate, billable_rate: props.member.billable_rate,
}); });
async function submitBillableRate() {
if (memberBody.value.role === 'owner' && props.member.role !== 'owner') {
show.value = false;
showOwnershipTransferConfirmModal.value = true;
} else {
await submit();
}
}
async function submit() { async function submit() {
await updateMember(props.member.id, memberBody.value); await updateMember(props.member.id, memberBody.value);
show.value = false; show.value = false;
showBillableRateModal.value = false;
showOwnershipTransferConfirmModal.value = false;
} }
const showBillableRateModal = ref(false);
const showOwnershipTransferConfirmModal = ref(false);
function saveWithChecks() {
if (memberBody.value.billable_rate !== props.member.billable_rate) {
showBillableRateModal.value = true;
show.value = false;
} else if (
memberBody.value.role === 'owner' &&
props.member.role !== 'owner'
) {
show.value = false;
showOwnershipTransferConfirmModal.value = true;
} else {
submitBillableRate();
}
}
const billableRateSelect = ref<MemberBillableKey>('default-rate');
onMounted(() => {
if (props.member.billable_rate !== null) {
billableRateSelect.value = 'custom-rate';
} else {
billableRateSelect.value = 'default-rate';
}
});
watch(billableRateSelect, () => {
if (billableRateSelect.value === 'default-rate') {
memberBody.value.billable_rate = null;
} else if (billableRateSelect.value === 'custom-rate') {
memberBody.value.billable_rate = props.member.billable_rate ?? 0;
}
});
const roleDescriptionTexts = {
'owner':
'The owner has full access of the organization. The owner is the only role that can: delete the organization, transfer the ownership to another user and access to the billing settings',
'admin':
'The admin has full access to the organization, except for the stuff that only the owner can do.',
'manager':
'The manager has full access to projects, clients, tags, time entries, and reports, but can not manage the organization or the users.',
'employee':
'An employee is a user that is only using the application to track time, but has no administrative rights.',
'placeholder':
'Placeholder users can not do anything in the organization. They are not billed and can be used to remove users from the organization without deleting their time entries.',
};
const roleDescription = computed(() => {
if (
memberBody.value.role &&
memberBody.value.role in roleDescriptionTexts
) {
return roleDescriptionTexts[memberBody.value.role];
}
return '';
});
</script> </script>
<template> <template>
<MemberBillableRateModal
v-model:saving="saving"
v-model:show="showBillableRateModal"
:member-name="member.name"
:newBillableRate="memberBody.billable_rate"
@submit="submitBillableRate"></MemberBillableRateModal>
<MemberOwnershipTransferConfirmModal
:member-name="member.name"
v-model:show="showOwnershipTransferConfirmModal"
@submit="submit"></MemberOwnershipTransferConfirmModal>
<DialogModal closeable :show="show" @close="show = false"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
@@ -36,24 +121,58 @@ async function submit() {
</template> </template>
<template #content> <template #content>
<div class="flex items-center space-x-4"> <div class="pb-5 pt-2 divide-y divide-border-secondary">
<div class="col-span-6 sm:col-span-4 flex-1"> <div class="pb-5 flex space-x-6">
<BillableRateInput <div>
focus <InputLabel for="role" value="Role" />
name="billable_rate" <MemberRoleSelect
v-model="memberBody.billable_rate"></BillableRateInput> class="mt-2"
name="role"
v-model="memberBody.role"></MemberRoleSelect>
</div>
<div class="flex-1 text-xs flex items-center pt-6">
<p>{{ roleDescription }}</p>
</div>
</div>
<div class="flex items-center space-x-4 pt-5">
<div class="col-span-6 sm:col-span-4 flex-1 flex space-x-5">
<div>
<InputLabel for="billableType" value="Billable" />
<MemberBillableSelect
class="mt-2"
name="billableType"
v-model="
billableRateSelect
"></MemberBillableSelect>
</div>
<div
class="flex-1"
v-if="billableRateSelect === 'custom-rate'">
<InputLabel
for="memberBillableRate"
value="Billable Rate" />
<BillableRateInput
focus
class="w-full"
@keydown.enter="saveWithChecks()"
name="memberBillableRate"
v-model="
memberBody.billable_rate
"></BillableRateInput>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
<template #footer> <template #footer>
<SecondaryButton @click="show = false"> Cancel </SecondaryButton> <SecondaryButton @click="show = false"> Cancel</SecondaryButton>
<PrimaryButton <PrimaryButton
class="ms-3" class="ms-3"
:class="{ 'opacity-25': saving }" :class="{ 'opacity-25': saving }"
:disabled="saving" :disabled="saving"
@click="submit"> @click="saveWithChecks()">
Update Client Update Member
</PrimaryButton> </PrimaryButton>
</template> </template>
</DialogModal> </DialogModal>

View File

@@ -8,9 +8,16 @@ import { useFocus } from '@vueuse/core';
import InputLabel from '@/Components/InputLabel.vue'; import InputLabel from '@/Components/InputLabel.vue';
import InputError from '@/Components/InputError.vue'; import InputError from '@/Components/InputError.vue';
import type { Role } from '@/types/jetstream'; import type { Role } from '@/types/jetstream';
import { useForm } from '@inertiajs/vue3'; import { Link, useForm } from '@inertiajs/vue3';
import { getCurrentOrganizationId } from '@/utils/useUser'; import { getCurrentOrganizationId } from '@/utils/useUser';
import { filterRoles } from '@/utils/roles'; import { filterRoles } from '@/utils/roles';
import { hasActiveSubscription, isBillingActivated } from '@/utils/billing';
import { CreditCardIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
import { canUpdateOrganization } from '@/utils/permissions';
import { api } from '../../../../../openapi.json.client';
import type { MemberRole } from '@/utils/api';
import { z } from 'zod';
import { useNotificationsStore } from '@/utils/notification';
const show = defineModel('show', { default: false }); const show = defineModel('show', { default: false });
const saving = ref(false); const saving = ref(false);
@@ -19,25 +26,55 @@ defineProps<{
availableRoles: Role[]; availableRoles: Role[];
}>(); }>();
const errors = ref({
email: '',
role: '',
});
const addTeamMemberForm = useForm({ const addTeamMemberForm = useForm({
email: '', email: '',
role: null as string | null, role: null as string | null,
}); });
const emit = defineEmits(['close']); const emit = defineEmits(['close']);
const { handleApiRequestNotifications } = useNotificationsStore();
async function submit() { async function submit() {
if (addTeamMemberForm.role === null || addTeamMemberForm.email === '') {
errors.value.email = z
.string()
.email()
.safeParse(addTeamMemberForm.email).success
? ''
: 'Please enter a valid email address';
errors.value.role =
addTeamMemberForm.role === null ? 'Please select a role' : '';
return;
}
const organizationId = getCurrentOrganizationId(); const organizationId = getCurrentOrganizationId();
if (organizationId) { if (organizationId) {
addTeamMemberForm.post(route('team-members.store', organizationId), { await handleApiRequestNotifications(
errorBag: 'addTeamMember', () =>
preserveScroll: true, api.invite(
onSuccess: () => { {
email: addTeamMemberForm.email,
role: addTeamMemberForm.role as MemberRole,
},
{
params: {
organization: organizationId,
},
}
),
'Member invited',
'Failed to invite member',
() => {
addTeamMemberForm.reset(); addTeamMemberForm.reset();
emit('close'); emit('close');
show.value = false; show.value = false;
}, }
}); );
} }
} }
@@ -54,7 +91,34 @@ useFocus(clientNameInput, { initialValue: true });
</template> </template>
<template #content> <template #content>
<div class="space-y-4"> <div v-if="isBillingActivated() && !hasActiveSubscription()">
<div
class="rounded-full flex items-center justify-center w-20 h-20 mx-auto border border-border-tertiary bg-secondary">
<UserGroupIcon class="w-12"></UserGroupIcon>
</div>
<div class="max-w-sm text-center mx-auto py-4 text-base">
<p class="py-1">
The Free plan is <strong>limited to one member</strong>
</p>
<p class="py-1">
To add new team members to your organization you,
<strong>please upgrade to a paid plan</strong>.
</p>
<Link href="/billing">
<PrimaryButton
type="button"
class="mt-6"
v-if="
isBillingActivated() && canUpdateOrganization()
">
<CreditCardIcon class="w-5 h-5 me-2" />
Go to Billing
</PrimaryButton>
</Link>
</div>
</div>
<div v-else class="space-y-4">
<div class="col-span-6 sm:col-span-4 flex-1"> <div class="col-span-6 sm:col-span-4 flex-1">
<InputLabel for="email" value="Email" /> <InputLabel for="email" value="Email" />
<TextInput <TextInput
@@ -68,16 +132,12 @@ useFocus(clientNameInput, { initialValue: true });
class="mt-1 block w-full" class="mt-1 block w-full"
required required
autocomplete="memberName" /> autocomplete="memberName" />
<InputError <InputError :message="errors.email" class="mt-2" />
:message="addTeamMemberForm.errors.email"
class="mt-2" />
</div> </div>
<div v-if="availableRoles.length > 0"> <div v-if="availableRoles.length > 0">
<InputLabel for="roles" value="Role" /> <InputLabel for="roles" value="Role" />
<InputError <InputError :message="errors.role" class="mt-2" />
:message="addTeamMemberForm.errors.role"
class="mt-2" />
<div <div
class="relative z-0 mt-1 border border-card-border rounded-lg cursor-pointer"> class="relative z-0 mt-1 border border-card-border rounded-lg cursor-pointer">
@@ -140,8 +200,8 @@ useFocus(clientNameInput, { initialValue: true });
</template> </template>
<template #footer> <template #footer>
<SecondaryButton @click="show = false"> Cancel</SecondaryButton> <SecondaryButton @click="show = false"> Cancel</SecondaryButton>
<PrimaryButton <PrimaryButton
v-if="!isBillingActivated() || hasActiveSubscription()"
class="ms-3" class="ms-3"
:class="{ 'opacity-25': saving }" :class="{ 'opacity-25': saving }"
:disabled="saving" :disabled="saving"

View File

@@ -14,22 +14,26 @@ const props = defineProps<{
</script> </script>
<template> <template>
<Dropdown align="bottom-end"> <Dropdown
v-if="canUpdateMembers() || canDeleteMembers()"
align="bottom-end">
<template #trigger> <template #trigger>
<svg <button
data-testid="client_actions" class="focus-visible:outline-none focus-visible:bg-card-background rounded-full focus-visible:ring-1 focus-visible:ring-input-border-active focus-visible:opacity-100 hover:bg-card-background group-hover:opacity-100 opacity-20 transition-opacity"
:aria-label="'Actions for Member ' + props.member.name" :aria-label="'Actions for Member ' + props.member.name">
class="h-10 w-10 p-2 rounded-full hover:bg-card-background opacity-20 group-hover:opacity-100 transition" <svg
viewBox="0 0 24 24" class="h-10 w-10 p-2 rounded-full"
xmlns="http://www.w3.org/2000/svg"> viewBox="0 0 24 24"
<path xmlns="http://www.w3.org/2000/svg">
fill="none" <path
stroke="currentColor" fill="none"
stroke-linecap="round" stroke="currentColor"
stroke-linejoin="round" stroke-linecap="round"
stroke-width="1.5" stroke-linejoin="round"
d="M12 5.92A.96.96 0 1 0 12 4a.96.96 0 0 0 0 1.92m0 7.04a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92M12 20a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92" /> stroke-width="1.5"
</svg> d="M12 5.92A.96.96 0 1 0 12 4a.96.96 0 0 0 0 1.92m0 7.04a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92M12 20a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92" />
</svg>
</button>
</template> </template>
<template #content> <template #content>
<div class="min-w-[150px]"> <div class="min-w-[150px]">

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
import SecondaryButton from '@/Components/SecondaryButton.vue';
import DialogModal from '@/Components/DialogModal.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
const show = defineModel('show', { default: false });
const saving = defineModel('saving', { default: false });
defineProps<{
memberName: string;
}>();
const emit = defineEmits<{
submit: [];
}>();
</script>
<template>
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex justify-center">
<span> Confirm Ownership Transfer </span>
</div>
</template>
<template #content>
<div class="flex items-center space-x-4">
<div class="col-span-6 sm:col-span-4 flex-1">
<p class="py-1 text-center">
You are about to transfer the ownership of this
organization to {{ memberName }}.
</p>
</div>
</div>
</template>
<template #footer>
<SecondaryButton @click="show = false"> Cancel</SecondaryButton>
<PrimaryButton
class="ms-3"
@click="emit('submit')"
:class="{ 'opacity-25': saving }"
:disabled="saving">
Confirm Transfer
</PrimaryButton>
</template>
</DialogModal>
</template>
<style scoped></style>

View File

@@ -0,0 +1,52 @@
<script setup lang="ts">
import SelectDropdown from '@/Components/Common/SelectDropdown.vue';
import Badge from '@/Components/Common/Badge.vue';
import { ChevronDownIcon } from '@heroicons/vue/20/solid';
import type { Role } from '@/types/jetstream';
import { usePage } from '@inertiajs/vue3';
const model = defineModel<string>({
default: 'employee',
});
const page = usePage<{
availableRoles: Role[];
}>();
function getKeyFromItem(item: Role) {
return item.key;
}
function getNameFromItem(item: Role) {
return item.name;
}
function getNameForKey(key: string | undefined) {
const item = page.props.availableRoles.find(
(item) => getKeyFromItem(item) === key
);
if (item) {
return getNameFromItem(item);
}
return '';
}
</script>
<template>
<SelectDropdown
v-model="model"
:get-key-from-item="getKeyFromItem"
:get-name-for-item="getNameFromItem"
:items="page.props.availableRoles">
<template #trigger>
<Badge size="xlarge" class="bg-input-background cursor-pointer">
<span>
{{ getNameForKey(model) }}
</span>
<ChevronDownIcon class="text-muted w-5"></ChevronDownIcon>
</Badge>
</template>
</SelectDropdown>
</template>
<style scoped></style>

View File

@@ -49,7 +49,7 @@ async function invitePlaceholder(id: string) {
<template> <template>
<TableRow> <TableRow>
<div <div
class="whitespace-nowrap flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-white pl-4 sm:pl-6 lg:pl-8 3xl:pl-12"> class="whitespace-nowrap flex items-center space-x-5 py-4 pr-3 text-sm font-medium text-white pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<span> <span>
{{ member.name }} {{ member.name }}
</span> </span>

View File

@@ -0,0 +1,37 @@
<script setup lang="ts">
import { formatCents } from '../../../utils/money';
import BillableRateModal from '@/Components/Common/BillableRateModal.vue';
const show = defineModel('show', { default: false });
const saving = defineModel('saving', { default: false });
defineProps<{
newBillableRate?: number | null;
}>();
defineEmits<{
submit: [];
}>();
</script>
<template>
<BillableRateModal
@submit="$emit('submit')"
v-model:show="show"
v-model:saving="saving"
title="Update Organization Billable Rate">
<p class="py-0.5 text-center">
The organization billable rate will be updated to
<strong>{{
newBillableRate ? formatCents(newBillableRate) : ' none.'
}}</strong
>.
</p>
<p class="py-0.5 text-center font-semibold">
Do you want to update all existing time entries, where the
organization billable rate applies as well?
</p>
</BillableRateModal>
</template>
<style scoped></style>

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import { formatCents } from '../../../utils/money';
import BillableRateModal from '@/Components/Common/BillableRateModal.vue';
const show = defineModel('show', { default: false });
const saving = defineModel('saving', { default: false });
defineProps<{
newBillableRate?: number | null;
projectName: string;
}>();
defineEmits<{
submit: [];
}>();
</script>
<template>
<BillableRateModal
@submit="$emit('submit')"
v-model:show="show"
v-model:saving="saving"
title="Update Project Billable Rate">
<p class="py-1 text-center">
The billable rate of {{ projectName }} will be updated to
<strong>{{
newBillableRate
? formatCents(newBillableRate)
: ' the default rate of the organization member'
}}</strong
>.
</p>
<p class="py-1 text-center font-semibold max-w-md mx-auto">
Do you want to update all existing time entries, where the project
billable rate applies as well?
</p>
</BillableRateModal>
</template>
<style scoped></style>

View File

@@ -15,7 +15,7 @@ const model = defineModel<string>({ default: '' });
:style="{ :style="{
backgroundColor: model, backgroundColor: model,
}" }"
class="w-5 h-5 rounded-full cursor-pointer"></div> class="w-6 h-6 rounded-full cursor-pointer"></div>
</button> </button>
</template> </template>
<template #content> <template #content>

View File

@@ -36,7 +36,7 @@ billableRateSelect.value = 'non-billable';
const billableOptionInfoTexts: { [key in BillableKey]: string } = { const billableOptionInfoTexts: { [key in BillableKey]: string } = {
'non-billable': 'non-billable':
'New time entries for this project not be marked billable by default.', 'New time entries for this project will not be marked billable by default.',
'default-rate': 'default-rate':
'New time entries for this project will be billable at the default rate by default.', 'New time entries for this project will be billable at the default rate by default.',
'custom-rate': 'custom-rate':

View File

@@ -15,12 +15,13 @@ import ProjectColorSelector from '@/Components/Common/Project/ProjectColorSelect
import ProjectEditBillableSection from '@/Components/Common/Project/ProjectEditBillableSection.vue'; import ProjectEditBillableSection from '@/Components/Common/Project/ProjectEditBillableSection.vue';
import { UserCircleIcon } from '@heroicons/vue/20/solid'; import { UserCircleIcon } from '@heroicons/vue/20/solid';
import InputLabel from '@/Components/InputLabel.vue'; import InputLabel from '@/Components/InputLabel.vue';
import ProjectBillableRateModal from '@/Components/Common/Project/ProjectBillableRateModal.vue';
const { updateProject } = useProjectsStore(); const { updateProject } = useProjectsStore();
const { clients } = storeToRefs(useClientsStore()); const { clients } = storeToRefs(useClientsStore());
const show = defineModel('show', { default: false }); const show = defineModel('show', { default: false });
const saving = ref(false); const saving = ref(false);
const showBillableRateModal = ref(false);
const props = defineProps<{ const props = defineProps<{
originalProject: Project; originalProject: Project;
}>(); }>();
@@ -34,6 +35,10 @@ const project = ref<CreateProjectBody>({
}); });
async function submit() { async function submit() {
if (props.originalProject.billable_rate !== project.value.billable_rate) {
showBillableRateModal.value = true;
return;
}
await updateProject(props.originalProject.id, project.value); await updateProject(props.originalProject.id, project.value);
show.value = false; show.value = false;
} }
@@ -50,6 +55,12 @@ const currentClientName = computed(() => {
} }
return 'No Client'; return 'No Client';
}); });
async function submitBillableRate() {
await updateProject(props.originalProject.id, project.value);
show.value = false;
showBillableRateModal.value = false;
}
</script> </script>
<template> <template>
@@ -62,11 +73,12 @@ const currentClientName = computed(() => {
<template #content> <template #content>
<div <div
class="sm:flex items-center space-y-2 sm:space-y-0 sm:space-x-4"> class="sm:flex items-center space-y-2 sm:space-y-0 sm:space-x-5">
<div class="flex-1 flex items-center"> <div class="flex-1 flex items-center">
<div class="text-center pr-5"> <div class="text-center">
<InputLabel for="color" value="Color" /> <InputLabel for="color" value="Color" />
<ProjectColorSelector <ProjectColorSelector
class="mt-1"
v-model="project.color"></ProjectColorSelector> v-model="project.color"></ProjectColorSelector>
</div> </div>
</div> </div>
@@ -85,7 +97,7 @@ const currentClientName = computed(() => {
</div> </div>
<div class=""> <div class="">
<InputLabel for="client" value="Client" /> <InputLabel for="client" value="Client" />
<ClientDropdown class="mt-2" v-model="project.client_id"> <ClientDropdown class="mt-1" v-model="project.client_id">
<template #trigger> <template #trigger>
<Badge <Badge
class="bg-input-background cursor-pointer hover:bg-tertiary" class="bg-input-background cursor-pointer hover:bg-tertiary"
@@ -93,7 +105,7 @@ const currentClientName = computed(() => {
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<UserCircleIcon <UserCircleIcon
class="w-5 text-icon-default"></UserCircleIcon> class="w-5 text-icon-default"></UserCircleIcon>
<span> <span class="whitespace-nowrap">
{{ currentClientName }} {{ currentClientName }}
</span> </span>
</div> </div>
@@ -121,6 +133,11 @@ const currentClientName = computed(() => {
</PrimaryButton> </PrimaryButton>
</template> </template>
</DialogModal> </DialogModal>
<ProjectBillableRateModal
v-model:show="showBillableRateModal"
@submit="submitBillableRate"
:new-billable-rate="project.billable_rate"
:project-name="project.name"></ProjectBillableRateModal>
</template> </template>
<style scoped></style> <style scoped></style>

View File

@@ -1,11 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import Dropdown from '@/Components/Dropdown.vue'; import Dropdown from '@/Components/Dropdown.vue';
import { TrashIcon, PencilSquareIcon } from '@heroicons/vue/20/solid'; import {
TrashIcon,
PencilSquareIcon,
ArchiveBoxIcon,
} from '@heroicons/vue/20/solid';
import type { Project } from '@/utils/api'; import type { Project } from '@/utils/api';
import { canDeleteProjects, canUpdateProjects } from '@/utils/permissions'; import { canDeleteProjects, canUpdateProjects } from '@/utils/permissions';
const emit = defineEmits<{ const emit = defineEmits<{
delete: []; delete: [];
edit: []; edit: [];
archive: [];
}>(); }>();
const props = defineProps<{ const props = defineProps<{
project: Project; project: Project;
@@ -15,20 +20,23 @@ const props = defineProps<{
<template> <template>
<Dropdown> <Dropdown>
<template #trigger> <template #trigger>
<svg <button
class="focus-visible:outline-none focus-visible:bg-card-background rounded-full focus-visible:ring-1 focus-visible:ring-input-border-active focus-visible:opacity-100 hover:bg-card-background group-hover:opacity-100 opacity-20 transition-opacity"
data-testid="project_actions" data-testid="project_actions"
:aria-label="'Actions for Project ' + props.project.name" :aria-label="'Actions for Project ' + props.project.name">
class="h-10 w-10 p-2 rounded-full hover:bg-card-background opacity-20 group-hover:opacity-100 transition" <svg
viewBox="0 0 24 24" class="h-10 w-10 p-2 rounded-full"
xmlns="http://www.w3.org/2000/svg"> viewBox="0 0 24 24"
<path xmlns="http://www.w3.org/2000/svg">
fill="none" <path
stroke="currentColor" fill="none"
stroke-linecap="round" stroke="currentColor"
stroke-linejoin="round" stroke-linecap="round"
stroke-width="1.5" stroke-linejoin="round"
d="M12 5.92A.96.96 0 1 0 12 4a.96.96 0 0 0 0 1.92m0 7.04a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92M12 20a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92" /> stroke-width="1.5"
</svg> d="M12 5.92A.96.96 0 1 0 12 4a.96.96 0 0 0 0 1.92m0 7.04a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92M12 20a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92" />
</svg>
</button>
</template> </template>
<template #content> <template #content>
<div class="min-w-[150px]"> <div class="min-w-[150px]">
@@ -42,6 +50,17 @@ const props = defineProps<{
class="w-5 text-icon-active"></PencilSquareIcon> class="w-5 text-icon-active"></PencilSquareIcon>
<span>Edit</span> <span>Edit</span>
</button> </button>
<button
@click.prevent="emit('archive')"
v-if="canUpdateProjects()"
:aria-label="'Archive Project ' + props.project.name"
class="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">
<ArchiveBoxIcon
class="w-5 text-icon-active"></ArchiveBoxIcon>
<span>{{
project.is_archived ? 'Unarchive' : 'Archive'
}}</span>
</button>
<button <button
@click.prevent="emit('delete')" @click.prevent="emit('delete')"
:aria-label="'Delete Project ' + props.project.name" :aria-label="'Delete Project ' + props.project.name"

View File

@@ -1,16 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { useProjectsStore } from '@/utils/useProjects';
import SecondaryButton from '@/Components/SecondaryButton.vue'; import SecondaryButton from '@/Components/SecondaryButton.vue';
import { FolderPlusIcon } from '@heroicons/vue/24/solid'; import { FolderPlusIcon } from '@heroicons/vue/24/solid';
import { PlusIcon } from '@heroicons/vue/16/solid'; import { PlusIcon } from '@heroicons/vue/16/solid';
import { ref } from 'vue'; import { ref } from 'vue';
import ProjectCreateModal from '@/Components/Common/Project/ProjectCreateModal.vue'; import ProjectCreateModal from '@/Components/Common/Project/ProjectCreateModal.vue';
import { storeToRefs } from 'pinia';
import ProjectTableHeading from '@/Components/Common/Project/ProjectTableHeading.vue'; import ProjectTableHeading from '@/Components/Common/Project/ProjectTableHeading.vue';
import ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.vue'; import ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.vue';
import { canCreateProjects } from '@/utils/permissions'; import { canCreateProjects } from '@/utils/permissions';
import type { Project } from '@/utils/api';
const { projects } = storeToRefs(useProjectsStore()); defineProps<{
projects: Project[];
}>();
const createProject = ref(false); const createProject = ref(false);
</script> </script>

Some files were not shown because too many files have changed in this diff Show More