mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-05-07 20:32:26 +00:00
Added billable rates; Added project members; Added visibility to projects
This commit is contained in:
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Enums\Weekday;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Service\TimezoneService;
|
||||
@@ -60,6 +61,7 @@ class CreateNewUser implements CreatesNewUsers
|
||||
'email' => $input['email'],
|
||||
'password' => Hash::make($input['password']),
|
||||
'timezone' => $timezone,
|
||||
'week_start' => Weekday::Monday,
|
||||
]), function (User $user) {
|
||||
$this->createTeam($user);
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Actions\Jetstream;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Rules\CurrencyRule;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
@@ -27,11 +28,21 @@ class UpdateOrganization implements UpdatesTeamNames
|
||||
Gate::forUser($user)->authorize('update', $organization);
|
||||
|
||||
Validator::make($input, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'name' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
],
|
||||
'currency' => [
|
||||
'required',
|
||||
'string',
|
||||
new CurrencyRule(),
|
||||
],
|
||||
])->validateWithBag('updateTeamName');
|
||||
|
||||
$organization->forceFill([
|
||||
'name' => $input['name'],
|
||||
'currency' => $input['currency'],
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,5 @@ class Kernel extends ConsoleKernel
|
||||
protected function commands(): void
|
||||
{
|
||||
$this->load(__DIR__.'/Commands');
|
||||
|
||||
require base_path('routes/console.php');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Api;
|
||||
|
||||
class TimeEntryCanNotBeRestartedApiException extends ApiException
|
||||
{
|
||||
public const string KEY = 'time_entry_can_not_be_restarted';
|
||||
}
|
||||
@@ -5,18 +5,29 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Service\PermissionStore;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class Controller extends \App\Http\Controllers\Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected PermissionStore $permissionStore,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
protected function checkPermission(Organization $organization, string $permission): void
|
||||
{
|
||||
if (! Auth::user()->hasTeamPermission($organization, $permission)) {
|
||||
if (! $this->permissionStore->has($organization, $permission)) {
|
||||
throw new AuthorizationException();
|
||||
}
|
||||
}
|
||||
|
||||
protected function hasPermission(Organization $organization, string $permission): bool
|
||||
{
|
||||
return $this->permissionStore->has($organization, $permission);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Exceptions\Api\UserNotPlaceholderApiException;
|
||||
use App\Http\Requests\V1\User\UserIndexRequest;
|
||||
use App\Http\Requests\V1\Member\MemberIndexRequest;
|
||||
use App\Http\Resources\V1\User\MemberCollection;
|
||||
use App\Http\Resources\V1\User\MemberResource;
|
||||
use App\Models\Organization;
|
||||
@@ -24,14 +24,14 @@ class MemberController extends Controller
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function index(Organization $organization, UserIndexRequest $request): MemberCollection
|
||||
public function index(Organization $organization, MemberIndexRequest $request): MemberCollection
|
||||
{
|
||||
$this->checkPermission($organization, 'users:view');
|
||||
$this->checkPermission($organization, 'members:view');
|
||||
|
||||
$users = $organization->users()
|
||||
$members = $organization->users()
|
||||
->paginate();
|
||||
|
||||
return MemberCollection::make($users);
|
||||
return MemberCollection::make($members);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,7 +41,7 @@ class MemberController extends Controller
|
||||
*/
|
||||
public function invitePlaceholder(Organization $organization, User $user, Request $request): JsonResponse
|
||||
{
|
||||
$this->checkPermission($organization, 'users:invite-placeholder');
|
||||
$this->checkPermission($organization, 'members:invite-placeholder');
|
||||
|
||||
if (! $user->is_placeholder) {
|
||||
throw new UserNotPlaceholderApiException();
|
||||
|
||||
@@ -33,6 +33,7 @@ class OrganizationController extends Controller
|
||||
$this->checkPermission($organization, 'organizations:update');
|
||||
|
||||
$organization->name = $request->input('name');
|
||||
$organization->billable_rate = $request->input('billable_rate');
|
||||
$organization->save();
|
||||
|
||||
return new OrganizationResource($organization);
|
||||
|
||||
@@ -10,9 +10,12 @@ use App\Http\Resources\V1\Project\ProjectCollection;
|
||||
use App\Http\Resources\V1\Project\ProjectResource;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class ProjectController extends Controller
|
||||
{
|
||||
@@ -36,9 +39,23 @@ class ProjectController extends Controller
|
||||
public function index(Organization $organization): ProjectCollection
|
||||
{
|
||||
$this->checkPermission($organization, 'projects:view');
|
||||
$projects = Project::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->paginate();
|
||||
$canViewAllProjects = $this->hasPermission($organization, 'projects:view:all');
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
$projectsQuery = Project::query()
|
||||
->whereBelongsTo($organization, 'organization');
|
||||
|
||||
if (! $canViewAllProjects) {
|
||||
$projectsQuery->where(function (Builder $builder) use ($user): Builder {
|
||||
return $builder->where('is_public', '=', true)
|
||||
->orWhereHas('members', function (Builder $builder) use ($user): Builder {
|
||||
return $builder->whereBelongsTo($user, 'user');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
$projects = $projectsQuery->paginate();
|
||||
|
||||
return new ProjectCollection($projects);
|
||||
}
|
||||
@@ -72,6 +89,7 @@ class ProjectController extends Controller
|
||||
$project = new Project();
|
||||
$project->name = $request->input('name');
|
||||
$project->color = $request->input('color');
|
||||
$project->billable_rate = $request->input('billable_rate');
|
||||
$project->client_id = $request->input('client_id');
|
||||
$project->organization()->associate($organization);
|
||||
$project->save();
|
||||
@@ -91,7 +109,8 @@ class ProjectController extends Controller
|
||||
$this->checkPermission($organization, 'projects:update', $project);
|
||||
$project->name = $request->input('name');
|
||||
$project->color = $request->input('color');
|
||||
$project->client_id = $request->input('project_id');
|
||||
$project->billable_rate = $request->input('billable_rate');
|
||||
$project->client_id = $request->input('client_id');
|
||||
$project->save();
|
||||
|
||||
return new ProjectResource($project);
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Requests\V1\ProjectMember\ProjectMemberStoreRequest;
|
||||
use App\Http\Requests\V1\ProjectMember\ProjectMemberUpdateRequest;
|
||||
use App\Http\Resources\V1\ProjectMember\ProjectMemberCollection;
|
||||
use App\Http\Resources\V1\ProjectMember\ProjectMemberResource;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectMember;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class ProjectMemberController extends Controller
|
||||
{
|
||||
protected function checkPermission(Organization $organization, string $permission, ?Project $project = null, ?ProjectMember $projectMember = null): void
|
||||
{
|
||||
parent::checkPermission($organization, $permission);
|
||||
if ($project !== null && $project->organization_id !== $organization->id) {
|
||||
throw new AuthorizationException('Project does not belong to organization');
|
||||
}
|
||||
if ($projectMember !== null && $projectMember->project->organization_id !== $organization->id) {
|
||||
throw new AuthorizationException('Project member does not belong to organization');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get project members for project
|
||||
*
|
||||
* @return ProjectMemberCollection<ProjectMemberResource>
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId getProjectMembers
|
||||
*/
|
||||
public function index(Organization $organization, Project $project): ProjectMemberCollection
|
||||
{
|
||||
$this->checkPermission($organization, 'project-members:view', $project);
|
||||
|
||||
$projectMembers = ProjectMember::query()
|
||||
->whereBelongsTo($project, 'project')
|
||||
->paginate();
|
||||
|
||||
return new ProjectMemberCollection($projectMembers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add project member to project
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId createProjectMember
|
||||
*/
|
||||
public function store(Organization $organization, Project $project, ProjectMemberStoreRequest $request): JsonResource
|
||||
{
|
||||
$this->checkPermission($organization, 'project-members:create', $project);
|
||||
$projectMember = new ProjectMember();
|
||||
$projectMember->user_id = $request->input('user_id');
|
||||
$projectMember->billable_rate = $request->input('billable_rate');
|
||||
$projectMember->project()->associate($project);
|
||||
$projectMember->save();
|
||||
|
||||
return new ProjectMemberResource($projectMember);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update project member
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId updateProjectMember
|
||||
*/
|
||||
public function update(Organization $organization, ProjectMember $projectMember, ProjectMemberUpdateRequest $request): JsonResource
|
||||
{
|
||||
$this->checkPermission($organization, 'project-members:update', projectMember: $projectMember);
|
||||
$projectMember->billable_rate = $request->input('billable_rate');
|
||||
$projectMember->save();
|
||||
|
||||
return new ProjectMemberResource($projectMember);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete project member
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId deleteProjectMember
|
||||
*/
|
||||
public function destroy(Organization $organization, ProjectMember $projectMember): JsonResponse
|
||||
{
|
||||
$this->checkPermission($organization, 'project-members:delete', projectMember: $projectMember);
|
||||
|
||||
$projectMember->delete();
|
||||
|
||||
return response()
|
||||
->json(null, 204);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
||||
use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
||||
use App\Http\Requests\V1\TimeEntry\TimeEntryIndexRequest;
|
||||
use App\Http\Requests\V1\TimeEntry\TimeEntryStoreRequest;
|
||||
@@ -71,6 +72,7 @@ class TimeEntryController extends Controller
|
||||
$timeEntries = $timeEntriesQuery->get();
|
||||
|
||||
if ($timeEntries->count() === $limit && $request->has('only_full_dates') && (bool) $request->get('only_full_dates') === true) {
|
||||
// TODO: handle user timezone!
|
||||
$lastDate = null;
|
||||
/** @var TimeEntry $timeEntry */
|
||||
foreach ($timeEntries as $timeEntry) {
|
||||
@@ -125,6 +127,7 @@ class TimeEntryController extends Controller
|
||||
$timeEntry->fill($request->validated());
|
||||
$timeEntry->description = $request->get('description') ?? '';
|
||||
$timeEntry->organization()->associate($organization);
|
||||
$timeEntry->setComputedAttributeValue('billable_rate');
|
||||
$timeEntry->save();
|
||||
|
||||
return new TimeEntryResource($timeEntry);
|
||||
@@ -133,7 +136,7 @@ class TimeEntryController extends Controller
|
||||
/**
|
||||
* Update time entry
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
* @throws AuthorizationException|TimeEntryCanNotBeRestartedApiException
|
||||
*
|
||||
* @operationId updateTimeEntry
|
||||
*/
|
||||
@@ -145,7 +148,9 @@ class TimeEntryController extends Controller
|
||||
$this->checkPermission($organization, 'time-entries:update:all', $timeEntry);
|
||||
}
|
||||
|
||||
// TODO: TimeEntryStillRunningApiException
|
||||
if ($timeEntry->end !== null && $request->has('end') && $request->get('end') === null) {
|
||||
throw new TimeEntryCanNotBeRestartedApiException();
|
||||
}
|
||||
|
||||
$timeEntry->fill($request->validated());
|
||||
$timeEntry->description = $request->get('description', $timeEntry->description) ?? '';
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Service\DashboardService;
|
||||
use Illuminate\Support\Str;
|
||||
@@ -16,27 +17,17 @@ class DashboardController extends Controller
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = auth()->user();
|
||||
$dailyTrackedHours = $dashboardService->getDailyTrackedHours($user, 60);
|
||||
$weeklyHistory = $dashboardService->getWeeklyHistory($user);
|
||||
/** @var Organization $organization */
|
||||
$organization = $user->currentTeam;
|
||||
$dailyTrackedHours = $dashboardService->getDailyTrackedHours($user, $organization, 60);
|
||||
$weeklyHistory = $dashboardService->getWeeklyHistory($user, $organization);
|
||||
$totalWeeklyTime = $dashboardService->totalWeeklyTime($user, $organization);
|
||||
$totalWeeklyBillableTime = $dashboardService->totalWeeklyBillableTime($user, $organization);
|
||||
$totalWeeklyBillableAmount = $dashboardService->totalWeeklyBillableAmount($user, $organization);
|
||||
$weeklyProjectOverview = $dashboardService->weeklyProjectOverview($user, $organization);
|
||||
|
||||
return Inertia::render('Dashboard', [
|
||||
'weeklyProjectOverview' => [
|
||||
[
|
||||
'value' => 120,
|
||||
'name' => 'Project 11',
|
||||
'color' => '#26a69a',
|
||||
],
|
||||
[
|
||||
'value' => 200,
|
||||
'name' => 'Project 2',
|
||||
'color' => '#d4e157',
|
||||
],
|
||||
[
|
||||
'value' => 150,
|
||||
'name' => 'Project 3',
|
||||
'color' => '#ff7043',
|
||||
],
|
||||
],
|
||||
'weeklyProjectOverview' => $weeklyProjectOverview,
|
||||
'latestTasks' => [
|
||||
// the 4 tasks with the most recent time entries
|
||||
[
|
||||
@@ -210,12 +201,9 @@ class DashboardController extends Controller
|
||||
],
|
||||
],
|
||||
'dailyTrackedHours' => $dailyTrackedHours,
|
||||
'totalWeeklyTime' => 400,
|
||||
'totalWeeklyBillableTime' => 300,
|
||||
'totalWeeklyBillableAmount' => [
|
||||
'value' => 300.5,
|
||||
'currency' => 'USD',
|
||||
],
|
||||
'totalWeeklyTime' => $totalWeeklyTime,
|
||||
'totalWeeklyBillableTime' => $totalWeeklyBillableTime,
|
||||
'totalWeeklyBillableAmount' => $totalWeeklyBillableAmount,
|
||||
'weeklyHistory' => $weeklyHistory,
|
||||
]);
|
||||
}
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\User;
|
||||
namespace App\Http\Requests\V1\Member;
|
||||
|
||||
use App\Models\Organization;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
@@ -11,7 +11,7 @@ use Illuminate\Foundation\Http\FormRequest;
|
||||
/**
|
||||
* @property Organization $organization
|
||||
*/
|
||||
class UserIndexRequest extends FormRequest
|
||||
class MemberIndexRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
@@ -26,6 +26,11 @@ class OrganizationUpdateRequest extends FormRequest
|
||||
'string',
|
||||
'max:255',
|
||||
],
|
||||
'billable_rate' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
'min:0',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,11 @@ class ProjectStoreRequest extends FormRequest
|
||||
'max:255',
|
||||
new ColorRule(),
|
||||
],
|
||||
'billable_rate' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
'min:0',
|
||||
],
|
||||
'client_id' => [
|
||||
'nullable',
|
||||
new ExistsEloquent(Client::class, null, function (Builder $builder): Builder {
|
||||
|
||||
@@ -37,6 +37,11 @@ class ProjectUpdateRequest extends FormRequest
|
||||
'max:255',
|
||||
new ColorRule(),
|
||||
],
|
||||
'billable_rate' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
'min:0',
|
||||
],
|
||||
'client_id' => [
|
||||
'nullable',
|
||||
new ExistsEloquent(Client::class, null, function (Builder $builder): Builder {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\ProjectMember;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||
|
||||
/**
|
||||
* @property Organization $organization Organization from model binding
|
||||
*/
|
||||
class ProjectMemberStoreRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, array<string|ValidationRule>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'user_id' => [
|
||||
'required',
|
||||
'uuid',
|
||||
new ExistsEloquent(User::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<User> $builder */
|
||||
return $builder->belongsToOrganization($this->organization);
|
||||
}),
|
||||
],
|
||||
'billable_rate' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
'min:0',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\ProjectMember;
|
||||
|
||||
use App\Models\Organization;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* @property Organization $organization Organization from model binding
|
||||
*/
|
||||
class ProjectMemberUpdateRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, array<string|ValidationRule>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'billable_rate' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
'min:0',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,8 @@ declare(strict_types=1);
|
||||
namespace App\Http\Requests\V1\Task;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||
|
||||
/**
|
||||
* @property Organization $organization Organization from model binding
|
||||
@@ -31,12 +28,6 @@ class TaskUpdateRequest extends FormRequest
|
||||
'min:1',
|
||||
'max:255',
|
||||
],
|
||||
'project_id' => [
|
||||
new ExistsEloquent(Project::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Project> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ class OrganizationResource extends BaseResource
|
||||
'name' => $this->resource->name,
|
||||
/** @var string $color Personal organizations automatically created after registration */
|
||||
'is_personal' => $this->resource->personal_team,
|
||||
/** @var int|null $billable_rate Billable rate in cents per hour */
|
||||
'billable_rate' => $this->resource->billable_rate,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ class ProjectResource extends BaseResource
|
||||
'color' => $this->resource->color,
|
||||
/** @var string|null $client_id ID of client */
|
||||
'client_id' => $this->resource->client_id,
|
||||
/** @var int|null $billable_rate Billable rate in cents per hour */
|
||||
'billable_rate' => $this->resource->billable_rate,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\ProjectMember;
|
||||
|
||||
use App\Http\Resources\PaginatedResourceCollection;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
class ProjectMemberCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||
{
|
||||
/**
|
||||
* The resource that this resource collects.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public $collects = ProjectMemberResource::class;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\ProjectMember;
|
||||
|
||||
use App\Http\Resources\V1\BaseResource;
|
||||
use App\Models\ProjectMember;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* @property ProjectMember $resource
|
||||
*/
|
||||
class ProjectMemberResource extends BaseResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, string|bool|int|null>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** @var string $id ID of project member */
|
||||
'id' => $this->resource->id,
|
||||
/** @var int|null $billable_rate Billable rate in cents per hour */
|
||||
'billable_rate' => $this->resource->billable_rate,
|
||||
/** @var string $user_id ID of the user */
|
||||
'user_id' => $this->resource->user_id,
|
||||
/** @var string $project_id ID of the project */
|
||||
'project_id' => $this->resource->project_id,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,8 @@ class MemberResource extends BaseResource
|
||||
'role' => $membership->role,
|
||||
/** @var bool $is_placeholder Placeholder user for imports, user might not really exist and does not know about this placeholder membership */
|
||||
'is_placeholder' => $this->resource->is_placeholder,
|
||||
/** @var int|null $billable_rate Billable rate in cents per hour */
|
||||
'billable_rate' => $membership->billable_rate,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ use Laravel\Jetstream\Membership as JetstreamMembership;
|
||||
/**
|
||||
* @property string $id
|
||||
* @property string $role
|
||||
* @property int|null $billable_rate
|
||||
* @property string $organization_id
|
||||
* @property string $user_id
|
||||
* @property string $created_at
|
||||
|
||||
@@ -20,6 +20,8 @@ use Laravel\Jetstream\Team as JetstreamTeam;
|
||||
* @property string $id
|
||||
* @property string $name
|
||||
* @property bool $personal_team
|
||||
* @property string $currency
|
||||
* @property int|null $billable_rate
|
||||
* @property User $owner
|
||||
* @property Collection<User> $users
|
||||
* @property Collection<string, User> $realUsers
|
||||
@@ -40,6 +42,7 @@ class Organization extends JetstreamTeam
|
||||
protected $casts = [
|
||||
'name' => 'string',
|
||||
'personal_team' => 'boolean',
|
||||
'currency' => 'string',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -63,6 +66,15 @@ class Organization extends JetstreamTeam
|
||||
'deleted' => TeamDeleted::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* The model's default values for attributes.
|
||||
*
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
protected $attributes = [
|
||||
'currency' => 'EUR',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get all the non-placeholder users of the organization including its owner.
|
||||
*
|
||||
@@ -88,7 +100,10 @@ class Organization extends JetstreamTeam
|
||||
public function users(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Jetstream::userModel(), Jetstream::membershipModel())
|
||||
->withPivot('role')
|
||||
->withPivot([
|
||||
'role',
|
||||
'billable_rate',
|
||||
])
|
||||
->withTimestamps()
|
||||
->as('membership');
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
* @property string $color
|
||||
* @property string $organization_id
|
||||
* @property string $client_id
|
||||
* @property int|null $billable_rate
|
||||
* @property-read Organization $organization
|
||||
* @property-read Client|null $client
|
||||
* @property-read Collection<Task> $tasks
|
||||
@@ -55,6 +56,14 @@ class Project extends Model
|
||||
return $this->belongsTo(Client::class, 'client_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<ProjectMember>
|
||||
*/
|
||||
public function members(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProjectMember::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<Task>
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\ProjectMemberFactory;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @property string $id
|
||||
* @property int|null $billable_rate
|
||||
* @property string $project_id
|
||||
* @property string $user_id
|
||||
* @property-read Project $project
|
||||
* @property-read User $user
|
||||
*
|
||||
* @method static ProjectMemberFactory factory()
|
||||
*/
|
||||
class ProjectMember extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasUuids;
|
||||
|
||||
/**
|
||||
* The attributes that should be cast.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
protected $casts = [
|
||||
'billable_rate' => 'int',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Project, ProjectMember>
|
||||
*/
|
||||
public function project(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Project::class, 'project_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, ProjectMember>
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Service\BillableRateService;
|
||||
use Carbon\CarbonInterval;
|
||||
use Database\Factories\TimeEntryFactory;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
@@ -11,12 +12,14 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Korridor\LaravelComputedAttributes\ComputedAttributes;
|
||||
|
||||
/**
|
||||
* @property string $id
|
||||
* @property string $description
|
||||
* @property Carbon $start
|
||||
* @property Carbon|null $end
|
||||
* @property int $billable_rate Billable rate per hour in cents
|
||||
* @property bool $billable
|
||||
* @property array $tags
|
||||
* @property string $user_id
|
||||
@@ -32,6 +35,7 @@ use Illuminate\Support\Carbon;
|
||||
*/
|
||||
class TimeEntry extends Model
|
||||
{
|
||||
use ComputedAttributes;
|
||||
use HasFactory;
|
||||
use HasUuids;
|
||||
|
||||
@@ -46,8 +50,24 @@ class TimeEntry extends Model
|
||||
'end' => 'datetime',
|
||||
'billable' => 'bool',
|
||||
'tags' => 'array',
|
||||
'billable_rate' => 'int',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that are computed. (f.e. for performance reasons)
|
||||
* These attributes can be regenerated at any time.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
protected array $computed = [
|
||||
'billable_rate',
|
||||
];
|
||||
|
||||
public function getBillableRateComputed(): ?int
|
||||
{
|
||||
return app(BillableRateService::class)->getBillableRateForTimeEntry($this);
|
||||
}
|
||||
|
||||
public function getDuration(): ?CarbonInterval
|
||||
{
|
||||
return $this->end === null ? null : $this->start->diffAsCarbonInterval($this->end);
|
||||
|
||||
@@ -115,6 +115,7 @@ class User extends Authenticatable
|
||||
return $this->belongsToMany(Organization::class, Membership::class)
|
||||
->withPivot([
|
||||
'role',
|
||||
'billable_rate',
|
||||
])
|
||||
->withTimestamps()
|
||||
->as('membership');
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Models\Tag;
|
||||
use App\Models\Task;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Models\User;
|
||||
use App\Service\PermissionStore;
|
||||
use Dedoc\Scramble\Scramble;
|
||||
use Dedoc\Scramble\Support\Generator\OpenApi;
|
||||
use Dedoc\Scramble\Support\Generator\SecurityScheme;
|
||||
@@ -20,6 +21,7 @@ use Dedoc\Scramble\Support\Generator\SecuritySchemes\OAuthFlow;
|
||||
use Filament\Forms\Components\Section;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
@@ -77,5 +79,9 @@ class AppServiceProvider extends ServiceProvider
|
||||
URL::forceScheme('https');
|
||||
request()->server->set('HTTPS', request()->header('X-Forwarded-Proto', 'https') === 'https' ? 'on' : 'off');
|
||||
}
|
||||
|
||||
$this->app->scoped(PermissionStore::class, function (Application $app): PermissionStore {
|
||||
return new PermissionStore();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\Facades\Broadcast;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class BroadcastServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
Broadcast::routes();
|
||||
|
||||
require base_path('routes/channels.php');
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ use App\Enums\Weekday;
|
||||
use App\Models\Organization;
|
||||
use App\Models\OrganizationInvitation;
|
||||
use App\Service\TimezoneService;
|
||||
use Brick\Money\Currency;
|
||||
use Brick\Money\ISOCurrencyProvider;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Laravel\Jetstream\Jetstream;
|
||||
@@ -56,9 +58,14 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
|
||||
Jetstream::role('admin', 'Administrator', [
|
||||
'projects:view',
|
||||
'projects:view:all',
|
||||
'projects:create',
|
||||
'projects:update',
|
||||
'projects:delete',
|
||||
'project-members:view',
|
||||
'project-members:create',
|
||||
'project-members:update',
|
||||
'project-members:delete',
|
||||
'tasks:view',
|
||||
'tasks:create',
|
||||
'tasks:update',
|
||||
@@ -82,15 +89,20 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'organizations:view',
|
||||
'organizations:update',
|
||||
'import',
|
||||
'users:invite-placeholder',
|
||||
'users:view',
|
||||
'members:view',
|
||||
'members:invite-placeholder',
|
||||
])->description('Administrator users can perform any action.');
|
||||
|
||||
Jetstream::role('manager', 'Manager', [
|
||||
'projects:view',
|
||||
'projects:view:all',
|
||||
'projects:create',
|
||||
'projects:update',
|
||||
'projects:delete',
|
||||
'project-members:view',
|
||||
'project-members:create',
|
||||
'project-members:update',
|
||||
'project-members:delete',
|
||||
'tasks:view',
|
||||
'tasks:create',
|
||||
'tasks:update',
|
||||
@@ -108,7 +120,7 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'tags:update',
|
||||
'tags:delete',
|
||||
'organizations:view',
|
||||
'users:view',
|
||||
'members:view',
|
||||
])->description('Managers have the ability to read, create, and update their own time entries as well as those of their team.');
|
||||
|
||||
Jetstream::role('employee', 'Employee', [
|
||||
@@ -125,14 +137,25 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
Jetstream::role('placeholder', 'Placeholder', [
|
||||
])->description('Placeholders are used for importing data. They cannot log in and have no permissions.');
|
||||
|
||||
Jetstream::inertia()->whenRendering(
|
||||
'Profile/Show',
|
||||
function (Request $request, array $data) {
|
||||
return array_merge($data, [
|
||||
'timezones' => $this->app->get(TimezoneService::class)->getSelectOptions(),
|
||||
'weekdays' => Weekday::toSelectArray(),
|
||||
]);
|
||||
}
|
||||
);
|
||||
Jetstream::inertia()
|
||||
->whenRendering(
|
||||
'Profile/Show',
|
||||
function (Request $request, array $data): array {
|
||||
return array_merge($data, [
|
||||
'timezones' => $this->app->get(TimezoneService::class)->getSelectOptions(),
|
||||
'weekdays' => Weekday::toSelectArray(),
|
||||
]);
|
||||
}
|
||||
)
|
||||
->whenRendering(
|
||||
'Teams/Show',
|
||||
function (Request $request, array $data): array {
|
||||
return array_merge($data, [
|
||||
'currencies' => array_map(function (Currency $currency): string {
|
||||
return $currency->getName();
|
||||
}, ISOCurrencyProvider::getInstance()->getAvailableCurrencies()),
|
||||
]);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use Brick\Money\ISOCurrencyProvider;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Translation\PotentiallyTranslatedString;
|
||||
|
||||
class CurrencyRule implements ValidationRule
|
||||
{
|
||||
/**
|
||||
* Run the validation rule.
|
||||
*
|
||||
* @param Closure(string): PotentiallyTranslatedString $fail
|
||||
*/
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if (! is_string($value)) {
|
||||
$fail(__('validation.string'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$currencies = ISOCurrencyProvider::getInstance()->getAvailableCurrencies();
|
||||
if (array_key_exists($value, $currencies)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$fail(__('validation.currency'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Models\Membership;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectMember;
|
||||
use App\Models\TimeEntry;
|
||||
|
||||
class BillableRateService
|
||||
{
|
||||
public function getBillableRateForTimeEntry(TimeEntry $timeEntry): ?int
|
||||
{
|
||||
if (! $timeEntry->billable) {
|
||||
return null;
|
||||
}
|
||||
if ($timeEntry->project_id !== null) {
|
||||
// Project member rate
|
||||
/** @var ProjectMember|null $projectMember */
|
||||
$projectMember = ProjectMember::query()
|
||||
->where('user_id', '=', $timeEntry->user_id)
|
||||
->where('project_id', '=', $timeEntry->project_id)
|
||||
->first();
|
||||
if ($projectMember !== null && $projectMember->billable_rate !== null) {
|
||||
return $projectMember->billable_rate;
|
||||
}
|
||||
|
||||
// Project rate
|
||||
/** @var Project|null $project */
|
||||
$project = Project::find($timeEntry->project_id);
|
||||
if ($project !== null && $project->billable_rate !== null) {
|
||||
return $project->billable_rate;
|
||||
}
|
||||
}
|
||||
// Member rate
|
||||
/** @var Membership|null $membership */
|
||||
$membership = Membership::query()
|
||||
->where('user_id', '=', $timeEntry->user_id)
|
||||
->where('organization_id', '=', $timeEntry->organization_id)
|
||||
->first();
|
||||
if ($membership !== null && $membership->billable_rate !== null) {
|
||||
return $membership->billable_rate;
|
||||
}
|
||||
|
||||
// Organization rate
|
||||
/** @var Organization|null $organization */
|
||||
$organization = Organization::query()
|
||||
->where('id', '=', $timeEntry->organization_id)
|
||||
->first();
|
||||
if ($organization !== null && $organization->billable_rate !== null) {
|
||||
return $organization->billable_rate;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Service;
|
||||
|
||||
use App\Enums\Weekday;
|
||||
use App\Models\Organization;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
@@ -86,7 +87,7 @@ class DashboardService
|
||||
*
|
||||
* @return array<int, array{date: string, duration: int}>
|
||||
*/
|
||||
public function getDailyTrackedHours(User $user, int $days): array
|
||||
public function getDailyTrackedHours(User $user, Organization $organization, int $days): array
|
||||
{
|
||||
$timezone = $this->timezoneService->getTimezoneFromUser($user);
|
||||
$timezoneShift = $this->timezoneService->getShiftFromUtc($timezone);
|
||||
@@ -103,7 +104,8 @@ class DashboardService
|
||||
|
||||
$query = TimeEntry::query()
|
||||
->select(DB::raw('DATE('.$dateWithTimeZone.') as date, round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate'))
|
||||
->where('user_id', '=', $user->id)
|
||||
->where('user_id', '=', $user->getKey())
|
||||
->where('organization_id', '=', $organization->getKey())
|
||||
->groupBy(DB::raw('DATE('.$dateWithTimeZone.')'))
|
||||
->orderBy('date');
|
||||
|
||||
@@ -128,7 +130,7 @@ class DashboardService
|
||||
*
|
||||
* @return array<int, array{date: string, duration: int}>
|
||||
*/
|
||||
public function getWeeklyHistory(User $user): array
|
||||
public function getWeeklyHistory(User $user, Organization $organization): array
|
||||
{
|
||||
$timezone = $this->timezoneService->getTimezoneFromUser($user);
|
||||
$timezoneShift = $this->timezoneService->getShiftFromUtc($timezone);
|
||||
@@ -143,7 +145,8 @@ class DashboardService
|
||||
|
||||
$query = TimeEntry::query()
|
||||
->select(DB::raw('DATE('.$dateWithTimeZone.') as date, round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate'))
|
||||
->where('user_id', '=', $user->id)
|
||||
->where('user_id', '=', $user->getKey())
|
||||
->where('organization_id', '=', $organization->getKey())
|
||||
->groupBy(DB::raw('DATE('.$dateWithTimeZone.')'))
|
||||
->orderBy('date');
|
||||
|
||||
@@ -162,4 +165,92 @@ class DashboardService
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function totalWeeklyTime(User $user, Organization $organization): int
|
||||
{
|
||||
$timezone = $this->timezoneService->getTimezoneFromUser($user);
|
||||
$possibleDays = $this->daysOfThisWeek($timezone, $user->week_start);
|
||||
|
||||
$query = TimeEntry::query()
|
||||
->select(DB::raw('round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate'))
|
||||
->where('user_id', '=', $user->getKey())
|
||||
->where('organization_id', '=', $organization->getKey());
|
||||
|
||||
$query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone);
|
||||
/** @var Collection<int, object{aggregate: int}> $resultDb */
|
||||
$resultDb = $query->get();
|
||||
|
||||
return (int) $resultDb->get(0)->aggregate;
|
||||
}
|
||||
|
||||
public function totalWeeklyBillableTime(User $user, Organization $organization): int
|
||||
{
|
||||
$timezone = $this->timezoneService->getTimezoneFromUser($user);
|
||||
$possibleDays = $this->daysOfThisWeek($timezone, $user->week_start);
|
||||
|
||||
$query = TimeEntry::query()
|
||||
->select(DB::raw('round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate'))
|
||||
->where('billable', '=', true)
|
||||
->where('user_id', '=', $user->getKey())
|
||||
->where('organization_id', '=', $organization->getKey());
|
||||
|
||||
$query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone);
|
||||
/** @var Collection<int, object{aggregate: int}> $resultDb */
|
||||
$resultDb = $query->get();
|
||||
|
||||
return (int) $resultDb->get(0)->aggregate;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{value: int, currency: string}
|
||||
*/
|
||||
public function totalWeeklyBillableAmount(User $user, Organization $organization): array
|
||||
{
|
||||
$timezone = $this->timezoneService->getTimezoneFromUser($user);
|
||||
$possibleDays = $this->daysOfThisWeek($timezone, $user->week_start);
|
||||
|
||||
$query = TimeEntry::query()
|
||||
->select(DB::raw('
|
||||
round(
|
||||
sum(
|
||||
extract(epoch from (coalesce("end", now()) - start)) * (billable_rate::float/60/60)
|
||||
)
|
||||
) as aggregate'))
|
||||
->where('billable', '=', true)
|
||||
->whereNotNull('billable_rate')
|
||||
->where('user_id', '=', $user->id);
|
||||
|
||||
$query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone);
|
||||
/** @var Collection<int, object{aggregate: int}> $resultDb */
|
||||
$resultDb = $query->get();
|
||||
|
||||
return [
|
||||
'value' => (int) $resultDb->get(0)->aggregate,
|
||||
'currency' => $organization->currency,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{value: int, name: string, color: string}>
|
||||
*/
|
||||
public function weeklyProjectOverview(User $user, Organization $organization): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'value' => 120,
|
||||
'name' => 'Project 11',
|
||||
'color' => '#26a69a',
|
||||
],
|
||||
[
|
||||
'value' => 200,
|
||||
'name' => 'Project 2',
|
||||
'color' => '#d4e157',
|
||||
],
|
||||
[
|
||||
'value' => 150,
|
||||
'name' => 'Project 3',
|
||||
'color' => '#ff7043',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class PermissionStore
|
||||
{
|
||||
/**
|
||||
* @var array<string, array<string>>
|
||||
*/
|
||||
private array $permissionCache = [];
|
||||
|
||||
public function has(Organization $organization, string $permission): bool
|
||||
{
|
||||
/** @var User|null $user */
|
||||
$user = Auth::user();
|
||||
if ($user === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! isset($this->permissionCache[$user->getKey().'|'.$organization->getKey()])) {
|
||||
if ($user->ownsTeam($organization)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! $user->belongsToTeam($organization)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$permissions = $user->teamPermissions($organization);
|
||||
$this->permissionCache[$user->getKey().'|'.$organization->getKey()] = $permissions;
|
||||
} else {
|
||||
$permissions = $this->permissionCache[$user->getKey().'|'.$organization->getKey()];
|
||||
}
|
||||
|
||||
return in_array($permission, $permissions, true);
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,8 @@ declare(strict_types=1);
|
||||
namespace App\Service;
|
||||
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonTimeZone;
|
||||
use DateTime;
|
||||
use DateTimeZone;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class TimezoneService
|
||||
@@ -17,9 +16,7 @@ class TimezoneService
|
||||
*/
|
||||
public function getTimezones(): array
|
||||
{
|
||||
$tzlist = CarbonTimeZone::listIdentifiers(DateTimeZone::ALL);
|
||||
|
||||
return $tzlist;
|
||||
return CarbonTimeZone::listIdentifiers();
|
||||
}
|
||||
|
||||
public function getTimezoneFromUser(User $user): CarbonTimeZone
|
||||
@@ -57,8 +54,6 @@ class TimezoneService
|
||||
|
||||
public function getShiftFromUtc(CarbonTimeZone $timeZone): int
|
||||
{
|
||||
$timezoneShift = $timeZone->getOffset(new DateTime('now', new DateTimeZone('UTC')));
|
||||
|
||||
return $timezoneShift;
|
||||
return $timeZone->getOffset(Carbon::now());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,12 @@
|
||||
"require": {
|
||||
"php": "8.3.*",
|
||||
"ext-zip": "*",
|
||||
"brick/money": "^0.8.1",
|
||||
"dedoc/scramble": "dev-main",
|
||||
"filament/filament": "^3.2",
|
||||
"guzzlehttp/guzzle": "^7.2",
|
||||
"inertiajs/inertia-laravel": "^1.0",
|
||||
"korridor/laravel-computed-attributes": "^3.1",
|
||||
"korridor/laravel-model-validation-rules": "^3.0",
|
||||
"laravel/framework": "^11.0",
|
||||
"laravel/jetstream": "^5.0",
|
||||
|
||||
Generated
+1289
-1
File diff suppressed because it is too large
Load Diff
@@ -34,4 +34,13 @@ class ClientFactory extends Factory
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
public function randomCreatedAt(): self
|
||||
{
|
||||
return $this->state(function (array $attributes): array {
|
||||
return [
|
||||
'created_at' => $this->faker->dateTimeBetween('-1 day', 'now'),
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ class OrganizationFactory extends Factory
|
||||
{
|
||||
return [
|
||||
'name' => $this->faker->unique()->company(),
|
||||
'currency' => $this->faker->currencyCode,
|
||||
'billable_rate' => $this->faker->numberBetween(50, 1000) * 100,
|
||||
'user_id' => User::factory(),
|
||||
'personal_team' => true,
|
||||
];
|
||||
|
||||
@@ -7,6 +7,8 @@ namespace Database\Factories;
|
||||
use App\Models\Client;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectMember;
|
||||
use App\Models\User;
|
||||
use App\Service\ColorService;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
@@ -25,8 +27,10 @@ class ProjectFactory extends Factory
|
||||
return [
|
||||
'name' => $this->faker->company(),
|
||||
'color' => app(ColorService::class)->getRandomColor(),
|
||||
'organization_id' => Organization::factory(),
|
||||
'billable_rate' => $this->faker->numberBetween(50, 1000) * 100,
|
||||
'is_public' => false,
|
||||
'client_id' => null,
|
||||
'organization_id' => Organization::factory(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -39,6 +43,34 @@ class ProjectFactory extends Factory
|
||||
});
|
||||
}
|
||||
|
||||
public function isPublic(): self
|
||||
{
|
||||
return $this->state(function (array $attributes): array {
|
||||
return [
|
||||
'is_public' => true,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
public function isPrivate(): self
|
||||
{
|
||||
return $this->state(function (array $attributes): array {
|
||||
return [
|
||||
'is_public' => false,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
public function addMember(User $user, array $attributes = []): self
|
||||
{
|
||||
return $this->afterCreating(function (Project $project) use ($user, $attributes): void {
|
||||
ProjectMember::factory()
|
||||
->forProject($project)
|
||||
->forUser($user)
|
||||
->create($attributes);
|
||||
});
|
||||
}
|
||||
|
||||
public function withClient(): self
|
||||
{
|
||||
return $this->state(function (array $attributes): array {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectMember;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<ProjectMember>
|
||||
*/
|
||||
class ProjectMemberFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'billable_rate' => $this->faker->numberBetween(50, 1000) * 100,
|
||||
'project_id' => Project::factory(),
|
||||
'user_id' => User::factory(),
|
||||
];
|
||||
}
|
||||
|
||||
public function forUser(User $user): self
|
||||
{
|
||||
return $this->state(function (array $attributes) use ($user): array {
|
||||
return [
|
||||
'user_id' => $user->getKey(),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
public function forProject(Project $project): self
|
||||
{
|
||||
return $this->state(function (array $attributes) use ($project): array {
|
||||
return [
|
||||
'project_id' => $project->getKey(),
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -34,4 +34,13 @@ class TagFactory extends Factory
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
public function randomCreatedAt(): self
|
||||
{
|
||||
return $this->state(function (array $attributes): array {
|
||||
return [
|
||||
'created_at' => $this->faker->dateTimeBetween('-1 day', 'now'),
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,13 @@ class UserFactory extends Factory
|
||||
});
|
||||
}
|
||||
|
||||
public function attachToOrganization(Organization $organization, array $pivot = []): static
|
||||
{
|
||||
return $this->afterCreating(function (User $user) use ($organization, $pivot) {
|
||||
$user->organizations()->attach($organization, $pivot);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate that the user should have a personal team.
|
||||
*/
|
||||
|
||||
@@ -18,6 +18,8 @@ return new class extends Migration
|
||||
$table->foreignUuid('user_id')->index();
|
||||
$table->string('name');
|
||||
$table->boolean('personal_team');
|
||||
$table->integer('billable_rate')->unsigned()->nullable();
|
||||
$table->string('currency', 3);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ return new class extends Migration
|
||||
$table->foreignUuid('organization_id');
|
||||
$table->foreignUuid('user_id');
|
||||
$table->string('role')->nullable();
|
||||
$table->integer('billable_rate')->unsigned()->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['organization_id', 'user_id']);
|
||||
|
||||
@@ -17,6 +17,8 @@ return new class extends Migration
|
||||
$table->uuid('id')->primary();
|
||||
$table->string('name', 255);
|
||||
$table->string('color', 16);
|
||||
$table->integer('billable_rate')->unsigned()->nullable();
|
||||
$table->boolean('is_public')->default(false);
|
||||
$table->uuid('client_id')->nullable();
|
||||
$table->foreign('client_id')
|
||||
->references('id')
|
||||
|
||||
@@ -18,6 +18,7 @@ return new class extends Migration
|
||||
$table->string('description', 500);
|
||||
$table->dateTime('start');
|
||||
$table->dateTime('end')->nullable();
|
||||
$table->integer('billable_rate')->unsigned()->nullable();
|
||||
$table->boolean('billable')->default(false);
|
||||
$table->uuid('user_id');
|
||||
$table->foreign('user_id')
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<?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::create('project_members', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->integer('billable_rate')->unsigned()->nullable();
|
||||
$table->uuid('project_id');
|
||||
$table->foreign('project_id')
|
||||
->references('id')
|
||||
->on('projects')
|
||||
->onDelete('restrict')
|
||||
->onUpdate('cascade');
|
||||
$table->uuid('user_id');
|
||||
$table->foreign('user_id')
|
||||
->references('id')
|
||||
->on('users')
|
||||
->onDelete('restrict')
|
||||
->onUpdate('cascade');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('project_members');
|
||||
}
|
||||
};
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
||||
use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
||||
use App\Exceptions\Api\UserNotPlaceholderApiException;
|
||||
|
||||
@@ -9,5 +10,6 @@ return [
|
||||
'api' => [
|
||||
TimeEntryStillRunningApiException::KEY => 'Time entry is still running',
|
||||
UserNotPlaceholderApiException::KEY => 'The given user is not a placeholder',
|
||||
TimeEntryCanNotBeRestartedApiException::KEY => 'Time entry is already stopped and can not be restarted',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -199,6 +199,7 @@ return [
|
||||
*/
|
||||
|
||||
'color' => 'The :attribute field must be a valid color.',
|
||||
'currency' => 'The :attribute field must be a valid currency code (ISO 4217).',
|
||||
'organization' => 'The :attribute does not exist.',
|
||||
'task_belongs_to_project' => 'The :attribute is not part of the given project.',
|
||||
];
|
||||
|
||||
@@ -16,6 +16,7 @@ const props = defineProps<{
|
||||
|
||||
const form = useForm({
|
||||
name: props.team.name,
|
||||
currency: props.team.currency,
|
||||
});
|
||||
|
||||
const updateTeamName = () => {
|
||||
@@ -69,6 +70,26 @@ const updateTeamName = () => {
|
||||
|
||||
<InputError :message="form.errors.name" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<!-- Currency -->
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="currency" value="Currency" />
|
||||
<select
|
||||
name="currency"
|
||||
id="currency"
|
||||
v-model="form.currency"
|
||||
:disabled="!permissions.canUpdateTeam"
|
||||
class="mt-1 block w-full border-input-border bg-input-background text-white focus:border-input-border-active rounded-md shadow-sm">
|
||||
<option value="" disabled>Select a currency</option>
|
||||
<option
|
||||
v-for="(currencyTranslated, currencyKey) in $page.props.currencies"
|
||||
:key="currencyKey"
|
||||
:value="currencyKey">
|
||||
{{ currencyKey }} - {{ currencyTranslated }}
|
||||
</option>
|
||||
</select>
|
||||
<InputError :message="form.errors.currency" class="mt-2" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="permissions.canUpdateTeam" #actions>
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface Organization {
|
||||
user_id: string;
|
||||
name: string;
|
||||
personal_team: boolean;
|
||||
currency: string;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
// relations
|
||||
|
||||
+11
-2
@@ -7,6 +7,7 @@ use App\Http\Controllers\Api\V1\ImportController;
|
||||
use App\Http\Controllers\Api\V1\MemberController;
|
||||
use App\Http\Controllers\Api\V1\OrganizationController;
|
||||
use App\Http\Controllers\Api\V1\ProjectController;
|
||||
use App\Http\Controllers\Api\V1\ProjectMemberController;
|
||||
use App\Http\Controllers\Api\V1\TagController;
|
||||
use App\Http\Controllers\Api\V1\TaskController;
|
||||
use App\Http\Controllers\Api\V1\TimeEntryController;
|
||||
@@ -31,8 +32,8 @@ Route::middleware('auth:api')->prefix('v1')->name('v1.')->group(static function
|
||||
Route::put('/organizations/{organization}', [OrganizationController::class, 'update'])->name('update');
|
||||
});
|
||||
|
||||
// User routes
|
||||
Route::name('users.')->group(static function () {
|
||||
// Member routes
|
||||
Route::name('members.')->group(static function () {
|
||||
Route::get('/organizations/{organization}/members', [MemberController::class, 'index'])->name('index');
|
||||
Route::post('/organizations/{organization}/members/{user}/invite-placeholder', [MemberController::class, 'invitePlaceholder'])->name('invite-placeholder');
|
||||
});
|
||||
@@ -46,6 +47,14 @@ Route::middleware('auth:api')->prefix('v1')->name('v1.')->group(static function
|
||||
Route::delete('/organizations/{organization}/projects/{project}', [ProjectController::class, 'destroy'])->name('destroy');
|
||||
});
|
||||
|
||||
// Project member routes
|
||||
Route::name('project-members.')->group(static function () {
|
||||
Route::get('/organizations/{organization}/projects/{project}/project-members', [ProjectMemberController::class, 'index'])->name('index');
|
||||
Route::post('/organizations/{organization}/projects/{project}/project-members', [ProjectMemberController::class, 'store'])->name('store');
|
||||
Route::put('/organizations/{organization}/project-members/{projectMember}', [ProjectMemberController::class, 'update'])->name('update');
|
||||
Route::delete('/organizations/{organization}/project-members/{projectMember}', [ProjectMemberController::class, 'destroy'])->name('destroy');
|
||||
});
|
||||
|
||||
// Time entry routes
|
||||
Route::name('time-entries.')->group(static function () {
|
||||
Route::get('/organizations/{organization}/time-entries', [TimeEntryController::class, 'index'])->name('index');
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\Broadcast;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Broadcast Channels
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may register all of the event broadcasting channels that your
|
||||
| application supports. The given channel authorization callbacks are
|
||||
| used to check if an authenticated user can listen to the channel.
|
||||
|
|
||||
*/
|
||||
|
||||
Broadcast::channel('App.Models.User.{id}', function ($user, $id) {
|
||||
return (int) $user->id === (int) $id;
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Console Routes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This file is where you may define all of your Closure based console
|
||||
| commands. Each Closure is bound to a command instance allowing a
|
||||
| simple approach to interacting with each command's IO methods.
|
||||
|
|
||||
*/
|
||||
|
||||
Artisan::command('inspire', function () {
|
||||
$this->comment(Inspiring::quote());
|
||||
})->purpose('Display an inspiring quote');
|
||||
@@ -39,6 +39,7 @@ class RegistrationTest extends TestCase
|
||||
|
||||
public function test_new_users_can_register(): void
|
||||
{
|
||||
// Act
|
||||
$response = $this->post('/register', [
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
@@ -47,6 +48,7 @@ class RegistrationTest extends TestCase
|
||||
'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(),
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$this->assertAuthenticated();
|
||||
$response->assertRedirect(RouteServiceProvider::HOME);
|
||||
$user = User::where('email', 'test@example.com')->firstOrFail();
|
||||
|
||||
@@ -14,13 +14,21 @@ class UpdateTeamNameTest extends TestCase
|
||||
|
||||
public function test_team_names_can_be_updated(): void
|
||||
{
|
||||
$this->actingAs($user = User::factory()->withPersonalOrganization()->create());
|
||||
// Arrange
|
||||
$user = User::factory()->withPersonalOrganization()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
// Act
|
||||
$response = $this->put('/teams/'.$user->currentTeam->id, [
|
||||
'name' => 'Test Organization',
|
||||
'currency' => 'USD',
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertValid(errorBag: 'updateTeamName');
|
||||
$this->assertCount(1, $user->fresh()->ownedTeams);
|
||||
$this->assertEquals('Test Organization', $user->currentTeam->fresh()->name);
|
||||
$organization = $user->currentTeam->fresh();
|
||||
$this->assertEquals('Test Organization', $organization->name);
|
||||
$this->assertEquals('USD', $organization->currency);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ class ClientEndpointTest extends ApiEndpointTestAbstract
|
||||
$data = $this->createUserWithPermission([
|
||||
'clients:view',
|
||||
]);
|
||||
$clients = Client::factory()->forOrganization($data->organization)->createMany(4);
|
||||
$clients = Client::factory()->forOrganization($data->organization)->randomCreatedAt()->createMany(4);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
@@ -41,15 +41,16 @@ class ClientEndpointTest extends ApiEndpointTestAbstract
|
||||
// Assert
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonCount(4, 'data');
|
||||
$clients = Client::query()->orderBy('created_at', 'desc')->get();
|
||||
$response->assertJson(fn (AssertableJson $json) => $json
|
||||
->has('data')
|
||||
->has('links')
|
||||
->has('meta')
|
||||
->count('data', 4)
|
||||
->where('data.0.id', $clients->sortByDesc('created_at')->get(0)->getKey())
|
||||
->where('data.1.id', $clients->sortByDesc('created_at')->get(1)->getKey())
|
||||
->where('data.2.id', $clients->sortByDesc('created_at')->get(2)->getKey())
|
||||
->where('data.3.id', $clients->sortByDesc('created_at')->get(3)->getKey())
|
||||
->where('data.0.id', $clients->get(0)->getKey())
|
||||
->where('data.1.id', $clients->get(1)->getKey())
|
||||
->where('data.2.id', $clients->get(2)->getKey())
|
||||
->where('data.3.id', $clients->get(3)->getKey())
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,12 +14,12 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'users:view',
|
||||
'members:view',
|
||||
]);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->getJson(route('api.v1.users.index', $data->organization->id));
|
||||
$response = $this->getJson(route('api.v1.members.index', $data->organization->id));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(200);
|
||||
@@ -28,7 +28,7 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
|
||||
public function test_invite_placeholder_succeeds_if_data_is_valid(): void
|
||||
{
|
||||
$data = $this->createUserWithPermission([
|
||||
'users:invite-placeholder',
|
||||
'members:invite-placeholder',
|
||||
], true);
|
||||
$user = User::factory()->create([
|
||||
'is_placeholder' => true,
|
||||
@@ -39,7 +39,7 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.users.invite-placeholder', [
|
||||
$response = $this->postJson(route('api.v1.members.invite-placeholder', [
|
||||
'organization' => $data->organization->id,
|
||||
'user' => $user->id,
|
||||
]));
|
||||
@@ -61,7 +61,7 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.users.invite-placeholder', ['organization' => $data->organization->id, 'user' => $user->id]));
|
||||
$response = $this->postJson(route('api.v1.members.invite-placeholder', ['organization' => $data->organization->id, 'user' => $user->id]));
|
||||
|
||||
// Assert
|
||||
$response->assertForbidden();
|
||||
@@ -71,7 +71,7 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'users:invite-placeholder',
|
||||
'members:invite-placeholder',
|
||||
]);
|
||||
$otherOrganization = Organization::factory()->create();
|
||||
$user = User::factory()->create([
|
||||
@@ -81,7 +81,7 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.users.invite-placeholder', ['organization' => $data->organization->id, 'user' => $user->id]));
|
||||
$response = $this->postJson(route('api.v1.members.invite-placeholder', ['organization' => $data->organization->id, 'user' => $user->id]));
|
||||
|
||||
// Assert
|
||||
$response->assertForbidden();
|
||||
@@ -91,12 +91,12 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'users:invite-placeholder',
|
||||
'members:invite-placeholder',
|
||||
]);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.users.invite-placeholder', ['organization' => $data->organization->id, 'user' => $data->user->id]));
|
||||
$response = $this->postJson(route('api.v1.members.invite-placeholder', ['organization' => $data->organization->id, 'user' => $data->user->id]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(400);
|
||||
|
||||
@@ -68,12 +68,37 @@ class OrganizationEndpointTest extends ApiEndpointTestAbstract
|
||||
// Act
|
||||
$response = $this->putJson(route('api.v1.organizations.update', [$data->organization->getKey()]), [
|
||||
'name' => $organizationFake->name,
|
||||
'billable_rate' => $organizationFake->billable_rate,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(200);
|
||||
$this->assertDatabaseHas(Organization::class, [
|
||||
'name' => $organizationFake->name,
|
||||
'billable_rate' => $organizationFake->billable_rate,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_update_endpoint_can_update_billable_rate_of_organization(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'organizations:update',
|
||||
]);
|
||||
$organizationFake = Organization::factory()->make();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->putJson(route('api.v1.organizations.update', [$data->organization->getKey()]), [
|
||||
'name' => $organizationFake->name,
|
||||
'billable_rate' => $organizationFake->billable_rate,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(200);
|
||||
$this->assertDatabaseHas(Organization::class, [
|
||||
'name' => $organizationFake->name,
|
||||
'billable_rate' => $organizationFake->billable_rate,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,13 +27,33 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_index_endpoint_returns_list_of_all_projects_of_organization(): void
|
||||
public function test_index_endpoint_returns_list_of_all_projects_of_organization_for_user_with_all_projects_permission(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'projects:view',
|
||||
'projects:view:all',
|
||||
]);
|
||||
$projects = Project::factory()->forOrganization($data->organization)->createMany(4);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->getJson(route('api.v1.projects.index', [$data->organization->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonCount(4, 'data');
|
||||
}
|
||||
|
||||
public function test_index_endpoint_returns_list_of_projects_of_organization_which_are_public_or_where_user_is_member_for_user_with_restricted_permission(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'projects:view',
|
||||
]);
|
||||
$projects = Project::factory()->forOrganization($data->organization)->createMany(4);
|
||||
$privateProjects = Project::factory()->forOrganization($data->organization)->isPrivate()->createMany(2);
|
||||
$publicProjects = Project::factory()->forOrganization($data->organization)->isPublic()->createMany(2);
|
||||
$privateProjectsWithMembership = Project::factory()->forOrganization($data->organization)->addMember($data->user)->isPrivate()->createMany(2);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
@@ -162,6 +182,32 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_store_endpoint_creates_new_project_with_billable_rate(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'projects:create',
|
||||
]);
|
||||
$project = Project::factory()->forOrganization($data->organization)->make();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
|
||||
'name' => $project->name,
|
||||
'color' => $project->color,
|
||||
'billable_rate' => 10001,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(201);
|
||||
$this->assertDatabaseHas(Project::class, [
|
||||
'name' => $project->name,
|
||||
'color' => $project->color,
|
||||
'organization_id' => $project->organization_id,
|
||||
'billable_rate' => 10001,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_update_endpoint_fails_if_user_is_not_part_of_project_organization(): void
|
||||
{
|
||||
// Arrange
|
||||
@@ -210,12 +256,14 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
|
||||
]);
|
||||
$project = Project::factory()->forOrganization($data->organization)->create();
|
||||
$projectFake = Project::factory()->make();
|
||||
$client = Client::factory()->forOrganization($data->organization)->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
|
||||
'name' => $projectFake->name,
|
||||
'color' => $projectFake->color,
|
||||
'client_id' => $client->getKey(),
|
||||
]);
|
||||
|
||||
// Assert
|
||||
@@ -223,6 +271,33 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
|
||||
$this->assertDatabaseHas(Project::class, [
|
||||
'name' => $projectFake->name,
|
||||
'color' => $projectFake->color,
|
||||
'client_id' => $client->getKey(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_update_endpoint_can_update_projects_billable_rate(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'projects:update',
|
||||
]);
|
||||
$project = Project::factory()->forOrganization($data->organization)->create();
|
||||
$projectFake = Project::factory()->make();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
|
||||
'name' => $projectFake->name,
|
||||
'color' => $projectFake->color,
|
||||
'billable_rate' => 10002,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(200);
|
||||
$this->assertDatabaseHas(Project::class, [
|
||||
'name' => $projectFake->name,
|
||||
'color' => $projectFake->color,
|
||||
'billable_rate' => 10002,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Endpoint\Api\V1;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectMember;
|
||||
use App\Models\User;
|
||||
use Laravel\Passport\Passport;
|
||||
|
||||
class ProjectMemberEndpointTest extends ApiEndpointTestAbstract
|
||||
{
|
||||
public function test_index_endpoint_fails_if_user_has_no_permission_to_view_project_members(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
]);
|
||||
$project = Project::factory()->forOrganization($data->organization)->create();
|
||||
$projectMembers = ProjectMember::factory()->forProject($project)->createMany(4);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->getJson(route('api.v1.project-members.index', [
|
||||
$data->organization->getKey(),
|
||||
$project->getKey(),
|
||||
]));
|
||||
|
||||
// Assert
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_index_endpoint_fails_if_the_project_does_not_belong_to_given_organization(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'project-members:view',
|
||||
]);
|
||||
$otherData = $this->createUserWithPermission([
|
||||
'project-members:view',
|
||||
]);
|
||||
$project = Project::factory()->forOrganization($otherData->organization)->create();
|
||||
$projectMembers = ProjectMember::factory()->forProject($project)->createMany(4);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->getJson(route('api.v1.project-members.index', [
|
||||
$data->organization->getKey(),
|
||||
$project->getKey(),
|
||||
]));
|
||||
|
||||
// Assert
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_index_endpoint_returns_list_of_all_project_members_of_a_project(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'project-members:view',
|
||||
]);
|
||||
$project = Project::factory()->forOrganization($data->organization)->create();
|
||||
$projectMembers = ProjectMember::factory()->forProject($project)->createMany(4);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->getJson(route('api.v1.project-members.index', [
|
||||
$data->organization->getKey(),
|
||||
$project->getKey(),
|
||||
]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonCount(4, 'data');
|
||||
}
|
||||
|
||||
public function test_store_endpoint_fails_if_user_has_no_permission_to_add_members_to_project(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
]);
|
||||
$project = Project::factory()->forOrganization($data->organization)->create();
|
||||
$projectMemberFake = ProjectMember::factory()->make();
|
||||
$user = User::factory()->attachToOrganization($data->organization)->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.project-members.store', [$data->organization->getKey(), $project->getKey()]), [
|
||||
'billable_rate' => $projectMemberFake->billable_rate,
|
||||
'user_id' => $user->getKey(),
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_store_endpoint_fails_if_given_project_does_not_belong_to_organization(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'project-members:create',
|
||||
]);
|
||||
$otherData = $this->createUserWithPermission([
|
||||
'project-members:create',
|
||||
]);
|
||||
$project = Project::factory()->forOrganization($otherData->organization)->create();
|
||||
$projectMemberFake = ProjectMember::factory()->make();
|
||||
$user = User::factory()->attachToOrganization($data->organization)->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.project-members.store', [$data->organization->getKey(), $project->getKey()]), [
|
||||
'billable_rate' => $projectMemberFake->billable_rate,
|
||||
'user_id' => $user->getKey(),
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_store_endpoint_fails_if_given_user_does_not_belong_to_organization(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'project-members:create',
|
||||
]);
|
||||
$otherData = $this->createUserWithPermission([
|
||||
'project-members:create',
|
||||
]);
|
||||
$project = Project::factory()->forOrganization($data->organization)->create();
|
||||
$projectMemberFake = ProjectMember::factory()->make();
|
||||
$user = User::factory()->attachToOrganization($otherData->organization)->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.project-members.store', [$data->organization->getKey(), $project->getKey()]), [
|
||||
'billable_rate' => $projectMemberFake->billable_rate,
|
||||
'user_id' => $user->getKey(),
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertInvalid(['user_id']);
|
||||
}
|
||||
|
||||
public function test_store_endpoint_creates_new_project_member(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'project-members:create',
|
||||
]);
|
||||
$project = Project::factory()->forOrganization($data->organization)->create();
|
||||
$projectMemberFake = ProjectMember::factory()->make();
|
||||
$user = User::factory()->attachToOrganization($data->organization)->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.project-members.store', [$data->organization->getKey(), $project->getKey()]), [
|
||||
'billable_rate' => $projectMemberFake->billable_rate,
|
||||
'user_id' => $user->getKey(),
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(201);
|
||||
$this->assertDatabaseHas(ProjectMember::class, [
|
||||
'billable_rate' => $projectMemberFake->billable_rate,
|
||||
'user_id' => $user->getKey(),
|
||||
'project_id' => $project->getKey(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_update_endpoint_fails_if_project_member_is_not_part_of_organization(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'project-members:update',
|
||||
]);
|
||||
$otherData = $this->createUserWithPermission([
|
||||
'project-members:update',
|
||||
]);
|
||||
$project = Project::factory()->forOrganization($otherData->organization)->create();
|
||||
$projectMember = ProjectMember::factory()->forProject($project)->create();
|
||||
$projectMemberFake = ProjectMember::factory()->make();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->putJson(route('api.v1.project-members.update', [$data->organization->getKey(), $projectMember->getKey()]), [
|
||||
'billable_rate' => $projectMemberFake->billable_rate,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_update_endpoint_fails_if_user_has_no_permission_to_update_projects(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
]);
|
||||
$project = Project::factory()->forOrganization($data->organization)->create();
|
||||
$projectMember = ProjectMember::factory()->forProject($project)->create();
|
||||
$projectMemberFake = ProjectMember::factory()->make();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->putJson(route('api.v1.project-members.update', [$data->organization->getKey(), $projectMember->getKey()]), [
|
||||
'billable_rate' => $projectMemberFake->billable_rate,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_update_endpoint_updates_project_member(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'project-members:update',
|
||||
]);
|
||||
$project = Project::factory()->forOrganization($data->organization)->create();
|
||||
$projectMember = ProjectMember::factory()->forProject($project)->create();
|
||||
$projectMemberFake = ProjectMember::factory()->make();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->putJson(route('api.v1.project-members.update', [$data->organization->getKey(), $projectMember->getKey()]), [
|
||||
'billable_rate' => $projectMemberFake->billable_rate,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(200);
|
||||
$this->assertDatabaseHas(ProjectMember::class, [
|
||||
'id' => $projectMember->getKey(),
|
||||
'billable_rate' => $projectMemberFake->billable_rate,
|
||||
'user_id' => $projectMember->user_id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_destroy_endpoint_fails_if_user_is_not_part_of_project_members_organization(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'project-members:delete',
|
||||
]);
|
||||
$otherData = $this->createUserWithPermission([
|
||||
'project-members:delete',
|
||||
]);
|
||||
$project = Project::factory()->forOrganization($otherData->organization)->create();
|
||||
$projectMember = ProjectMember::factory()->forProject($project)->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.project-members.destroy', [$data->organization->getKey(), $projectMember->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertForbidden();
|
||||
$this->assertDatabaseHas(ProjectMember::class, [
|
||||
'id' => $projectMember->getKey(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_destroy_endpoint_fails_if_user_has_no_permission_to_delete_projects(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
]);
|
||||
$project = Project::factory()->forOrganization($data->organization)->create();
|
||||
$projectMember = ProjectMember::factory()->forProject($project)->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.project-members.destroy', [$data->organization->getKey(), $projectMember->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertForbidden();
|
||||
$this->assertDatabaseHas(ProjectMember::class, [
|
||||
'id' => $projectMember->getKey(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_destroy_endpoint_deletes_project(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'project-members:delete',
|
||||
]);
|
||||
$project = Project::factory()->forOrganization($data->organization)->create();
|
||||
$projectMember = ProjectMember::factory()->forProject($project)->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.project-members.destroy', [$data->organization->getKey(), $projectMember->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(204);
|
||||
$response->assertNoContent();
|
||||
$this->assertDatabaseMissing(ProjectMember::class, [
|
||||
'id' => $projectMember->getKey(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ class TagEndpointTest extends ApiEndpointTestAbstract
|
||||
$data = $this->createUserWithPermission([
|
||||
'tags:view',
|
||||
]);
|
||||
$tags = Tag::factory()->forOrganization($data->organization)->createMany(4);
|
||||
$tags = Tag::factory()->forOrganization($data->organization)->randomCreatedAt()->createMany(4);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
@@ -41,15 +41,16 @@ class TagEndpointTest extends ApiEndpointTestAbstract
|
||||
// Assert
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonCount(4, 'data');
|
||||
$tags = Tag::query()->orderBy('created_at', 'desc')->get();
|
||||
$response->assertJson(fn (AssertableJson $json) => $json
|
||||
->has('data')
|
||||
->has('links')
|
||||
->has('meta')
|
||||
->count('data', 4)
|
||||
->where('data.0.id', $tags->sortByDesc('created_at')->get(0)->getKey())
|
||||
->where('data.1.id', $tags->sortByDesc('created_at')->get(1)->getKey())
|
||||
->where('data.2.id', $tags->sortByDesc('created_at')->get(2)->getKey())
|
||||
->where('data.3.id', $tags->sortByDesc('created_at')->get(3)->getKey())
|
||||
->where('data.0.id', $tags->get(0)->getKey())
|
||||
->where('data.1.id', $tags->get(1)->getKey())
|
||||
->where('data.2.id', $tags->get(2)->getKey())
|
||||
->where('data.3.id', $tags->get(3)->getKey())
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -382,6 +382,35 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_store_endpoint_validation_fails_if_project_id_is_missing_but_request_has_task_id(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'time-entries:create:own',
|
||||
]);
|
||||
$timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->withTask($data->organization)->make();
|
||||
$timeEntryFake2 = TimeEntry::factory()->forOrganization($data->organization)->withTask($data->organization)->make();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
|
||||
'description' => $timeEntryFake->description,
|
||||
'billable' => $timeEntryFake->billable,
|
||||
'start' => $timeEntryFake->start->toIso8601ZuluString(),
|
||||
'end' => $timeEntryFake->end->toIso8601ZuluString(),
|
||||
'tags' => $timeEntryFake->tags,
|
||||
'user_id' => $data->user->getKey(),
|
||||
'task_id' => $timeEntryFake2->task_id,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors([
|
||||
'project_id' => 'The project field is required when task is present.',
|
||||
'task_id' => 'The task is not part of the given project.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_store_endpoint_creates_new_time_entry_for_current_user(): void
|
||||
{
|
||||
// Arrange
|
||||
@@ -578,6 +607,66 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_update_endpoint_validation_fails_if_task_id_does_not_belong_to_project_id(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'time-entries:update:own',
|
||||
]);
|
||||
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->create();
|
||||
$timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->withTask($data->organization)->make();
|
||||
$timeEntryFake2 = TimeEntry::factory()->forOrganization($data->organization)->withTask($data->organization)->make();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $timeEntry->getKey()]), [
|
||||
'description' => $timeEntryFake->description,
|
||||
'billable' => $timeEntryFake->billable,
|
||||
'start' => $timeEntryFake->start->toIso8601ZuluString(),
|
||||
'end' => $timeEntryFake->end->toIso8601ZuluString(),
|
||||
'tags' => $timeEntryFake->tags,
|
||||
'user_id' => $data->user->getKey(),
|
||||
'project_id' => $timeEntryFake->project_id,
|
||||
'task_id' => $timeEntryFake2->task_id,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors([
|
||||
'task_id' => 'The task is not part of the given project.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_update_endpoint_validation_fails_if_project_id_is_missing_but_request_has_task_id(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'time-entries:update:own',
|
||||
]);
|
||||
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->create();
|
||||
$timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->withTask($data->organization)->make();
|
||||
$timeEntryFake2 = TimeEntry::factory()->forOrganization($data->organization)->withTask($data->organization)->make();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $timeEntry->getKey()]), [
|
||||
'description' => $timeEntryFake->description,
|
||||
'billable' => $timeEntryFake->billable,
|
||||
'start' => $timeEntryFake->start->toIso8601ZuluString(),
|
||||
'end' => $timeEntryFake->end->toIso8601ZuluString(),
|
||||
'tags' => $timeEntryFake->tags,
|
||||
'user_id' => $data->user->getKey(),
|
||||
'task_id' => $timeEntryFake2->task_id,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors([
|
||||
'project_id' => 'The project field is required when task is present.',
|
||||
'task_id' => 'The task is not part of the given project.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_update_endpoint_updates_time_entry_for_current_user(): void
|
||||
{
|
||||
// Arrange
|
||||
@@ -585,7 +674,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
||||
'time-entries:update:own',
|
||||
]);
|
||||
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->create();
|
||||
$timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->make();
|
||||
$timeEntryFake = TimeEntry::factory()->withTags($data->organization)->forOrganization($data->organization)->make();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
@@ -595,7 +684,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
||||
'end' => $timeEntryFake->end->toIso8601ZuluString(),
|
||||
'tags' => $timeEntryFake->tags,
|
||||
'user_id' => $data->user->getKey(),
|
||||
'task_id' => $timeEntryFake->task_id,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Model;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectMember;
|
||||
use App\Models\User;
|
||||
|
||||
class ProjectMemberModelTest extends ModelTestAbstract
|
||||
{
|
||||
public function test_it_belongs_to_a_project(): void
|
||||
{
|
||||
// Arrange
|
||||
$project = Project::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
$projectMember = ProjectMember::factory()->forProject($project)->forUser($user)->create();
|
||||
|
||||
// Act
|
||||
$projectMember->refresh();
|
||||
$projectRel = $projectMember->project;
|
||||
|
||||
// Assert
|
||||
$this->assertNotNull($projectRel);
|
||||
$this->assertTrue($projectRel->is($project));
|
||||
}
|
||||
|
||||
public function test_it_belongs_to_a_user(): void
|
||||
{
|
||||
// Arrange
|
||||
$user = User::factory()->create();
|
||||
$projectMember = ProjectMember::factory()->forUser($user)->create();
|
||||
|
||||
// Act
|
||||
$projectMember->refresh();
|
||||
$userRel = $projectMember->user;
|
||||
|
||||
// Assert
|
||||
$this->assertNotNull($userRel);
|
||||
$this->assertTrue($userRel->is($user));
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ namespace Tests\Unit\Model;
|
||||
use App\Models\Client;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectMember;
|
||||
use App\Models\Task;
|
||||
|
||||
class ProjectModelTest extends ModelTestAbstract
|
||||
@@ -69,4 +70,20 @@ class ProjectModelTest extends ModelTestAbstract
|
||||
$this->assertCount(3, $tasksRel);
|
||||
$this->assertTrue($tasksRel->first()->is($tasks->first()));
|
||||
}
|
||||
|
||||
public function test_it_has_many_members(): void
|
||||
{
|
||||
// Arrange
|
||||
$project = Project::factory()->create();
|
||||
$members = ProjectMember::factory()->forProject($project)->createMany(3);
|
||||
|
||||
// Act
|
||||
$project->refresh();
|
||||
$membersRel = $project->members;
|
||||
|
||||
// Assert
|
||||
$this->assertNotNull($membersRel);
|
||||
$this->assertCount(3, $membersRel);
|
||||
$this->assertTrue($membersRel->first()->is($members->first()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Rules;
|
||||
|
||||
use App\Rules\CurrencyRule;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CurrencyRuleTest extends TestCase
|
||||
{
|
||||
public function test_validation_passes_if_value_is_valid_currency_code(): void
|
||||
{
|
||||
// Arrange
|
||||
$validator = Validator::make([
|
||||
'currency' => 'EUR',
|
||||
], [
|
||||
'currency' => [new CurrencyRule()],
|
||||
]);
|
||||
|
||||
// Act
|
||||
$isValid = $validator->passes();
|
||||
$messages = $validator->messages()->toArray();
|
||||
|
||||
// Assert
|
||||
$this->assertTrue($isValid);
|
||||
$this->assertArrayNotHasKey('currency', $messages);
|
||||
}
|
||||
|
||||
public function test_validation_fails_if_value_is_not_a_string(): void
|
||||
{
|
||||
// Arrange
|
||||
$validator = Validator::make([
|
||||
'currency' => true,
|
||||
], [
|
||||
'currency' => [new CurrencyRule()],
|
||||
]);
|
||||
|
||||
// Act
|
||||
$isValid = $validator->passes();
|
||||
$messages = $validator->messages()->toArray();
|
||||
|
||||
// Assert
|
||||
$this->assertFalse($isValid);
|
||||
$this->assertEquals('The currency field must be a string.', $messages['currency'][0]);
|
||||
}
|
||||
|
||||
public function test_validation_fails_if_value_is_not_a_valid_currency(): void
|
||||
{
|
||||
// Arrange
|
||||
$validator = Validator::make([
|
||||
'currency' => 'XXX',
|
||||
], [
|
||||
'currency' => [new CurrencyRule()],
|
||||
]);
|
||||
|
||||
// Act
|
||||
$isValid = $validator->passes();
|
||||
$messages = $validator->messages()->toArray();
|
||||
|
||||
// Assert
|
||||
$this->assertFalse($isValid);
|
||||
$this->assertEquals('The currency field must be a valid currency code (ISO 4217).', $messages['currency'][0]);
|
||||
}
|
||||
|
||||
public function test_validation_fails_if_value_is_lower_case(): void
|
||||
{
|
||||
// Arrange
|
||||
$validator = Validator::make([
|
||||
'currency' => 'eur',
|
||||
], [
|
||||
'currency' => [new CurrencyRule()],
|
||||
]);
|
||||
|
||||
// Act
|
||||
$isValid = $validator->passes();
|
||||
$messages = $validator->messages()->toArray();
|
||||
|
||||
// Assert
|
||||
$this->assertFalse($isValid);
|
||||
$this->assertEquals('The currency field must be a valid currency code (ISO 4217).', $messages['currency'][0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Service;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectMember;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Models\User;
|
||||
use App\Service\BillableRateService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class BillableRateServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private BillableRateService $billableRateService;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->billableRateService = app(BillableRateService::class);
|
||||
}
|
||||
|
||||
public function test_billable_rate_is_null_if_time_entry_is_not_billable(): void
|
||||
{
|
||||
// Arrange
|
||||
$organization = Organization::factory()->create([
|
||||
'billable_rate' => 1001,
|
||||
]);
|
||||
$user = User::factory()->attachToOrganization($organization, [
|
||||
'billable_rate' => 2002,
|
||||
])->create();
|
||||
$project = Project::factory()->forOrganization($organization)->create([
|
||||
'billable_rate' => 3003,
|
||||
]);
|
||||
$projectMember = ProjectMember::factory()->forUser($user)->forProject($project)->create([
|
||||
'billable_rate' => 4004,
|
||||
]);
|
||||
$timeEntry = TimeEntry::factory()->forProject($project)->forUser($user)->forOrganization($organization)->create([
|
||||
'billable' => false,
|
||||
]);
|
||||
|
||||
// Act
|
||||
$billableRate = $this->billableRateService->getBillableRateForTimeEntry($timeEntry);
|
||||
|
||||
// Assert
|
||||
$this->assertSame(null, $billableRate);
|
||||
}
|
||||
|
||||
public function test_billable_rate_uses_project_member_rate_as_first_priority(): void
|
||||
{
|
||||
// Arrange
|
||||
$organization = Organization::factory()->create([
|
||||
'billable_rate' => 1001,
|
||||
]);
|
||||
$user = User::factory()->attachToOrganization($organization, [
|
||||
'billable_rate' => 2002,
|
||||
])->create();
|
||||
$project = Project::factory()->forOrganization($organization)->create([
|
||||
'billable_rate' => 3003,
|
||||
]);
|
||||
$projectMember = ProjectMember::factory()->forUser($user)->forProject($project)->create([
|
||||
'billable_rate' => 4004,
|
||||
]);
|
||||
$timeEntry = TimeEntry::factory()->forProject($project)->forUser($user)->forOrganization($organization)->create([
|
||||
'billable' => true,
|
||||
]);
|
||||
|
||||
// Act
|
||||
$billableRate = $this->billableRateService->getBillableRateForTimeEntry($timeEntry);
|
||||
|
||||
// Assert
|
||||
$this->assertSame(4004, $billableRate);
|
||||
}
|
||||
|
||||
public function test_billable_rate_uses_project_rate_as_second_priority_using_null_values_before(): void
|
||||
{
|
||||
// Arrange
|
||||
$organization = Organization::factory()->create([
|
||||
'billable_rate' => 1001,
|
||||
]);
|
||||
$user = User::factory()->attachToOrganization($organization, [
|
||||
'billable_rate' => 2002,
|
||||
])->create();
|
||||
$project = Project::factory()->forOrganization($organization)->create([
|
||||
'billable_rate' => 3003,
|
||||
]);
|
||||
$projectMember = ProjectMember::factory()->forUser($user)->forProject($project)->create([
|
||||
'billable_rate' => null,
|
||||
]);
|
||||
$timeEntry = TimeEntry::factory()->forProject($project)->forUser($user)->forOrganization($organization)->create([
|
||||
'billable' => true,
|
||||
]);
|
||||
|
||||
// Act
|
||||
$billableRate = $this->billableRateService->getBillableRateForTimeEntry($timeEntry);
|
||||
|
||||
// Assert
|
||||
$this->assertSame(3003, $billableRate);
|
||||
}
|
||||
|
||||
public function test_billable_rate_uses_project_rate_as_second_priority_using_non_existing_entities_before(): void
|
||||
{
|
||||
// Arrange
|
||||
$organization = Organization::factory()->create([
|
||||
'billable_rate' => 1001,
|
||||
]);
|
||||
$user = User::factory()->attachToOrganization($organization, [
|
||||
'billable_rate' => 2002,
|
||||
])->create();
|
||||
$project = Project::factory()->forOrganization($organization)->create([
|
||||
'billable_rate' => 3003,
|
||||
]);
|
||||
$timeEntry = TimeEntry::factory()->forProject($project)->forUser($user)->forOrganization($organization)->create([
|
||||
'billable' => true,
|
||||
]);
|
||||
|
||||
// Act
|
||||
$billableRate = $this->billableRateService->getBillableRateForTimeEntry($timeEntry);
|
||||
|
||||
// Assert
|
||||
$this->assertSame(3003, $billableRate);
|
||||
}
|
||||
|
||||
public function test_billable_rate_uses_organization_member_rate_as_third_priority_using_null_values_before(): void
|
||||
{
|
||||
// Arrange
|
||||
$organization = Organization::factory()->create([
|
||||
'billable_rate' => 1001,
|
||||
]);
|
||||
$user = User::factory()->attachToOrganization($organization, [
|
||||
'billable_rate' => 2002,
|
||||
])->create();
|
||||
$project = Project::factory()->forOrganization($organization)->create([
|
||||
'billable_rate' => null,
|
||||
]);
|
||||
$projectMember = ProjectMember::factory()->forUser($user)->forProject($project)->create([
|
||||
'billable_rate' => null,
|
||||
]);
|
||||
$timeEntry = TimeEntry::factory()->forProject($project)->forUser($user)->forOrganization($organization)->create([
|
||||
'billable' => true,
|
||||
]);
|
||||
|
||||
// Act
|
||||
$billableRate = $this->billableRateService->getBillableRateForTimeEntry($timeEntry);
|
||||
|
||||
// Assert
|
||||
$this->assertSame(2002, $billableRate);
|
||||
}
|
||||
|
||||
public function test_billable_rate_uses_organization_member_rate_as_third_priority_using_non_existing_entities_before(): void
|
||||
{
|
||||
// Arrange
|
||||
$organization = Organization::factory()->create([
|
||||
'billable_rate' => 1001,
|
||||
]);
|
||||
$user = User::factory()->attachToOrganization($organization, [
|
||||
'billable_rate' => 2002,
|
||||
])->create();
|
||||
$timeEntry = TimeEntry::factory()->forUser($user)->forOrganization($organization)->create([
|
||||
'billable' => true,
|
||||
]);
|
||||
|
||||
// Act
|
||||
$billableRate = $this->billableRateService->getBillableRateForTimeEntry($timeEntry);
|
||||
|
||||
// Assert
|
||||
$this->assertSame(2002, $billableRate);
|
||||
}
|
||||
|
||||
public function test_billable_rate_uses_organization_rate_as_fourth_priority_using_null_values_before(): void
|
||||
{
|
||||
// Arrange
|
||||
$organization = Organization::factory()->create([
|
||||
'billable_rate' => 1001,
|
||||
]);
|
||||
$user = User::factory()->attachToOrganization($organization, [
|
||||
'billable_rate' => null,
|
||||
])->create();
|
||||
$project = Project::factory()->forOrganization($organization)->create([
|
||||
'billable_rate' => null,
|
||||
]);
|
||||
$projectMember = ProjectMember::factory()->forUser($user)->forProject($project)->create([
|
||||
'billable_rate' => null,
|
||||
]);
|
||||
$timeEntry = TimeEntry::factory()->forProject($project)->forUser($user)->forOrganization($organization)->create([
|
||||
'billable' => true,
|
||||
]);
|
||||
|
||||
// Act
|
||||
$billableRate = $this->billableRateService->getBillableRateForTimeEntry($timeEntry);
|
||||
|
||||
// Assert
|
||||
$this->assertSame(1001, $billableRate);
|
||||
}
|
||||
|
||||
public function test_billable_rate_uses_organization_rate_as_fourth_priority_using_non_existing_entities_before(): void
|
||||
{
|
||||
// Arrange
|
||||
$organization = Organization::factory()->create([
|
||||
'billable_rate' => 1001,
|
||||
]);
|
||||
$user = User::factory()->create();
|
||||
$timeEntry = TimeEntry::factory()->forUser($user)->forOrganization($organization)->create([
|
||||
'billable' => true,
|
||||
]);
|
||||
|
||||
// Act
|
||||
$billableRate = $this->billableRateService->getBillableRateForTimeEntry($timeEntry);
|
||||
|
||||
// Assert
|
||||
$this->assertSame(1001, $billableRate);
|
||||
}
|
||||
|
||||
public function test_billable_rate_is_null_if_billable_rate_on_all_levels_are_null(): void
|
||||
{
|
||||
// Arrange
|
||||
$organization = Organization::factory()->create([
|
||||
'billable_rate' => null,
|
||||
]);
|
||||
$user = User::factory()->attachToOrganization($organization, [
|
||||
'billable_rate' => null,
|
||||
])->create();
|
||||
$project = Project::factory()->forOrganization($organization)->create([
|
||||
'billable_rate' => null,
|
||||
]);
|
||||
$projectMember = ProjectMember::factory()->forUser($user)->forProject($project)->create([
|
||||
'billable_rate' => null,
|
||||
]);
|
||||
$timeEntry = TimeEntry::factory()->forProject($project)->forUser($user)->forOrganization($organization)->create([
|
||||
'billable' => true,
|
||||
]);
|
||||
|
||||
// Act
|
||||
$billableRate = $this->billableRateService->getBillableRateForTimeEntry($timeEntry);
|
||||
|
||||
// Assert
|
||||
$this->assertSame(null, $billableRate);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace Tests\Unit\Service;
|
||||
|
||||
use App\Enums\Weekday;
|
||||
use App\Models\Organization;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Models\User;
|
||||
use App\Service\DashboardService;
|
||||
@@ -28,22 +29,24 @@ class DashboardServiceTest extends TestCase
|
||||
{
|
||||
// Arrange
|
||||
$this->travelTo(Carbon::create(2024, 1, 1, 12, 0, 0, 'Europe/Vienna'));
|
||||
$organization = Organization::factory()->create();
|
||||
$user = User::factory()->create([
|
||||
'timezone' => 'Europe/Vienna',
|
||||
]);
|
||||
$timeEntry1 = TimeEntry::factory()->forUser($user)->create([
|
||||
$user->organizations()->attach($organization);
|
||||
$timeEntry1 = TimeEntry::factory()->forUser($user)->forOrganization($organization)->create([
|
||||
// Note: The start time shifts in timezone Europe/Vienna to the next day
|
||||
'start' => Carbon::create(2023, 12, 30, 23, 0, 0, 'UTC'),
|
||||
'end' => Carbon::create(2023, 12, 30, 23, 0, 40, 'UTC'),
|
||||
]);
|
||||
$timeEntry2 = TimeEntry::factory()->forUser($user)->create([
|
||||
$timeEntry2 = TimeEntry::factory()->forUser($user)->forOrganization($organization)->create([
|
||||
// Note: The start time NOT shifts in timezone Europe/Vienna to the next day
|
||||
'start' => Carbon::create(2023, 12, 30, 22, 59, 59, 'UTC'),
|
||||
'end' => Carbon::create(2023, 12, 30, 23, 0, 39, 'UTC'),
|
||||
]);
|
||||
|
||||
// Act
|
||||
$result = $this->dashboardService->getDailyTrackedHours($user, 5);
|
||||
$result = $this->dashboardService->getDailyTrackedHours($user, $organization, 5);
|
||||
|
||||
// Assert
|
||||
$this->assertSame([
|
||||
@@ -75,25 +78,27 @@ class DashboardServiceTest extends TestCase
|
||||
// Arrange
|
||||
// Note: Is a Monday
|
||||
$this->travelTo(Carbon::create(2024, 1, 1, 12, 0, 0, 'Europe/Vienna'));
|
||||
$organization = Organization::factory()->create();
|
||||
$user = User::factory()->create([
|
||||
'timezone' => 'Europe/Vienna',
|
||||
'week_start' => Weekday::Sunday,
|
||||
]);
|
||||
$user->organizations()->attach($organization);
|
||||
// Note: This is a Sunday
|
||||
$timeEntry1 = TimeEntry::factory()->forUser($user)->create([
|
||||
$timeEntry1 = TimeEntry::factory()->forUser($user)->forOrganization($organization)->create([
|
||||
// Note: The start time shifts in timezone Europe/Vienna to the next day
|
||||
'start' => Carbon::create(2023, 12, 30, 23, 0, 0, 'UTC'),
|
||||
'end' => Carbon::create(2023, 12, 30, 23, 0, 40, 'UTC'),
|
||||
]);
|
||||
// Note: This is a Saturday
|
||||
$timeEntry2 = TimeEntry::factory()->forUser($user)->create([
|
||||
$timeEntry2 = TimeEntry::factory()->forUser($user)->forOrganization($organization)->create([
|
||||
// Note: The start time NOT shifts in timezone Europe/Vienna to the next day
|
||||
'start' => Carbon::create(2023, 12, 30, 22, 59, 59, 'UTC'),
|
||||
'end' => Carbon::create(2023, 12, 30, 23, 0, 39, 'UTC'),
|
||||
]);
|
||||
|
||||
// Act
|
||||
$result = $this->dashboardService->getWeeklyHistory($user);
|
||||
$result = $this->dashboardService->getWeeklyHistory($user, $organization);
|
||||
|
||||
// Assert
|
||||
$this->assertSame([
|
||||
@@ -127,4 +132,123 @@ class DashboardServiceTest extends TestCase
|
||||
],
|
||||
], $result);
|
||||
}
|
||||
|
||||
public function test_total_weekly_time_returns_correct_value(): void
|
||||
{
|
||||
// Arrange
|
||||
// Note: Is a Monday
|
||||
$this->travelTo(Carbon::create(2024, 1, 1, 12, 0, 0, 'Europe/Vienna'));
|
||||
$organization = Organization::factory()->create();
|
||||
$user = User::factory()->create([
|
||||
'timezone' => 'Europe/Vienna',
|
||||
'week_start' => Weekday::Sunday,
|
||||
]);
|
||||
$user->organizations()->attach($organization);
|
||||
// Note: This is a Sunday
|
||||
$timeEntry1 = TimeEntry::factory()->forUser($user)->forOrganization($organization)->create([
|
||||
// Note: The start time shifts in timezone Europe/Vienna to the next day
|
||||
'start' => Carbon::create(2023, 12, 30, 23, 0, 0, 'UTC'),
|
||||
'end' => Carbon::create(2023, 12, 30, 23, 0, 40, 'UTC'),
|
||||
]);
|
||||
// Note: This is a Saturday
|
||||
$timeEntry2 = TimeEntry::factory()->forUser($user)->forOrganization($organization)->create([
|
||||
// Note: The start time NOT shifts in timezone Europe/Vienna to the next day
|
||||
'start' => Carbon::create(2023, 12, 30, 22, 59, 59, 'UTC'),
|
||||
'end' => Carbon::create(2023, 12, 30, 23, 0, 39, 'UTC'),
|
||||
]);
|
||||
|
||||
// Act
|
||||
$result = $this->dashboardService->totalWeeklyTime($user, $organization);
|
||||
|
||||
// Assert
|
||||
$this->assertSame(40, $result);
|
||||
}
|
||||
|
||||
public function test_total_weekly_billable_time_returns_correct_value(): void
|
||||
{
|
||||
// Arrange
|
||||
// Note: Is a Monday
|
||||
$this->travelTo(Carbon::create(2024, 1, 1, 12, 0, 0, 'Europe/Vienna'));
|
||||
$organization = Organization::factory()->create();
|
||||
$user = User::factory()->create([
|
||||
'timezone' => 'Europe/Vienna',
|
||||
'week_start' => Weekday::Sunday,
|
||||
]);
|
||||
$user->organizations()->attach($organization);
|
||||
// Note: This is a Sunday
|
||||
$timeEntry1 = TimeEntry::factory()->forUser($user)->forOrganization($organization)->create([
|
||||
// Note: The start time shifts in timezone Europe/Vienna to the next day
|
||||
'billable' => true,
|
||||
'start' => Carbon::create(2023, 12, 30, 23, 0, 0, 'UTC'),
|
||||
'end' => Carbon::create(2023, 12, 30, 23, 0, 40, 'UTC'),
|
||||
]);
|
||||
// Note: This is a Sunday (non-billable)
|
||||
$timeEntry1 = TimeEntry::factory()->forUser($user)->forOrganization($organization)->create([
|
||||
// Note: The start time shifts in timezone Europe/Vienna to the next day
|
||||
'billable' => false,
|
||||
'start' => Carbon::create(2023, 12, 30, 23, 0, 40, 'UTC'),
|
||||
'end' => Carbon::create(2023, 12, 30, 23, 0, 59, 'UTC'),
|
||||
]);
|
||||
// Note: This is a Saturday
|
||||
$timeEntry2 = TimeEntry::factory()->forUser($user)->forOrganization($organization)->create([
|
||||
// Note: The start time NOT shifts in timezone Europe/Vienna to the next day
|
||||
'billable' => true,
|
||||
'start' => Carbon::create(2023, 12, 30, 22, 59, 59, 'UTC'),
|
||||
'end' => Carbon::create(2023, 12, 30, 23, 0, 39, 'UTC'),
|
||||
]);
|
||||
|
||||
// Act
|
||||
$result = $this->dashboardService->totalWeeklyBillableTime($user, $organization);
|
||||
|
||||
// Assert
|
||||
$this->assertSame(40, $result);
|
||||
}
|
||||
|
||||
public function test_total_weekly_billable_amount_returns_correct_value(): void
|
||||
{
|
||||
// Arrange
|
||||
// Note: Is a Monday
|
||||
$this->travelTo(Carbon::create(2024, 1, 1, 12, 0, 0, 'Europe/Vienna'));
|
||||
$currency = 'USD';
|
||||
$organization = Organization::factory()->create([
|
||||
'currency' => $currency,
|
||||
]);
|
||||
$user = User::factory()->create([
|
||||
'timezone' => 'Europe/Vienna',
|
||||
'week_start' => Weekday::Sunday,
|
||||
]);
|
||||
$user->organizations()->attach($organization);
|
||||
// Note: This is a Sunday
|
||||
$timeEntry1 = TimeEntry::factory()->forUser($user)->forOrganization($organization)->create([
|
||||
// Note: The start time shifts in timezone Europe/Vienna to the next day
|
||||
'billable' => true,
|
||||
'billable_rate' => 50 * 100,
|
||||
'start' => Carbon::create(2023, 12, 30, 23, 0, 0, 'UTC'),
|
||||
'end' => Carbon::create(2023, 12, 31, 0, 0, 0, 'UTC'),
|
||||
]);
|
||||
// Note: This is a Sunday (non-billable)
|
||||
$timeEntry2 = TimeEntry::factory()->forUser($user)->forOrganization($organization)->create([
|
||||
// Note: The start time shifts in timezone Europe/Vienna to the next day
|
||||
'billable' => false,
|
||||
'start' => Carbon::create(2023, 12, 30, 23, 0, 40, 'UTC'),
|
||||
'end' => Carbon::create(2023, 12, 30, 23, 0, 59, 'UTC'),
|
||||
]);
|
||||
// Note: This is a Saturday
|
||||
$timeEntry3 = TimeEntry::factory()->forUser($user)->forOrganization($organization)->create([
|
||||
// Note: The start time NOT shifts in timezone Europe/Vienna to the next day
|
||||
'billable' => true,
|
||||
'billable_rate' => 100 * 100,
|
||||
'start' => Carbon::create(2023, 12, 30, 22, 59, 59, 'UTC'),
|
||||
'end' => Carbon::create(2023, 12, 30, 23, 0, 39, 'UTC'),
|
||||
]);
|
||||
|
||||
// Act
|
||||
$result = $this->dashboardService->totalWeeklyBillableAmount($user, $organization);
|
||||
|
||||
// Assert
|
||||
$this->assertSame([
|
||||
'value' => 5000,
|
||||
'currency' => $currency,
|
||||
], $result);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user