mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-14 19:22:14 +01:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee2f125062 | ||
|
|
fd8d596e9b | ||
|
|
555417dbbd | ||
|
|
7aab3d98fc | ||
|
|
1dc35f1f55 | ||
|
|
be50397775 | ||
|
|
e3b4cfd881 | ||
|
|
7fd5d25781 | ||
|
|
4c2748ff50 | ||
|
|
c69701aa66 | ||
|
|
c194785034 | ||
|
|
53e5805937 | ||
|
|
a8d82d0d2c | ||
|
|
8f0be6efce | ||
|
|
6593a8c24f | ||
|
|
0f32e42002 | ||
|
|
8ddce667cc | ||
|
|
726c2ee623 | ||
|
|
7decb095ee | ||
|
|
442da936d0 | ||
|
|
3a17ae83ae | ||
|
|
264b7c9b8d | ||
|
|
c3a7ef7585 | ||
|
|
de1accba4a | ||
|
|
364168debd | ||
|
|
75e739f6fb | ||
|
|
a69d1cb4c4 | ||
|
|
f21a2d4bdd | ||
|
|
512089ccbd | ||
|
|
313cee2db0 | ||
|
|
2184b3c835 | ||
|
|
7c26cee1ea | ||
|
|
ce82dddc6a | ||
|
|
099926f95c |
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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.')
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
10
app/Exceptions/Api/ChangingRoleToPlaceholderIsNotAllowed.php
Normal file
10
app/Exceptions/Api/ChangingRoleToPlaceholderIsNotAllowed.php
Normal 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';
|
||||||
|
}
|
||||||
10
app/Exceptions/Api/OnlyOwnerCanChangeOwnership.php
Normal file
10
app/Exceptions/Api/OnlyOwnerCanChangeOwnership.php
Normal 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';
|
||||||
|
}
|
||||||
10
app/Exceptions/Api/OrganizationNeedsAtLeastOneOwner.php
Normal file
10
app/Exceptions/Api/OrganizationNeedsAtLeastOneOwner.php
Normal 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';
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
15
app/Exceptions/MovedToApiException.php
Normal file
15
app/Exceptions/MovedToApiException.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'),
|
||||||
],
|
],
|
||||||
|
|||||||
35
app/Http/Requests/V1/Client/ClientIndexRequest.php
Normal file
35
app/Http/Requests/V1/Client/ClientIndexRequest.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
39
app/Http/Requests/V1/Client/ClientStoreRequest.php
Normal file
39
app/Http/Requests/V1/Client/ClientStoreRequest.php
Normal 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'),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/Http/Requests/V1/Client/ClientUpdateRequest.php
Normal file
51
app/Http/Requests/V1/Client/ClientUpdateRequest.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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'),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
40
app/Mail/OrganizationInvitationMail.php
Normal file
40
app/Mail/OrganizationInvitationMail.php
Normal 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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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']),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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']),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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']),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.');
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
15
app/Service/BillingContract.php
Normal file
15
app/Service/BillingContract.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
43
app/Service/InvitationService.php
Normal file
43
app/Service/InvitationService.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
23
app/Service/IpLookup/IpLookupResponseDto.php
Normal file
23
app/Service/IpLookup/IpLookupResponseDto.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
app/Service/IpLookup/IpLookupServiceContract.php
Normal file
10
app/Service/IpLookup/IpLookupServiceContract.php
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\IpLookup;
|
||||||
|
|
||||||
|
interface IpLookupServiceContract
|
||||||
|
{
|
||||||
|
public function lookup(string $ip): ?IpLookupResponseDto;
|
||||||
|
}
|
||||||
13
app/Service/IpLookup/NoIpLookupService.php
Normal file
13
app/Service/IpLookup/NoIpLookupService.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
61
app/Service/MemberService.php
Normal file
61
app/Service/MemberService.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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' => [
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) => [
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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.'
|
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
63
e2e/project-members.spec.ts
Normal file
63
e2e/project-members.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 `time-entries:view:own` 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',
|
||||||
|
|||||||
10
phpunit.xml
10
phpunit.xml
@@ -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"/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
58
resources/js/Components/Common/BillableRateModal.vue
Normal file
58
resources/js/Components/Common/BillableRateModal.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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]">
|
||||||
|
|||||||
@@ -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>
|
||||||
52
resources/js/Components/Common/Member/MemberRoleSelect.vue
Normal file
52
resources/js/Components/Common/Member/MemberRoleSelect.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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':
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user